Pak Jabit: full stack — storefront, Spree customisations, catalogue tooling
A South African men's apparel store on Spree 5.6 + Next.js 15. 160 products
live, three markets, live Malaysian courier rates, customer-owned carts.
What is here:
storefront/ the Next.js app
backend/ ONLY the custom Spree code. Spree itself is a prebuilt image
that mounts no app code, so every file here needs an explicit
volume entry in docker-compose.yml or it is silently ignored.
catalogue/ the scripts that loaded and maintain the products
docs/ manifest, mock registry, napkins
Real .env files are committed deliberately. This lives on the house's private
Gitea, and a clone that runs beats a clone that needs a secrets handover.
LANDMINE.md maps every key, what it opens, and what must be rotated before this
repo goes anywhere less private.
The admin API key used to be a literal in six catalogue scripts. They now read
SPREE_ADMIN_KEY from the environment and refuse to run without it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+14
@@ -0,0 +1,14 @@
|
||||
# Build output and dependencies only.
|
||||
#
|
||||
# .env files are deliberately NOT ignored: this lives on the house's private
|
||||
# Gitea, and a clone that runs beats a clone that needs a secrets handover.
|
||||
# LANDMINE.md maps every key and says what has to be rotated before this repo
|
||||
# goes anywhere less private.
|
||||
node_modules/
|
||||
.next/
|
||||
*.tsbuildinfo
|
||||
Archived/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
products/
|
||||
products-enhanced/
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
# 🧨 LANDMINE — where every key lives and what it opens
|
||||
|
||||
This repo is on the house's **private** Gitea (`gitmirah:3000`, Tailscale-only),
|
||||
so real `.env` files are committed on purpose. That is a deliberate trade: no
|
||||
`.env.example` drift, no "works on my box", and anyone cloning has a running
|
||||
stack. It also means **a clone of this repo is a full set of production
|
||||
credentials.** Treat it that way.
|
||||
|
||||
Before this repo goes anywhere else — a public mirror, GitHub, a zip to a
|
||||
client — every secret below has to be rotated first. Deleting the file later does
|
||||
not help; git keeps history.
|
||||
|
||||
---
|
||||
|
||||
## What each key opens
|
||||
|
||||
| Key | Lives in | Opens | Blast radius if leaked |
|
||||
|---|---|---|---|
|
||||
| **`SPREE_ADMIN_KEY`** | `catalogue/.env` | Full Spree **admin** API | 🔴 Total. Create/delete/reprice every product, read every order and customer. |
|
||||
| `SECRET_KEY_BASE` | `backend/.env` | Rails signing | 🔴 Forges sessions and signed URLs. **Rotating breaks every signed image URL and the admin API key** — see below. |
|
||||
| `NEXT_PUBLIC_SPREE_API_KEY` | `storefront/.env.production` | Spree **store** API | 🟡 Low. Public by design — it ships to every browser. Read products, create carts. Not admin. |
|
||||
| `EASYPARCEL_CLIENT_ID` / `_SECRET` | `storefront/.env.production` | EasyParcel account | 🟠 Book real shipments, spend real money. |
|
||||
| `PHOTOROOM_API_KEY` | `backend/.env` | Photoroom **sandbox** | 🟢 Low. Watermarked output, 1000/month free. The **live** key is not in this repo. |
|
||||
|
||||
## Not in this repo
|
||||
|
||||
| Thing | Where it actually is |
|
||||
|---|---|
|
||||
| Photoroom **live** key | `/root/.photoroom/credentials` on the house box (chmod 600). 10 images/month. |
|
||||
| Spree **admin password** | Nowhere written down. One shared account — see below. |
|
||||
| GCloud SSH key | `/root/.ssh/google_compute_engine` on the house box. |
|
||||
| `storefront/.easyparcel-token.json` | A **cached OAuth token**, committed with the rest. It expires and regenerates itself; harmless, but it is a live credential while valid. |
|
||||
|
||||
---
|
||||
|
||||
## 🔴 Do not rotate `SECRET_KEY_BASE` casually
|
||||
|
||||
It signs more than sessions. Changing it:
|
||||
|
||||
- breaks **every ActiveStorage image URL** — 3,228 blobs, the whole catalogue goes blank
|
||||
- invalidates the **admin API key**, so every script in `catalogue/` stops working
|
||||
|
||||
To force everyone out of the admin, change the **admin password** instead — that
|
||||
invalidates sessions without touching signed URLs.
|
||||
|
||||
## 🔴 One shared admin account
|
||||
|
||||
Every admin session is indistinguishable. This already cost an investigation: an
|
||||
unknown IP (`42.188.122.106`, Windows/Brave, valid session cookie) was browsing
|
||||
`/admin/api_keys` and `/admin/admin_users`, and there was no way to attribute it.
|
||||
It turned out to be a colleague.
|
||||
|
||||
~5 people are expected to use the admin. Give each one their own account
|
||||
(Settings → Users) before that number grows.
|
||||
|
||||
---
|
||||
|
||||
## Where the boxes are
|
||||
|
||||
| | Host | Access |
|
||||
|---|---|---|
|
||||
| Storefront | `celest-proto` 34.21.217.116 | `ssh ijat@34.21.217.116` |
|
||||
| Backend + DB | `pak-jabit-backend` 35.247.170.3 | `ssh -i ~/.ssh/google_compute_engine ijat@35.247.170.3` |
|
||||
|
||||
Public: `shop.jux-lab.com` · `api.jux-lab.com` · `admin.jux-lab.com`
|
||||
|
||||
---
|
||||
|
||||
## 🔴 The custom backend code exists ONLY as bind mounts
|
||||
|
||||
`ghcr.io/spree/spree` is prebuilt and mounts **no app code**. Everything under
|
||||
`backend/config/` and `backend/app/` needs an explicit volume entry in
|
||||
`docker-compose.yml`, or the container simply never loads it — **no error, no
|
||||
warning, dead file**. The 10MB body-limit initializer sat on the host for a whole
|
||||
day doing nothing before anyone noticed.
|
||||
|
||||
None of it is in the database. A rebuilt box without these files comes back with
|
||||
no Image Studio, no address labels, no region preference, and no EasyParcel
|
||||
rates.
|
||||
|
||||
---
|
||||
|
||||
## Sandbox things that are NOT real
|
||||
|
||||
Full list in `docs/MOCKS.md`. The ones that matter most:
|
||||
|
||||
- **`Test Card (sandbox)`** — a Bogus gateway that approves any card ending in
|
||||
the right digits. **Deactivate before launch.**
|
||||
- **`Test Payment`** — offline placeholder, no money moves.
|
||||
- **`Standard Delivery (South Africa)` R99** and **`Free Shipping` US $0** —
|
||||
placeholder rates, not quotes. (Malaysia is real: live EasyParcel.)
|
||||
- **`studio-test-chinos`** — a fake product, kept deliberately as the worked
|
||||
example for the Image Studio.
|
||||
|
||||
---
|
||||
|
||||
*Written 2026-09-15, the night the stack got its region anchor and a customer-owned cart.*
|
||||
@@ -0,0 +1,149 @@
|
||||
# 🧵 Pak Jabit — men's apparel store
|
||||
|
||||
A South African e-commerce stack built for NSH. Spree Commerce backend, Next.js
|
||||
storefront, 160 real products.
|
||||
|
||||
| | Live | What it is |
|
||||
|---|---|---|
|
||||
| Storefront | https://shop.jux-lab.com | Next.js 15 (App Router), systemd `pak-jabit-store` :3200 |
|
||||
| Store API | https://api.jux-lab.com | Spree 5.6 in Docker |
|
||||
| Admin | https://admin.jux-lab.com | Same container, split by hostname |
|
||||
| Image Studio | https://admin.jux-lab.com/admin/image_studio | Custom admin page, Photoroom pipeline |
|
||||
|
||||
🔴 **Read [`LANDMINE.md`](LANDMINE.md) before touching anything.** Real
|
||||
credentials are committed here on purpose (private Gitea), and several traps in
|
||||
this stack fail *silently* rather than erroring.
|
||||
|
||||
---
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
storefront/ Next.js app. Everything a shopper sees.
|
||||
backend/ ONLY the custom Spree code + compose file. Spree itself is a
|
||||
prebuilt image — see LANDMINE.md on bind mounts.
|
||||
catalogue/ Scripts that loaded and maintain the 160 products.
|
||||
docs/ Manifest, mock registry, and the napkins (ideas + open questions).
|
||||
```
|
||||
|
||||
## Running it
|
||||
|
||||
**Backend** — on `pak-jabit-backend`:
|
||||
|
||||
```bash
|
||||
cd ~/pak-jabit
|
||||
docker compose up -d
|
||||
docker compose exec web bin/rails runner 'puts Spree::Product.count'
|
||||
```
|
||||
|
||||
**Storefront** — on `celest-proto`:
|
||||
|
||||
```bash
|
||||
cd ~/pak-jabit-store
|
||||
npm ci
|
||||
npm run build
|
||||
sudo systemctl restart pak-jabit-store
|
||||
```
|
||||
|
||||
**Catalogue scripts** — they need the admin key in the environment and refuse to
|
||||
run without it:
|
||||
|
||||
```bash
|
||||
export SPREE_ADMIN_KEY=$(grep SPREE_ADMIN_KEY catalogue/.env | cut -d= -f2)
|
||||
python3 catalogue/reprice_zar.py --dry-run
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How it fits together
|
||||
|
||||
### Region anchor — one choice, three consequences
|
||||
|
||||
A customer picks a **currency**. In Spree a market *is* currency + shippable
|
||||
countries + shipping methods, so that single choice sets all three:
|
||||
|
||||
| Currency | Market | Ships to | Delivery |
|
||||
|---|---|---|---|
|
||||
| ZAR | South Africa | ZA | R99 flat (placeholder) |
|
||||
| MYR | Malaysia | MY | Live EasyParcel courier rates |
|
||||
| USD | United States | US | Free |
|
||||
|
||||
The choice lives in `localStorage` for guests and in the customer's
|
||||
`public_metadata` when signed in; the profile wins.
|
||||
|
||||
⚠️ **Spree fixes a cart's currency AND market at creation and neither can change
|
||||
afterwards.** That is why the anchor exists at all, and why switching currency
|
||||
*rebuilds* the cart and re-adds every line rather than converting it.
|
||||
|
||||
🔴 This model has a known hole — one person with addresses in several countries.
|
||||
Written up in [`docs/napkin-storefront.md`](docs/napkin-storefront.md) §2.
|
||||
|
||||
### Cart belongs to the customer
|
||||
|
||||
A guest cart lives in `localStorage`. On sign-in it is **claimed**
|
||||
(`PATCH /carts/:id/associate`) and from then on the cart is fetched from the
|
||||
server, so it follows the customer to any device.
|
||||
|
||||
### EasyParcel shipping is real, and verified server-side
|
||||
|
||||
Malaysian addresses get live courier quotes — 20 couriers, real prices. The
|
||||
chosen one becomes the actual charge through a custom calculator
|
||||
(`backend/config/initializers/easyparcel_calculator.rb`).
|
||||
|
||||
🔴 The price is confirmed against a fresh quote inside
|
||||
`/api/shipping/select` before it can become a cost, and `/api/cart` **rejects**
|
||||
any body carrying `metadata`. Both guards exist because cart metadata is
|
||||
client-writable — without them a customer could set their own shipping to one
|
||||
sen. Do not loosen either.
|
||||
|
||||
### Image Studio
|
||||
|
||||
Upload phone photos in the admin → Photoroom (background removal, lighting,
|
||||
crop) → review before/after → attach to a product or create a new listing.
|
||||
Nothing touches the catalogue until Apply.
|
||||
|
||||
---
|
||||
|
||||
## Traps that bite
|
||||
|
||||
Full list in [`docs/pak-jabit-manifest.md`](docs/pak-jabit-manifest.md) and
|
||||
[`LANDMINE.md`](LANDMINE.md). The pattern worth internalising:
|
||||
|
||||
> **Spree's store API answers `200` and silently saves nothing** for fields it
|
||||
> does not permit.
|
||||
|
||||
That cost three separate debugging rounds in one night — address `label`,
|
||||
customer `public_metadata`, and product `price`. If a field vanishes after a
|
||||
successful write, it was never permitted; the fix is a decorator in
|
||||
`backend/config/initializers/`.
|
||||
|
||||
Others:
|
||||
|
||||
- **A shipping rate only applies when its calculator's currency matches the
|
||||
order's.** A mismatch produces no rate, no fulfillment, and a checkout with
|
||||
nothing to charge — and no error anywhere.
|
||||
- **`POST /admin/products` drops a scalar `price`.** It needs
|
||||
`prices: [{amount, currency}]`. A product with no price row in the store
|
||||
currency is invisible on the storefront.
|
||||
- **Product visibility is per-channel** via `Spree::ProductPublication`, not
|
||||
`status`. No publication row → the join drops it, however active and priced.
|
||||
- **New assets default to position 1** — they silently become the thumbnail.
|
||||
- **`require` in a Rails initializer dies under Zeitwerk.** Define classes inside
|
||||
`config.to_prepare` instead. It took the container down once.
|
||||
- **`metadata` in the cart API maps to `private_metadata`**, not `public_metadata`.
|
||||
- **`curl -o` loses binary bodies on the backend box** — 200, correct
|
||||
`content-length`, zero-byte file. Use Python for binary downloads.
|
||||
|
||||
---
|
||||
|
||||
## Where it stands
|
||||
|
||||
**Working:** catalogue, currencies, cart, addresses, checkout in all three
|
||||
markets, live MY shipping, saved cards (sandbox), Image Studio, nightly backups,
|
||||
fail2ban.
|
||||
|
||||
**Not done:** real payment gateway (Payfast, gated on NSH merchant
|
||||
registration), SA courier account (Bob Go), product variants — the S/M/L/XL
|
||||
buttons are decorative, and stock/restock has never been tested.
|
||||
|
||||
See `docs/napkin-*.md` for the open questions and ideas.
|
||||
@@ -0,0 +1,5 @@
|
||||
SECRET_KEY_BASE=0f8a1c6b4075f08553321d397557c0970e90f88eb12bfd971adb0e1d18050d771a194035fa69be0216f46e78e311cfc64d4520ac48e0f06eacba9351d9f8db13
|
||||
RAILS_HOST=api.jux-lab.com
|
||||
SPREE_PORT=3000
|
||||
# #mock — sandbox key — every output is watermarked. Live key is in /root/.photoroom/credentials on the house box (10/month allowance).
|
||||
PHOTOROOM_API_KEY=sandbox_sk_pr_default_e10d721df37e9884f1d638b44a140c3698410d67
|
||||
@@ -0,0 +1,336 @@
|
||||
require 'net/http'
|
||||
require 'securerandom'
|
||||
|
||||
module Spree
|
||||
module Admin
|
||||
# Image Studio — the seller's control surface for the Photoroom pipeline.
|
||||
#
|
||||
# The real flow this exists for: photograph stock on a phone, open this page,
|
||||
# upload the shots, see what they look like standardised, and post them to
|
||||
# the store — either onto an existing product or as a brand new listing.
|
||||
#
|
||||
# Three steps, deliberately:
|
||||
#
|
||||
# show choose a source (uploaded photos, or an existing product's image)
|
||||
# preview send each image to Photoroom, hold the results as UNATTACHED
|
||||
# ActiveStorage blobs, show them beside the originals
|
||||
# apply attach those blobs — to an existing product, or to one created here
|
||||
#
|
||||
# Nothing touches the catalogue until `apply`. Rejecting a batch is just
|
||||
# navigating away: the blobs were never attached, so there is nothing to undo
|
||||
# and no rejects left behind in a product gallery.
|
||||
class ImageStudioController < BaseController
|
||||
PHOTOROOM_EDIT = 'https://image-api.photoroom.com/v2/edit'.freeze
|
||||
|
||||
# Each image is a round trip of roughly 3-5 seconds, run one after another.
|
||||
# Past this many the request starts flirting with the web server's timeout,
|
||||
# so the cap is enforced rather than left to chance.
|
||||
MAX_PHOTOS = 8
|
||||
|
||||
BACKGROUNDS = {
|
||||
'White' => 'FFFFFF',
|
||||
'Studio grey' => 'F2F2F2',
|
||||
'Beige' => 'EDE6DA',
|
||||
'Black' => '0E0D0B'
|
||||
}.freeze
|
||||
|
||||
SIZES = ['1000x1000', '1500x1500', '2000x2000'].freeze
|
||||
|
||||
def show
|
||||
load_form
|
||||
end
|
||||
|
||||
def preview
|
||||
load_form
|
||||
|
||||
key = photoroom_key
|
||||
if key.blank?
|
||||
# Loud, not a guess. A missing key must never quietly become a no-op
|
||||
# that leaves the seller believing the photos were processed.
|
||||
flash.now[:error] = 'PHOTOROOM_API_KEY is not set on the server. ' \
|
||||
'Add it to .env and run `docker compose up -d`.'
|
||||
return render :show, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
sources = collect_sources
|
||||
if sources.empty?
|
||||
flash.now[:error] = 'Choose some photos to upload, or pick a product to reprocess.'
|
||||
return render :show, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
size = params[:size].presence_in(SIZES) || SIZES.first
|
||||
background = BACKGROUNDS.values.include?(params[:background]) ? params[:background] : 'FFFFFF'
|
||||
|
||||
@results = []
|
||||
sources.each do |src|
|
||||
begin
|
||||
png = photoroom_edit(src[:bytes], src[:filename], key, size, background)
|
||||
rescue StandardError => e
|
||||
@results << { filename: src[:filename], error: e.message }
|
||||
next
|
||||
end
|
||||
|
||||
after_blob = ActiveStorage::Blob.create_and_upload!(
|
||||
io: StringIO.new(png),
|
||||
filename: "#{File.basename(src[:filename], '.*')}-photoroom.png",
|
||||
content_type: 'image/png'
|
||||
)
|
||||
|
||||
@results << {
|
||||
filename: src[:filename],
|
||||
before_url: blob_path(src[:before_blob]),
|
||||
after_url: blob_path(after_blob),
|
||||
signed_id: after_blob.signed_id,
|
||||
before_bytes: src[:bytes].bytesize,
|
||||
after_bytes: png.bytesize
|
||||
}
|
||||
end
|
||||
|
||||
@processed = @results.count { |r| r[:error].blank? }
|
||||
@failed = @results.count { |r| r[:error].present? }
|
||||
@size = size
|
||||
|
||||
if @processed.zero?
|
||||
flash.now[:error] = 'Every image failed. Nothing to apply.'
|
||||
return render :show, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
render :show
|
||||
end
|
||||
|
||||
def apply
|
||||
signed_ids = Array(params[:signed_ids]).reject(&:blank?)
|
||||
if signed_ids.empty?
|
||||
return redirect_to(spree.admin_image_studio_path, alert: 'Nothing to apply.')
|
||||
end
|
||||
|
||||
product =
|
||||
if params[:destination] == 'new'
|
||||
build_new_product
|
||||
else
|
||||
current_store.products.find_by!(slug: params[:target_slug])
|
||||
end
|
||||
|
||||
return redirect_to(spree.admin_image_studio_path, alert: @create_error) if @create_error
|
||||
|
||||
make_primary = params[:make_primary] == '1'
|
||||
position = make_primary ? 1 : next_position(product)
|
||||
attached = 0
|
||||
|
||||
signed_ids.each_with_index do |signed_id, i|
|
||||
asset = product.media.build(position: position + i)
|
||||
asset.type = 'Spree::Image'
|
||||
asset.attachment.attach(signed_id)
|
||||
attached += 1 if asset.save
|
||||
end
|
||||
|
||||
if attached.zero?
|
||||
return redirect_to(spree.admin_image_studio_path, alert: 'No images could be attached.')
|
||||
end
|
||||
|
||||
notice = "#{attached} image#{'s' if attached != 1} added to #{product.name}"
|
||||
notice += ' — created as a draft, publish it once the details are filled in' if @created_draft
|
||||
if @visibility_warning
|
||||
return redirect_to spree.admin_product_path(product),
|
||||
alert: "#{notice}, #{@visibility_warning}."
|
||||
end
|
||||
redirect_to spree.admin_product_path(product), notice: notice
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_form
|
||||
@breadcrumb_icon = 'photo'
|
||||
add_breadcrumb 'Image Studio', spree.admin_image_studio_path
|
||||
@products = current_store.products.where(deleted_at: nil).order(:name)
|
||||
@backgrounds = BACKGROUNDS
|
||||
@sizes = SIZES
|
||||
@currency = current_store.default_currency
|
||||
@max_photos = MAX_PHOTOS
|
||||
end
|
||||
|
||||
# Two ways in: files straight off the seller's device, or an image already
|
||||
# on a product (useful for re-cutting an old listing).
|
||||
def collect_sources
|
||||
uploads = Array(params[:photos]).reject(&:blank?).first(MAX_PHOTOS)
|
||||
|
||||
if uploads.any?
|
||||
return uploads.map do |file|
|
||||
bytes = file.read
|
||||
blob = ActiveStorage::Blob.create_and_upload!(
|
||||
io: StringIO.new(bytes),
|
||||
filename: file.original_filename,
|
||||
content_type: file.content_type.presence || 'image/jpeg'
|
||||
)
|
||||
{ bytes: bytes, filename: file.original_filename, before_blob: blob }
|
||||
end
|
||||
end
|
||||
|
||||
return [] if params[:slug].blank?
|
||||
|
||||
product = current_store.products.find_by(slug: params[:slug])
|
||||
asset = product && primary_asset(product)
|
||||
return [] if asset.nil?
|
||||
|
||||
@product = product
|
||||
[{
|
||||
bytes: asset.attachment.download,
|
||||
filename: asset.attachment.filename.to_s,
|
||||
before_blob: asset.attachment.blob
|
||||
}]
|
||||
end
|
||||
|
||||
def build_new_product
|
||||
name = params[:new_name].to_s.strip
|
||||
if name.blank?
|
||||
@create_error = 'A new product needs a name.'
|
||||
return nil
|
||||
end
|
||||
|
||||
price = params[:new_price].to_s.strip
|
||||
if price.blank? || price.to_f <= 0
|
||||
# A product with no price row in the store currency is excluded from
|
||||
# the storefront with no error anywhere. Refuse rather than create one.
|
||||
@create_error = "A new product needs a price in #{current_store.default_currency}."
|
||||
return nil
|
||||
end
|
||||
|
||||
publish = params[:publish] == '1'
|
||||
@created_draft = !publish
|
||||
|
||||
# A product belongs to ONE store in Spree 5.6 (`belongs_to :store`).
|
||||
# `product.stores` is a Relation, not a collection to push onto —
|
||||
# `stores << store` raises NoMethodError.
|
||||
product = Spree::Product.new(
|
||||
name: name,
|
||||
store: current_store,
|
||||
shipping_category_id: Spree::ShippingCategory.first&.id,
|
||||
status: publish ? 'active' : 'draft',
|
||||
# beginning_of_minute, not Time.current: the storefront scope compares
|
||||
# against cutoff.beginning_of_minute, which truncates DOWN, so a
|
||||
# timestamp from seconds ago reads as the future and the product stays
|
||||
# invisible until the minute ticks over.
|
||||
available_on: publish ? Time.current.beginning_of_minute : nil
|
||||
)
|
||||
product.save!
|
||||
|
||||
# Set the price explicitly against the STORE currency. Spree's generic
|
||||
# price setter uses the global currency preference, which is not the same
|
||||
# thing — that mismatch is what makes products invisible on the storefront.
|
||||
product.master.prices
|
||||
.find_or_initialize_by(currency: current_store.default_currency)
|
||||
.update!(amount: price.to_f)
|
||||
|
||||
# Stock, or the storefront will never show it. `available` requires the
|
||||
# product to be purchasable, and a master with no stock item is not:
|
||||
# in_stock? false -> purchasable? false -> excluded from the storefront
|
||||
# with no error anywhere. Matching how the existing catalogue is set up:
|
||||
# inventory untracked (on_hand Infinity) with a stock item per location.
|
||||
master = product.master
|
||||
master.update!(track_inventory: false)
|
||||
Spree::StockLocation.where(active: true).find_each do |location|
|
||||
master.stock_items.find_or_create_by!(stock_location_id: location.id)
|
||||
end
|
||||
|
||||
# Publish to the channel. Visibility is per-channel via
|
||||
# Spree::ProductPublication, NOT the product's `status` alone — a product
|
||||
# with no publication row is dropped by the storefront's join, no matter
|
||||
# how active and priced and stocked it is.
|
||||
if publish
|
||||
channel = Spree::Current.channel ||
|
||||
Spree::Channel.find_by(name: 'Online Store') ||
|
||||
Spree::Channel.order(:id).first
|
||||
if channel
|
||||
Spree::ProductPublication
|
||||
.find_or_create_by!(product_id: product.id, channel_id: channel.id) do |pub|
|
||||
pub.published_at = Time.current.beginning_of_minute
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Check rather than assume. This is the exact failure that hid 130
|
||||
# products for an hour: every write returned success and the storefront
|
||||
# showed nothing.
|
||||
unless current_store.products
|
||||
.available(Time.current, current_store.default_currency)
|
||||
.where(id: product.id).exists?
|
||||
# A warning, not an error: the product exists now, so bailing out here
|
||||
# would leave it behind with no images at all. Attach them, then say so.
|
||||
@visibility_warning = 'but it is NOT visible on the storefront yet — ' \
|
||||
'check its price, stock and status'
|
||||
end
|
||||
|
||||
product
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
@create_error = "Could not create product: #{e.record.errors.full_messages.to_sentence}"
|
||||
nil
|
||||
end
|
||||
|
||||
# A product's gallery is the UNION of product-level `media` and any legacy
|
||||
# assets pinned to the master variant, so the next free slot has to clear
|
||||
# both. Taking max(:position) from `media` alone put an appended image at
|
||||
# position 2, in the middle of the gallery, instead of at the end.
|
||||
def next_position(product)
|
||||
product_max = product.media.maximum(:position).to_i
|
||||
master_max = product.master&.images&.maximum(:position).to_i || 0
|
||||
[product_max, master_max].max + 1
|
||||
end
|
||||
|
||||
def primary_asset(product)
|
||||
product.media.order(:position).first ||
|
||||
product.master&.images&.order(:position)&.first
|
||||
end
|
||||
|
||||
# url_for on an attachment resolves to attachment_url, which does not exist
|
||||
# on a Spree engine controller. Go through the ActiveStorage helpers.
|
||||
def blob_path(blob_or_attachment)
|
||||
Rails.application.routes.url_helpers.rails_blob_path(blob_or_attachment, only_path: true)
|
||||
end
|
||||
|
||||
def photoroom_key
|
||||
ENV['PHOTOROOM_API_KEY']
|
||||
end
|
||||
|
||||
def photoroom_edit(image_bytes, filename, key, size, background)
|
||||
boundary = SecureRandom.hex(16)
|
||||
fields = {
|
||||
'background.color' => background,
|
||||
'outputSize' => size,
|
||||
'padding' => '0.05'
|
||||
}
|
||||
|
||||
body = +''
|
||||
fields.each do |k, v|
|
||||
body << "--#{boundary}\r\nContent-Disposition: form-data; name=\"#{k}\"\r\n\r\n#{v}\r\n"
|
||||
end
|
||||
body << "--#{boundary}\r\nContent-Disposition: form-data; " \
|
||||
"name=\"imageFile\"; filename=\"#{filename}\"\r\n" \
|
||||
"Content-Type: image/jpeg\r\n\r\n"
|
||||
body = body.b + image_bytes.b + "\r\n--#{boundary}--\r\n".b
|
||||
|
||||
uri = URI(PHOTOROOM_EDIT)
|
||||
http = Net::HTTP.new(uri.host, uri.port)
|
||||
http.use_ssl = true
|
||||
http.read_timeout = 180
|
||||
|
||||
req = Net::HTTP::Post.new(uri)
|
||||
req['x-api-key'] = key
|
||||
req['Content-Type'] = "multipart/form-data; boundary=#{boundary}"
|
||||
req.body = body
|
||||
|
||||
res = http.request(req)
|
||||
|
||||
raise "HTTP #{res.code}: #{res.body.to_s[0, 200]}" unless res.is_a?(Net::HTTPSuccess)
|
||||
|
||||
# A 200 is not proof. Photoroom answers 200 with an empty body on some
|
||||
# failures, and an empty attachment saves cleanly and serves nothing.
|
||||
raise 'returned 200 with an empty body' if res.body.nil? || res.body.empty?
|
||||
unless res['content-type'].to_s.start_with?('image/')
|
||||
raise "returned #{res['content-type']} instead of an image"
|
||||
end
|
||||
|
||||
res.body
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,233 @@
|
||||
<% content_for :page_title do %>
|
||||
Image Studio
|
||||
<% end %>
|
||||
|
||||
<div class="container-fluid">
|
||||
<p class="text-muted">
|
||||
Upload photos straight off your phone or camera — background, framing and size
|
||||
are standardised, then posted to the store.
|
||||
Nothing is saved until you press <strong>Apply</strong>.
|
||||
</p>
|
||||
|
||||
<%= form_with url: spree.admin_preview_image_studio_path, method: :post,
|
||||
local: true, multipart: true do |f| %>
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title mb-3">1 · Photos</h5>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="photos">Upload from this device</label>
|
||||
<%= file_field_tag 'photos[]', multiple: true, accept: 'image/*',
|
||||
id: 'photos', class: 'form-control' %>
|
||||
<div class="form-text">
|
||||
Pick several at once. On a phone this opens the camera roll.
|
||||
Up to <%= @max_photos %> per batch — each takes a few seconds.
|
||||
</div>
|
||||
|
||||
<!-- Choosing a file does nothing on its own; the button is what runs
|
||||
it. Without this line people pick a photo and wait for something
|
||||
to happen. -->
|
||||
<div id="picked" class="alert alert-info mt-3 mb-0 py-2" style="display:none;">
|
||||
<span id="picked_text"></span>
|
||||
<button type="submit" class="btn btn-primary btn-sm ms-3">
|
||||
Process now →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details>
|
||||
<summary class="text-muted small" style="cursor: pointer;">
|
||||
…or reprocess an image already on a product
|
||||
</summary>
|
||||
<div class="mt-3">
|
||||
<select name="slug" class="form-control custom-select">
|
||||
<option value="">— none —</option>
|
||||
<% @products.each do |product| %>
|
||||
<option value="<%= product.slug %>" <%= 'selected' if @product&.slug == product.slug %>>
|
||||
<%= product.name %>
|
||||
</option>
|
||||
<% end %>
|
||||
</select>
|
||||
<div class="form-text">Ignored when you upload files above.</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title mb-3">2 · Look</h5>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="background">Background</label>
|
||||
<select name="background" id="background" class="form-control custom-select">
|
||||
<% @backgrounds.each do |label, hex| %>
|
||||
<option value="<%= hex %>" <%= 'selected' if params[:background] == hex %>>
|
||||
<%= label %>
|
||||
</option>
|
||||
<% end %>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="size">Size</label>
|
||||
<select name="size" id="size" class="form-control custom-select">
|
||||
<% @sizes.each do |size| %>
|
||||
<option value="<%= size %>" <%= 'selected' if params[:size] == size %>>
|
||||
<%= size %>
|
||||
</option>
|
||||
<% end %>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4 d-flex align-items-end">
|
||||
<%= submit_tag 'Process photos', class: 'btn btn-primary w-100',
|
||||
data: { disable_with: 'Processing… this can take a minute' } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if @results.present? %>
|
||||
<div class="card mb-4" id="review">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title mb-1">3 · Review</h5>
|
||||
<p class="text-muted small">
|
||||
<%= @processed %> processed<%= ", #{@failed} failed" if @failed.to_i.positive? %>
|
||||
· <%= @size %>
|
||||
</p>
|
||||
|
||||
<% @results.each_with_index do |r, i| %>
|
||||
<div class="row align-items-start py-3 <%= 'border-top' if i.positive? %>">
|
||||
<div class="col-12 mb-2">
|
||||
<strong class="small"><%= r[:filename] %></strong>
|
||||
</div>
|
||||
|
||||
<% if r[:error].present? %>
|
||||
<div class="col-12">
|
||||
<div class="alert alert-danger mb-0 py-2 small">
|
||||
Failed: <%= r[:error] %>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="col-6">
|
||||
<div class="border rounded p-2 text-center" style="background:#f8f9fa;">
|
||||
<%= image_tag r[:before_url], style: 'max-width:100%; height:auto;' %>
|
||||
</div>
|
||||
<p class="text-muted small mt-1 mb-0">
|
||||
Before · <%= number_to_human_size(r[:before_bytes]) %>
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="border rounded p-2 text-center" style="background:#f8f9fa;">
|
||||
<%= image_tag r[:after_url], style: 'max-width:100%; height:auto;' %>
|
||||
</div>
|
||||
<p class="text-muted small mt-1 mb-0">
|
||||
After · <%= number_to_human_size(r[:after_bytes]) %>
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= form_with url: spree.admin_apply_image_studio_path, method: :post, local: true do |f| %>
|
||||
<% @results.each do |r| %>
|
||||
<% next if r[:error].present? %>
|
||||
<%= hidden_field_tag 'signed_ids[]', r[:signed_id], id: nil %>
|
||||
<% end %>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title mb-3">4 · Post to store</h5>
|
||||
|
||||
<div class="form-check mb-2">
|
||||
<%= radio_button_tag :destination, 'existing', true, class: 'form-check-input', id: 'dest_existing' %>
|
||||
<label class="form-check-label" for="dest_existing">Add to an existing product</label>
|
||||
</div>
|
||||
<div class="mb-3 ps-4">
|
||||
<select name="target_slug" class="form-control custom-select">
|
||||
<% @products.each do |product| %>
|
||||
<option value="<%= product.slug %>" <%= 'selected' if @product&.slug == product.slug %>>
|
||||
<%= product.name %>
|
||||
</option>
|
||||
<% end %>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-2">
|
||||
<%= radio_button_tag :destination, 'new', false, class: 'form-check-input', id: 'dest_new' %>
|
||||
<label class="form-check-label" for="dest_new">Create a new product</label>
|
||||
</div>
|
||||
<div class="row g-2 ps-4 mb-3">
|
||||
<div class="col-md-6">
|
||||
<%= text_field_tag :new_name, params[:new_name], class: 'form-control',
|
||||
placeholder: 'Product name' %>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<%= text_field_tag :new_price, params[:new_price], class: 'form-control',
|
||||
placeholder: "Price (#{@currency})" %>
|
||||
</div>
|
||||
<div class="col-md-3 d-flex align-items-center">
|
||||
<div class="form-check mb-0">
|
||||
<%= check_box_tag :publish, '1', false, class: 'form-check-input', id: 'publish' %>
|
||||
<label class="form-check-label small" for="publish">Publish now</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="form-text">
|
||||
Left unpublished it is created as a draft, so you can add the
|
||||
description and details before customers see it.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="form-check mb-3">
|
||||
<%= check_box_tag :make_primary, '1', true, class: 'form-check-input', id: 'make_primary' %>
|
||||
<label class="form-check-label" for="make_primary">
|
||||
Use the first image as the product's main photo
|
||||
</label>
|
||||
<div class="form-text">
|
||||
Unchecked, they are added to the end of the gallery and the current
|
||||
main photo is left alone.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= submit_tag 'Apply to store', class: 'btn btn-primary' %>
|
||||
<%= link_to 'Discard', spree.admin_image_studio_path, class: 'btn btn-outline-secondary ms-2' %>
|
||||
|
||||
<p class="text-muted small mt-3 mb-0">
|
||||
Discarding leaves the catalogue untouched — nothing was attached.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var input = document.getElementById('photos');
|
||||
var box = document.getElementById('picked');
|
||||
var text = document.getElementById('picked_text');
|
||||
|
||||
if (input && box && text) {
|
||||
input.addEventListener('change', function () {
|
||||
var n = input.files.length;
|
||||
if (!n) { box.style.display = 'none'; return; }
|
||||
var mb = 0;
|
||||
for (var i = 0; i < n; i++) { mb += input.files[i].size; }
|
||||
mb = (mb / 1048576).toFixed(1);
|
||||
text.textContent = n + (n === 1 ? ' photo' : ' photos') + ' ready (' + mb + ' MB).';
|
||||
box.style.display = '';
|
||||
});
|
||||
}
|
||||
|
||||
// The results render below the fold, so a successful run looks like
|
||||
// nothing happened until you scroll. Land on them instead.
|
||||
var review = document.getElementById('review');
|
||||
if (review) { review.scrollIntoView({ block: 'start' }); }
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,33 @@
|
||||
# Customer address labels — "Home", "Office", "Mom's place".
|
||||
#
|
||||
# Spree::Address already HAS a `label` column, and the ADMIN serializer already
|
||||
# exposes it. The STORE API does neither: AddressesController#permitted_params
|
||||
# omits :label, so a POST carrying one answers 201 and saves nil — no error, no
|
||||
# warning, the field just vanishes. Spree::Addresses::Create passes params
|
||||
# straight to Address.new, so permitting it is enough to persist it.
|
||||
#
|
||||
# prepend, not class_eval: permitted_params is defined directly on that
|
||||
# controller, so redefining it in place would send `super` past the original to
|
||||
# ResourceController and silently drop every other permitted field.
|
||||
#
|
||||
# Bind-mounted — the prebuilt ghcr.io/spree/spree image mounts no app code, so
|
||||
# this file needs its own entry in docker-compose.yml's web volumes block.
|
||||
|
||||
module PakJabit
|
||||
module CustomerAddressLabel
|
||||
def permitted_params
|
||||
super.merge(params.permit(:label))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Rails.application.config.to_prepare do
|
||||
unless Spree::Api::V3::Store::Customer::AddressesController.include?(PakJabit::CustomerAddressLabel)
|
||||
Spree::Api::V3::Store::Customer::AddressesController.prepend(PakJabit::CustomerAddressLabel)
|
||||
end
|
||||
|
||||
Spree::Api::V3::AddressSerializer.class_eval do
|
||||
typelize label: [:string, nullable: true]
|
||||
attributes :label
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1 @@
|
||||
Spree::Api::Config[:max_request_body_size] = 10_485_760
|
||||
@@ -0,0 +1,38 @@
|
||||
# Customer region preference — the anchor for currency, prices and shipping.
|
||||
#
|
||||
# A cart's currency and market are fixed by Spree at CREATION and cannot be
|
||||
# changed afterwards, so the choice has to exist before the cart does. Holding it
|
||||
# on the customer is what makes that possible: sign in, and every cart from then
|
||||
# on is created in the right market without asking again.
|
||||
#
|
||||
# Stock Spree's store API neither permits nor serializes public_metadata — a
|
||||
# PATCH carrying it answers 200 and saves nothing, the same silent drop that
|
||||
# hides a bad address label. This adds both.
|
||||
#
|
||||
# prepend, not class_eval: permitted_params is defined directly on that
|
||||
# controller, so redefining it in place would send `super` past the original and
|
||||
# drop every other permitted field.
|
||||
#
|
||||
# Bind-mounted — the prebuilt ghcr.io/spree/spree image mounts no app code, so
|
||||
# this file needs its own entry in docker-compose.yml's web volumes block.
|
||||
|
||||
module PakJabit
|
||||
module CustomerPublicMetadata
|
||||
def permitted_params
|
||||
super.merge(params.permit(public_metadata: {}))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Rails.application.config.to_prepare do
|
||||
unless Spree::Api::V3::Store::CustomersController.include?(PakJabit::CustomerPublicMetadata)
|
||||
Spree::Api::V3::Store::CustomersController.prepend(PakJabit::CustomerPublicMetadata)
|
||||
end
|
||||
|
||||
Spree::Api::V3::CustomerSerializer.class_eval do
|
||||
typelize public_metadata: 'Record<string, unknown>'
|
||||
attribute :public_metadata do |user|
|
||||
user.public_metadata || {}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,58 @@
|
||||
# EasyParcel live courier rates as a real Spree shipping cost.
|
||||
#
|
||||
# The storefront has always shown live EasyParcel quotes — 20 couriers for a
|
||||
# Melaka address, with logos and durations. What was missing is that the chosen
|
||||
# one never reached Spree, so the customer picked "City-Link RM6.79" and the
|
||||
# order was charged whatever the flat-rate method said. The quote screen looked
|
||||
# decorative because of that last missing step, and I removed it on that reading.
|
||||
# This restores it by making the selection the thing Spree actually charges.
|
||||
#
|
||||
# The amount is read from order.private_metadata['easyparcel_amount'], written
|
||||
# ONLY by the storefront's /api/shipping/select route, which re-fetches the live
|
||||
# quotes server-side and confirms the chosen service_id really costs that. The
|
||||
# browser can PATCH cart metadata directly, so an unverified amount here would
|
||||
# let a customer set their own shipping to one sen.
|
||||
#
|
||||
# No quote chosen => nil => no shipping rate => no fulfillment => checkout cannot
|
||||
# complete. That is deliberate: a Malaysian order must carry a real courier.
|
||||
#
|
||||
# The class is defined INSIDE to_prepare: at initializer load time Spree is not
|
||||
# autoloaded yet, and `require 'spree/shipping_calculator'` raises LoadError
|
||||
# under Zeitwerk — that took the container down once.
|
||||
#
|
||||
# Bind-mounted — the prebuilt image mounts no app code. See docker-compose.yml.
|
||||
|
||||
Rails.application.config.to_prepare do
|
||||
unless defined?(Spree::Calculator::Shipping::EasyParcelQuote)
|
||||
Spree::Calculator::Shipping.const_set(
|
||||
:EasyParcelQuote,
|
||||
Class.new(Spree::ShippingCalculator) do
|
||||
preference :currency, :string, default: 'MYR'
|
||||
|
||||
def self.description
|
||||
'EasyParcel live courier quote'
|
||||
end
|
||||
|
||||
def compute_package(package)
|
||||
order = package.respond_to?(:order) ? package.order : nil
|
||||
return nil if order.nil?
|
||||
|
||||
meta = order.private_metadata || {}
|
||||
raw = meta['easyparcel_amount'] || meta[:easyparcel_amount]
|
||||
return nil if raw.blank?
|
||||
|
||||
BigDecimal(raw.to_s)
|
||||
rescue ArgumentError, TypeError
|
||||
# A malformed amount must not become a free shipment.
|
||||
nil
|
||||
end
|
||||
end
|
||||
)
|
||||
end
|
||||
|
||||
# Registering only populates the admin's calculator dropdown; the method below
|
||||
# gets the class assigned directly, so a nil list here must not abort boot.
|
||||
klass = Spree::Calculator::Shipping::EasyParcelQuote
|
||||
list = Rails.application.config.spree.calculators&.shipping_methods
|
||||
list << klass if list.respond_to?(:include?) && !list.include?(klass)
|
||||
end
|
||||
@@ -0,0 +1,17 @@
|
||||
# Image Studio — custom admin page for the Photoroom pipeline.
|
||||
#
|
||||
# Delivered as bind-mounted files rather than a rebuilt image: this deployment
|
||||
# runs the prebuilt ghcr.io/spree/spree image with no app code mounted, so any
|
||||
# custom file needs an explicit mount in docker-compose.yml. See the web
|
||||
# service's volumes block.
|
||||
|
||||
# Routes live in config/routes.rb — see the note there.
|
||||
|
||||
Rails.application.config.after_initialize do
|
||||
Spree.admin.navigation.sidebar.add :image_studio,
|
||||
label: 'Image Studio',
|
||||
url: :admin_image_studio_path,
|
||||
icon: 'photo',
|
||||
position: 35,
|
||||
active: -> { controller_name == 'image_studio' }
|
||||
end
|
||||
@@ -0,0 +1,49 @@
|
||||
|
||||
Rails.application.routes.draw do
|
||||
Spree::Core::Engine.add_routes do
|
||||
# Image Studio - custom admin page for the Photoroom pipeline.
|
||||
# Must live here, not in an initializer: Spree draws engine routes before
|
||||
# config/initializers run, so add_routes from an initializer is a silent
|
||||
# no-op (the sidebar entry registers, the path helper never exists).
|
||||
namespace :admin do
|
||||
get 'image_studio', to: 'image_studio#show', as: :image_studio
|
||||
post 'image_studio/preview', to: 'image_studio#preview', as: :preview_image_studio
|
||||
post 'image_studio/apply', to: 'image_studio#apply', as: :apply_image_studio
|
||||
end
|
||||
|
||||
# Admin authentication
|
||||
devise_for(
|
||||
Spree.admin_user_class.model_name.singular_route_key,
|
||||
class_name: Spree.admin_user_class.to_s,
|
||||
controllers: {
|
||||
sessions: 'spree/admin/user_sessions',
|
||||
passwords: 'spree/admin/user_passwords'
|
||||
},
|
||||
skip: :registrations,
|
||||
path: :admin_user,
|
||||
router_name: :spree
|
||||
)
|
||||
end
|
||||
# This line mounts Spree's routes at the root of your application.
|
||||
# This means, any requests to URLs such as /products, will go to
|
||||
# Spree::ProductsController.
|
||||
# If you would like to change where this engine is mounted, simply change the
|
||||
# :at option to something different.
|
||||
#
|
||||
# We ask that you don't use the :as option here, as Spree relies on it being
|
||||
# the default of "spree".
|
||||
mount Spree::Core::Engine, at: '/'
|
||||
devise_for :admin_users, class_name: "Spree::AdminUser"
|
||||
devise_for :users, class_name: "Spree::User"
|
||||
|
||||
# Job dashboard (Mission Control) — inspect, retry, and discard Solid Queue
|
||||
# jobs at http://localhost:3000/jobs. Guarded by its own HTTP Basic auth
|
||||
# (see config/application.rb), independent of app sessions.
|
||||
mount MissionControl::Jobs::Engine, at: "/jobs"
|
||||
|
||||
# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
|
||||
# Can be used by load balancers and uptime monitors to verify that the app is live.
|
||||
get "up" => "rails/health#show", as: :rails_health_check
|
||||
|
||||
root to: redirect('/admin')
|
||||
end
|
||||
@@ -0,0 +1,106 @@
|
||||
# Quick-start: docker compose up
|
||||
# Uses prebuilt Spree image — no local build needed.
|
||||
# To customize the backend, run: docker compose -f docker-compose.dev.yml up
|
||||
x-app: &app
|
||||
image: ghcr.io/spree/spree:${SPREE_VERSION_TAG:-latest}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
# Uncomment when enabling the optional meilisearch service below.
|
||||
# meilisearch:
|
||||
# condition: service_healthy
|
||||
mailpit:
|
||||
condition: service_started
|
||||
env_file: .env
|
||||
environment:
|
||||
DATABASE_URL: postgres://postgres@postgres:5432/spree_production
|
||||
SECRET_KEY_BASE: ${SECRET_KEY_BASE}
|
||||
RAILS_FORCE_SSL: ${RAILS_FORCE_SSL:-false}
|
||||
RAILS_ASSUME_SSL: ${RAILS_ASSUME_SSL:-false}
|
||||
# Public host for generated URLs (image/attachment URLs in API responses,
|
||||
# email links). Override in .env when serving from a real domain or IP.
|
||||
RAILS_HOST: ${RAILS_HOST:-localhost:${SPREE_PORT:-3000}}
|
||||
# Product search runs on the database by default. To switch to Meilisearch,
|
||||
# uncomment this together with the meilisearch depends_on/service/volume
|
||||
# blocks, then reindex: docker compose exec web bin/rails spree:search:reindex
|
||||
# MEILISEARCH_URL: http://meilisearch:7700
|
||||
# All outgoing mail is captured by Mailpit — read it at http://localhost:8025.
|
||||
# Set SMTP_HOST (and friends) in .env to deliver through a real provider instead.
|
||||
SMTP_HOST: ${SMTP_HOST:-mailpit}
|
||||
SMTP_PORT: ${SMTP_PORT:-1025}
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18-alpine
|
||||
environment:
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
PGDATA: /var/lib/postgresql/18/docker
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql
|
||||
healthcheck:
|
||||
test: pg_isready -U postgres
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# Optional Meilisearch search provider (typo tolerance, instant facets).
|
||||
# Uncomment together with the meilisearch lines under x-app's depends_on and
|
||||
# environment, and the meilisearch_data volume at the bottom.
|
||||
# meilisearch:
|
||||
# image: getmeili/meilisearch:latest
|
||||
# volumes:
|
||||
# - meilisearch_data:/meili_data
|
||||
# ports:
|
||||
# # Loopback-only: the app reaches Meilisearch over the compose network;
|
||||
# # the host port is for debugging and must not be LAN-reachable (Docker's
|
||||
# # NAT rules bypass host firewalls on Linux, and no API key is set).
|
||||
# - "127.0.0.1:${SPREE_MEILISEARCH_PORT:-7700}:7700"
|
||||
# healthcheck:
|
||||
# test: curl -f http://localhost:7700/health || exit 1
|
||||
# interval: 5s
|
||||
# timeout: 5s
|
||||
# retries: 5
|
||||
|
||||
mailpit:
|
||||
image: axllent/mailpit:latest
|
||||
ports:
|
||||
# Loopback-only, matching meilisearch: reachable from the host browser
|
||||
# but not the LAN (Docker's NAT rules bypass host firewalls on Linux).
|
||||
# 8025 = web UI — open http://localhost:8025 to read captured mail.
|
||||
# 1025 = SMTP endpoint the app delivers to (in-cluster as mailpit:1025).
|
||||
- "127.0.0.1:${MAILPIT_UI_PORT:-8025}:8025"
|
||||
- "127.0.0.1:${MAILPIT_SMTP_PORT:-1025}:1025"
|
||||
environment:
|
||||
MP_MAX_MESSAGES: "500"
|
||||
MP_SMTP_AUTH_ACCEPT_ANY: "true"
|
||||
MP_SMTP_AUTH_ALLOW_INSECURE: "true"
|
||||
|
||||
# One app container: Puma serves the web AND runs Solid Queue's job
|
||||
# supervisor in-process (SOLID_QUEUE_IN_PUMA, on by default). To split job
|
||||
# processing out, add a service from the same image with
|
||||
# `command: bin/jobs` and set SOLID_QUEUE_IN_PUMA: "false" here.
|
||||
web:
|
||||
<<: *app
|
||||
ports:
|
||||
- "${SPREE_PORT:-3000}:3000"
|
||||
volumes:
|
||||
- storage_data:/rails/storage
|
||||
- ./config/initializers/api_body_limit.rb:/rails/config/initializers/api_body_limit.rb:ro
|
||||
- ./config/initializers/image_studio.rb:/rails/config/initializers/image_studio.rb:ro
|
||||
- ./config/initializers/address_label.rb:/rails/config/initializers/address_label.rb:ro
|
||||
- ./config/initializers/customer_preferences.rb:/rails/config/initializers/customer_preferences.rb:ro
|
||||
- ./config/initializers/easyparcel_calculator.rb:/rails/config/initializers/easyparcel_calculator.rb:ro
|
||||
- ./config/routes.rb:/rails/config/routes.rb:ro
|
||||
- ./app/controllers/spree:/rails/app/controllers/spree:ro
|
||||
- ./app/views/spree:/rails/app/views/spree:ro
|
||||
healthcheck:
|
||||
test: curl -f http://localhost:3000/up || exit 1
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
# meilisearch_data:
|
||||
storage_data:
|
||||
@@ -0,0 +1,6 @@
|
||||
# Spree ADMIN API key — full write access to products, prices and orders.
|
||||
# The scripts here read it from the environment and refuse to run without it;
|
||||
# it used to be a literal in six files, which is why this exists.
|
||||
#
|
||||
# export SPREE_ADMIN_KEY=$(grep SPREE_ADMIN_KEY catalogue/.env | cut -d= -f2)
|
||||
SPREE_ADMIN_KEY=sk_ntDFxFE1KwiDwqGzpCBGTJ8N
|
||||
Executable
+79
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env bash
|
||||
# Pull a full Pak Jabit backend backup to the house.
|
||||
# - pg_dump (custom format) of spree_production
|
||||
# - ActiveStorage volume, rsync'd mirror (~208MB, only deltas after the first run)
|
||||
# - .env (SECRET_KEY_BASE lives here — without it every signed image URL and
|
||||
# the admin API token die on restore)
|
||||
#
|
||||
# Usage: backup-pak-jabit.sh # normal run
|
||||
# backup-pak-jabit.sh --verify # also list the dump's table counts
|
||||
set -euo pipefail
|
||||
|
||||
HOST=ijat@35.247.170.3
|
||||
KEY=/root/.ssh/google_compute_engine
|
||||
SSH=(ssh -i "$KEY" -o ConnectTimeout=15 -o BatchMode=yes)
|
||||
DEST=/root/Backups/pak-jabit
|
||||
KEEP=14 # dated dumps to retain
|
||||
STAMP=$(date +%Y%m%d-%H%M)
|
||||
|
||||
STORAGE_REMOTE=/var/lib/docker/volumes/pak-jabit_storage_data/_data/
|
||||
mkdir -p "$DEST"/{db,storage}
|
||||
chmod 700 "$DEST"
|
||||
|
||||
log() { printf '%s %s\n' "$(date +%H:%M:%S)" "$*"; }
|
||||
|
||||
# --- 1. database -------------------------------------------------------------
|
||||
log "pg_dump → db/spree-$STAMP.dump"
|
||||
"${SSH[@]}" "$HOST" \
|
||||
'docker exec pak-jabit-postgres-1 pg_dump -U postgres -Fc spree_production' \
|
||||
> "$DEST/db/spree-$STAMP.dump.part"
|
||||
mv "$DEST/db/spree-$STAMP.dump.part" "$DEST/db/spree-$STAMP.dump"
|
||||
|
||||
# A truncated dump is the classic silent-success backup. Refuse to keep one.
|
||||
if ! pg_restore -l "$DEST/db/spree-$STAMP.dump" >/dev/null 2>&1; then
|
||||
rm -f "$DEST/db/spree-$STAMP.dump"
|
||||
echo "FATAL: dump failed pg_restore -l — not a usable archive, deleted." >&2
|
||||
exit 1
|
||||
fi
|
||||
log " ok $(du -h "$DEST/db/spree-$STAMP.dump" | cut -f1)"
|
||||
|
||||
# --- 2. ActiveStorage --------------------------------------------------------
|
||||
log "rsync storage/"
|
||||
rsync -a --delete --info=stats2 \
|
||||
-e "ssh -i $KEY -o BatchMode=yes" \
|
||||
--rsync-path='sudo rsync' \
|
||||
"$HOST:$STORAGE_REMOTE" "$DEST/storage/" | tail -n 6
|
||||
|
||||
# --- 3. secrets & compose ----------------------------------------------------
|
||||
log "env + compose"
|
||||
"${SSH[@]}" "$HOST" 'cat ~/pak-jabit/.env' > "$DEST/env-$STAMP"
|
||||
"${SSH[@]}" "$HOST" 'cat ~/pak-jabit/docker-compose.yml' > "$DEST/docker-compose.yml"
|
||||
chmod 600 "$DEST"/env-*
|
||||
ln -sfn "env-$STAMP" "$DEST/env.latest"
|
||||
|
||||
# --- 3b. custom app code -----------------------------------------------------
|
||||
# The Spree image is prebuilt and mounts no app code, so every customisation
|
||||
# (Image Studio, the API body-limit initializer, the patched routes.rb) exists
|
||||
# ONLY as bind-mounted files on this VM. The database dump does not contain them
|
||||
# and a rebuilt box would come back without them.
|
||||
log "custom code"
|
||||
rsync -a --delete \
|
||||
-e "ssh -i $KEY -o BatchMode=yes" \
|
||||
--include='config/' --include='config/routes.rb' \
|
||||
--include='config/initializers/***' \
|
||||
--include='app/' --include='app/controllers/***' --include='app/views/***' \
|
||||
--exclude='*' \
|
||||
"$HOST:pak-jabit/" "$DEST/custom/"
|
||||
find "$DEST/custom" -type f | sed "s|$DEST/custom/| |"
|
||||
|
||||
# --- 4. rotate ---------------------------------------------------------------
|
||||
ls -1t "$DEST"/db/spree-*.dump 2>/dev/null | tail -n +$((KEEP + 1)) | xargs -r rm -v
|
||||
ls -1t "$DEST"/env-* 2>/dev/null | tail -n +$((KEEP + 1)) | xargs -r rm -v
|
||||
|
||||
if [[ ${1:-} == --verify ]]; then
|
||||
log "dump contents:"
|
||||
pg_restore -l "$DEST/db/spree-$STAMP.dump" | grep -c 'TABLE DATA' \
|
||||
| xargs printf ' %s tables with data\n'
|
||||
fi
|
||||
|
||||
log "done → $DEST ($(du -sh "$DEST" | cut -f1) total)"
|
||||
@@ -0,0 +1,236 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Pak Jabit Store — Bulk Product Import via Spree Admin API v3
|
||||
|
||||
Creates products, uploads images, publishes to Online Store channel,
|
||||
and sets stock — all through the official Admin API.
|
||||
|
||||
Usage:
|
||||
python3 bulk_import.py [--dry-run] [--start N] [--limit N]
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import requests
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
import os as _os
|
||||
|
||||
|
||||
def _spree_admin_key() -> str:
|
||||
"""The Spree admin API key. Required — never defaulted."""
|
||||
key = _os.environ.get("SPREE_ADMIN_KEY", "").strip()
|
||||
if not key:
|
||||
raise SystemExit(
|
||||
"SPREE_ADMIN_KEY is not set.\n"
|
||||
" export SPREE_ADMIN_KEY=<the admin API key>\n"
|
||||
"It is deliberately not stored in this file — see README.md."
|
||||
)
|
||||
return key
|
||||
|
||||
|
||||
# --- Config ---
|
||||
API_BASE = "https://api.jux-lab.com/api/v3/admin"
|
||||
API_KEY = _spree_admin_key()
|
||||
HEADERS = {"X-Spree-Api-Key": API_KEY, "Content-Type": "application/json"}
|
||||
CHANNEL_ID = "ch_UkLWZg9DAJ" # Online Store
|
||||
MYR_TO_USD = 4.8
|
||||
PUBLISHED_AT = "2026-09-15T00:00:00Z"
|
||||
|
||||
CATALOGUE_DIR = Path("/root/Opus/Projects/PeakyBuildr/scraped-catalogue")
|
||||
PRODUCTS_DIR = CATALOGUE_DIR / "products"
|
||||
BATCH_FILES = ["products-batch2.jsonl", "products-batch3.jsonl"]
|
||||
|
||||
LOG_FILE = CATALOGUE_DIR / "import_log.jsonl"
|
||||
|
||||
|
||||
def api(method, path, **kwargs):
|
||||
url = f"{API_BASE}{path}"
|
||||
headers = dict(HEADERS)
|
||||
if "files" in kwargs:
|
||||
headers.pop("Content-Type", None)
|
||||
r = getattr(requests, method)(url, headers=headers, timeout=60, **kwargs)
|
||||
try:
|
||||
return r.json()
|
||||
except Exception:
|
||||
return {"error": {"code": r.status_code, "message": r.text[:200]}}
|
||||
|
||||
|
||||
def find_product_folder(code, name):
|
||||
"""Match JSONL entry to local image folder."""
|
||||
for folder in sorted(PRODUCTS_DIR.iterdir()):
|
||||
info_file = folder / "info.json"
|
||||
if info_file.exists():
|
||||
info = json.loads(info_file.read_text())
|
||||
if info.get("source_code") == code:
|
||||
return folder
|
||||
return None
|
||||
|
||||
|
||||
def create_product(name, brand, price_myr, slug=None):
|
||||
price = round(float(price_myr) * MYR_TO_USD, 2)
|
||||
desc = f"{brand} - {name}"
|
||||
# A scalar "price" is silently dropped; Spree wants a prices array, and a
|
||||
# product with no price row is excluded from the storefront's
|
||||
# available(currency) scope no matter how it is published.
|
||||
payload = {
|
||||
"name": name,
|
||||
"description": desc,
|
||||
"prices": [{"amount": price, "currency": "USD"}],
|
||||
"status": "active",
|
||||
"available_on": "2026-09-15T00:00:00Z",
|
||||
}
|
||||
if slug:
|
||||
payload["slug"] = slug
|
||||
return api("post", "/products", json=payload)
|
||||
|
||||
|
||||
def upload_image(product_id, variant_id, image_path):
|
||||
with open(image_path, "rb") as f:
|
||||
return api("post",
|
||||
f"/products/{product_id}/variants/{variant_id}/media",
|
||||
files={"attachment": (image_path.name, f, "image/jpeg")})
|
||||
|
||||
|
||||
def publish_to_channel(product_id):
|
||||
# Without published_at the publication row exists but is not live.
|
||||
return api("post",
|
||||
f"/channels/{CHANNEL_ID}/add_products",
|
||||
json={"product_ids": [product_id], "published_at": PUBLISHED_AT})
|
||||
|
||||
|
||||
def make_purchasable(product_id, variant_id):
|
||||
return api("patch", f"/products/{product_id}/variants/{variant_id}",
|
||||
json={"track_inventory": False})
|
||||
|
||||
|
||||
def log_result(entry):
|
||||
with open(LOG_FILE, "a") as f:
|
||||
f.write(json.dumps(entry) + "\n")
|
||||
|
||||
|
||||
def get_existing_slugs():
|
||||
slugs = set()
|
||||
page = 1
|
||||
while True:
|
||||
data = api("get", f"/products?per_page=100&page={page}")
|
||||
products = data.get("data", [])
|
||||
for p in products:
|
||||
slugs.add(p.get("slug", ""))
|
||||
meta = data.get("meta", {})
|
||||
if page >= meta.get("pages", 1):
|
||||
break
|
||||
page += 1
|
||||
return slugs
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument("--start", type=int, default=0)
|
||||
parser.add_argument("--limit", type=int, default=999)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Load all products from batch files
|
||||
products = []
|
||||
for batch_file in BATCH_FILES:
|
||||
path = CATALOGUE_DIR / batch_file
|
||||
if path.exists():
|
||||
with open(path) as f:
|
||||
for line in f:
|
||||
products.append(json.loads(line.strip()))
|
||||
|
||||
print(f"Loaded {len(products)} products from {len(BATCH_FILES)} batch files")
|
||||
|
||||
# Get existing to skip duplicates
|
||||
print("Fetching existing products...")
|
||||
existing = get_existing_slugs()
|
||||
print(f" {len(existing)} products already in store")
|
||||
|
||||
# Process
|
||||
imported = 0
|
||||
skipped = 0
|
||||
failed = 0
|
||||
products_slice = products[args.start:args.start + args.limit]
|
||||
|
||||
for i, prod in enumerate(products_slice):
|
||||
idx = args.start + i
|
||||
name = prod["name"]
|
||||
brand = prod["brand"]
|
||||
code = prod["code"]
|
||||
price = prod["price"]
|
||||
|
||||
folder = find_product_folder(code, name)
|
||||
if not folder:
|
||||
print(f" [{idx+1}] SKIP (no folder): {brand} - {name}")
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# Check slug collision
|
||||
info = json.loads((folder / "info.json").read_text())
|
||||
slug = folder.name
|
||||
|
||||
if slug in existing:
|
||||
print(f" [{idx+1}] SKIP (exists): {slug}")
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
if args.dry_run:
|
||||
images = list(folder.glob("image-*.jpg"))
|
||||
print(f" [{idx+1}] DRY: {brand} - {name} ({len(images)} imgs, ${float(price)*MYR_TO_USD:.2f})")
|
||||
continue
|
||||
|
||||
# 1. Create product
|
||||
result = create_product(name, brand, price, slug)
|
||||
if "error" in result:
|
||||
print(f" [{idx+1}] FAIL create: {result['error']}")
|
||||
log_result({"code": code, "action": "create", "error": result["error"]})
|
||||
failed += 1
|
||||
continue
|
||||
|
||||
product_id = result["id"]
|
||||
variant_id = result.get("default_variant_id", "")
|
||||
print(f" [{idx+1}] Created: {product_id} — {brand} - {name}")
|
||||
|
||||
# 2. Upload images
|
||||
images = sorted(folder.glob("image-*.jpg"))
|
||||
img_ok = 0
|
||||
for img in images:
|
||||
r = upload_image(product_id, variant_id, img)
|
||||
if "error" not in r:
|
||||
img_ok += 1
|
||||
else:
|
||||
print(f" WARN image {img.name}: {r['error']}")
|
||||
time.sleep(0.3)
|
||||
print(f" Images: {img_ok}/{len(images)}")
|
||||
|
||||
# 3. Publish to Online Store
|
||||
pub = publish_to_channel(product_id)
|
||||
if "error" in pub:
|
||||
print(f" WARN publish: {pub['error']}")
|
||||
|
||||
# 4. Make purchasable
|
||||
stk = make_purchasable(product_id, variant_id)
|
||||
if "error" in stk:
|
||||
print(f" WARN stock: {stk['error']}")
|
||||
|
||||
log_result({
|
||||
"code": code, "product_id": product_id, "slug": slug,
|
||||
"brand": brand, "name": name, "images": img_ok,
|
||||
"price_usd": round(float(price) * MYR_TO_USD, 2)
|
||||
})
|
||||
|
||||
imported += 1
|
||||
existing.add(slug)
|
||||
time.sleep(0.5)
|
||||
|
||||
print(f"\nDone! Imported: {imported}, Skipped: {skipped}, Failed: {failed}")
|
||||
if not args.dry_run and imported > 0:
|
||||
print(f"Log: {LOG_FILE}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,33 @@
|
||||
import json, os, re, urllib.request, time
|
||||
|
||||
base = os.path.dirname(os.path.abspath(__file__))
|
||||
out_root = os.path.join(base, "products")
|
||||
os.makedirs(out_root, exist_ok=True)
|
||||
|
||||
def slugify(name):
|
||||
s = re.sub(r'[^a-zA-Z0-9]+', '-', name).strip('-').lower()
|
||||
return s[:60]
|
||||
|
||||
catalogue = []
|
||||
with open(os.path.join(base, "products.jsonl")) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
catalogue.append(json.loads(line))
|
||||
|
||||
for i, p in enumerate(catalogue, 1):
|
||||
dirname = f"{i:02d}-{slugify(p['brand'])}-{slugify(p['name'])}"
|
||||
pdir = os.path.join(out_root, dirname)
|
||||
os.makedirs(pdir, exist_ok=True)
|
||||
with open(os.path.join(pdir, "info.json"), "w") as f:
|
||||
json.dump({"name": p["name"], "brand": p["brand"], "price_myr": p["price"], "source_code": p["code"]}, f, indent=2)
|
||||
for j, url in enumerate(p["imgs"], 1):
|
||||
dest = os.path.join(pdir, f"image-{j}.jpg")
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
||||
with urllib.request.urlopen(req, timeout=15) as r, open(dest, "wb") as out:
|
||||
out.write(r.read())
|
||||
except Exception as e:
|
||||
print(f"FAIL {url}: {e}")
|
||||
print(f"[{i}/{len(catalogue)}] {p['brand']} - {p['name']} -> {len(p['imgs'])} imgs")
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Download images for batch2 + batch3 products."""
|
||||
import json, os, re, urllib.request, time, sys
|
||||
|
||||
base = os.path.dirname(os.path.abspath(__file__))
|
||||
out_root = os.path.join(base, "products")
|
||||
os.makedirs(out_root, exist_ok=True)
|
||||
|
||||
def slugify(name):
|
||||
s = re.sub(r'[^a-zA-Z0-9]+', '-', name).strip('-').lower()
|
||||
return s[:60]
|
||||
|
||||
def load_jsonl(path):
|
||||
items = []
|
||||
with open(path) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line:
|
||||
items.append(json.loads(line))
|
||||
return items
|
||||
|
||||
existing = load_jsonl(os.path.join(base, "products.jsonl"))
|
||||
offset = len(existing)
|
||||
|
||||
batches = []
|
||||
for fname in ["products-batch2.jsonl", "products-batch3.jsonl"]:
|
||||
path = os.path.join(base, fname)
|
||||
if os.path.exists(path):
|
||||
batches.extend(load_jsonl(path))
|
||||
|
||||
print(f"Existing: {offset} products, new: {len(batches)} to download")
|
||||
|
||||
ok, fail = 0, 0
|
||||
for i, p in enumerate(batches, 1):
|
||||
idx = offset + i
|
||||
dirname = f"{idx:03d}-{slugify(p['brand'])}-{slugify(p['name'])}"
|
||||
pdir = os.path.join(out_root, dirname)
|
||||
os.makedirs(pdir, exist_ok=True)
|
||||
|
||||
info = {"name": p["name"], "brand": p["brand"], "price_myr": p["price"], "source_code": p["code"]}
|
||||
if "category" in p:
|
||||
info["category"] = p["category"]
|
||||
with open(os.path.join(pdir, "info.json"), "w") as f:
|
||||
json.dump(info, f, indent=2)
|
||||
|
||||
img_ok = 0
|
||||
for j, url in enumerate(p["imgs"], 1):
|
||||
dest = os.path.join(pdir, f"image-{j}.jpg")
|
||||
if os.path.exists(dest) and os.path.getsize(dest) > 1000:
|
||||
img_ok += 1
|
||||
continue
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36"})
|
||||
with urllib.request.urlopen(req, timeout=15) as r, open(dest, "wb") as out:
|
||||
out.write(r.read())
|
||||
img_ok += 1
|
||||
except Exception as e:
|
||||
print(f" FAIL {url}: {e}")
|
||||
fail += 1
|
||||
time.sleep(0.2)
|
||||
|
||||
ok += img_ok
|
||||
print(f"[{i}/{len(batches)}] {p['brand']} - {p['name'][:40]} -> {img_ok}/{len(p['imgs'])} imgs")
|
||||
|
||||
print(f"\nDone: {ok} images downloaded, {fail} failed")
|
||||
@@ -0,0 +1,57 @@
|
||||
import json, time, requests
|
||||
from pathlib import Path
|
||||
|
||||
import os as _os
|
||||
|
||||
|
||||
def _spree_admin_key() -> str:
|
||||
"""The Spree admin API key. Required — never defaulted."""
|
||||
key = _os.environ.get("SPREE_ADMIN_KEY", "").strip()
|
||||
if not key:
|
||||
raise SystemExit(
|
||||
"SPREE_ADMIN_KEY is not set.\n"
|
||||
" export SPREE_ADMIN_KEY=<the admin API key>\n"
|
||||
"It is deliberately not stored in this file — see README.md."
|
||||
)
|
||||
return key
|
||||
|
||||
|
||||
API = "https://api.jux-lab.com/api/v3/admin"
|
||||
H = {"X-Spree-Api-Key": _spree_admin_key(), "Content-Type": "application/json"}
|
||||
PRODUCTS = Path("/root/Opus/Projects/PeakyBuildr/scraped-catalogue/products")
|
||||
MYR_TO_USD = 4.8
|
||||
|
||||
prices = {}
|
||||
for folder in PRODUCTS.iterdir():
|
||||
info = folder / "info.json"
|
||||
if info.exists():
|
||||
prices[folder.name] = round(float(json.loads(info.read_text())["price_myr"]) * MYR_TO_USD, 2)
|
||||
|
||||
items, page = [], 1
|
||||
while True:
|
||||
d = requests.get(f"{API}/products?per_page=100&page={page}", headers=H, timeout=60).json()
|
||||
items += d.get("data", [])
|
||||
if page >= d.get("meta", {}).get("pages", 1):
|
||||
break
|
||||
page += 1
|
||||
|
||||
print(f"{len(items)} products, {len(prices)} source prices")
|
||||
ok = fail = 0
|
||||
for i, p in enumerate(items):
|
||||
amt = prices.get(p["slug"])
|
||||
if amt is None:
|
||||
print(f" no source price: {p['slug'][:45]}")
|
||||
fail += 1
|
||||
continue
|
||||
r = requests.patch(f"{API}/products/{p['id']}", headers=H,
|
||||
json={"prices": [{"amount": amt, "currency": "USD"}]}, timeout=60).json()
|
||||
if "error" in r:
|
||||
fail += 1
|
||||
print(f" FAIL {p['slug'][:40]}: {r['error'].get('message')}")
|
||||
else:
|
||||
ok += 1
|
||||
if (i + 1) % 40 == 0:
|
||||
print(f" {i+1}/{len(items)}")
|
||||
time.sleep(0.12)
|
||||
|
||||
print(f"Done ok={ok} fail={fail}")
|
||||
@@ -0,0 +1,48 @@
|
||||
import json, sys, time, requests
|
||||
|
||||
import os as _os
|
||||
|
||||
|
||||
def _spree_admin_key() -> str:
|
||||
"""The Spree admin API key. Required — never defaulted."""
|
||||
key = _os.environ.get("SPREE_ADMIN_KEY", "").strip()
|
||||
if not key:
|
||||
raise SystemExit(
|
||||
"SPREE_ADMIN_KEY is not set.\n"
|
||||
" export SPREE_ADMIN_KEY=<the admin API key>\n"
|
||||
"It is deliberately not stored in this file — see README.md."
|
||||
)
|
||||
return key
|
||||
|
||||
|
||||
API = "https://api.jux-lab.com/api/v3/admin"
|
||||
KEY = _spree_admin_key()
|
||||
H = {"X-Spree-Api-Key": KEY, "Content-Type": "application/json"}
|
||||
|
||||
products, page = [], 1
|
||||
while True:
|
||||
d = requests.get(f"{API}/products?per_page=100&page={page}", headers=H, timeout=60).json()
|
||||
products += d.get("data", [])
|
||||
if page >= d.get("meta", {}).get("pages", 1):
|
||||
break
|
||||
page += 1
|
||||
|
||||
print(f"{len(products)} products")
|
||||
ok = fail = 0
|
||||
for i, p in enumerate(products):
|
||||
vid = p.get("default_variant_id")
|
||||
if not vid:
|
||||
fail += 1
|
||||
continue
|
||||
r = requests.patch(f"{API}/products/{p['id']}/variants/{vid}", headers=H,
|
||||
json={"count_on_hand": 100, "track_inventory": False}, timeout=60).json()
|
||||
if "error" in r:
|
||||
fail += 1
|
||||
print(f" FAIL {p['slug'][:40]}: {r['error'].get('message')}")
|
||||
else:
|
||||
ok += 1
|
||||
if (i + 1) % 25 == 0:
|
||||
print(f" {i+1}/{len(products)}")
|
||||
time.sleep(0.15)
|
||||
|
||||
print(f"Done. ok={ok} fail={fail}")
|
||||
@@ -0,0 +1,164 @@
|
||||
{"code": "hm-3377-7607064", "product_id": "prod_rsClYtECFI", "slug": "031-h-m-oversized-fit-scuba-zip-through-hoodie", "brand": "H&M", "name": "Oversized Fit Scuba zip-through hoodie", "images": 5, "price_usd": 719.52}
|
||||
{"code": "urban-revivo-3653-8941654", "product_id": "prod_8DR0I7fEbw", "slug": "032-urban-revivo-casual-crew-neck-short-sleeve-tee", "brand": "Urban Revivo", "name": "Casual Crew Neck Short Sleeve Tee", "images": 6, "price_usd": 571.2}
|
||||
{"code": "urban-revivo-4852-8181854", "product_id": "prod_aAtNvLSUAr", "slug": "033-urban-revivo-short-sleeve-shirts", "brand": "Urban Revivo", "name": "Short Sleeve Shirts", "images": 7, "price_usd": 557.28}
|
||||
{"code": "nike-4039-6920264", "product_id": "prod_QiA48cIW7A", "slug": "034-nike-training-t-shirt", "brand": "Nike", "name": "Training T-Shirt", "images": 1, "price_usd": 706.8}
|
||||
{"code": "hm-8811-9083444", "product_id": "prod_QDA48cIW7A", "slug": "035-h-m-regular-fit-track-pants-with-drymove", "brand": "H&M", "name": "Regular Fit Track pants with DryMove\u2122", "images": 8, "price_usd": 719.52}
|
||||
{"code": "trendyol-6397-0057634", "product_id": "prod_8cR0I7fEbw", "slug": "036-trendyol-beige-oversize-wide-cut-long-sleeve-animal-embroidered-sweat", "brand": "Trendyol", "name": "Beige Oversize/Wide Cut Long Sleeve Animal Embroidered Sweatshirt TMNAW24SW00164", "images": 5, "price_usd": 269.04}
|
||||
{"code": "trendyol-9722-5541154", "product_id": "prod_oqlrKdXjaP", "slug": "037-trendyol-horse-embroidered-sweatshirt", "brand": "Trendyol", "name": "Horse Embroidered Sweatshirt", "images": 1, "price_usd": 264.96}
|
||||
{"code": "hm-1602-9539354", "product_id": "prod_48Olu6p4zZ", "slug": "038-h-m-relaxed-fit-resort-shirt", "brand": "H&M", "name": "Relaxed Fit Resort shirt", "images": 6, "price_usd": 479.52}
|
||||
{"code": "trendyol-6524-2362464", "product_id": "prod_qp31JYFduR", "slug": "039-trendyol-anthracite-001-relaxed-mock-neck-plain-sweater-tmnaw26kz0001", "brand": "Trendyol", "name": "Anthracite*001 Relaxed Mock Neck Plain Sweater Tmnaw26Kz00013", "images": 5, "price_usd": 405.6}
|
||||
{"code": "giordano-7623-8198654", "product_id": "prod_JgaEBgznCp", "slug": "040-giordano-men-s-cotton-short-sleeve-slim-tee-01026250", "brand": "Giordano", "name": "Men's Cotton Short Sleeve Slim Tee 01026250", "images": 2, "price_usd": 178.42}
|
||||
{"code": "hm-4373-6317064", "product_id": "prod_tXyJwvFCuK", "slug": "041-h-m-ultra-baggy-jeans", "brand": "H&M", "name": "Ultra Baggy Jeans", "images": 5, "price_usd": 719.52}
|
||||
{"code": "hm-0024-5243544", "product_id": "prod_NJzXvxTk8u", "slug": "042-h-m-relaxed-jeans", "brand": "H&M", "name": "Relaxed Jeans", "images": 4, "price_usd": 623.52}
|
||||
{"code": "ysocool-brand-7905-5999931", "product_id": "prod_XLbb5HTmVa", "slug": "043-ysocool-men-compression-body-shaper-vests-top", "brand": "YSoCool", "name": "Men Compression Body Shaper Vests Top", "images": 3, "price_usd": 223.44}
|
||||
{"code": "nike-8330-6036864", "product_id": "prod_k2KT9MVfb0", "slug": "044-nike-dri-fit-training-t-shirt", "brand": "Nike", "name": "Dri-FIT Training T-Shirt", "images": 1, "price_usd": 542.64}
|
||||
{"code": "hm-8820-5290854", "product_id": "prod_Z5MUPd7wVj", "slug": "045-h-m-loose-fit-t-shirt", "brand": "H&M", "name": "Loose Fit T-shirt", "images": 4, "price_usd": 239.52}
|
||||
{"code": "hm-6806-1878823", "product_id": "prod_STIszOMIvc", "slug": "046-h-m-slim-fit-t-shirt", "brand": "H&M", "name": "Slim Fit T-shirt", "images": 5, "price_usd": 119.52}
|
||||
{"code": "hm-6462-8250054", "product_id": "prod_LYpHOn63Vw", "slug": "047-h-m-relaxed-fit-textured-resort-shirt", "brand": "H&M", "name": "Relaxed Fit Textured resort shirt", "images": 4, "price_usd": 479.52}
|
||||
{"code": "trendyol-3134-8272734", "product_id": "prod_Kj5Q8YFGzd", "slug": "048-trendyol-black-regular-fit-100-viscose-loose-and-loose-summer-shirt-t", "brand": "Trendyol", "name": "Black Regular Fit 100% Viscose Loose and Loose Summer Shirt TMNSS25GO00048", "images": 1, "price_usd": 433.44}
|
||||
{"code": "altinyildiz-8185-0860924", "product_id": "prod_hDBvQ85Ws9", "slug": "049-altinyildiz-basic-slim-fit-t-shirt", "brand": "Altinyildiz", "name": "Basic Slim Fit T-Shirt", "images": 1, "price_usd": 250.37}
|
||||
{"code": "nike-0596-4602954", "product_id": "prod_IOvBlcSU2T", "slug": "050-nike-palm-tree-men-s-tee", "brand": "Nike", "name": "Palm Tree Men's Tee", "images": 1, "price_usd": 446.4}
|
||||
{"code": "lacoste-6939-3474274", "product_id": "prod_K95Q8YFGzd", "slug": "051-lacoste-monogram-jacquard-t-shirt", "brand": "Lacoste", "name": "Monogram Jacquard T-shirt", "images": 4, "price_usd": 2587.2}
|
||||
{"code": "hm-2842-2711854", "product_id": "prod_iIDfaj7G4u", "slug": "052-h-m-relaxed-fit-cotton-chinos", "brand": "H&M", "name": "Relaxed Fit Cotton chinos", "images": 2, "price_usd": 623.52}
|
||||
{"code": "urban-revivo-8524-3567374", "product_id": "prod_6AgxZbenF4", "slug": "053-urban-revivo-classic-solid-color-thick-shirt-in-viscose-blend", "brand": "Urban Revivo", "name": "Classic Solid Color Thick Shirt in Viscose Blend", "images": 8, "price_usd": 907.44}
|
||||
{"code": "hm-4144-4577954", "product_id": "prod_Aks1igzRC6", "slug": "054-h-m-relaxed-fit-oxford-shirt", "brand": "H&M", "name": "Relaxed Fit Oxford shirt", "images": 5, "price_usd": 623.52}
|
||||
{"code": "trendyol-7335-7898364", "product_id": "prod_lmiGAYKIUz", "slug": "055-trendyol-black-regular-normal-cut-hooded-textured-sweatshirt-tmnaw26s", "brand": "Trendyol", "name": "Black Regular/Normal Cut Hooded Textured Sweatshirt Tmnaw26Sw00068", "images": 3, "price_usd": 379.34}
|
||||
{"code": "trendyol-7769-9773614", "product_id": "prod_yar3EWPH85", "slug": "056-trendyol-striped-knit-polo-shirt", "brand": "Trendyol", "name": "Striped Knit Polo Shirt", "images": 4, "price_usd": 410.5}
|
||||
{"code": "hm-4087-3660754", "product_id": "prod_zNcwP3ZbDr", "slug": "057-h-m-regular-fit-hole-knit-sweater-vest", "brand": "H&M", "name": "Regular Fit Hole-knit sweater vest", "images": 6, "price_usd": 671.52}
|
||||
{"code": "hm-9694-6720944", "product_id": "prod_ccuasQi8rC", "slug": "058-h-m-easy-care-relaxed-fit-linen-blend-shorts", "brand": "H&M", "name": "Easy-care Relaxed Fit Linen-blend shorts", "images": 4, "price_usd": 719.52}
|
||||
{"code": "hm-9884-9573454", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}}
|
||||
{"code": "trendyol-1466-7949634", "product_id": "prod_bnnZn4Y5Fq", "slug": "060-trendyol-brown-oversize-wide-cut-buttoned-polo-collar-thick-polar-fle", "brand": "Trendyol", "name": "Brown Oversize/Wide Cut Buttoned Polo Collar Thick Polar Fleece Sweatshirt TMNAW24SW00107", "images": 1, "price_usd": 396.14}
|
||||
{"code": "ben-sherman-1585-5545954", "product_id": "prod_UkKLWZg9DA", "slug": "061-ben-sherman-short-sleeves-jacquard-polo-shirt", "brand": "Ben Sherman", "name": "Short Sleeves Jacquard Polo Shirt", "images": 1, "price_usd": 1500.24}
|
||||
{"code": "hm-9884-9573454", "product_id": "prod_UkpLWZg9DA", "slug": "059-h-m-oversized-fit-t-shirt", "brand": "H&M", "name": "Oversized Fit T-shirt", "images": 6, "price_usd": 335.52}
|
||||
{"code": "trendyol-1466-7949634", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}}
|
||||
{"code": "ben-sherman-1585-5545954", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}}
|
||||
{"code": "hm-3289-5938864", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}}
|
||||
{"code": "hm-3289-5938864", "product_id": "prod_gXZHJdmfrX", "slug": "062-h-m-relaxed-fit-sweatshirt", "brand": "H&M", "name": "Relaxed Fit Sweatshirt", "images": 2, "price_usd": 287.52}
|
||||
{"code": "hm-7368-5259764", "product_id": "prod_Ej9hxLZ9ck", "slug": "063-h-m-regular-fit-easy-iron-shirt", "brand": "H&M", "name": "Regular Fit Easy-iron shirt", "images": 2, "price_usd": 383.52}
|
||||
{"code": "hm-7368-5259764", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}}
|
||||
{"code": "hm-0914-3034864", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}}
|
||||
{"code": "hm-0914-3034864", "product_id": "prod_VZ3XmZF31w", "slug": "064-h-m-polo-shirt", "brand": "H&M", "name": "Polo shirt", "images": 2, "price_usd": 479.52}
|
||||
{"code": "adidas-0957-2958274", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}}
|
||||
{"code": "tommy-hilfiger-6431-9934964", "product_id": "prod_OtvJLhNcSb", "slug": "066-tommy-hilfiger-linear-logo-slim-crew-neck-t-shirt-tommy-jeans", "brand": "Tommy Hilfiger", "name": "Linear Logo Slim Crew Neck T-Shirt - Tommy Jeans", "images": 1, "price_usd": 725.04}
|
||||
{"code": "adidas-0957-2958274", "product_id": "prod_uEf2YK1rnl", "slug": "065-adidas-liverpool-fc-26-27-third-jersey", "brand": "ADIDAS", "name": "Liverpool Fc 26/27 Third Jersey", "images": 4, "price_usd": 1579.2}
|
||||
{"code": "tommy-hilfiger-6431-9934964", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}}
|
||||
{"code": "gap-2969-6099274", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}}
|
||||
{"code": "gap-2969-6099274", "product_id": "prod_ARbs1igzRC", "slug": "067-gap-logo-t-shirt", "brand": "GAP", "name": "Logo T-Shirt", "images": 1, "price_usd": 479.95}
|
||||
{"code": "gap-0710-4300374", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}}
|
||||
{"code": "gap-0710-4300374", "product_id": "prod_p5gklVeMgb", "slug": "068-gap-logo-t-shirt", "brand": "GAP", "name": "Logo T-Shirt", "images": 1, "price_usd": 479.95}
|
||||
{"code": "hm-0924-7104664", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}}
|
||||
{"code": "adidas-9961-7990474", "product_id": "prod_9URFqgRua4", "slug": "070-adidas-liverpool-fc-26-27-home-fan-jersey", "brand": "ADIDAS", "name": "Liverpool FC 26/27 Home Fan Jersey", "images": 1, "price_usd": 451.44}
|
||||
{"code": "gap-3366-0689274", "product_id": "prod_xiBoCpeNMW", "slug": "071-gap-pique-polo-shirt", "brand": "GAP", "name": "Pique Polo Shirt", "images": 1, "price_usd": 647.14}
|
||||
{"code": "hm-0924-7104664", "product_id": "prod_nWaqfPa3Cp", "slug": "069-h-m-loose-fit-interlock-t-shirt", "brand": "H&M", "name": "Loose Fit Interlock T-shirt", "images": 6, "price_usd": 239.52}
|
||||
{"code": "adidas-9961-7990474", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}}
|
||||
{"code": "gap-3366-0689274", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}}
|
||||
{"code": "adidas-6543-9526964", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}}
|
||||
{"code": "adidas-6543-9526964", "product_id": "prod_vohmzDoNU0", "slug": "072-adidas-mercedes-amg-petronas-formula-1-team-miami-tee", "brand": "ADIDAS", "name": "Mercedes - AMG Petronas Formula 1 Team Miami Tee", "images": 1, "price_usd": 679.44}
|
||||
{"code": "giordano-8229-4161474", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}}
|
||||
{"code": "giordano-8229-4161474", "product_id": "prod_2h5Y5XM6fr", "slug": "073-giordano-giordano-by-poly-pac-20-24-28-fury-unbreakable-pp-expandable", "brand": "Giordano", "name": "Giordano by Poly Pac 20'' 24\" 28'' Fury UNBREAKABLE PP Expandable Travel Luggage - GA2620 - Black", "images": 6, "price_usd": 3835.2}
|
||||
{"code": "adidas-9235-1679564", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}}
|
||||
{"code": "adidas-9235-1679564", "product_id": "prod_HbJVtlDrOm", "slug": "074-adidas-vl-court-base-shoes", "brand": "ADIDAS", "name": "VL Court Base Shoes", "images": 8, "price_usd": 745.92}
|
||||
{"code": "hm-3158-4937164", "product_id": "prod_eH46l8wVqS", "slug": "075-h-m-relaxed-fit-zip-top-rib-knit-jumper", "brand": "H&M", "name": "Relaxed Fit Zip-top rib-knit jumper", "images": 1, "price_usd": 719.52}
|
||||
{"code": "hm-3158-4937164", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}}
|
||||
{"code": "hm-5561-3429464", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}}
|
||||
{"code": "mango-man-6546-3612374", "product_id": "prod_GpMUcnUYI5", "slug": "077-mango-man-fine-knit-cotton-blend-sweater", "brand": "MANGO Man", "name": "Fine Knit Cotton-Blend Sweater", "images": 1, "price_usd": 959.52}
|
||||
{"code": "hm-5561-3429464", "product_id": "prod_se78jaGony", "slug": "076-h-m-loose-fit-scuba-zip-through-hoodie", "brand": "H&M", "name": "Loose Fit Scuba Zip-through hoodie", "images": 2, "price_usd": 719.52}
|
||||
{"code": "mango-man-6546-3612374", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}}
|
||||
{"code": "adidas-3065-8268274", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}}
|
||||
{"code": "adidas-3065-8268274", "product_id": "prod_RgJeNhrwyV", "slug": "078-adidas-trefoil-cushion-crew-socks-3-pairs", "brand": "ADIDAS", "name": "Trefoil Cushion Crew Socks 3 Pairs", "images": 1, "price_usd": 314.64}
|
||||
{"code": "mango-man-5487-0122374", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}}
|
||||
{"code": "mango-man-5487-0122374", "product_id": "prod_LKWpHOn63V", "slug": "079-mango-man-fine-knit-sweater-with-zip", "brand": "MANGO Man", "name": "Fine-Knit Sweater With Zip", "images": 1, "price_usd": 1055.52}
|
||||
{"code": "hm-6071-6565464", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}}
|
||||
{"code": "harley-davidson-6140-0807274", "product_id": "prod_CvFSrJgpzQ", "slug": "081-harley-davidson-h-d-tech-vented-short-sleeve-shirt", "brand": "Harley-Davidson", "name": "H-D\u00ae TECH Vented Short Sleeve Shirt", "images": 3, "price_usd": 2409.6}
|
||||
{"code": "hm-6071-6565464", "product_id": "prod_PcANMcnNFW", "slug": "080-h-m-10-pack-mid-trunks-with-lycra", "brand": "H&M", "name": "10-pack mid trunks with Lycra\u00ae", "images": 5, "price_usd": 959.52}
|
||||
{"code": "harley-davidson-6140-0807274", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}}
|
||||
{"code": "harley-davidson-6555-0017274", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}}
|
||||
{"code": "mango-man-6179-9132374", "product_id": "prod_5sJQZnCRf8", "slug": "083-mango-man-fine-knit-cotton-blend-sweater", "brand": "MANGO Man", "name": "Fine Knit Cotton-Blend Sweater", "images": 1, "price_usd": 959.52}
|
||||
{"code": "harley-davidson-6555-0017274", "product_id": "prod_YBVETxCGlb", "slug": "082-harley-davidson-harley-davidson-vintage-drop-h-ringer-tee", "brand": "Harley-Davidson", "name": "Harley-Davidson\u00ae Vintage Drop H Ringer Tee", "images": 3, "price_usd": 993.6}
|
||||
{"code": "mango-man-6179-9132374", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}}
|
||||
{"code": "hm-3272-3490754", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}}
|
||||
{"code": "hm-3272-3490754", "product_id": "prod_FLTGX8NaP3", "slug": "084-h-m-regular-jeans", "brand": "H&M", "name": "Regular Jeans", "images": 3, "price_usd": 479.52}
|
||||
{"code": "levi-s-8540-2089554", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}}
|
||||
{"code": "levi-s-8540-2089554", "product_id": "prod_kQJKT9MVfb", "slug": "085-levi-s-levi-s-men-s-505-regular-jeans-00505-3166", "brand": "Levi's", "name": "Levi's\u00ae Men's 505\u2122 Regular Jeans 00505-3166", "images": 5, "price_usd": 879.36}
|
||||
{"code": "volkswagen-4831-1155093", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}}
|
||||
{"code": "cotton-on-6837-1419164", "product_id": "prod_DzgPsx31Wa", "slug": "087-cotton-on-baggy-jeans", "brand": "Cotton On", "name": "Baggy Jeans", "images": 1, "price_usd": 822.86}
|
||||
{"code": "volkswagen-4831-1155093", "product_id": "prod_3T2WpPTcf5", "slug": "086-volkswagen-slim-long-jeans", "brand": "Volkswagen", "name": "Slim Long Jeans", "images": 4, "price_usd": 763.2}
|
||||
{"code": "cotton-on-6837-1419164", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}}
|
||||
{"code": "levi-s-4894-3133664", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}}
|
||||
{"code": "levi-s-4894-3133664", "product_id": "prod_ml8x3nOJap", "slug": "088-levi-s-levi-s-men-s-505-regular-jeans-00505-3276", "brand": "Levi's", "name": "Levi's\u00ae Men's 505\u2122 Regular Jeans 00505-3276", "images": 3, "price_usd": 879.36}
|
||||
{"code": "volkswagen-4840-0055093", "product_id": "prod_5sSQZnCRf8", "slug": "089-volkswagen-straight-long-jeans", "brand": "Volkswagen", "name": "Straight Long Jeans", "images": 2, "price_usd": 763.2}
|
||||
{"code": "volkswagen-4840-0055093", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}}
|
||||
{"code": "connor-3964-1987644", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}}
|
||||
{"code": "connor-3964-1987644", "product_id": "prod_M3YnyloHRU", "slug": "090-connor-toronto-straight-jean", "brand": "Connor", "name": "Toronto Straight Jean", "images": 3, "price_usd": 690.77}
|
||||
{"code": "levi-s-8200-6615651", "product_id": "prod_p5CklVeMgb", "slug": "091-levi-s-511-slim-fit-jeans-04511-1907", "brand": "Levi's", "name": "511 Slim Fit Jeans 04511-1907", "images": 2, "price_usd": 1071.36}
|
||||
{"code": "levi-s-8200-6615651", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}}
|
||||
{"code": "fidelio-1184-7387944", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}}
|
||||
{"code": "fidelio-1184-7387944", "product_id": "prod_sel8jaGony", "slug": "092-fidelio-601-slim-fit-washed-denim-jeans", "brand": "FIDELIO", "name": "601 Slim Fit Washed Denim Jeans", "images": 3, "price_usd": 523.2}
|
||||
{"code": "levi-s-9015-6656924", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}}
|
||||
{"code": "drum-3906-4664754", "product_id": "prod_86PR0I7fEb", "slug": "094-drum-drum-casual-skinny-to-slim-fit-jeans-dark-blue", "brand": "DRUM", "name": "DRUM Casual Skinny to Slim Fit Jeans- Dark Blue", "images": 2, "price_usd": 479.52}
|
||||
{"code": "connor-3962-2987644", "product_id": "prod_arstNvLSUA", "slug": "095-connor-arran-straight-jean", "brand": "Connor", "name": "Arran Straight Jean", "images": 5, "price_usd": 690.77}
|
||||
{"code": "trendyol-3864-8297064", "product_id": "prod_Q8DA48cIW7", "slug": "096-trendyol-essential-fit-jeans-denim-pants", "brand": "Trendyol", "name": "Essential Fit Jeans Denim Pants", "images": 1, "price_usd": 520.46}
|
||||
{"code": "electro-denim-lab-7932-5806954", "product_id": "prod_Q8PA48cIW7", "slug": "097-electro-denim-lab-dub-relaxed-fit-jeans", "brand": "Electro Denim Lab", "name": "Dub Relaxed Fit Jeans", "images": 5, "price_usd": 374.4}
|
||||
{"code": "drum-5753-5393434", "product_id": "prod_86uR0I7fEb", "slug": "098-drum-drum-digital-print-denim-straight-cut-jeans-brown", "brand": "DRUM", "name": "DRUM Digital Print Denim Straight Cut Jeans- Brown", "images": 4, "price_usd": 671.52}
|
||||
{"code": "trendyol-4181-4687064", "product_id": "prod_oD3lrKdXja", "slug": "099-trendyol-slim-fit-stretch-fabric-denim-pants-jeans", "brand": "Trendyol", "name": "Slim Fit Stretch Fabric Denim Pants Jeans", "images": 1, "price_usd": 702.24}
|
||||
{"code": "urban-revivo-4820-1281854", "product_id": "prod_4aROlu6p4z", "slug": "100-urban-revivo-denim-shorts", "brand": "Urban Revivo", "name": "Denim Shorts", "images": 6, "price_usd": 431.57}
|
||||
{"code": "hm-2256-5380854", "product_id": "prod_q0k31JYFdu", "slug": "101-h-m-2-pack-regular-fit-shorts", "brand": "H&M", "name": "2-pack Regular Fit Shorts", "images": 2, "price_usd": 479.52}
|
||||
{"code": "hm-5374-3237954", "product_id": "prod_JyHaEBgznC", "slug": "102-h-m-regular-fit-linen-blend-shorts", "brand": "H&M", "name": "Regular Fit Linen-blend shorts", "images": 2, "price_usd": 479.52}
|
||||
{"code": "cotton-on-4000-9099341", "product_id": "prod_tCbyJwvFCu", "slug": "103-cotton-on-easy-shorts", "brand": "Cotton On", "name": "Easy Shorts", "images": 1, "price_usd": 588.24}
|
||||
{"code": "connor-3932-5487644", "product_id": "prod_NuazXvxTk8", "slug": "104-connor-darwin-chino-stretch-short", "brand": "Connor", "name": "Darwin Chino Stretch Short", "images": 3, "price_usd": 561.17}
|
||||
{"code": "fidelio-4233-8849254", "product_id": "prod_XMpbb5HTmV", "slug": "105-fidelio-chino-super-shorts", "brand": "FIDELIO", "name": "Chino Super Shorts", "images": 5, "price_usd": 427.2}
|
||||
{"code": "cotton-on-5986-9744864", "product_id": "prod_kQYKT9MVfb", "slug": "106-cotton-on-easy-active-shorts", "brand": "Cotton On", "name": "Easy Active Shorts", "images": 1, "price_usd": 428.54}
|
||||
{"code": "cotton-on-6085-0724713", "product_id": "prod_ZqQMUPd7wV", "slug": "107-cotton-on-stretch-swim-shorts", "brand": "Cotton On", "name": "Stretch Swim Shorts", "images": 7, "price_usd": 588.24}
|
||||
{"code": "under-armour-0166-9009864", "product_id": "prod_Sm1IszOMIv", "slug": "108-under-armour-challenger-core-men-s-7-5-shorts", "brand": "Under Armour", "name": "Challenger Core Men's 7.5\" Shorts", "images": 1, "price_usd": 360.24}
|
||||
{"code": "cotton-on-1627-2412493", "product_id": "prod_LKEpHOn63V", "slug": "109-cotton-on-everyday-fleece-shorts", "brand": "Cotton On", "name": "Everyday Fleece Shorts", "images": 2, "price_usd": 433.44}
|
||||
{"code": "connor-3936-8387644", "product_id": "prod_KAw5Q8YFGz", "slug": "110-connor-jethro-cargo-short", "brand": "Connor", "name": "Jethro Cargo Short", "images": 1, "price_usd": 561.17}
|
||||
{"code": "nike-9182-7836064", "product_id": "prod_hwPBvQ85Ws", "slug": "111-nike-miler-dri-fit-5-brief-lined-running-shorts", "brand": "Nike", "name": "Miler Dri-FIT 5\" Brief-Lined Running Shorts", "images": 5, "price_usd": 615.6}
|
||||
{"code": "electro-denim-lab-8284-8755973", "product_id": "prod_IxJvBlcSU2", "slug": "112-electro-denim-lab-tailored-shorts", "brand": "Electro Denim Lab", "name": "Tailored Shorts", "images": 4, "price_usd": 345.6}
|
||||
{"code": "electro-denim-lab-5883-6755973", "product_id": "prod_KAF5Q8YFGz", "slug": "113-electro-denim-lab-tailored-shorts", "brand": "Electro Denim Lab", "name": "Tailored Shorts", "images": 3, "price_usd": 345.6}
|
||||
{"code": "under-armour-0103-4898864", "product_id": "prod_iNvDfaj7G4", "slug": "114-under-armour-vanish-woven-2-0-men-s-8-shorts", "brand": "Under Armour", "name": "Vanish Woven 2.0 Men's 8\" Shorts", "images": 2, "price_usd": 668.64}
|
||||
{"code": "under-armour-9965-8909864", "product_id": "prod_6VsgxZbenF", "slug": "115-under-armour-vanish-woven-2-0-men-s-6-shorts", "brand": "Under Armour", "name": "Vanish Woven 2.0 Men's 6\" Shorts", "images": 2, "price_usd": 668.64}
|
||||
{"code": "hm-4916-5208854", "product_id": "prod_ARKs1igzRC", "slug": "116-h-m-oversized-fit-printed-boxy-vest-top", "brand": "H&M", "name": "Oversized Fit Printed boxy vest top", "images": 2, "price_usd": 383.52}
|
||||
{"code": "hm-3300-2208854", "product_id": "prod_lPxiGAYKIU", "slug": "117-h-m-loose-fit-printed-vest-top", "brand": "H&M", "name": "Loose Fit Printed vest top", "images": 2, "price_usd": 143.52}
|
||||
{"code": "hm-0791-0454064", "product_id": "prod_yStr3EWPH8", "slug": "118-h-m-regular-fit-cotton-vest-top", "brand": "H&M", "name": "Regular Fit Cotton vest top", "images": 3, "price_usd": 239.52}
|
||||
{"code": "jack-jones-5952-7458954", "product_id": "prod_z2zcwP3ZbD", "slug": "119-jack-jones-charge-bomber-jacket", "brand": "Jack & Jones", "name": "Charge Bomber Jacket", "images": 1, "price_usd": 673.63}
|
||||
{"code": "jordan-9145-3516754", "product_id": "prod_cE2uasQi8r", "slug": "120-jordan-brooklyn-tricot-track-jacket", "brand": "Jordan", "name": "Brooklyn Tricot Track Jacket", "images": 4, "price_usd": 861.12}
|
||||
{"code": "under-armour-5681-1265724", "product_id": "prod_UKkLWZg9DA", "slug": "121-under-armour-meridian-zip-jacket", "brand": "Under Armour", "name": "Meridian \u00bc Zip Jacket", "images": 1, "price_usd": 861.6}
|
||||
{"code": "desigual-1923-6483534", "product_id": "prod_bqqZn4Y5Fq", "slug": "122-desigual-contrast-collar-jacket", "brand": "Desigual", "name": "Contrast collar jacket", "images": 3, "price_usd": 2373.6}
|
||||
{"code": "mango-man-1932-0456174", "product_id": "prod_UK5LWZg9DA", "slug": "123-mango-man-denim-overshirt-with-pockets", "brand": "MANGO Man", "name": "Denim Overshirt With Pockets", "images": 7, "price_usd": 1727.52}
|
||||
{"code": "nike-3433-7819904", "product_id": "prod_gbMHJdmfrX", "slug": "124-nike-stride-repel-uv-running-jacket", "brand": "Nike", "name": "Stride Repel UV Running Jacket", "images": 1, "price_usd": 1773.84}
|
||||
{"code": "fitleasure-6107-0154414", "product_id": "prod_EwFhxLZ9ck", "slug": "125-fitleasure-men-s-navy-training-track-jacket", "brand": "Fitleasure", "name": "Men's Navy Training Track Jacket", "images": 3, "price_usd": 331.2}
|
||||
{"code": "mango-man-7595-9855154", "product_id": "prod_VMWXmZF31w", "slug": "126-mango-man-wool-overshirt-with-buttons", "brand": "MANGO Man", "name": "Wool Overshirt with Buttons", "images": 1, "price_usd": 1919.52}
|
||||
{"code": "fitleasure-7254-8558544", "product_id": "prod_uh92YK1rnl", "slug": "127-fitleasure-ultraflex-men-s-premium-grey-athleisure-blazer", "brand": "Fitleasure", "name": "UltraFlex Men\u2019s Premium Grey Athleisure Blazer", "images": 6, "price_usd": 448.8}
|
||||
{"code": "tommy-hilfiger-3102-7968464", "product_id": "prod_OymJLhNcSb", "slug": "128-tommy-hilfiger-washed-flag-embroidery-denim-chore-jacket-th-mainline", "brand": "Tommy Hilfiger", "name": "Washed Flag Embroidery Denim Chore Jacket - Th Mainline", "images": 1, "price_usd": 2292.48}
|
||||
{"code": "adidas-6064-9829864", "product_id": "prod_AeZs1igzRC", "slug": "129-adidas-lifestyle-barreda-shoes-men-white-jp5928", "brand": "ADIDAS", "name": "Lifestyle Barreda Shoes Men White JP5928", "images": 1, "price_usd": 803.52}
|
||||
{"code": "adidas-7467-6165014", "product_id": "prod_pQHklVeMgb", "slug": "130-adidas-runfalcon-5-running-shoes", "brand": "ADIDAS", "name": "Runfalcon 5 Running Shoes", "images": 1, "price_usd": 717.12}
|
||||
{"code": "adidas-3835-1386964", "product_id": "prod_n0tqfPa3Cp", "slug": "131-adidas-lifestyle-vl-court-3-0-shoes-men-white-jr8635", "brand": "ADIDAS", "name": "Lifestyle VL Court 3.0 Shoes Men White JR8635", "images": 4, "price_usd": 947.52}
|
||||
{"code": "adidas-3902-2344624", "product_id": "prod_9LeFqgRua4", "slug": "132-adidas-adizero-evo-sl-shoes", "brand": "ADIDAS", "name": "Adizero EVO SL Shoes", "images": 3, "price_usd": 3211.2}
|
||||
{"code": "new-balance-7276-4452864", "product_id": "prod_xDjoCpeNMW", "slug": "133-new-balance-fuelcell-rebel-v5-running-shoes", "brand": "New Balance", "name": "FuelCell Rebel v5 Running Shoes", "images": 1, "price_usd": 2180.64}
|
||||
{"code": "nike-8718-6589064", "product_id": "prod_vlBmzDoNU0", "slug": "134-nike-air-monarch-iv-workout-shoes", "brand": "Nike", "name": "Air Monarch IV Workout Shoes", "images": 2, "price_usd": 825.6}
|
||||
{"code": "under-armour-5894-7391454", "product_id": "prod_2BQY5XM6fr", "slug": "135-under-armour-halo-runner-se-men-s-running-shoes", "brand": "Under Armour", "name": "Halo Runner SE Men's Running Shoes", "images": 5, "price_usd": 1725.12}
|
||||
{"code": "new-balance-0654-0936864", "product_id": "prod_HZaVtlDrOm", "slug": "136-new-balance-603-lifestyle-shoes", "brand": "New Balance", "name": "603 Lifestyle Shoes", "images": 1, "price_usd": 1163.81}
|
||||
{"code": "hm-1277-2755944", "product_id": "prod_eVO6l8wVqS", "slug": "137-h-m-canvas-espadrilles", "brand": "H&M", "name": "Canvas espadrilles", "images": 3, "price_usd": 383.52}
|
||||
{"code": "adidas-7643-4564454", "product_id": "prod_s6d8jaGony", "slug": "138-adidas-handball-spezial-shoes", "brand": "ADIDAS", "name": "Handball Spezial Shoes", "images": 1, "price_usd": 1437.12}
|
||||
{"code": "puma-7967-0821193", "product_id": "prod_GknUcnUYI5", "slug": "139-puma-puma-shuffle", "brand": "PUMA", "name": "Puma Shuffle", "images": 3, "price_usd": 971.04}
|
||||
{"code": "veja-7352-9893832", "product_id": "prod_RHaeNhrwyV", "slug": "140-veja-campo-chromefree-sneakers", "brand": "Veja", "name": "Campo Chromefree Sneakers", "images": 1, "price_usd": 3600.0}
|
||||
{"code": "new-balance-2615-5260964", "product_id": "prod_L50pHOn63V", "slug": "141-new-balance-530-lifestyle-shoes", "brand": "New Balance", "name": "530 Lifestyle Shoes", "images": 1, "price_usd": 1569.46}
|
||||
{"code": "tommy-hilfiger-6391-3924164", "product_id": "prod_PusNMcnNFW", "slug": "142-tommy-hilfiger-the-greenwich-edge-leather-sneakers-tommy-jeans", "brand": "Tommy Hilfiger", "name": "The Greenwich Edge Leather Sneakers - Tommy Jeans", "images": 2, "price_usd": 1615.68}
|
||||
{"code": "veja-6434-3897744", "product_id": "prod_CmGSrJgpzQ", "slug": "143-veja-campo-chromefree-sneakers", "brand": "Veja", "name": "Campo Chromefree Sneakers", "images": 2, "price_usd": 3600.0}
|
||||
{"code": "veja-7346-2897744", "product_id": "prod_YjXETxCGlb", "slug": "144-veja-volley-o-t-leather-sneakers", "brand": "Veja", "name": "Volley O.T. Leather Sneakers", "images": 1, "price_usd": 3480.0}
|
||||
{"code": "new-balance-8119-1352864", "product_id": "prod_58aQZnCRf8", "slug": "145-new-balance-2010-lifestyle-shoes", "brand": "New Balance", "name": "2010 Lifestyle Shoes", "images": 1, "price_usd": 2128.32}
|
||||
{"code": "new-balance-8362-1136064", "product_id": "prod_Fp1GX8NaP3", "slug": "146-new-balance-9060-lifestyle-shoes", "brand": "New Balance", "name": "9060 Lifestyle Shoes", "images": 5, "price_usd": 2106.38}
|
||||
{"code": "hm-3543-9075264", "product_id": "prod_kAaKT9MVfb", "slug": "147-h-m-chelsea-boots", "brand": "H&M", "name": "Chelsea boots", "images": 3, "price_usd": 719.52}
|
||||
{"code": "dr-cardin-3393-7528864", "product_id": "prod_31YWpPTcf5", "slug": "148-dr-cardin-dr-cardin-men-pillow-foam-faux-leather-chelsea-boot-shoe-tbp", "brand": "Dr. Cardin", "name": "Dr Cardin Men Pillow Foam Faux Leather Chelsea Boot Shoe TBP-6855", "images": 7, "price_usd": 420.34}
|
||||
{"code": "hunter-boots-3275-8311344", "product_id": "prod_DcHPsx31Wa", "slug": "149-hunter-boots-men-s-play-short-boots", "brand": "Hunter Boots", "name": "MEN'S PLAY SHORT BOOTS", "images": 6, "price_usd": 2731.2}
|
||||
{"code": "mango-man-1327-3466174", "product_id": "prod_miRx3nOJap", "slug": "150-mango-man-suede-leather-ankle-boots", "brand": "MANGO Man", "name": "Suede Leather Ankle Boots", "images": 1, "price_usd": 2303.52}
|
||||
{"code": "clarks-5776-2553954", "product_id": "prod_58IQZnCRf8", "slug": "151-clarks-mens-wallabee-shoes-blue-green-stripe", "brand": "CLARKS", "name": "Mens Wallabee Shoes - Blue Green Stripe", "images": 2, "price_usd": 3643.2}
|
||||
{"code": "clarks-2605-0299864", "product_id": "prod_MWEnyloHRU", "slug": "152-clarks-mens-paulson-up-boots-black-leather", "brand": "CLARKS", "name": "Mens Paulson Up Boots - Black Leather", "images": 6, "price_usd": 2395.2}
|
||||
{"code": "hm-5557-0718954", "product_id": "prod_pQSklVeMgb", "slug": "153-h-m-regular-fit-merino-wool-jumper", "brand": "H&M", "name": "Regular Fit Merino wool jumper", "images": 5, "price_usd": 959.52}
|
||||
{"code": "hm-3731-3617164", "product_id": "prod_s6i8jaGony", "slug": "154-h-m-regular-fit-rib-knit-cotton-jumper", "brand": "H&M", "name": "Regular Fit Rib-knit cotton jumper", "images": 4, "price_usd": 671.52}
|
||||
{"code": "harley-davidson-0026-6212544", "product_id": "prod_rvRClYtECF", "slug": "155-harley-davidson-h-d-realtree-apg-pullover-hoodie", "brand": "Harley-Davidson", "name": "H-D\u00ae Realtree\u00ae APG Pullover Hoodie", "images": 4, "price_usd": 1881.6}
|
||||
{"code": "gap-6704-2923414", "product_id": "prod_8gNR0I7fEb", "slug": "156-gap-heritage-logo-zip-hoodie", "brand": "GAP", "name": "Heritage Logo Zip Hoodie", "images": 1, "price_usd": 1071.6}
|
||||
{"code": "gap-1231-2925864", "product_id": "prod_aC8tNvLSUA", "slug": "157-gap-arch-sherpa-zip-hoodie", "brand": "GAP", "name": "Arch Sherpa Zip Hoodie", "images": 2, "price_usd": 840.67}
|
||||
{"code": "cotton-on-6809-2319164", "product_id": "prod_QRPA48cIW7", "slug": "158-cotton-on-zip-up-hoodie", "brand": "Cotton On", "name": "Zip Up Hoodie", "images": 1, "price_usd": 573.12}
|
||||
{"code": "cotton-on-6979-5652664", "product_id": "prod_QRNA48cIW7", "slug": "159-cotton-on-box-fit-hoodie", "brand": "Cotton On", "name": "Box Fit Hoodie", "images": 1, "price_usd": 357.6}
|
||||
{"code": "gap-1516-1025214", "product_id": "prod_8g2R0I7fEb", "slug": "160-gap-french-heritage-logo-zip-hoodie", "brand": "GAP", "name": "French Heritage Logo Zip Hoodie", "images": 1, "price_usd": 735.5}
|
||||
@@ -0,0 +1,42 @@
|
||||
require 'json'
|
||||
require 'open-uri'
|
||||
|
||||
store = Spree::Store.default
|
||||
taxonomy = Spree::Taxonomy.find_or_create_by!(name: "Categories", store: store)
|
||||
shipping_category = Spree::ShippingCategory.first_or_create!(name: "Default")
|
||||
|
||||
products_file = "/home/juxtapo/products.jsonl"
|
||||
|
||||
File.readlines(products_file).each_with_index do |line, idx|
|
||||
data = JSON.parse(line)
|
||||
|
||||
taxon = taxonomy.taxons.find_or_create_by!(name: data["brand"]) do |t|
|
||||
t.parent = taxonomy.root
|
||||
end
|
||||
|
||||
product = Spree::Product.create!(
|
||||
name: data["name"],
|
||||
price: data["price"].to_f,
|
||||
description: "#{data["brand"]} - #{data["name"]}",
|
||||
available_on: Time.current,
|
||||
shipping_category: shipping_category,
|
||||
status: "active",
|
||||
stores: [store]
|
||||
)
|
||||
|
||||
product.taxons << taxon
|
||||
|
||||
# attach first image
|
||||
if data["imgs"] && data["imgs"].first
|
||||
begin
|
||||
img_url = data["imgs"].first
|
||||
downloaded = URI.open(img_url)
|
||||
product.images.create!(attachment: { io: downloaded, filename: "#{data["code"]}.jpg" })
|
||||
puts "✅ #{idx+1}/30: #{data["name"]} (#{data["brand"]}) - RM#{data["price"]}"
|
||||
rescue => e
|
||||
puts "⚠️ #{idx+1}/30: #{data["name"]} - image failed: #{e.message}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
puts "Done! Total products: #{Spree::Product.count}"
|
||||
@@ -0,0 +1,186 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Pak Jabit — Product Image Pipeline
|
||||
PhotoRoom API: remove background + studio-quality packshot
|
||||
|
||||
Usage:
|
||||
python3 photoroom_pipeline.py --input products/01-gshock-*/image-1.jpg
|
||||
python3 photoroom_pipeline.py --input-dir products/ --all
|
||||
python3 photoroom_pipeline.py --input products/01-gshock-*/image-1.jpg --dry-run
|
||||
|
||||
Env:
|
||||
PHOTOROOM_API_KEY — sandbox_ prefix for testing (watermarked), real key for production
|
||||
"""
|
||||
|
||||
import argparse, glob, json, os, sys, time
|
||||
from pathlib import Path
|
||||
import urllib.request, urllib.error
|
||||
|
||||
API_KEY = os.environ.get("PHOTOROOM_API_KEY")
|
||||
EDIT_URL = "https://image-api.photoroom.com/v2/edit"
|
||||
SEGMENT_URL = "https://sdk.photoroom.com/v1/segment"
|
||||
|
||||
def check_account():
|
||||
req = urllib.request.Request(
|
||||
"https://image-api.photoroom.com/v2/account",
|
||||
headers={"x-api-key": API_KEY},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=15) as r:
|
||||
data = json.loads(r.read())
|
||||
print(f"Plan: {data['plan']} | Credits: {data['images']['available']}/{data['images']['subscription']}")
|
||||
return data
|
||||
|
||||
def remove_background(image_path: str, output_path: str):
|
||||
boundary = "----PakJabitBoundary"
|
||||
filename = os.path.basename(image_path)
|
||||
with open(image_path, "rb") as f:
|
||||
image_data = f.read()
|
||||
|
||||
body = (
|
||||
f"--{boundary}\r\n"
|
||||
f'Content-Disposition: form-data; name="image_file"; filename="{filename}"\r\n'
|
||||
f"Content-Type: image/jpeg\r\n\r\n"
|
||||
).encode() + image_data + (
|
||||
f"\r\n--{boundary}\r\n"
|
||||
f'Content-Disposition: form-data; name="format"\r\n\r\npng\r\n'
|
||||
f"--{boundary}\r\n"
|
||||
f'Content-Disposition: form-data; name="size"\r\n\r\nhd\r\n'
|
||||
f"--{boundary}--\r\n"
|
||||
).encode()
|
||||
|
||||
req = urllib.request.Request(SEGMENT_URL, data=body, method="POST", headers={
|
||||
"x-api-key": API_KEY,
|
||||
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
||||
})
|
||||
with urllib.request.urlopen(req, timeout=60) as r:
|
||||
with open(output_path, "wb") as out:
|
||||
out.write(r.read())
|
||||
return output_path
|
||||
|
||||
def studio_packshot(image_path: str, output_path: str, bg_color: str = "FFFFFF"):
|
||||
boundary = "----PakJabitBoundary"
|
||||
filename = os.path.basename(image_path)
|
||||
with open(image_path, "rb") as f:
|
||||
image_data = f.read()
|
||||
|
||||
fields = [
|
||||
("background.color", bg_color),
|
||||
("padding", "0.1"),
|
||||
("shadow.mode", "ai.soft"),
|
||||
("outputSize", "1200x1600"),
|
||||
("export.format", "jpeg"),
|
||||
("removeBackground", "true"),
|
||||
]
|
||||
|
||||
body = b""
|
||||
for name, value in fields:
|
||||
body += (
|
||||
f"--{boundary}\r\n"
|
||||
f'Content-Disposition: form-data; name="{name}"\r\n\r\n{value}\r\n'
|
||||
).encode()
|
||||
|
||||
body += (
|
||||
f"--{boundary}\r\n"
|
||||
f'Content-Disposition: form-data; name="imageFile"; filename="{filename}"\r\n'
|
||||
f"Content-Type: image/jpeg\r\n\r\n"
|
||||
).encode() + image_data + f"\r\n--{boundary}--\r\n".encode()
|
||||
|
||||
req = urllib.request.Request(EDIT_URL, data=body, method="POST", headers={
|
||||
"x-api-key": API_KEY,
|
||||
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
||||
})
|
||||
with urllib.request.urlopen(req, timeout=90) as r:
|
||||
with open(output_path, "wb") as out:
|
||||
out.write(r.read())
|
||||
return output_path
|
||||
|
||||
def process_image(image_path: str, output_dir: str, mode: str = "packshot"):
|
||||
stem = Path(image_path).stem
|
||||
parent = Path(image_path).parent.name
|
||||
|
||||
out_subdir = os.path.join(output_dir, parent)
|
||||
os.makedirs(out_subdir, exist_ok=True)
|
||||
|
||||
if mode == "remove-bg":
|
||||
out_path = os.path.join(out_subdir, f"{stem}-nobg.png")
|
||||
remove_background(image_path, out_path)
|
||||
elif mode == "packshot":
|
||||
out_path = os.path.join(out_subdir, f"{stem}-studio.jpg")
|
||||
studio_packshot(image_path, out_path)
|
||||
else:
|
||||
raise ValueError(f"Unknown mode: {mode}")
|
||||
|
||||
print(f" ✅ {os.path.basename(image_path)} → {os.path.basename(out_path)}")
|
||||
return out_path
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="PhotoRoom product image pipeline")
|
||||
parser.add_argument("--input", nargs="+", help="Image file(s) or glob pattern")
|
||||
parser.add_argument("--input-dir", help="Process all images in product subdirs")
|
||||
parser.add_argument("--all", action="store_true", help="Process all image-*.jpg in input-dir")
|
||||
parser.add_argument("--first-only", action="store_true", help="Only process image-1.jpg per product")
|
||||
parser.add_argument("--output-dir", default="products-enhanced", help="Output directory")
|
||||
parser.add_argument("--mode", choices=["remove-bg", "packshot"], default="packshot")
|
||||
parser.add_argument("--dry-run", action="store_true", help="List files without processing")
|
||||
parser.add_argument("--account", action="store_true", help="Check account credits")
|
||||
parser.add_argument("--delay", type=float, default=1.0, help="Delay between API calls (seconds)")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not API_KEY:
|
||||
print("❌ Set PHOTOROOM_API_KEY env var first")
|
||||
print(" Sandbox key (free, watermarked): starts with sandbox_")
|
||||
print(" Get it from: https://app.photoroom.com/api-dashboard")
|
||||
sys.exit(1)
|
||||
|
||||
if args.account:
|
||||
check_account()
|
||||
return
|
||||
|
||||
images = []
|
||||
if args.input:
|
||||
for pattern in args.input:
|
||||
images.extend(glob.glob(pattern))
|
||||
elif args.input_dir and args.all:
|
||||
pattern = "image-1.jpg" if args.first_only else "image-*.jpg"
|
||||
images = sorted(glob.glob(os.path.join(args.input_dir, "**", pattern), recursive=True))
|
||||
else:
|
||||
parser.print_help()
|
||||
return
|
||||
|
||||
if not images:
|
||||
print("No images found")
|
||||
return
|
||||
|
||||
print(f"📸 {len(images)} images | mode: {args.mode} | output: {args.output_dir}")
|
||||
|
||||
if args.dry_run:
|
||||
for img in images:
|
||||
print(f" would process: {img}")
|
||||
return
|
||||
|
||||
check_account()
|
||||
print()
|
||||
|
||||
ok, fail = 0, 0
|
||||
for i, img in enumerate(images, 1):
|
||||
print(f"[{i}/{len(images)}] {img}")
|
||||
try:
|
||||
process_image(img, args.output_dir, args.mode)
|
||||
ok += 1
|
||||
except urllib.error.HTTPError as e:
|
||||
print(f" ❌ HTTP {e.code}: {e.read().decode()[:200]}")
|
||||
fail += 1
|
||||
if e.code == 429:
|
||||
print(" ⏳ Rate limited, waiting 30s...")
|
||||
time.sleep(30)
|
||||
except Exception as e:
|
||||
print(f" ❌ {e}")
|
||||
fail += 1
|
||||
|
||||
if i < len(images):
|
||||
time.sleep(args.delay)
|
||||
|
||||
print(f"\n✅ Done: {ok} ok, {fail} failed")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+218
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Pak Jabit — Photoroom return leg.
|
||||
|
||||
The missing half of photoroom_pipeline.py: takes a source image, runs it through
|
||||
Photoroom, and pushes the result back onto the Spree product as a media asset.
|
||||
|
||||
source jpg -> Photoroom /v2/edit -> Spree POST /products/:id/media -> verify
|
||||
|
||||
Verification is not optional here. Spree's media#create has a url path that
|
||||
enqueues a background job and answers `head :accepted` — a 202 with no body and
|
||||
no way to know if the image ever landed. This uses the multipart path, which
|
||||
returns 201 plus the serialized asset, and then re-reads the product's media to
|
||||
confirm the asset id is really there.
|
||||
|
||||
Usage:
|
||||
photoroom_to_spree.py --slug 19-electro-denim-lab-chino-pants --live
|
||||
photoroom_to_spree.py --slug <slug> --position 1 # make it the thumbnail
|
||||
photoroom_to_spree.py --slug <slug> --dry-run
|
||||
|
||||
Keys come from /root/.photoroom/credentials. Sandbox is the default and
|
||||
watermarks every image; --live uses the paid allowance (10/month) and is the
|
||||
only way to get a clean result onto a live storefront.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import mimetypes
|
||||
import pathlib
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
import os as _os
|
||||
|
||||
|
||||
def _spree_admin_key() -> str:
|
||||
"""The Spree admin API key. Required — never defaulted."""
|
||||
key = _os.environ.get("SPREE_ADMIN_KEY", "").strip()
|
||||
if not key:
|
||||
raise SystemExit(
|
||||
"SPREE_ADMIN_KEY is not set.\n"
|
||||
" export SPREE_ADMIN_KEY=<the admin API key>\n"
|
||||
"It is deliberately not stored in this file — see README.md."
|
||||
)
|
||||
return key
|
||||
|
||||
|
||||
SPREE_API = "https://api.jux-lab.com/api/v3/admin"
|
||||
SPREE_KEY = _spree_admin_key()
|
||||
PHOTOROOM_EDIT = "https://image-api.photoroom.com/v2/edit"
|
||||
CREDS = pathlib.Path("/root/.photoroom/credentials")
|
||||
SOURCE_ROOT = pathlib.Path(
|
||||
"/root/Opus/Projects/PeakyBuildr/scraped-catalogue/products"
|
||||
)
|
||||
|
||||
|
||||
def fatal(msg):
|
||||
print(f"FATAL: {msg}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def load_key(live: bool) -> str:
|
||||
if not CREDS.exists():
|
||||
fatal(f"{CREDS} missing — no API key to use")
|
||||
want = "PHOTOROOM_LIVE_KEY" if live else "PHOTOROOM_SANDBOX_KEY"
|
||||
for line in CREDS.read_text().splitlines():
|
||||
if line.startswith(want + "="):
|
||||
return line.split("=", 1)[1].strip()
|
||||
fatal(f"{want} not found in {CREDS}")
|
||||
|
||||
|
||||
def multipart(fields: dict, file_field: str, filename: str, blob: bytes, content_type: str):
|
||||
boundary = uuid.uuid4().hex
|
||||
parts = []
|
||||
for k, v in fields.items():
|
||||
parts.append(
|
||||
f'--{boundary}\r\nContent-Disposition: form-data; name="{k}"\r\n\r\n{v}\r\n'.encode()
|
||||
)
|
||||
parts.append(
|
||||
f'--{boundary}\r\nContent-Disposition: form-data; name="{file_field}"; '
|
||||
f'filename="{filename}"\r\nContent-Type: {content_type}\r\n\r\n'.encode()
|
||||
+ blob
|
||||
+ b"\r\n"
|
||||
)
|
||||
parts.append(f"--{boundary}--\r\n".encode())
|
||||
return b"".join(parts), f"multipart/form-data; boundary={boundary}"
|
||||
|
||||
|
||||
def spree_get(path):
|
||||
req = urllib.request.Request(
|
||||
f"{SPREE_API}{path}", headers={"X-Spree-Api-Key": SPREE_KEY}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=60) as r:
|
||||
return json.loads(r.read())
|
||||
|
||||
|
||||
def find_product(slug):
|
||||
data = spree_get(f"/products?filter[slug_eq]={slug}&per_page=50")
|
||||
for p in data.get("data", []):
|
||||
if p.get("slug") == slug or p.get("attributes", {}).get("slug") == slug:
|
||||
return p
|
||||
# The filter key varies by Spree build; fall back to a full scan rather than
|
||||
# reporting "not found" for a product that is actually there.
|
||||
page = 1
|
||||
while True:
|
||||
d = spree_get(f"/products?per_page=100&page={page}")
|
||||
for p in d.get("data", []):
|
||||
if p.get("slug") == slug:
|
||||
return p
|
||||
if page >= d.get("meta", {}).get("pages", 1):
|
||||
return None
|
||||
page += 1
|
||||
|
||||
|
||||
def photoroom(img_path: pathlib.Path, key: str, size: str, bg: str) -> bytes:
|
||||
body, ctype = multipart(
|
||||
{"background.color": bg, "outputSize": size, "padding": "0.05"},
|
||||
"imageFile",
|
||||
img_path.name,
|
||||
img_path.read_bytes(),
|
||||
mimetypes.guess_type(img_path.name)[0] or "image/jpeg",
|
||||
)
|
||||
req = urllib.request.Request(
|
||||
PHOTOROOM_EDIT, data=body, headers={"x-api-key": key, "Content-Type": ctype}
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=180) as r:
|
||||
data = r.read()
|
||||
if not data:
|
||||
fatal("Photoroom returned 200 with an empty body")
|
||||
if not r.headers.get("content-type", "").startswith("image/"):
|
||||
fatal(f"Photoroom returned non-image: {r.headers.get('content-type')}")
|
||||
return data
|
||||
except urllib.error.HTTPError as e:
|
||||
fatal(f"Photoroom HTTP {e.code}: {e.read().decode()[:300]}")
|
||||
|
||||
|
||||
def upload_to_spree(product_id: str, blob: bytes, filename: str, position):
|
||||
# position is always sent. Spree defaults a new asset to position 1, which
|
||||
# silently makes it the product thumbnail — a watermarked sandbox image went
|
||||
# live on the storefront that way. Callers pass the append position unless
|
||||
# they explicitly want the front.
|
||||
fields = {"type": "Spree::Image", "position": str(position)}
|
||||
body, ctype = multipart(fields, "attachment", filename, blob, "image/png")
|
||||
req = urllib.request.Request(
|
||||
f"{SPREE_API}/products/{product_id}/media",
|
||||
data=body,
|
||||
headers={"X-Spree-Api-Key": SPREE_KEY, "Content-Type": ctype},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=180) as r:
|
||||
return r.status, json.loads(r.read() or b"{}")
|
||||
except urllib.error.HTTPError as e:
|
||||
fatal(f"Spree HTTP {e.code}: {e.read().decode()[:400]}")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--slug", required=True)
|
||||
ap.add_argument("--image", help="source file (default: products/<slug>/image-1.jpg)")
|
||||
ap.add_argument("--live", action="store_true", help="use the paid key (10/month, no watermark)")
|
||||
ap.add_argument("--position", type=int,
|
||||
help="1 makes it the product thumbnail. Default: appended last.")
|
||||
ap.add_argument("--size", default="1000x1000")
|
||||
ap.add_argument("--bg", default="FFFFFF")
|
||||
ap.add_argument("--dry-run", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
src = pathlib.Path(args.image) if args.image else SOURCE_ROOT / args.slug / "image-1.jpg"
|
||||
if not src.exists():
|
||||
fatal(f"source image not found: {src}")
|
||||
|
||||
product = find_product(args.slug)
|
||||
if not product:
|
||||
fatal(f"no Spree product with slug {args.slug}")
|
||||
pid = product["id"]
|
||||
name = product.get("name", "?")
|
||||
|
||||
before = spree_get(f"/products/{pid}/media").get("data", [])
|
||||
print(f"product : {name} ({pid})")
|
||||
print(f"source : {src} ({src.stat().st_size:,} bytes)")
|
||||
print(f"media : {len(before)} existing")
|
||||
print(f"key : {'LIVE (consumes paid quota)' if args.live else 'sandbox (watermarked)'}")
|
||||
|
||||
if args.dry_run:
|
||||
print("\n--dry-run: nothing sent.")
|
||||
return
|
||||
|
||||
key = load_key(args.live)
|
||||
position = args.position if args.position is not None else len(before) + 1
|
||||
print(f"\nprocessing... (will land at position {position}"
|
||||
f"{' — THUMBNAIL' if position == 1 else ''})")
|
||||
out = photoroom(src, key, args.size, args.bg)
|
||||
print(f" photoroom ok: {len(out):,} bytes png")
|
||||
|
||||
status, resp = upload_to_spree(pid, out, f"{args.slug}-photoroom.png", position)
|
||||
asset_id = resp.get("data", resp).get("id")
|
||||
print(f" spree upload: HTTP {status} asset={asset_id} position={position}")
|
||||
|
||||
# Re-read rather than trusting the response. A 201 is not proof the asset is
|
||||
# attached and serving.
|
||||
after = spree_get(f"/products/{pid}/media").get("data", [])
|
||||
ids = [m.get("id") for m in after]
|
||||
if asset_id and asset_id in ids:
|
||||
url = next((m.get("original_url") or m.get("url") for m in after if m.get("id") == asset_id), None)
|
||||
print(f"\ncheck ok: media {len(before)} -> {len(after)}, asset present")
|
||||
if url:
|
||||
print(f"url: {url}")
|
||||
else:
|
||||
print(f"\nCHECK FAILED: asset {asset_id} not in product media after upload", file=sys.stderr)
|
||||
print(f" media ids: {ids}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,53 @@
|
||||
{"brand": "H&M", "code": "hm-3377-7607064", "name": "Oversized Fit Scuba zip-through hoodie", "price": "149.90", "category": "t-shirts", "imgs": ["https://static-my.zacdn.com/p/hm-3377-7607064-1.jpg", "https://static-my.zacdn.com/p/hm-3377-7607064-2.jpg", "https://static-my.zacdn.com/p/hm-3377-7607064-3.jpg", "https://static-my.zacdn.com/p/hm-3377-7607064-4.jpg", "https://static-my.zacdn.com/p/hm-3377-7607064-5.jpg"]}
|
||||
{"brand": "Urban Revivo", "code": "urban-revivo-3653-8941654", "name": "Casual Crew Neck Short Sleeve Tee", "price": "119.00", "category": "t-shirts", "imgs": ["https://static-my.zacdn.com/p/urban-revivo-3653-8941654-1.jpg", "https://static-my.zacdn.com/p/urban-revivo-3653-8941654-2.jpg", "https://static-my.zacdn.com/p/urban-revivo-3653-8941654-3.jpg", "https://static-my.zacdn.com/p/urban-revivo-3653-8941654-4.jpg", "https://static-my.zacdn.com/p/urban-revivo-3653-8941654-5.jpg", "https://static-my.zacdn.com/p/urban-revivo-3653-8941654-6.jpg"]}
|
||||
{"brand": "Urban Revivo", "code": "urban-revivo-4852-8181854", "name": "Short Sleeve Shirts", "price": "116.10", "category": "t-shirts", "imgs": ["https://static-my.zacdn.com/p/urban-revivo-4852-8181854-1.jpg", "https://static-my.zacdn.com/p/urban-revivo-4852-8181854-2.jpg", "https://static-my.zacdn.com/p/urban-revivo-4852-8181854-3.jpg", "https://static-my.zacdn.com/p/urban-revivo-4852-8181854-4.jpg", "https://static-my.zacdn.com/p/urban-revivo-4852-8181854-5.jpg", "https://static-my.zacdn.com/p/urban-revivo-4852-8181854-6.jpg", "https://static-my.zacdn.com/p/urban-revivo-4852-8181854-7.jpg"]}
|
||||
{"brand": "Nike", "code": "nike-4039-6920264", "name": "Training T-Shirt", "price": "147.25", "category": "t-shirts", "imgs": ["https://static-my.zacdn.com/p/nike-4039-6920264-1.jpg"]}
|
||||
{"brand": "H&M", "code": "hm-8811-9083444", "name": "Regular Fit Track pants with DryMove™", "price": "149.90", "category": "t-shirts", "imgs": ["https://static-my.zacdn.com/p/hm-8811-9083444-1.jpg", "https://static-my.zacdn.com/p/hm-8811-9083444-2.jpg", "https://static-my.zacdn.com/p/hm-8811-9083444-3.jpg", "https://static-my.zacdn.com/p/hm-8811-9083444-4.jpg", "https://static-my.zacdn.com/p/hm-8811-9083444-5.jpg", "https://static-my.zacdn.com/p/hm-8811-9083444-6.jpg", "https://static-my.zacdn.com/p/hm-8811-9083444-7.jpg", "https://static-my.zacdn.com/p/hm-8811-9083444-8.jpg"]}
|
||||
{"brand": "Trendyol", "code": "trendyol-6397-0057634", "name": "Beige Oversize/Wide Cut Long Sleeve Animal Embroidered Sweatshirt TMNAW24SW00164", "price": "56.05", "category": "t-shirts", "imgs": ["https://static-my.zacdn.com/p/trendyol-6397-0057634-1.jpg", "https://static-my.zacdn.com/p/trendyol-6397-0057634-2.jpg", "https://static-my.zacdn.com/p/trendyol-6397-0057634-3.jpg", "https://static-my.zacdn.com/p/trendyol-6397-0057634-4.jpg", "https://static-my.zacdn.com/p/trendyol-6397-0057634-5.jpg"]}
|
||||
{"brand": "Trendyol", "code": "trendyol-9722-5541154", "name": "Horse Embroidered Sweatshirt", "price": "55.20", "category": "t-shirts", "imgs": ["https://static-my.zacdn.com/p/trendyol-9722-5541154-1.jpg"]}
|
||||
{"brand": "H&M", "code": "hm-1602-9539354", "name": "Relaxed Fit Resort shirt", "price": "99.90", "category": "t-shirts", "imgs": ["https://static-my.zacdn.com/p/hm-1602-9539354-1.jpg", "https://static-my.zacdn.com/p/hm-1602-9539354-2.jpg", "https://static-my.zacdn.com/p/hm-1602-9539354-3.jpg", "https://static-my.zacdn.com/p/hm-1602-9539354-4.jpg", "https://static-my.zacdn.com/p/hm-1602-9539354-5.jpg", "https://static-my.zacdn.com/p/hm-1602-9539354-6.jpg"]}
|
||||
{"brand": "Trendyol", "code": "trendyol-6524-2362464", "name": "Anthracite*001 Relaxed Mock Neck Plain Sweater Tmnaw26Kz00013", "price": "84.50", "category": "t-shirts", "imgs": ["https://static-my.zacdn.com/p/trendyol-6524-2362464-1.jpg", "https://static-my.zacdn.com/p/trendyol-6524-2362464-2.jpg", "https://static-my.zacdn.com/p/trendyol-6524-2362464-3.jpg", "https://static-my.zacdn.com/p/trendyol-6524-2362464-4.jpg", "https://static-my.zacdn.com/p/trendyol-6524-2362464-5.jpg"]}
|
||||
{"brand": "Giordano", "code": "giordano-7623-8198654", "name": "Men's Cotton Short Sleeve Slim Tee 01026250", "price": "37.17", "category": "t-shirts", "imgs": ["https://static-my.zacdn.com/p/giordano-7623-8198654-1.jpg", "https://static-my.zacdn.com/p/giordano-7623-8198654-2.jpg"]}
|
||||
{"brand": "H&M", "code": "hm-4373-6317064", "name": "Ultra Baggy Jeans", "price": "149.90", "category": "t-shirts", "imgs": ["https://static-my.zacdn.com/p/hm-4373-6317064-1.jpg", "https://static-my.zacdn.com/p/hm-4373-6317064-2.jpg", "https://static-my.zacdn.com/p/hm-4373-6317064-3.jpg", "https://static-my.zacdn.com/p/hm-4373-6317064-4.jpg", "https://static-my.zacdn.com/p/hm-4373-6317064-5.jpg"]}
|
||||
{"brand": "H&M", "code": "hm-0024-5243544", "name": "Relaxed Jeans", "price": "129.90", "category": "t-shirts", "imgs": ["https://static-my.zacdn.com/p/hm-0024-5243544-1.jpg", "https://static-my.zacdn.com/p/hm-0024-5243544-2.jpg", "https://static-my.zacdn.com/p/hm-0024-5243544-3.jpg", "https://static-my.zacdn.com/p/hm-0024-5243544-4.jpg"]}
|
||||
{"brand": "YSoCool", "code": "ysocool-brand-7905-5999931", "name": "Men Compression Body Shaper Vests Top", "price": "46.55", "category": "t-shirts", "imgs": ["https://static-my.zacdn.com/p/ysocool-brand-7905-5999931-1.jpg", "https://static-my.zacdn.com/p/ysocool-brand-7905-5999931-2.jpg", "https://static-my.zacdn.com/p/ysocool-brand-7905-5999931-3.jpg"]}
|
||||
{"brand": "Nike", "code": "nike-8330-6036864", "name": "Dri-FIT Training T-Shirt", "price": "113.05", "category": "t-shirts", "imgs": ["https://static-my.zacdn.com/p/nike-8330-6036864-1.jpg"]}
|
||||
{"brand": "H&M", "code": "hm-8820-5290854", "name": "Loose Fit T-shirt", "price": "49.90", "category": "t-shirts", "imgs": ["https://static-my.zacdn.com/p/hm-8820-5290854-1.jpg", "https://static-my.zacdn.com/p/hm-8820-5290854-2.jpg", "https://static-my.zacdn.com/p/hm-8820-5290854-3.jpg", "https://static-my.zacdn.com/p/hm-8820-5290854-4.jpg"]}
|
||||
{"brand": "H&M", "code": "hm-6806-1878823", "name": "Slim Fit T-shirt", "price": "24.90", "category": "t-shirts", "imgs": ["https://static-my.zacdn.com/p/hm-6806-1878823-1.jpg", "https://static-my.zacdn.com/p/hm-6806-1878823-2.jpg", "https://static-my.zacdn.com/p/hm-6806-1878823-3.jpg", "https://static-my.zacdn.com/p/hm-6806-1878823-4.jpg", "https://static-my.zacdn.com/p/hm-6806-1878823-5.jpg"]}
|
||||
{"brand": "H&M", "code": "hm-6462-8250054", "name": "Relaxed Fit Textured resort shirt", "price": "99.90", "category": "t-shirts", "imgs": ["https://static-my.zacdn.com/p/hm-6462-8250054-1.jpg", "https://static-my.zacdn.com/p/hm-6462-8250054-2.jpg", "https://static-my.zacdn.com/p/hm-6462-8250054-3.jpg", "https://static-my.zacdn.com/p/hm-6462-8250054-4.jpg"]}
|
||||
{"brand": "Trendyol", "code": "trendyol-3134-8272734", "name": "Black Regular Fit 100% Viscose Loose and Loose Summer Shirt TMNSS25GO00048", "price": "90.30", "category": "t-shirts", "imgs": ["https://static-my.zacdn.com/p/trendyol-3134-8272734-1.jpg"]}
|
||||
{"brand": "Altinyildiz", "code": "altinyildiz-8185-0860924", "name": "Basic Slim Fit T-Shirt", "price": "52.16", "category": "t-shirts", "imgs": ["https://static-my.zacdn.com/p/altinyildiz-8185-0860924-1.jpg"]}
|
||||
{"brand": "Nike", "code": "nike-0596-4602954", "name": "Palm Tree Men's Tee", "price": "93.00", "category": "t-shirts", "imgs": ["https://static-my.zacdn.com/p/nike-0596-4602954-1.jpg"]}
|
||||
{"brand": "Lacoste", "code": "lacoste-6939-3474274", "name": "Monogram Jacquard T-shirt", "price": "539.00", "category": "t-shirts", "imgs": ["https://static-my.zacdn.com/p/lacoste-6939-3474274-1.jpg", "https://static-my.zacdn.com/p/lacoste-6939-3474274-2.jpg", "https://static-my.zacdn.com/p/lacoste-6939-3474274-3.jpg", "https://static-my.zacdn.com/p/lacoste-6939-3474274-4.jpg"]}
|
||||
{"brand": "H&M", "code": "hm-2842-2711854", "name": "Relaxed Fit Cotton chinos", "price": "129.90", "category": "t-shirts", "imgs": ["https://static-my.zacdn.com/p/hm-2842-2711854-1.jpg", "https://static-my.zacdn.com/p/hm-2842-2711854-2.jpg"]}
|
||||
{"brand": "Urban Revivo", "code": "urban-revivo-8524-3567374", "name": "Classic Solid Color Thick Shirt in Viscose Blend", "price": "189.05", "category": "shirts", "imgs": ["https://static-my.zacdn.com/p/urban-revivo-8524-3567374-1.jpg", "https://static-my.zacdn.com/p/urban-revivo-8524-3567374-2.jpg", "https://static-my.zacdn.com/p/urban-revivo-8524-3567374-3.jpg", "https://static-my.zacdn.com/p/urban-revivo-8524-3567374-4.jpg", "https://static-my.zacdn.com/p/urban-revivo-8524-3567374-5.jpg", "https://static-my.zacdn.com/p/urban-revivo-8524-3567374-6.jpg", "https://static-my.zacdn.com/p/urban-revivo-8524-3567374-7.jpg", "https://static-my.zacdn.com/p/urban-revivo-8524-3567374-8.jpg"]}
|
||||
{"brand": "H&M", "code": "hm-4144-4577954", "name": "Relaxed Fit Oxford shirt", "price": "129.90", "category": "shirts", "imgs": ["https://static-my.zacdn.com/p/hm-4144-4577954-1.jpg", "https://static-my.zacdn.com/p/hm-4144-4577954-2.jpg", "https://static-my.zacdn.com/p/hm-4144-4577954-3.jpg", "https://static-my.zacdn.com/p/hm-4144-4577954-4.jpg", "https://static-my.zacdn.com/p/hm-4144-4577954-5.jpg"]}
|
||||
{"brand": "Trendyol", "code": "trendyol-7335-7898364", "name": "Black Regular/Normal Cut Hooded Textured Sweatshirt Tmnaw26Sw00068", "price": "79.03", "category": "shirts", "imgs": ["https://static-my.zacdn.com/p/trendyol-7335-7898364-1.jpg", "https://static-my.zacdn.com/p/trendyol-7335-7898364-2.jpg", "https://static-my.zacdn.com/p/trendyol-7335-7898364-3.jpg"]}
|
||||
{"brand": "Trendyol", "code": "trendyol-7769-9773614", "name": "Striped Knit Polo Shirt", "price": "85.52", "category": "pants", "imgs": ["https://static-my.zacdn.com/p/trendyol-7769-9773614-1.jpg", "https://static-my.zacdn.com/p/trendyol-7769-9773614-2.jpg", "https://static-my.zacdn.com/p/trendyol-7769-9773614-3.jpg", "https://static-my.zacdn.com/p/trendyol-7769-9773614-4.jpg"]}
|
||||
{"brand": "H&M", "code": "hm-4087-3660754", "name": "Regular Fit Hole-knit sweater vest", "price": "139.90", "category": "pants", "imgs": ["https://static-my.zacdn.com/p/hm-4087-3660754-1.jpg", "https://static-my.zacdn.com/p/hm-4087-3660754-2.jpg", "https://static-my.zacdn.com/p/hm-4087-3660754-3.jpg", "https://static-my.zacdn.com/p/hm-4087-3660754-4.jpg", "https://static-my.zacdn.com/p/hm-4087-3660754-5.jpg", "https://static-my.zacdn.com/p/hm-4087-3660754-6.jpg"]}
|
||||
{"brand": "H&M", "code": "hm-9694-6720944", "name": "Easy-care Relaxed Fit Linen-blend shorts", "price": "149.90", "category": "pants", "imgs": ["https://static-my.zacdn.com/p/hm-9694-6720944-1.jpg", "https://static-my.zacdn.com/p/hm-9694-6720944-2.jpg", "https://static-my.zacdn.com/p/hm-9694-6720944-3.jpg", "https://static-my.zacdn.com/p/hm-9694-6720944-4.jpg"]}
|
||||
{"brand": "H&M", "code": "hm-9884-9573454", "name": "Oversized Fit T-shirt", "price": "69.90", "category": "pants", "imgs": ["https://static-my.zacdn.com/p/hm-9884-9573454-1.jpg", "https://static-my.zacdn.com/p/hm-9884-9573454-2.jpg", "https://static-my.zacdn.com/p/hm-9884-9573454-3.jpg", "https://static-my.zacdn.com/p/hm-9884-9573454-4.jpg", "https://static-my.zacdn.com/p/hm-9884-9573454-5.jpg", "https://static-my.zacdn.com/p/hm-9884-9573454-6.jpg"]}
|
||||
{"brand": "Trendyol", "code": "trendyol-1466-7949634", "name": "Brown Oversize/Wide Cut Buttoned Polo Collar Thick Polar Fleece Sweatshirt TMNAW24SW00107", "price": "82.53", "category": "pants", "imgs": ["https://static-my.zacdn.com/p/trendyol-1466-7949634-1.jpg"]}
|
||||
{"brand": "Ben Sherman", "code": "ben-sherman-1585-5545954", "name": "Short Sleeves Jacquard Polo Shirt", "price": "312.55", "category": "accessories", "imgs": ["https://static-my.zacdn.com/p/ben-sherman-1585-5545954-1.jpg"]}
|
||||
{"brand": "H&M", "code": "hm-3289-5938864", "name": "Relaxed Fit Sweatshirt", "price": "59.90", "category": "new", "imgs": ["https://static-my.zacdn.com/p/hm-3289-5938864-1.jpg", "https://static-my.zacdn.com/p/hm-3289-5938864-2.jpg"]}
|
||||
{"brand": "H&M", "code": "hm-7368-5259764", "name": "Regular Fit Easy-iron shirt", "price": "79.90", "category": "new", "imgs": ["https://static-my.zacdn.com/p/hm-7368-5259764-1.jpg", "https://static-my.zacdn.com/p/hm-7368-5259764-2.jpg"]}
|
||||
{"brand": "H&M", "code": "hm-0914-3034864", "name": "Polo shirt", "price": "99.90", "category": "new", "imgs": ["https://static-my.zacdn.com/p/hm-0914-3034864-1.jpg", "https://static-my.zacdn.com/p/hm-0914-3034864-2.jpg"]}
|
||||
{"brand": "ADIDAS", "code": "adidas-0957-2958274", "name": "Liverpool Fc 26/27 Third Jersey", "price": "329.00", "category": "new", "imgs": ["https://static-my.zacdn.com/p/adidas-0957-2958274-1.jpg", "https://static-my.zacdn.com/p/adidas-0957-2958274-2.jpg", "https://static-my.zacdn.com/p/adidas-0957-2958274-3.jpg", "https://static-my.zacdn.com/p/adidas-0957-2958274-4.jpg"]}
|
||||
{"brand": "Tommy Hilfiger", "code": "tommy-hilfiger-6431-9934964", "name": "Linear Logo Slim Crew Neck T-Shirt - Tommy Jeans", "price": "151.05", "category": "new", "imgs": ["https://static-my.zacdn.com/p/tommy-hilfiger-6431-9934964-1.jpg"]}
|
||||
{"brand": "GAP", "code": "gap-2969-6099274", "name": "Logo T-Shirt", "price": "99.99", "category": "new", "imgs": ["https://static-my.zacdn.com/p/gap-2969-6099274-1.jpg"]}
|
||||
{"brand": "GAP", "code": "gap-0710-4300374", "name": "Logo T-Shirt", "price": "99.99", "category": "new", "imgs": ["https://static-my.zacdn.com/p/gap-0710-4300374-1.jpg"]}
|
||||
{"brand": "H&M", "code": "hm-0924-7104664", "name": "Loose Fit Interlock T-shirt", "price": "49.90", "category": "new", "imgs": ["https://static-my.zacdn.com/p/hm-0924-7104664-1.jpg", "https://static-my.zacdn.com/p/hm-0924-7104664-2.jpg", "https://static-my.zacdn.com/p/hm-0924-7104664-3.jpg", "https://static-my.zacdn.com/p/hm-0924-7104664-4.jpg", "https://static-my.zacdn.com/p/hm-0924-7104664-5.jpg", "https://static-my.zacdn.com/p/hm-0924-7104664-6.jpg"]}
|
||||
{"brand": "ADIDAS", "code": "adidas-9961-7990474", "name": "Liverpool FC 26/27 Home Fan Jersey", "price": "94.05", "category": "new", "imgs": ["https://static-my.zacdn.com/p/adidas-9961-7990474-1.jpg"]}
|
||||
{"brand": "GAP", "code": "gap-3366-0689274", "name": "Pique Polo Shirt", "price": "134.82", "category": "new", "imgs": ["https://static-my.zacdn.com/p/gap-3366-0689274-1.jpg"]}
|
||||
{"brand": "ADIDAS", "code": "adidas-6543-9526964", "name": "Mercedes - AMG Petronas Formula 1 Team Miami Tee", "price": "141.55", "category": "new", "imgs": ["https://static-my.zacdn.com/p/adidas-6543-9526964-1.jpg"]}
|
||||
{"brand": "Giordano", "code": "giordano-8229-4161474", "name": "Giordano by Poly Pac 20'' 24\" 28'' Fury UNBREAKABLE PP Expandable Travel Luggage - GA2620 - Black", "price": "799.00", "category": "new", "imgs": ["https://static-my.zacdn.com/p/giordano-8229-4161474-1.jpg", "https://static-my.zacdn.com/p/giordano-8229-4161474-2.jpg", "https://static-my.zacdn.com/p/giordano-8229-4161474-3.jpg", "https://static-my.zacdn.com/p/giordano-8229-4161474-4.jpg", "https://static-my.zacdn.com/p/giordano-8229-4161474-5.jpg", "https://static-my.zacdn.com/p/giordano-8229-4161474-6.jpg"]}
|
||||
{"brand": "ADIDAS", "code": "adidas-9235-1679564", "name": "VL Court Base Shoes", "price": "155.40", "category": "new", "imgs": ["https://static-my.zacdn.com/p/adidas-9235-1679564-1.jpg", "https://static-my.zacdn.com/p/adidas-9235-1679564-2.jpg", "https://static-my.zacdn.com/p/adidas-9235-1679564-3.jpg", "https://static-my.zacdn.com/p/adidas-9235-1679564-4.jpg", "https://static-my.zacdn.com/p/adidas-9235-1679564-5.jpg", "https://static-my.zacdn.com/p/adidas-9235-1679564-6.jpg", "https://static-my.zacdn.com/p/adidas-9235-1679564-7.jpg", "https://static-my.zacdn.com/p/adidas-9235-1679564-8.jpg"]}
|
||||
{"brand": "H&M", "code": "hm-3158-4937164", "name": "Relaxed Fit Zip-top rib-knit jumper", "price": "149.90", "category": "new", "imgs": ["https://static-my.zacdn.com/p/hm-3158-4937164-1.jpg"]}
|
||||
{"brand": "H&M", "code": "hm-5561-3429464", "name": "Loose Fit Scuba Zip-through hoodie", "price": "149.90", "category": "new", "imgs": ["https://static-my.zacdn.com/p/hm-5561-3429464-1.jpg", "https://static-my.zacdn.com/p/hm-5561-3429464-2.jpg"]}
|
||||
{"brand": "MANGO Man", "code": "mango-man-6546-3612374", "name": "Fine Knit Cotton-Blend Sweater", "price": "199.90", "category": "new", "imgs": ["https://static-my.zacdn.com/p/mango-man-6546-3612374-1.jpg"]}
|
||||
{"brand": "ADIDAS", "code": "adidas-3065-8268274", "name": "Trefoil Cushion Crew Socks 3 Pairs", "price": "65.55", "category": "new", "imgs": ["https://static-my.zacdn.com/p/adidas-3065-8268274-1.jpg"]}
|
||||
{"brand": "MANGO Man", "code": "mango-man-5487-0122374", "name": "Fine-Knit Sweater With Zip", "price": "219.90", "category": "new", "imgs": ["https://static-my.zacdn.com/p/mango-man-5487-0122374-1.jpg"]}
|
||||
{"brand": "H&M", "code": "hm-6071-6565464", "name": "10-pack mid trunks with Lycra®", "price": "199.90", "category": "new", "imgs": ["https://static-my.zacdn.com/p/hm-6071-6565464-1.jpg", "https://static-my.zacdn.com/p/hm-6071-6565464-2.jpg", "https://static-my.zacdn.com/p/hm-6071-6565464-3.jpg", "https://static-my.zacdn.com/p/hm-6071-6565464-4.jpg", "https://static-my.zacdn.com/p/hm-6071-6565464-5.jpg"]}
|
||||
{"brand": "Harley-Davidson", "code": "harley-davidson-6140-0807274", "name": "H-D® TECH Vented Short Sleeve Shirt", "price": "502.00", "category": "new", "imgs": ["https://static-my.zacdn.com/p/harley-davidson-6140-0807274-1.jpg", "https://static-my.zacdn.com/p/harley-davidson-6140-0807274-2.jpg", "https://static-my.zacdn.com/p/harley-davidson-6140-0807274-3.jpg"]}
|
||||
{"brand": "Harley-Davidson", "code": "harley-davidson-6555-0017274", "name": "Harley-Davidson® Vintage Drop H Ringer Tee", "price": "207.00", "category": "new", "imgs": ["https://static-my.zacdn.com/p/harley-davidson-6555-0017274-1.jpg", "https://static-my.zacdn.com/p/harley-davidson-6555-0017274-2.jpg", "https://static-my.zacdn.com/p/harley-davidson-6555-0017274-3.jpg"]}
|
||||
{"brand": "MANGO Man", "code": "mango-man-6179-9132374", "name": "Fine Knit Cotton-Blend Sweater", "price": "199.90", "category": "new", "imgs": ["https://static-my.zacdn.com/p/mango-man-6179-9132374-1.jpg"]}
|
||||
@@ -0,0 +1,77 @@
|
||||
{"brand": "H&M", "code": "hm-3272-3490754", "name": "Regular Jeans", "price": "99.90", "category": "jeans", "imgs": ["https://static-my.zacdn.com/p/hm-3272-3490754-1.jpg", "https://static-my.zacdn.com/p/hm-3272-3490754-2.jpg", "https://static-my.zacdn.com/p/hm-3272-3490754-3.jpg"]}
|
||||
{"brand": "Levi's", "code": "levi-s-8540-2089554", "name": "Levi's® Men's 505™ Regular Jeans 00505-3166", "price": "183.20", "category": "jeans", "imgs": ["https://static-my.zacdn.com/p/levi-s-8540-2089554-1.jpg", "https://static-my.zacdn.com/p/levi-s-8540-2089554-2.jpg", "https://static-my.zacdn.com/p/levi-s-8540-2089554-3.jpg", "https://static-my.zacdn.com/p/levi-s-8540-2089554-4.jpg", "https://static-my.zacdn.com/p/levi-s-8540-2089554-5.jpg"]}
|
||||
{"brand": "Volkswagen", "code": "volkswagen-4831-1155093", "name": "Slim Long Jeans", "price": "159.00", "category": "jeans", "imgs": ["https://static-my.zacdn.com/p/volkswagen-4831-1155093-1.jpg", "https://static-my.zacdn.com/p/volkswagen-4831-1155093-2.jpg", "https://static-my.zacdn.com/p/volkswagen-4831-1155093-3.jpg", "https://static-my.zacdn.com/p/volkswagen-4831-1155093-4.jpg"]}
|
||||
{"brand": "Cotton On", "code": "cotton-on-6837-1419164", "name": "Baggy Jeans", "price": "171.43", "category": "jeans", "imgs": ["https://static-my.zacdn.com/p/cotton-on-6837-1419164-1.jpg"]}
|
||||
{"brand": "Levi's", "code": "levi-s-4894-3133664", "name": "Levi's® Men's 505™ Regular Jeans 00505-3276", "price": "183.20", "category": "jeans", "imgs": ["https://static-my.zacdn.com/p/levi-s-4894-3133664-1.jpg", "https://static-my.zacdn.com/p/levi-s-4894-3133664-2.jpg", "https://static-my.zacdn.com/p/levi-s-4894-3133664-3.jpg"]}
|
||||
{"brand": "Volkswagen", "code": "volkswagen-4840-0055093", "name": "Straight Long Jeans", "price": "159.00", "category": "jeans", "imgs": ["https://static-my.zacdn.com/p/volkswagen-4840-0055093-1.jpg", "https://static-my.zacdn.com/p/volkswagen-4840-0055093-2.jpg"]}
|
||||
{"brand": "Connor", "code": "connor-3964-1987644", "name": "Toronto Straight Jean", "price": "143.91", "category": "jeans", "imgs": ["https://static-my.zacdn.com/p/connor-3964-1987644-1.jpg", "https://static-my.zacdn.com/p/connor-3964-1987644-2.jpg", "https://static-my.zacdn.com/p/connor-3964-1987644-3.jpg"]}
|
||||
{"brand": "Levi's", "code": "levi-s-8200-6615651", "name": "511 Slim Fit Jeans 04511-1907", "price": "223.20", "category": "jeans", "imgs": ["https://static-my.zacdn.com/p/levi-s-8200-6615651-1.jpg", "https://static-my.zacdn.com/p/levi-s-8200-6615651-2.jpg"]}
|
||||
{"brand": "FIDELIO", "code": "fidelio-1184-7387944", "name": "601 Slim Fit Washed Denim Jeans", "price": "109.00", "category": "jeans", "imgs": ["https://static-my.zacdn.com/p/fidelio-1184-7387944-1.jpg", "https://static-my.zacdn.com/p/fidelio-1184-7387944-2.jpg", "https://static-my.zacdn.com/p/fidelio-1184-7387944-3.jpg"]}
|
||||
{"brand": "Levi's", "code": "levi-s-9015-6656924", "name": "Men's 511™ Slim Jeans 04511-6225", "price": "239.20", "category": "jeans", "imgs": ["https://static-my.zacdn.com/p/levi-s-9015-6656924-1.jpg", "https://static-my.zacdn.com/p/levi-s-9015-6656924-2.jpg"]}
|
||||
{"brand": "DRUM", "code": "drum-3906-4664754", "name": "DRUM Casual Skinny to Slim Fit Jeans- Dark Blue", "price": "99.90", "category": "jeans", "imgs": ["https://static-my.zacdn.com/p/drum-3906-4664754-1.jpg", "https://static-my.zacdn.com/p/drum-3906-4664754-2.jpg"]}
|
||||
{"brand": "Connor", "code": "connor-3962-2987644", "name": "Arran Straight Jean", "price": "143.91", "category": "jeans", "imgs": ["https://static-my.zacdn.com/p/connor-3962-2987644-1.jpg", "https://static-my.zacdn.com/p/connor-3962-2987644-2.jpg", "https://static-my.zacdn.com/p/connor-3962-2987644-3.jpg", "https://static-my.zacdn.com/p/connor-3962-2987644-4.jpg", "https://static-my.zacdn.com/p/connor-3962-2987644-5.jpg"]}
|
||||
{"brand": "Trendyol", "code": "trendyol-3864-8297064", "name": "Essential Fit Jeans Denim Pants", "price": "108.43", "category": "jeans", "imgs": ["https://static-my.zacdn.com/p/trendyol-3864-8297064-1.jpg"]}
|
||||
{"brand": "Electro Denim Lab", "code": "electro-denim-lab-7932-5806954", "name": "Dub Relaxed Fit Jeans", "price": "78.00", "category": "jeans", "imgs": ["https://static-my.zacdn.com/p/electro-denim-lab-7932-5806954-1.jpg", "https://static-my.zacdn.com/p/electro-denim-lab-7932-5806954-2.jpg", "https://static-my.zacdn.com/p/electro-denim-lab-7932-5806954-3.jpg", "https://static-my.zacdn.com/p/electro-denim-lab-7932-5806954-4.jpg", "https://static-my.zacdn.com/p/electro-denim-lab-7932-5806954-5.jpg"]}
|
||||
{"brand": "DRUM", "code": "drum-5753-5393434", "name": "DRUM Digital Print Denim Straight Cut Jeans- Brown", "price": "139.90", "category": "jeans", "imgs": ["https://static-my.zacdn.com/p/drum-5753-5393434-1.jpg", "https://static-my.zacdn.com/p/drum-5753-5393434-2.jpg", "https://static-my.zacdn.com/p/drum-5753-5393434-3.jpg", "https://static-my.zacdn.com/p/drum-5753-5393434-4.jpg"]}
|
||||
{"brand": "Trendyol", "code": "trendyol-4181-4687064", "name": "Slim Fit Stretch Fabric Denim Pants Jeans", "price": "146.30", "category": "jeans", "imgs": ["https://static-my.zacdn.com/p/trendyol-4181-4687064-1.jpg"]}
|
||||
{"brand": "Urban Revivo", "code": "urban-revivo-4820-1281854", "name": "Denim Shorts", "price": "89.91", "category": "shorts", "imgs": ["https://static-my.zacdn.com/p/urban-revivo-4820-1281854-1.jpg", "https://static-my.zacdn.com/p/urban-revivo-4820-1281854-2.jpg", "https://static-my.zacdn.com/p/urban-revivo-4820-1281854-3.jpg", "https://static-my.zacdn.com/p/urban-revivo-4820-1281854-4.jpg", "https://static-my.zacdn.com/p/urban-revivo-4820-1281854-5.jpg", "https://static-my.zacdn.com/p/urban-revivo-4820-1281854-6.jpg"]}
|
||||
{"brand": "H&M", "code": "hm-2256-5380854", "name": "2-pack Regular Fit Shorts", "price": "99.90", "category": "shorts", "imgs": ["https://static-my.zacdn.com/p/hm-2256-5380854-1.jpg", "https://static-my.zacdn.com/p/hm-2256-5380854-2.jpg"]}
|
||||
{"brand": "H&M", "code": "hm-5374-3237954", "name": "Regular Fit Linen-blend shorts", "price": "99.90", "category": "shorts", "imgs": ["https://static-my.zacdn.com/p/hm-5374-3237954-1.jpg", "https://static-my.zacdn.com/p/hm-5374-3237954-2.jpg"]}
|
||||
{"brand": "Cotton On", "code": "cotton-on-4000-9099341", "name": "Easy Shorts", "price": "122.55", "category": "shorts", "imgs": ["https://static-my.zacdn.com/p/cotton-on-4000-9099341-1.jpg"]}
|
||||
{"brand": "Connor", "code": "connor-3932-5487644", "name": "Darwin Chino Stretch Short", "price": "116.91", "category": "shorts", "imgs": ["https://static-my.zacdn.com/p/connor-3932-5487644-1.jpg", "https://static-my.zacdn.com/p/connor-3932-5487644-2.jpg", "https://static-my.zacdn.com/p/connor-3932-5487644-3.jpg"]}
|
||||
{"brand": "FIDELIO", "code": "fidelio-4233-8849254", "name": "Chino Super Shorts", "price": "89.00", "category": "shorts", "imgs": ["https://static-my.zacdn.com/p/fidelio-4233-8849254-1.jpg", "https://static-my.zacdn.com/p/fidelio-4233-8849254-2.jpg", "https://static-my.zacdn.com/p/fidelio-4233-8849254-3.jpg", "https://static-my.zacdn.com/p/fidelio-4233-8849254-4.jpg", "https://static-my.zacdn.com/p/fidelio-4233-8849254-5.jpg"]}
|
||||
{"brand": "Cotton On", "code": "cotton-on-5986-9744864", "name": "Easy Active Shorts", "price": "89.28", "category": "shorts", "imgs": ["https://static-my.zacdn.com/p/cotton-on-5986-9744864-1.jpg"]}
|
||||
{"brand": "Cotton On", "code": "cotton-on-6085-0724713", "name": "Stretch Swim Shorts", "price": "122.55", "category": "shorts", "imgs": ["https://static-my.zacdn.com/p/cotton-on-6085-0724713-1.jpg", "https://static-my.zacdn.com/p/cotton-on-6085-0724713-2.jpg", "https://static-my.zacdn.com/p/cotton-on-6085-0724713-3.jpg", "https://static-my.zacdn.com/p/cotton-on-6085-0724713-4.jpg", "https://static-my.zacdn.com/p/cotton-on-6085-0724713-5.jpg", "https://static-my.zacdn.com/p/cotton-on-6085-0724713-6.jpg", "https://static-my.zacdn.com/p/cotton-on-6085-0724713-7.jpg"]}
|
||||
{"brand": "Under Armour", "code": "under-armour-0166-9009864", "name": "Challenger Core Men's 7.5\" Shorts", "price": "75.05", "category": "shorts", "imgs": ["https://static-my.zacdn.com/p/under-armour-0166-9009864-1.jpg"]}
|
||||
{"brand": "Cotton On", "code": "cotton-on-1627-2412493", "name": "Everyday Fleece Shorts", "price": "90.30", "category": "shorts", "imgs": ["https://static-my.zacdn.com/p/cotton-on-1627-2412493-1.jpg", "https://static-my.zacdn.com/p/cotton-on-1627-2412493-2.jpg"]}
|
||||
{"brand": "Connor", "code": "connor-3936-8387644", "name": "Jethro Cargo Short", "price": "116.91", "category": "shorts", "imgs": ["https://static-my.zacdn.com/p/connor-3936-8387644-1.jpg"]}
|
||||
{"brand": "Nike", "code": "nike-9182-7836064", "name": "Miler Dri-FIT 5\" Brief-Lined Running Shorts", "price": "128.25", "category": "shorts", "imgs": ["https://static-my.zacdn.com/p/nike-9182-7836064-1.jpg", "https://static-my.zacdn.com/p/nike-9182-7836064-2.jpg", "https://static-my.zacdn.com/p/nike-9182-7836064-3.jpg", "https://static-my.zacdn.com/p/nike-9182-7836064-4.jpg", "https://static-my.zacdn.com/p/nike-9182-7836064-5.jpg"]}
|
||||
{"brand": "Electro Denim Lab", "code": "electro-denim-lab-8284-8755973", "name": "Tailored Shorts", "price": "72.00", "category": "shorts", "imgs": ["https://static-my.zacdn.com/p/electro-denim-lab-8284-8755973-1.jpg", "https://static-my.zacdn.com/p/electro-denim-lab-8284-8755973-2.jpg", "https://static-my.zacdn.com/p/electro-denim-lab-8284-8755973-3.jpg", "https://static-my.zacdn.com/p/electro-denim-lab-8284-8755973-4.jpg"]}
|
||||
{"brand": "Electro Denim Lab", "code": "electro-denim-lab-5883-6755973", "name": "Tailored Shorts", "price": "72.00", "category": "shorts", "imgs": ["https://static-my.zacdn.com/p/electro-denim-lab-5883-6755973-1.jpg", "https://static-my.zacdn.com/p/electro-denim-lab-5883-6755973-2.jpg", "https://static-my.zacdn.com/p/electro-denim-lab-5883-6755973-3.jpg"]}
|
||||
{"brand": "Under Armour", "code": "under-armour-0103-4898864", "name": "Vanish Woven 2.0 Men's 8\" Shorts", "price": "139.30", "category": "shorts", "imgs": ["https://static-my.zacdn.com/p/under-armour-0103-4898864-1.jpg", "https://static-my.zacdn.com/p/under-armour-0103-4898864-2.jpg"]}
|
||||
{"brand": "Under Armour", "code": "under-armour-9965-8909864", "name": "Vanish Woven 2.0 Men's 6\" Shorts", "price": "139.30", "category": "shorts", "imgs": ["https://static-my.zacdn.com/p/under-armour-9965-8909864-1.jpg", "https://static-my.zacdn.com/p/under-armour-9965-8909864-2.jpg"]}
|
||||
{"brand": "H&M", "code": "hm-4916-5208854", "name": "Oversized Fit Printed boxy vest top", "price": "79.90", "category": "jackets-coats", "imgs": ["https://static-my.zacdn.com/p/hm-4916-5208854-1.jpg", "https://static-my.zacdn.com/p/hm-4916-5208854-2.jpg"]}
|
||||
{"brand": "H&M", "code": "hm-3300-2208854", "name": "Loose Fit Printed vest top", "price": "29.90", "category": "jackets-coats", "imgs": ["https://static-my.zacdn.com/p/hm-3300-2208854-1.jpg", "https://static-my.zacdn.com/p/hm-3300-2208854-2.jpg"]}
|
||||
{"brand": "H&M", "code": "hm-0791-0454064", "name": "Regular Fit Cotton vest top", "price": "49.90", "category": "jackets-coats", "imgs": ["https://static-my.zacdn.com/p/hm-0791-0454064-1.jpg", "https://static-my.zacdn.com/p/hm-0791-0454064-2.jpg", "https://static-my.zacdn.com/p/hm-0791-0454064-3.jpg"]}
|
||||
{"brand": "Jack & Jones", "code": "jack-jones-5952-7458954", "name": "Charge Bomber Jacket", "price": "140.34", "category": "jackets-coats", "imgs": ["https://static-my.zacdn.com/p/jack-jones-5952-7458954-1.jpg"]}
|
||||
{"brand": "Jordan", "code": "jordan-9145-3516754", "name": "Brooklyn Tricot Track Jacket", "price": "179.40", "category": "jackets-coats", "imgs": ["https://static-my.zacdn.com/p/jordan-9145-3516754-1.jpg", "https://static-my.zacdn.com/p/jordan-9145-3516754-2.jpg", "https://static-my.zacdn.com/p/jordan-9145-3516754-3.jpg", "https://static-my.zacdn.com/p/jordan-9145-3516754-4.jpg"]}
|
||||
{"brand": "Under Armour", "code": "under-armour-5681-1265724", "name": "Meridian ¼ Zip Jacket", "price": "179.50", "category": "jackets-coats", "imgs": ["https://static-my.zacdn.com/p/under-armour-5681-1265724-1.jpg"]}
|
||||
{"brand": "Desigual", "code": "desigual-1923-6483534", "name": "Contrast collar jacket", "price": "494.50", "category": "jackets-coats", "imgs": ["https://static-my.zacdn.com/p/desigual-1923-6483534-1.jpg", "https://static-my.zacdn.com/p/desigual-1923-6483534-2.jpg", "https://static-my.zacdn.com/p/desigual-1923-6483534-3.jpg"]}
|
||||
{"brand": "MANGO Man", "code": "mango-man-1932-0456174", "name": "Denim Overshirt With Pockets", "price": "359.90", "category": "jackets-coats", "imgs": ["https://static-my.zacdn.com/p/mango-man-1932-0456174-1.jpg", "https://static-my.zacdn.com/p/mango-man-1932-0456174-2.jpg", "https://static-my.zacdn.com/p/mango-man-1932-0456174-3.jpg", "https://static-my.zacdn.com/p/mango-man-1932-0456174-4.jpg", "https://static-my.zacdn.com/p/mango-man-1932-0456174-5.jpg", "https://static-my.zacdn.com/p/mango-man-1932-0456174-6.jpg", "https://static-my.zacdn.com/p/mango-man-1932-0456174-7.jpg"]}
|
||||
{"brand": "Nike", "code": "nike-3433-7819904", "name": "Stride Repel UV Running Jacket", "price": "369.55", "category": "jackets-coats", "imgs": ["https://static-my.zacdn.com/p/nike-3433-7819904-1.jpg"]}
|
||||
{"brand": "Fitleasure", "code": "fitleasure-6107-0154414", "name": "Men's Navy Training Track Jacket", "price": "69.00", "category": "jackets-coats", "imgs": ["https://static-my.zacdn.com/p/fitleasure-6107-0154414-1.jpg", "https://static-my.zacdn.com/p/fitleasure-6107-0154414-2.jpg", "https://static-my.zacdn.com/p/fitleasure-6107-0154414-3.jpg"]}
|
||||
{"brand": "MANGO Man", "code": "mango-man-7595-9855154", "name": "Wool Overshirt with Buttons", "price": "399.90", "category": "jackets-coats", "imgs": ["https://static-my.zacdn.com/p/mango-man-7595-9855154-1.jpg"]}
|
||||
{"brand": "Fitleasure", "code": "fitleasure-7254-8558544", "name": "UltraFlex Men’s Premium Grey Athleisure Blazer", "price": "93.50", "category": "jackets-coats", "imgs": ["https://static-my.zacdn.com/p/fitleasure-7254-8558544-1.jpg", "https://static-my.zacdn.com/p/fitleasure-7254-8558544-2.jpg", "https://static-my.zacdn.com/p/fitleasure-7254-8558544-3.jpg", "https://static-my.zacdn.com/p/fitleasure-7254-8558544-4.jpg", "https://static-my.zacdn.com/p/fitleasure-7254-8558544-5.jpg", "https://static-my.zacdn.com/p/fitleasure-7254-8558544-6.jpg"]}
|
||||
{"brand": "Tommy Hilfiger", "code": "tommy-hilfiger-3102-7968464", "name": "Washed Flag Embroidery Denim Chore Jacket - Th Mainline", "price": "477.60", "category": "jackets-coats", "imgs": ["https://static-my.zacdn.com/p/tommy-hilfiger-3102-7968464-1.jpg"]}
|
||||
{"brand": "ADIDAS", "code": "adidas-6064-9829864", "name": "Lifestyle Barreda Shoes Men White JP5928", "price": "167.40", "category": "shoes-sports", "imgs": ["https://static-my.zacdn.com/p/adidas-6064-9829864-1.jpg"]}
|
||||
{"brand": "ADIDAS", "code": "adidas-7467-6165014", "name": "Runfalcon 5 Running Shoes", "price": "149.40", "category": "shoes-sports", "imgs": ["https://static-my.zacdn.com/p/adidas-7467-6165014-1.jpg"]}
|
||||
{"brand": "ADIDAS", "code": "adidas-3835-1386964", "name": "Lifestyle VL Court 3.0 Shoes Men White JR8635", "price": "197.40", "category": "shoes-sports", "imgs": ["https://static-my.zacdn.com/p/adidas-3835-1386964-1.jpg", "https://static-my.zacdn.com/p/adidas-3835-1386964-2.jpg", "https://static-my.zacdn.com/p/adidas-3835-1386964-3.jpg", "https://static-my.zacdn.com/p/adidas-3835-1386964-4.jpg"]}
|
||||
{"brand": "ADIDAS", "code": "adidas-3902-2344624", "name": "Adizero EVO SL Shoes", "price": "669.00", "category": "shoes-sports", "imgs": ["https://static-my.zacdn.com/p/adidas-3902-2344624-1.jpg", "https://static-my.zacdn.com/p/adidas-3902-2344624-2.jpg", "https://static-my.zacdn.com/p/adidas-3902-2344624-3.jpg"]}
|
||||
{"brand": "New Balance", "code": "new-balance-7276-4452864", "name": "FuelCell Rebel v5 Running Shoes", "price": "454.30", "category": "shoes-sports", "imgs": ["https://static-my.zacdn.com/p/new-balance-7276-4452864-1.jpg"]}
|
||||
{"brand": "Nike", "code": "nike-8718-6589064", "name": "Air Monarch IV Workout Shoes", "price": "172.00", "category": "shoes-sports", "imgs": ["https://static-my.zacdn.com/p/nike-8718-6589064-1.jpg", "https://static-my.zacdn.com/p/nike-8718-6589064-2.jpg"]}
|
||||
{"brand": "Under Armour", "code": "under-armour-5894-7391454", "name": "Halo Runner SE Men's Running Shoes", "price": "359.40", "category": "shoes-sports", "imgs": ["https://static-my.zacdn.com/p/under-armour-5894-7391454-1.jpg", "https://static-my.zacdn.com/p/under-armour-5894-7391454-2.jpg", "https://static-my.zacdn.com/p/under-armour-5894-7391454-3.jpg", "https://static-my.zacdn.com/p/under-armour-5894-7391454-4.jpg", "https://static-my.zacdn.com/p/under-armour-5894-7391454-5.jpg"]}
|
||||
{"brand": "New Balance", "code": "new-balance-0654-0936864", "name": "603 Lifestyle Shoes", "price": "242.46", "category": "shoes-sports", "imgs": ["https://static-my.zacdn.com/p/new-balance-0654-0936864-1.jpg"]}
|
||||
{"brand": "H&M", "code": "hm-1277-2755944", "name": "Canvas espadrilles", "price": "79.90", "category": "shoes-sneakers", "imgs": ["https://static-my.zacdn.com/p/hm-1277-2755944-1.jpg", "https://static-my.zacdn.com/p/hm-1277-2755944-2.jpg", "https://static-my.zacdn.com/p/hm-1277-2755944-3.jpg"]}
|
||||
{"brand": "ADIDAS", "code": "adidas-7643-4564454", "name": "Handball Spezial Shoes", "price": "299.40", "category": "shoes-sneakers", "imgs": ["https://static-my.zacdn.com/p/adidas-7643-4564454-1.jpg"]}
|
||||
{"brand": "PUMA", "code": "puma-7967-0821193", "name": "Puma Shuffle", "price": "202.30", "category": "shoes-sneakers", "imgs": ["https://static-my.zacdn.com/p/puma-7967-0821193-1.jpg", "https://static-my.zacdn.com/p/puma-7967-0821193-2.jpg", "https://static-my.zacdn.com/p/puma-7967-0821193-3.jpg"]}
|
||||
{"brand": "Veja", "code": "veja-7352-9893832", "name": "Campo Chromefree Sneakers", "price": "750.00", "category": "shoes-sneakers", "imgs": ["https://static-my.zacdn.com/p/veja-7352-9893832-1.jpg"]}
|
||||
{"brand": "New Balance", "code": "new-balance-2615-5260964", "name": "530 Lifestyle Shoes", "price": "326.97", "category": "shoes-sneakers", "imgs": ["https://static-my.zacdn.com/p/new-balance-2615-5260964-1.jpg"]}
|
||||
{"brand": "Tommy Hilfiger", "code": "tommy-hilfiger-6391-3924164", "name": "The Greenwich Edge Leather Sneakers - Tommy Jeans", "price": "336.60", "category": "shoes-sneakers", "imgs": ["https://static-my.zacdn.com/p/tommy-hilfiger-6391-3924164-1.jpg", "https://static-my.zacdn.com/p/tommy-hilfiger-6391-3924164-2.jpg"]}
|
||||
{"brand": "Veja", "code": "veja-6434-3897744", "name": "Campo Chromefree Sneakers", "price": "750.00", "category": "shoes-sneakers", "imgs": ["https://static-my.zacdn.com/p/veja-6434-3897744-1.jpg", "https://static-my.zacdn.com/p/veja-6434-3897744-2.jpg"]}
|
||||
{"brand": "Veja", "code": "veja-7346-2897744", "name": "Volley O.T. Leather Sneakers", "price": "725.00", "category": "shoes-sneakers", "imgs": ["https://static-my.zacdn.com/p/veja-7346-2897744-1.jpg"]}
|
||||
{"brand": "New Balance", "code": "new-balance-8119-1352864", "name": "2010 Lifestyle Shoes", "price": "443.40", "category": "shoes-sneakers", "imgs": ["https://static-my.zacdn.com/p/new-balance-8119-1352864-1.jpg"]}
|
||||
{"brand": "New Balance", "code": "new-balance-8362-1136064", "name": "9060 Lifestyle Shoes", "price": "438.83", "category": "shoes-sneakers", "imgs": ["https://static-my.zacdn.com/p/new-balance-8362-1136064-1.jpg", "https://static-my.zacdn.com/p/new-balance-8362-1136064-2.jpg", "https://static-my.zacdn.com/p/new-balance-8362-1136064-3.jpg", "https://static-my.zacdn.com/p/new-balance-8362-1136064-4.jpg", "https://static-my.zacdn.com/p/new-balance-8362-1136064-5.jpg"]}
|
||||
{"brand": "H&M", "code": "hm-3543-9075264", "name": "Chelsea boots", "price": "149.90", "category": "shoes-boots", "imgs": ["https://static-my.zacdn.com/p/hm-3543-9075264-1.jpg", "https://static-my.zacdn.com/p/hm-3543-9075264-2.jpg", "https://static-my.zacdn.com/p/hm-3543-9075264-3.jpg"]}
|
||||
{"brand": "Dr. Cardin", "code": "dr-cardin-3393-7528864", "name": "Dr Cardin Men Pillow Foam Faux Leather Chelsea Boot Shoe TBP-6855", "price": "87.57", "category": "shoes-boots", "imgs": ["https://static-my.zacdn.com/p/dr-cardin-3393-7528864-1.jpg", "https://static-my.zacdn.com/p/dr-cardin-3393-7528864-2.jpg", "https://static-my.zacdn.com/p/dr-cardin-3393-7528864-3.jpg", "https://static-my.zacdn.com/p/dr-cardin-3393-7528864-4.jpg", "https://static-my.zacdn.com/p/dr-cardin-3393-7528864-5.jpg", "https://static-my.zacdn.com/p/dr-cardin-3393-7528864-6.jpg", "https://static-my.zacdn.com/p/dr-cardin-3393-7528864-7.jpg"]}
|
||||
{"brand": "Hunter Boots", "code": "hunter-boots-3275-8311344", "name": "MEN'S PLAY SHORT BOOTS", "price": "569.00", "category": "shoes-boots", "imgs": ["https://static-my.zacdn.com/p/hunter-boots-3275-8311344-1.jpg", "https://static-my.zacdn.com/p/hunter-boots-3275-8311344-2.jpg", "https://static-my.zacdn.com/p/hunter-boots-3275-8311344-3.jpg", "https://static-my.zacdn.com/p/hunter-boots-3275-8311344-4.jpg", "https://static-my.zacdn.com/p/hunter-boots-3275-8311344-5.jpg", "https://static-my.zacdn.com/p/hunter-boots-3275-8311344-6.jpg"]}
|
||||
{"brand": "MANGO Man", "code": "mango-man-1327-3466174", "name": "Suede Leather Ankle Boots", "price": "479.90", "category": "shoes-boots", "imgs": ["https://static-my.zacdn.com/p/mango-man-1327-3466174-1.jpg"]}
|
||||
{"brand": "CLARKS", "code": "clarks-5776-2553954", "name": "Mens Wallabee Shoes - Blue Green Stripe", "price": "759.00", "category": "shoes-boots", "imgs": ["https://static-my.zacdn.com/p/clarks-5776-2553954-1.jpg", "https://static-my.zacdn.com/p/clarks-5776-2553954-2.jpg"]}
|
||||
{"brand": "CLARKS", "code": "clarks-2605-0299864", "name": "Mens Paulson Up Boots - Black Leather", "price": "499.00", "category": "shoes-boots", "imgs": ["https://static-my.zacdn.com/p/clarks-2605-0299864-1.jpg", "https://static-my.zacdn.com/p/clarks-2605-0299864-2.jpg", "https://static-my.zacdn.com/p/clarks-2605-0299864-3.jpg", "https://static-my.zacdn.com/p/clarks-2605-0299864-4.jpg", "https://static-my.zacdn.com/p/clarks-2605-0299864-5.jpg", "https://static-my.zacdn.com/p/clarks-2605-0299864-6.jpg"]}
|
||||
{"brand": "H&M", "code": "hm-5557-0718954", "name": "Regular Fit Merino wool jumper", "price": "199.90", "category": "hoodies", "imgs": ["https://static-my.zacdn.com/p/hm-5557-0718954-1.jpg", "https://static-my.zacdn.com/p/hm-5557-0718954-2.jpg", "https://static-my.zacdn.com/p/hm-5557-0718954-3.jpg", "https://static-my.zacdn.com/p/hm-5557-0718954-4.jpg", "https://static-my.zacdn.com/p/hm-5557-0718954-5.jpg"]}
|
||||
{"brand": "H&M", "code": "hm-3731-3617164", "name": "Regular Fit Rib-knit cotton jumper", "price": "139.90", "category": "hoodies", "imgs": ["https://static-my.zacdn.com/p/hm-3731-3617164-1.jpg", "https://static-my.zacdn.com/p/hm-3731-3617164-2.jpg", "https://static-my.zacdn.com/p/hm-3731-3617164-3.jpg", "https://static-my.zacdn.com/p/hm-3731-3617164-4.jpg"]}
|
||||
{"brand": "Harley-Davidson", "code": "harley-davidson-0026-6212544", "name": "H-D® Realtree® APG Pullover Hoodie", "price": "392.00", "category": "hoodies", "imgs": ["https://static-my.zacdn.com/p/harley-davidson-0026-6212544-1.jpg", "https://static-my.zacdn.com/p/harley-davidson-0026-6212544-2.jpg", "https://static-my.zacdn.com/p/harley-davidson-0026-6212544-3.jpg", "https://static-my.zacdn.com/p/harley-davidson-0026-6212544-4.jpg"]}
|
||||
{"brand": "GAP", "code": "gap-6704-2923414", "name": "Heritage Logo Zip Hoodie", "price": "223.25", "category": "hoodies", "imgs": ["https://static-my.zacdn.com/p/gap-6704-2923414-1.jpg"]}
|
||||
{"brand": "GAP", "code": "gap-1231-2925864", "name": "Arch Sherpa Zip Hoodie", "price": "175.14", "category": "hoodies", "imgs": ["https://static-my.zacdn.com/p/gap-1231-2925864-1.jpg", "https://static-my.zacdn.com/p/gap-1231-2925864-2.jpg"]}
|
||||
{"brand": "Cotton On", "code": "cotton-on-6809-2319164", "name": "Zip Up Hoodie", "price": "119.40", "category": "hoodies", "imgs": ["https://static-my.zacdn.com/p/cotton-on-6809-2319164-1.jpg"]}
|
||||
{"brand": "Cotton On", "code": "cotton-on-6979-5652664", "name": "Box Fit Hoodie", "price": "74.50", "category": "hoodies", "imgs": ["https://static-my.zacdn.com/p/cotton-on-6979-5652664-1.jpg"]}
|
||||
{"brand": "GAP", "code": "gap-1516-1025214", "name": "French Heritage Logo Zip Hoodie", "price": "153.23", "category": "hoodies", "imgs": ["https://static-my.zacdn.com/p/gap-1516-1025214-1.jpg"]}
|
||||
@@ -0,0 +1,30 @@
|
||||
{"brand":"G-Shock","code":"g-shock-9407-8856754","name":"DW-5600STT-1 Stranger Things Men Digital Watch Black Resin Band","price":"536.90","imgs":["https://static-my.zacdn.com/p/g-shock-9407-8856754-1.jpg","https://static-my.zacdn.com/p/g-shock-9407-8856754-2.jpg","https://static-my.zacdn.com/p/g-shock-9407-8856754-3.jpg","https://static-my.zacdn.com/p/g-shock-9407-8856754-4.jpg"]}
|
||||
{"brand":"H&M","code":"hm-4512-4118154","name":"Relaxed Fit Printed T-shirt","price":"29.90","imgs":["https://static-my.zacdn.com/p/hm-4512-4118154-1.jpg","https://static-my.zacdn.com/p/hm-4512-4118154-2.jpg","https://static-my.zacdn.com/p/hm-4512-4118154-3.jpg","https://static-my.zacdn.com/p/hm-4512-4118154-4.jpg","https://static-my.zacdn.com/p/hm-4512-4118154-5.jpg"]}
|
||||
{"brand":"H&M","code":"hm-4434-6513734","name":"Regular Jeans","price":"99.90","imgs":["https://static-my.zacdn.com/p/hm-4434-6513734-1.jpg","https://static-my.zacdn.com/p/hm-4434-6513734-2.jpg","https://static-my.zacdn.com/p/hm-4434-6513734-3.jpg","https://static-my.zacdn.com/p/hm-4434-6513734-4.jpg","https://static-my.zacdn.com/p/hm-4434-6513734-5.jpg","https://static-my.zacdn.com/p/hm-4434-6513734-6.jpg","https://static-my.zacdn.com/p/hm-4434-6513734-7.jpg"]}
|
||||
{"brand":"Lacoste","code":"lacoste-2548-0143293","name":"Cotton T-shirt with Breast Pocket","price":"184.50","imgs":["https://static-my.zacdn.com/p/lacoste-2548-0143293-1.jpg","https://static-my.zacdn.com/p/lacoste-2548-0143293-2.jpg"]}
|
||||
{"brand":"Tommy Hilfiger","code":"tommy-hilfiger-9447-9378554","name":"Heritage Logo Washed Baseball Cap - Tommy Jeans","price":"116.45","imgs":["https://static-my.zacdn.com/p/tommy-hilfiger-9447-9378554-1.jpg"]}
|
||||
{"brand":"Cotton On","code":"cotton-on-4997-4515844","name":"Baggy Worker Pants","price":"114.45","imgs":["https://static-my.zacdn.com/p/cotton-on-4997-4515844-1.jpg"]}
|
||||
{"brand":"GAP","code":"gap-0235-5300064","name":"Arch Logo T-Shirt","price":"60.95","imgs":["https://static-my.zacdn.com/p/gap-0235-5300064-1.jpg"]}
|
||||
{"brand":"Giordano","code":"giordano-4368-3595764","name":"Men's Cotton Short Sleeve Print Tee 13096224","price":"28.03","imgs":["https://static-my.zacdn.com/p/giordano-4368-3595764-1.jpg","https://static-my.zacdn.com/p/giordano-4368-3595764-2.jpg","https://static-my.zacdn.com/p/giordano-4368-3595764-3.jpg","https://static-my.zacdn.com/p/giordano-4368-3595764-4.jpg","https://static-my.zacdn.com/p/giordano-4368-3595764-5.jpg"]}
|
||||
{"brand":"Tommy Hilfiger","code":"tommy-hilfiger-1700-4474664","name":"Square Buckle Leather Belt - TH Mainline","price":"164.34","imgs":["https://static-my.zacdn.com/p/tommy-hilfiger-1700-4474664-1.jpg"]}
|
||||
{"brand":"Superdry","code":"superdry-9309-2538964","name":"Vintage Script T-Shirt","price":"80.73","imgs":["https://static-my.zacdn.com/p/superdry-9309-2538964-1.jpg","https://static-my.zacdn.com/p/superdry-9309-2538964-2.jpg","https://static-my.zacdn.com/p/superdry-9309-2538964-3.jpg","https://static-my.zacdn.com/p/superdry-9309-2538964-4.jpg","https://static-my.zacdn.com/p/superdry-9309-2538964-5.jpg","https://static-my.zacdn.com/p/superdry-9309-2538964-6.jpg","https://static-my.zacdn.com/p/superdry-9309-2538964-7.jpg","https://static-my.zacdn.com/p/superdry-9309-2538964-8.jpg"]}
|
||||
{"brand":"Under Armour","code":"under-armour-4667-9098804","name":"Tech™ Short Sleeve T-Shirt","price":"44.24","imgs":["https://static-my.zacdn.com/p/under-armour-4667-9098804-1.jpg"]}
|
||||
{"brand":"Under Armour","code":"under-armour-8098-5270454","name":"Tech™ Polo Shirt","price":"114.56","imgs":["https://static-my.zacdn.com/p/under-armour-8098-5270454-1.jpg"]}
|
||||
{"brand":"Tommy Hilfiger","code":"tommy-hilfiger-1774-1674664","name":"Six-Panel Baseball Cap - Th Mainline","price":"143.34","imgs":["https://static-my.zacdn.com/p/tommy-hilfiger-1774-1674664-1.jpg"]}
|
||||
{"brand":"Tommy Hilfiger","code":"tommy-hilfiger-2343-0274664","name":"1985 Regular Polo Shirt - Th Mainline","price":"238.14","imgs":["https://static-my.zacdn.com/p/tommy-hilfiger-2343-0274664-1.jpg"]}
|
||||
{"brand":"Cotton On","code":"cotton-on-1718-5169564","name":"Box Fit Text T-Shirt","price":"78.33","imgs":["https://static-my.zacdn.com/p/cotton-on-1718-5169564-1.jpg"]}
|
||||
{"brand":"Giordano","code":"giordano-5996-5838764","name":"Men's Cotton Short Sleeve Print Tee 13096299","price":"28.03","imgs":["https://static-my.zacdn.com/p/giordano-5996-5838764-1.jpg","https://static-my.zacdn.com/p/giordano-5996-5838764-2.jpg","https://static-my.zacdn.com/p/giordano-5996-5838764-3.jpg","https://static-my.zacdn.com/p/giordano-5996-5838764-4.jpg","https://static-my.zacdn.com/p/giordano-5996-5838764-5.jpg"]}
|
||||
{"brand":"GAP","code":"gap-7655-7195541","name":"Gap Original Arch Crew","price":"143.36","imgs":["https://static-my.zacdn.com/p/gap-7655-7195541-1.jpg"]}
|
||||
{"brand":"Under Armour","code":"under-armour-3483-5826724","name":"Performance Tech™ Unisex 3-Pack Crew Socks","price":"52.43","imgs":["https://static-my.zacdn.com/p/under-armour-3483-5826724-1.jpg","https://static-my.zacdn.com/p/under-armour-3483-5826724-2.jpg","https://static-my.zacdn.com/p/under-armour-3483-5826724-3.jpg"]}
|
||||
{"brand":"Electro Denim Lab","code":"electro-denim-lab-8086-0906954","name":"Chino Pants","price":"65.00","imgs":["https://static-my.zacdn.com/p/electro-denim-lab-8086-0906954-1.jpg","https://static-my.zacdn.com/p/electro-denim-lab-8086-0906954-2.jpg","https://static-my.zacdn.com/p/electro-denim-lab-8086-0906954-3.jpg","https://static-my.zacdn.com/p/electro-denim-lab-8086-0906954-4.jpg","https://static-my.zacdn.com/p/electro-denim-lab-8086-0906954-5.jpg"]}
|
||||
{"brand":"Tommy Hilfiger","code":"tommy-hilfiger-2147-5964664","name":"Flag Raglan Sleeves Relaxed Jersey T-Shirt - Th Mainline","price":"179.94","imgs":["https://static-my.zacdn.com/p/tommy-hilfiger-2147-5964664-1.jpg"]}
|
||||
{"brand":"Cotton On","code":"cotton-on-6238-0809164","name":"3-Pack Essential Crew Socks","price":"33.05","imgs":["https://static-my.zacdn.com/p/cotton-on-6238-0809164-1.jpg"]}
|
||||
{"brand":"Cotton On","code":"cotton-on-4057-8012493","name":"Everyday Fleece Shorts","price":"60.95","imgs":["https://static-my.zacdn.com/p/cotton-on-4057-8012493-1.jpg"]}
|
||||
{"brand":"Cotton On","code":"cotton-on-6382-0063854","name":"Box Fit Graphic Hoodie","price":"142.03","imgs":["https://static-my.zacdn.com/p/cotton-on-6382-0063854-1.jpg"]}
|
||||
{"brand":"Trendyol","code":"trendyol-3907-9299634","name":"Grimelanj Regular Fit Plaid Knitted Shorts Pajama Set TMNSS25PT00034","price":"79.03","imgs":["https://static-my.zacdn.com/p/trendyol-3907-9299634-1.jpg"]}
|
||||
{"brand":"Camel Active","code":"camel-active-5848-1383954","name":"C BY CAMEL ACTIVE OUTDOOR HIKING TRAVEL BACKPACK - 51250081","price":"269.00","imgs":["https://static-my.zacdn.com/p/camel-active-5848-1383954-1.jpg","https://static-my.zacdn.com/p/camel-active-5848-1383954-2.jpg","https://static-my.zacdn.com/p/camel-active-5848-1383954-3.jpg","https://static-my.zacdn.com/p/camel-active-5848-1383954-4.jpg","https://static-my.zacdn.com/p/camel-active-5848-1383954-5.jpg","https://static-my.zacdn.com/p/camel-active-5848-1383954-6.jpg","https://static-my.zacdn.com/p/camel-active-5848-1383954-7.jpg"]}
|
||||
{"brand":"Lacoste","code":"lacoste-8093-9302554","name":"Cotton T-shirt","price":"258.30","imgs":["https://static-my.zacdn.com/p/lacoste-8093-9302554-1.jpg"]}
|
||||
{"brand":"Under Armour","code":"under-armour-6462-2343264","name":"Under Armour UA Tech™ Vent Men's Short Sleeve","price":"83.44","imgs":["https://static-my.zacdn.com/p/under-armour-6462-2343264-1.jpg","https://static-my.zacdn.com/p/under-armour-6462-2343264-2.jpg","https://static-my.zacdn.com/p/under-armour-6462-2343264-3.jpg","https://static-my.zacdn.com/p/under-armour-6462-2343264-4.jpg","https://static-my.zacdn.com/p/under-armour-6462-2343264-5.jpg"]}
|
||||
{"brand":"Tommy Hilfiger","code":"tommy-hilfiger-1355-5874664","name":"Pool Slide Sandals - TH Mainline","price":"191.94","imgs":["https://static-my.zacdn.com/p/tommy-hilfiger-1355-5874664-1.jpg"]}
|
||||
{"brand":"Calvin Klein","code":"calvin-klein-3281-1558554","name":"Low Rise Trunks 3 Pack - Calvin Klein Underwear","price":"180.45","imgs":["https://static-my.zacdn.com/p/calvin-klein-3281-1558554-1.jpg"]}
|
||||
{"brand":"Swiss Polo","code":"swiss-polo-9699-7969744","name":"Men's Backpack - Black","price":"103.55","imgs":["https://static-my.zacdn.com/p/swiss-polo-9699-7969744-1.jpg","https://static-my.zacdn.com/p/swiss-polo-9699-7969744-2.jpg"]}
|
||||
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Replace product 129's square thumbnail with the reframed 762x1100 version.
|
||||
|
||||
The source was a 2400x2400 square. The storefront grid renders at aspect-3/4
|
||||
with object-cover, so a 1:1 image loses 12.5% off each side — enough to cut the
|
||||
heel and the toe off the shoe. Every other shoe in the catalogue is 762x1100
|
||||
with the subject at ~93% width, so this reframes 129 to match its siblings.
|
||||
|
||||
Uploads the new asset at position 1, verifies it is really there, then removes
|
||||
the square original. --apply is required; the default is a dry run.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
import os as _os
|
||||
|
||||
|
||||
def _spree_admin_key() -> str:
|
||||
"""The Spree admin API key. Required — never defaulted."""
|
||||
key = _os.environ.get("SPREE_ADMIN_KEY", "").strip()
|
||||
if not key:
|
||||
raise SystemExit(
|
||||
"SPREE_ADMIN_KEY is not set.\n"
|
||||
" export SPREE_ADMIN_KEY=<the admin API key>\n"
|
||||
"It is deliberately not stored in this file — see README.md."
|
||||
)
|
||||
return key
|
||||
|
||||
|
||||
SPREE_API = "https://api.jux-lab.com/api/v3/admin"
|
||||
SPREE_KEY = _spree_admin_key()
|
||||
SLUG = "129-adidas-lifestyle-barreda-shoes-men-white-jp5928"
|
||||
NEW_IMAGE = pathlib.Path(
|
||||
"/root/Opus/Projects/PeakyBuildr/scraped-catalogue/products/"
|
||||
"129-adidas-lifestyle-barreda-shoes-men-white-jp5928/image-1-reframed.jpg"
|
||||
)
|
||||
|
||||
|
||||
def fatal(msg):
|
||||
print(f"FATAL: {msg}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def multipart(fields, file_field, filename, blob, content_type):
|
||||
boundary = uuid.uuid4().hex
|
||||
parts = []
|
||||
for k, v in fields.items():
|
||||
parts.append(
|
||||
f'--{boundary}\r\nContent-Disposition: form-data; name="{k}"\r\n\r\n{v}\r\n'.encode()
|
||||
)
|
||||
parts.append(
|
||||
f'--{boundary}\r\nContent-Disposition: form-data; name="{file_field}"; '
|
||||
f'filename="{filename}"\r\nContent-Type: {content_type}\r\n\r\n'.encode()
|
||||
+ blob
|
||||
+ b"\r\n"
|
||||
)
|
||||
parts.append(f"--{boundary}--\r\n".encode())
|
||||
return b"".join(parts), f"multipart/form-data; boundary={boundary}"
|
||||
|
||||
|
||||
def api(path, method="GET", data=None, ctype=None):
|
||||
headers = {"X-Spree-Api-Key": SPREE_KEY}
|
||||
if ctype:
|
||||
headers["Content-Type"] = ctype
|
||||
req = urllib.request.Request(
|
||||
f"{SPREE_API}{path}", data=data, headers=headers, method=method
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=180) as r:
|
||||
raw = r.read()
|
||||
return r.status, (json.loads(raw) if raw else {})
|
||||
except urllib.error.HTTPError as e:
|
||||
fatal(f"{method} {path} -> HTTP {e.code}: {e.read().decode()[:400]}")
|
||||
|
||||
|
||||
# This Spree build serves flat objects, not JSON:API — the fields are on the
|
||||
# object itself, not under "attributes" — and it pages on `limit`, ignoring
|
||||
# `per_page` entirely.
|
||||
def find_product(slug):
|
||||
page = 1
|
||||
while True:
|
||||
_, d = api(f"/products?limit=100&page={page}")
|
||||
for p in d.get("data", []):
|
||||
if p.get("slug") == slug:
|
||||
return p
|
||||
if page >= d.get("meta", {}).get("pages", 1):
|
||||
return None
|
||||
page += 1
|
||||
|
||||
|
||||
def media_list(pid):
|
||||
_, d = api(f"/products/{pid}/media?limit=100")
|
||||
return d.get("data", [])
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--apply", action="store_true", help="actually write to Spree")
|
||||
args = ap.parse_args()
|
||||
|
||||
if not NEW_IMAGE.exists():
|
||||
fatal(f"{NEW_IMAGE} missing")
|
||||
|
||||
p = find_product(SLUG)
|
||||
if not p:
|
||||
fatal(f"product {SLUG} not found")
|
||||
pid = p["id"]
|
||||
print(f"product {pid} {p.get('name')}")
|
||||
|
||||
before = media_list(pid)
|
||||
print(f"media before: {len(before)}")
|
||||
for m in before:
|
||||
print(f" id={m['id']} position={m.get('position')} {str(m.get('original_url',''))[:80]}")
|
||||
|
||||
if not args.apply:
|
||||
print("\nDRY RUN — re-run with --apply to upload and remove the square original.")
|
||||
return
|
||||
|
||||
old_ids = [m["id"] for m in before]
|
||||
|
||||
body, ctype = multipart(
|
||||
{"type": "Spree::Image", "position": "1"},
|
||||
"attachment",
|
||||
NEW_IMAGE.name,
|
||||
NEW_IMAGE.read_bytes(),
|
||||
"image/jpeg",
|
||||
)
|
||||
status, resp = api(f"/products/{pid}/media", "POST", body, ctype)
|
||||
new_id = (resp.get("data") or resp).get("id")
|
||||
print(f"\nupload -> HTTP {status}, new asset id={new_id}")
|
||||
if status == 202 or not new_id:
|
||||
fatal("Spree answered without a serialized asset — cannot confirm the image landed")
|
||||
|
||||
# Re-read rather than trusting the create response: media#create has a path
|
||||
# that enqueues a background job and answers 202 with no body.
|
||||
after = media_list(pid)
|
||||
if new_id not in [m["id"] for m in after]:
|
||||
fatal(f"asset {new_id} is not in the product media after upload")
|
||||
print(f"verified: asset {new_id} present, {len(after)} media on product")
|
||||
|
||||
for oid in old_ids:
|
||||
st, _ = api(f"/products/{pid}/media/{oid}", "DELETE")
|
||||
print(f"removed old asset {oid} -> HTTP {st}")
|
||||
|
||||
final = media_list(pid)
|
||||
print(f"\nmedia after: {len(final)}")
|
||||
for m in final:
|
||||
print(f" id={m['id']} position={m.get('position')} {str(m.get('original_url',''))[:80]}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Reprice all products from the source MYR catalogue.
|
||||
|
||||
Replaces the prices written by fix_prices.py, which multiplied by 4.8 instead of
|
||||
dividing — putting every product on the store at ~23x its real price.
|
||||
|
||||
Writes BOTH currency rows the store supports:
|
||||
ZAR (store default) = price_myr * MYR_TO_ZAR
|
||||
USD (still in supported_currencies) = price_myr * MYR_TO_USD
|
||||
|
||||
The USD row is corrected in the same pass on purpose: leaving the old 23x rows
|
||||
behind means anyone switching the store currency to USD sees the broken prices
|
||||
again, with nothing to warn them.
|
||||
|
||||
Rates are fetched live and printed. No hardcoded rate, no fallback rate — if the
|
||||
fetch fails this exits rather than pricing a live store off a guess.
|
||||
|
||||
Usage:
|
||||
reprice_zar.py --dry-run # show the changes, write nothing (do this first)
|
||||
reprice_zar.py # apply, then read back and check every row
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
import os as _os
|
||||
|
||||
|
||||
def _spree_admin_key() -> str:
|
||||
"""The Spree admin API key. Required — never defaulted."""
|
||||
key = _os.environ.get("SPREE_ADMIN_KEY", "").strip()
|
||||
if not key:
|
||||
raise SystemExit(
|
||||
"SPREE_ADMIN_KEY is not set.\n"
|
||||
" export SPREE_ADMIN_KEY=<the admin API key>\n"
|
||||
"It is deliberately not stored in this file — see README.md."
|
||||
)
|
||||
return key
|
||||
|
||||
|
||||
API = "https://api.jux-lab.com/api/v3/admin"
|
||||
H = {"X-Spree-Api-Key": _spree_admin_key(), "Content-Type": "application/json"}
|
||||
PRODUCTS = Path("/root/Opus/Projects/PeakyBuildr/scraped-catalogue/products")
|
||||
DRY = "--dry-run" in sys.argv
|
||||
|
||||
|
||||
def fatal(msg):
|
||||
print(f"\nFATAL: {msg}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# --- rates -------------------------------------------------------------------
|
||||
def get_rates():
|
||||
"""Live MYR base rates. Loud failure beats a stale constant — see fix_prices.py."""
|
||||
try:
|
||||
d = requests.get("https://open.er-api.com/v6/latest/MYR", timeout=20).json()
|
||||
except Exception as e:
|
||||
fatal(f"rate fetch failed: {e}")
|
||||
r = d.get("rates") or {}
|
||||
zar, usd = r.get("ZAR"), r.get("USD")
|
||||
if not zar or not usd:
|
||||
fatal(f"rate response missing ZAR/USD: {d}")
|
||||
print(f"rates 1 MYR = {zar} ZAR = {usd} USD ({d.get('time_last_update_utc')})")
|
||||
return float(zar), float(usd)
|
||||
|
||||
|
||||
# --- source catalogue --------------------------------------------------------
|
||||
def load_source():
|
||||
prices = {}
|
||||
for folder in sorted(PRODUCTS.iterdir()):
|
||||
info = folder / "info.json"
|
||||
if not info.exists():
|
||||
continue
|
||||
myr = json.loads(info.read_text()).get("price_myr")
|
||||
if myr is None:
|
||||
fatal(f"{folder.name}/info.json has no price_myr")
|
||||
prices[folder.name] = float(myr)
|
||||
if not prices:
|
||||
fatal(f"no source prices found under {PRODUCTS}")
|
||||
print(f"source {len(prices)} products, MYR {min(prices.values()):.2f}-{max(prices.values()):.2f}")
|
||||
return prices
|
||||
|
||||
|
||||
def fetch_products():
|
||||
items, page = [], 1
|
||||
while True:
|
||||
d = requests.get(f"{API}/products?per_page=100&page={page}", headers=H, timeout=60).json()
|
||||
items += d.get("data", [])
|
||||
if page >= d.get("meta", {}).get("pages", 1):
|
||||
break
|
||||
page += 1
|
||||
return items
|
||||
|
||||
|
||||
def main():
|
||||
zar_rate, usd_rate = get_rates()
|
||||
src = load_source()
|
||||
items = fetch_products()
|
||||
print(f"store {len(items)} products")
|
||||
|
||||
planned, missing = {}, []
|
||||
for p in items:
|
||||
slug = p["slug"]
|
||||
myr = src.get(slug)
|
||||
if myr is None:
|
||||
missing.append(slug)
|
||||
continue
|
||||
planned[p["id"]] = {
|
||||
"slug": slug,
|
||||
"myr": myr,
|
||||
"ZAR": round(myr * zar_rate, 2),
|
||||
"USD": round(myr * usd_rate, 2),
|
||||
}
|
||||
|
||||
if missing:
|
||||
# Not fatal: these keep their current prices. But they must be named, not
|
||||
# silently skipped — a product left at 23x is the whole bug again.
|
||||
print(f"\n{len(missing)} products have NO source price and will be LEFT UNCHANGED:")
|
||||
for s in missing:
|
||||
print(f" ! {s}")
|
||||
|
||||
sample = list(planned.values())[:5]
|
||||
print(f"\n{len(planned)} to reprice. Sample:")
|
||||
for s in sample:
|
||||
print(f" MYR {s['myr']:>7.2f} -> ZAR {s['ZAR']:>8.2f} / USD {s['USD']:>7.2f} {s['slug'][:44]}")
|
||||
zars = [v["ZAR"] for v in planned.values()]
|
||||
print(f" new ZAR range: {min(zars):.2f} - {max(zars):.2f}")
|
||||
|
||||
if DRY:
|
||||
print("\n--dry-run: nothing written.")
|
||||
return
|
||||
|
||||
# --- apply ---------------------------------------------------------------
|
||||
print()
|
||||
ok = fail = 0
|
||||
for n, (pid, s) in enumerate(planned.items(), 1):
|
||||
body = {"prices": [
|
||||
{"amount": s["ZAR"], "currency": "ZAR"},
|
||||
{"amount": s["USD"], "currency": "USD"},
|
||||
]}
|
||||
try:
|
||||
r = requests.patch(f"{API}/products/{pid}", headers=H, json=body, timeout=60).json()
|
||||
except Exception as e:
|
||||
fail += 1
|
||||
print(f" ERROR {s['slug'][:40]}: {e}")
|
||||
continue
|
||||
if "error" in r:
|
||||
fail += 1
|
||||
print(f" FAIL {s['slug'][:40]}: {r['error'].get('message')}")
|
||||
else:
|
||||
ok += 1
|
||||
if n % 40 == 0:
|
||||
print(f" {n}/{len(planned)}")
|
||||
time.sleep(0.12)
|
||||
print(f"patched ok={ok} fail={fail}")
|
||||
|
||||
# --- read back -----------------------------------------------------------
|
||||
# The store returns 200 for a price write that never created a row in the
|
||||
# store currency. Trusting that 200 is what hid 130 products for an hour.
|
||||
print("\nreading back...")
|
||||
time.sleep(2)
|
||||
bad = []
|
||||
for p in fetch_products():
|
||||
want = planned.get(p["id"])
|
||||
if not want:
|
||||
continue
|
||||
a = p.get("attributes", p)
|
||||
got = a.get("price")
|
||||
cur = a.get("currency")
|
||||
if got is None or abs(float(got) - want["ZAR"]) > 0.01 or cur != "ZAR":
|
||||
bad.append((want["slug"], want["ZAR"], got, cur))
|
||||
if bad:
|
||||
print(f"\n{len(bad)} products did NOT take the ZAR price:")
|
||||
for slug, want, got, cur in bad[:15]:
|
||||
print(f" ! {slug[:40]} want ZAR {want} got {got} {cur}")
|
||||
sys.exit(1)
|
||||
print(f"check ok: all {len(planned)} products report the expected ZAR price.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
# 🏷️ MOCKS — every fake value in Pak Jabit
|
||||
|
||||
**The convention:** anything standing in for a real credential, rate, or record is
|
||||
tagged `#mock`. Going live = find them all, replace them, delete this list.
|
||||
|
||||
```bash
|
||||
# storefront + backend files
|
||||
rg '#mock' ~/pak-jabit-store ~/pak-jabit
|
||||
```
|
||||
|
||||
🔴 **`rg` cannot find half of them.** Mocks that live in the Spree **database** —
|
||||
payment methods, test products, test orders, test users — have no file to grep.
|
||||
This file is the authority; the inline tags are a convenience. When you add a
|
||||
mock, add it here first.
|
||||
|
||||
⚠️ **Never tag an env var inline.** Docker Compose's `env_file` does not strip
|
||||
trailing comments — it takes the rest of the line as the value, so
|
||||
`PHOTOROOM_API_KEY=abc123 #mock` makes the key literally `abc123 #mock` and
|
||||
Photoroom fails with no error anywhere. The tag goes on its **own line above**,
|
||||
which is safe in both Compose and dotenv.
|
||||
|
||||
---
|
||||
|
||||
## Files — `rg '#mock'` finds these
|
||||
|
||||
### Storefront `~/pak-jabit-store/.env.production` (celest-proto, 34.21.217.116)
|
||||
|
||||
| Var | Why it's mock | Replace with |
|
||||
|---|---|---|
|
||||
| `EASYPARCEL_CLIENT_ID` | Malaysia-only courier, left from the MY phase | Bob Go credentials |
|
||||
| `EASYPARCEL_CLIENT_SECRET` | same | Bob Go credentials |
|
||||
| `STORE_POSTCODE` | `10150` = Penang, Malaysia | real SA warehouse postcode |
|
||||
| `STORE_STATE` | `MY-07` = Penang, Malaysia | real SA province |
|
||||
|
||||
The whole EasyParcel lane (`src/lib/easyparcel.ts`, `src/app/api/shipping/*`,
|
||||
`src/app/api/webhooks/easyparcel`) is MY-only and dead for a South African store.
|
||||
It points at `https://api.easyparcel.com` — the production host, not a sandbox.
|
||||
It is not wired into the SA checkout path; it just hasn't been removed.
|
||||
|
||||
### Backend `~/pak-jabit/.env` (pak-jabit-backend, 35.247.170.3)
|
||||
|
||||
| Var | Why it's mock | Replace with |
|
||||
|---|---|---|
|
||||
| `PHOTOROOM_API_KEY` | sandbox key — **every output is watermarked** | live key from `/root/.photoroom/credentials` (house box), 10/month |
|
||||
|
||||
### Storefront env — `SPREE_CARD_PAYMENT_METHOD_ID`
|
||||
|
||||
Tagged in `.env.production`. Holds `pm_EfhxLZ9ck8`, the prefixed id of the Bogus
|
||||
gateway. Repoint it at the Payfast payment method when that lands. The route that
|
||||
reads it (`src/app/api/auth/cards/setup/route.ts`) errors loud with an explicit
|
||||
message when it is unset — it never guesses an id, because a wrong one would
|
||||
attach cards to whichever gateway happened to match.
|
||||
|
||||
That route is itself `#mock`-shaped: it does BOTH halves of card setup in one
|
||||
request, which only works because Bogus needs no card entry. A real gateway must
|
||||
split it — server creates the session, the CLIENT collects the card in the
|
||||
gateway's hosted field against `external_client_secret`, then the server
|
||||
completes. Card numbers must never reach our server. The file says so at the top.
|
||||
|
||||
---
|
||||
|
||||
## Database — grep will never find these
|
||||
|
||||
### Payment methods
|
||||
|
||||
| Name | Class | Why it's mock |
|
||||
|---|---|---|
|
||||
| `Test Card (sandbox)` | `Spree::Gateway::Bogus` | Fake card gateway, added 2026-09-15. Accepts `4111111111111111` (Visa), `5555555555554444` (MC), `378282246310005` (Amex), `6011111111111117` (Discover); **anything else is a forced decline**, which is how to test the failure path. `show_in_admin?` is false so it stays out of the admin list. |
|
||||
| `Test Payment` | `Spree::PaymentMethod::Check` | Offline placeholder. No money moves. |
|
||||
|
||||
Both carry `#mock` in their `description` column, visible in admin.
|
||||
|
||||
**Replace with:** Payfast, once NSH's merchant registration lands. Payflex after.
|
||||
Deactivate both of these before launch — a live store must not offer a gateway
|
||||
that approves every card ending in the right digits.
|
||||
|
||||
### Shipping methods
|
||||
|
||||
| Name | Why it's mock |
|
||||
|---|---|
|
||||
| `Standard Delivery (South Africa)` | Flat **R99**, zone `South Africa` (ZA only), added 2026-09-15. The number is a PLACEHOLDER, not a quoted rate — it exists so SA checkout can complete at all. Replace with Bob Go once the SA courier account is registered. |
|
||||
|
||||
Before this, ZA sat in the `Africa` zone with no method attached and every
|
||||
physical method was bound to Malaysia/US, so a South African buyer got no quote
|
||||
and checkout dead-ended.
|
||||
|
||||
### Shipping rates per market — all placeholders
|
||||
|
||||
| Market | Method | Rate |
|
||||
|---|---|---|
|
||||
| South Africa (ZAR) | `Standard Delivery (South Africa)` | R99 |
|
||||
| Malaysia (MYR) | `EasyParcel Domestic` | **live EasyParcel quote** — not mock |
|
||||
| United States (USD) | `Free Shipping` | $0 |
|
||||
|
||||
🔴 **ZA and US are placeholders; Malaysia is real.** MY charges the courier the customer picked, verified server-side against a live EasyParcel quote. They exist so each market can complete a
|
||||
checkout at all. A shipping rate only applies when its calculator's currency
|
||||
matches the ORDER's currency — every non-SA method was priced in a currency no
|
||||
order used, which produced no rate, no fulfillment, and a checkout with nothing
|
||||
to charge. That, not zones or stock, is why Malaysian checkout died silently.
|
||||
|
||||
`Standard Shipping` (USD $15) sits in the Malaysia zone and will never offer —
|
||||
wrong currency for that market. Harmless, but it is noise to clear when the MY
|
||||
lane is removed.
|
||||
|
||||
### Products
|
||||
|
||||
| Slug | Why it's mock |
|
||||
|---|---|
|
||||
| `studio-test-chinos` | Built 2026-09-15 proving the Image Studio pipe end to end. Kept deliberately as the worked example for colleagues testing the image editor. R259.11, watermarked sandbox images. **Delete before launch.** |
|
||||
|
||||
That's 1 of 161 products. The other 160 are real scraped stock.
|
||||
|
||||
### Orders
|
||||
|
||||
14 orders, 4 completed, all test. They are priced in the **old currency at the
|
||||
old 23× rate** — the newest completed one reads `$2,577.12`, which is neither
|
||||
ZAR nor a real price. They predate both the price fix and the ZAR switch.
|
||||
**Purge before launch** or the first real report is nonsense.
|
||||
|
||||
### Users
|
||||
|
||||
Test accounts matching `celeste-*@example.com` — created during API verification
|
||||
on 2026-09-15. No real customers exist yet.
|
||||
|
||||
---
|
||||
|
||||
## Not mock, but not real either
|
||||
|
||||
These aren't fake credentials; they're claims the store makes that nothing backs.
|
||||
|
||||
| Thing | Problem |
|
||||
|---|---|
|
||||
| **"Free shipping over R500"** | Hardcoded storefront copy. No shipping rule exists behind it. A buyer who qualifies gets charged anyway — or gets no quote at all, see below. |
|
||||
| **SA shipping** | ZA is in the `Africa` zone with **no shipping method attached**. Every physical method is bound to Malaysia/US. A South African buyer gets no quote and cannot complete checkout. Blocked on courier registration. |
|
||||
| **S/M/L/XL buttons** | Decorative. No `Spree::Variant` behind them — every product is one variant. |
|
||||
|
||||
---
|
||||
|
||||
*Started 2026-09-15. Add to this file when you add a mock, not after.*
|
||||
@@ -0,0 +1,169 @@
|
||||
# 🎨 Image Pipeline — Napkin Notes
|
||||
|
||||
## What the spec wants
|
||||
Seller uploads phone photo → system auto-standardizes → consistent product catalogue
|
||||
- Background removal
|
||||
- Colour correction
|
||||
- Lighting normalization
|
||||
- Resize/crop to exact same dimensions
|
||||
- Store processed image against product variant
|
||||
|
||||
## What we found (FAFO'd 2026-09-13)
|
||||
|
||||
### 🥇 Photoroom (recommended for production)
|
||||
- **URL:** https://app.photoroom.com/create (signed up as `celestjux`)
|
||||
- **Docs:** https://docs.photoroom.com/
|
||||
- **Pricing:** Basic $20/mo (BG removal only) · Plus $100/mo (full pipeline) · Enterprise custom
|
||||
- **Per image:** $0.02 (Basic) / $0.10 (Plus)
|
||||
- **Apparel-specific features:**
|
||||
- Ghost Mannequin — makes clothes look worn without a real model
|
||||
- Virtual Model — AI fashion models wearing the clothes
|
||||
- Virtual Try-On — customers see clothes on themselves
|
||||
- AI Ironing — smooth wrinkles on clothes
|
||||
- AI Relight — fix lighting
|
||||
- AI Shadows — realistic shadows
|
||||
- Product Staging, AI Backgrounds, AI Beautifier, AI Expand
|
||||
- **Batch:** yes, directory/Google Drive/Sheets
|
||||
- **Sandbox:** 1,000 free watermarked calls + 10 real for testing
|
||||
- **Verdict:** Does EVERYTHING the spec asks in one API call. $100/mo for 1K images.
|
||||
Ghost mannequin + AI ironing = killer for men's apparel.
|
||||
|
||||
### 🥈 Bria AI (good BG removal, lighter features)
|
||||
- **API key:** saved at `~/.bria/credentials`
|
||||
- **Skills installed:** `/root/.claude/skills/bria-skills/`
|
||||
- **Tested:** ✅ BG removal works (RMBG 2.0), 39% transparent pixels on test image
|
||||
- **Features:** BG removal, relighting, upscale, product photography, object editing
|
||||
- **No ghost mannequin, no AI ironing, no virtual try-on**
|
||||
- **Verdict:** Good for BG removal + basic edits. Fallback to Photoroom for apparel-specific stuff.
|
||||
|
||||
### 🥉 rembg (free, self-hosted, offline)
|
||||
- **Installed:** ✅ via pip, u2net model (170MB) cached at `/root/.rembg/models/`
|
||||
- **Tested:** ✅ works, decent edges, white-on-light handled ok
|
||||
- **Limit:** BG removal ONLY. No colour correction, no lighting, no crop.
|
||||
- **Speed:** ~20s on CPU per image
|
||||
- **Verdict:** Free forever. Good offline fallback. Need OpenCV/Pillow on top for the rest.
|
||||
|
||||
### Quick-RMBG / Claude Background Removal Plugin
|
||||
- **Repo:** cloned at `/mnt/playground/Playground_V2/Claude-Background-Removal-Plugin/`
|
||||
- **What it is:** Claude Code skill wrapper around rembg (single/two-pass/batch/Dolphin right-click)
|
||||
- **pipx install blocked** by ctx git shim — not installed yet
|
||||
- **Verdict:** Nice wrapper but not needed if we use Photoroom/Bria for the platform.
|
||||
|
||||
## Architecture decision
|
||||
|
||||
```
|
||||
Production: Photoroom Plus API ($100/mo)
|
||||
└─ one call = bg removal + lighting + shadows + crop + resize
|
||||
└─ ghost mannequin for clothes-on-invisible-model shots
|
||||
└─ AI ironing for wrinkle-free product photos
|
||||
|
||||
Fallback: Bria RMBG 2.0 (free tier / per-image)
|
||||
└─ BG removal when Photoroom is down or over quota
|
||||
|
||||
Offline: rembg + Pillow/OpenCV
|
||||
└─ dev/testing, batch processing without API costs
|
||||
```
|
||||
|
||||
## ✅ BUILT 2026-09-15 evening — the pipe is real, end to end
|
||||
|
||||
```
|
||||
phone/disk photo -> Image Studio (admin page) -> Photoroom /v2/edit
|
||||
-> unattached ActiveStorage blob -> review before/after
|
||||
-> Apply -> Spree product (existing, or created here)
|
||||
```
|
||||
|
||||
- **Admin page:** `/admin/image_studio` (sidebar, between Products and Customers).
|
||||
Multi-upload from the device, background + size presets, per-file before/after,
|
||||
then attach to an existing product **or create a new listing** (name, price,
|
||||
publish toggle). Nothing touches the catalogue until Apply — the result is held
|
||||
as an unattached blob, so Discard is just navigating away.
|
||||
- **CLI:** `scraped-catalogue/photoroom_to_spree.py --slug <slug> [--live]`
|
||||
- **Proven live:** a 6.52 MB phone PNG → 543 KB at 1000×1000; and two uploads →
|
||||
a new published product visible on the storefront at R259.11.
|
||||
|
||||
### The architecture question, settled
|
||||
|
||||
Abang's call, and he was right: **the page is needed.** I had argued for a silent
|
||||
auto-on-upload hook (the spec says *"seller uploads → system standardises"*), but
|
||||
a seller wants to choose the shot and see the result before it ships. Fully
|
||||
automatic means a bad cutout goes live silently and the customer finds it first.
|
||||
**Manual control at the front, programmatic pipe behind it.**
|
||||
|
||||
Photoroom's *Shopify Products* connector is a red herring for us — it exists for
|
||||
merchants with no backend. We have one, so the plain API is the simpler path.
|
||||
|
||||
### Money, accurately
|
||||
|
||||
| | Reality |
|
||||
|---|---|
|
||||
| API free tier | **10 images/month**, not 7 days. 9 left as of 2026-09-15 |
|
||||
| Sandbox | 1000/month, free, **every output watermarked** |
|
||||
| 7-day trial | That's the **web app**, a separate meter. Its Batch tool does **250 images at once** through the browser and costs no API credits |
|
||||
| Plus | $100/mo for the full pipeline |
|
||||
|
||||
🔴 **Photoroom adds little to the existing 160 products.** They are scraped
|
||||
studio shots already on white — background removal removes a background that is
|
||||
already gone. Across 160 first-images there are only **3 aspect ratios and 4
|
||||
sizes**; framing is the sole real defect, and Pillow squares them for free,
|
||||
offline. Photoroom earns its money on *phone photos of new stock*, which is
|
||||
exactly what Image Studio is for.
|
||||
|
||||
### Still open
|
||||
- [ ] **Batch** — capped at `MAX_PHOTOS = 8`, synchronous, ~3-5s per image. More
|
||||
needs a background job
|
||||
- [ ] Currently on the **sandbox** key, so everything is watermarked. Flip
|
||||
`PHOTOROOM_API_KEY` in the backend `.env` for clean output
|
||||
- [x] ~~Pillow square-and-pad backfill for the existing 160~~ — **scrapped 2026-09-15,
|
||||
measured and unnecessary.** See below.
|
||||
- [ ] Never tested on a real phone photo from Lisa — the actual input this exists
|
||||
for. Ghost mannequin and AI ironing have nothing to work with on a scraped
|
||||
studio shot
|
||||
- [ ] Virtual Try-On — could be a differentiator for the platform
|
||||
- [ ] Self-hosted option — for v2, when volume justifies building our own pipeline
|
||||
|
||||
## 📐 Framing survey — 2026-09-15, the backfill that wasn't needed
|
||||
|
||||
I had the Pillow square-and-pad backfill queued as the next job. Measuring the
|
||||
160 source images first killed it. The real distribution of **first images**:
|
||||
|
||||
| Size | Ratio | Count |
|
||||
|---|---|---|
|
||||
| 762×1100 | 0.693 | 122 |
|
||||
| 800×1200 / 801×1200 | 0.667 | 32 |
|
||||
| 1200×1800 | 0.667 | 4 |
|
||||
| 1524×2200 | 0.693 | 1 |
|
||||
| **2400×2400** | **1.000** | **1** |
|
||||
|
||||
The grid renders at `aspect-[3/4]` (0.75) with `object-cover`. At 0.693 the crop
|
||||
takes 7.6% of the height; at 0.667 it takes 11%. The **entire** inconsistency
|
||||
across 159 products is a 3.4% difference in vertical crop — invisible. Padding
|
||||
them to square would also be self-defeating: a square displayed in a 3/4 frame
|
||||
gets re-cropped, so the padding I just added is what gets cut.
|
||||
|
||||
🔴 **One image was genuinely broken** — `129-adidas-lifestyle-barreda-shoes-men-white-jp5928`,
|
||||
the only 1:1 in the catalogue. A square in a 3/4 frame loses **12.5% off each
|
||||
side**, which cut the heel and the toe off the shoe. Its 22 sibling shoe
|
||||
listings are all 762×1100 with the subject at 90-95% width and 24-33% height, so
|
||||
it was reframed to match: crop to the subject bbox, scale to 93% width, centre on
|
||||
the source's own background (234,238,239). 186KB → 65KB. Applied live via
|
||||
`reframe_129.py --apply` (upload at position 1, verify by re-read, then delete
|
||||
the original).
|
||||
|
||||
**The lesson, not the fix:** the napkin said "3 aspect ratios and 4 sizes,
|
||||
framing is the sole real defect" and I turned that into a 160-file job. The
|
||||
numbers were right and the conclusion was wrong — nobody had checked how far
|
||||
apart those ratios actually were. Measure the spread, not the count.
|
||||
|
||||
### Spree API shape — two gotchas that cost a run
|
||||
|
||||
- **Flat objects, not JSON:API.** `/api/v3/admin/products` returns fields on the
|
||||
object itself (`p["slug"]`), not under `p["attributes"]["slug"]`. The helper in
|
||||
`photoroom_to_spree.py` only survives because its fallback scan happens to use
|
||||
the flat form.
|
||||
- **`per_page` is silently ignored** — it pages on `limit`. Sending `per_page=100`
|
||||
gets you 25 rows and no error, so a scan that stops at page 1 "finds" nothing.
|
||||
Meta key is `pages`, not `total_pages`.
|
||||
|
||||
|
||||
**Keys:** `/root/.photoroom/credentials` (chmod 600) on the house box;
|
||||
`PHOTOROOM_API_KEY` in the backend `.env`.
|
||||
@@ -0,0 +1,109 @@
|
||||
# 💳 Payment & Courier — Napkin Notes
|
||||
|
||||
## Payment Gateways (SA Market)
|
||||
|
||||
### Recommended Stack
|
||||
| Gateway | What it covers | Fees | Priority |
|
||||
|---------|---------------|------|----------|
|
||||
| **Payfast** | Cards (Visa/MC), EFT, SnapScan, Masterpass | 3.5% + R2/tx | Must-have |
|
||||
| **Payflex** | Buy now pay later (4 instalments) | They pay you upfront | High — big conversion booster for fashion |
|
||||
| **Yoco** | Cards, tap-to-pay (if physical retail later) | 2.6-2.95%/tx | Nice-to-have |
|
||||
|
||||
### Why this stack
|
||||
- Payfast alone covers ~90% of SA payment methods
|
||||
- Payflex is the killer feature for apparel — "R1,500 jacket? Pay R375 × 4" removes price friction
|
||||
- Cards (Visa/MC/Amex) come free with Payfast
|
||||
- EFT = still king in SA, people trust bank transfers
|
||||
|
||||
### NOT needed (yet)
|
||||
- Stripe — limited SA support, overkill for local-first
|
||||
- PayPal — high fees, withdrawal pain in SA
|
||||
- Crypto — niche, not the market
|
||||
|
||||
### Integration notes
|
||||
- Spree has payment provider plugin architecture — write adapter, plug in API keys
|
||||
- Morne's company (NSH/INET) registers as merchant — they get the API keys
|
||||
- We build the pipe, they plug in credentials
|
||||
- Payfast has well-documented REST API + existing Spree community plugins
|
||||
|
||||
---
|
||||
|
||||
## Courier / Logistics (SA Market)
|
||||
|
||||
### Recommended: Bob Go (aggregator)
|
||||
- **One API** → connects to 10+ SA couriers
|
||||
- API docs: https://api-docs.bob.co.za/bobgo
|
||||
- Couriers included: The Courier Guy, Pargo, RAM, SkyNet, Internet Express, DPD/Dawn Wing
|
||||
- Features: compare rates, print waybills, track parcels
|
||||
- Shopify integration exists → Spree adapter needed
|
||||
|
||||
### What Bob Go solves (all 3 spec blockers)
|
||||
| Spec Action Item | Status |
|
||||
|-----------------|--------|
|
||||
| 🔴 Identify suitable SA courier/API | ✅ Bob Go = one API, all couriers |
|
||||
| 🔴 Confirm automated shipment creation | ✅ Bob Go API supports it |
|
||||
| 🔴 Confirm waybill generation | ✅ Bob Go handles waybills |
|
||||
|
||||
### Direct courier alternatives (if Bob Go doesn't fit)
|
||||
- **The Courier Guy** — most popular SA e-commerce courier
|
||||
- **Internet Express** — free API integration
|
||||
- **CourierIT** — volume discounts
|
||||
- **Evership** — another aggregator (Fastway, RAM, Aramex, TCG, DPD)
|
||||
|
||||
### Shipping zones consideration
|
||||
- SA is big — courier coverage varies by province
|
||||
- Major metros (Joburg, Cape Town, Durban) = next day
|
||||
- Rural = 3-5 days, some couriers don't cover
|
||||
- Bob Go handles zone logic per courier
|
||||
|
||||
---
|
||||
|
||||
## 🏷️ Mock Convention
|
||||
|
||||
All test/sandbox values tagged with `#mock` in code and config.
|
||||
When going live: `rg '#mock'` → find all → replace with production credentials.
|
||||
|
||||
```
|
||||
# Example in config:
|
||||
PAYFAST_MERCHANT_ID=10000100 #mock
|
||||
PAYFAST_MERCHANT_KEY=46f0cd694581a #mock
|
||||
PAYFAST_PASSPHRASE=jt7NOE43FZPn #mock
|
||||
PAYFAST_MODE=sandbox #mock → change to "live"
|
||||
BOBGO_API_KEY=test_xxxxxxxxxxxx #mock
|
||||
PAYFLEX_API_KEY=staging_xxxxx #mock
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
- [ ] Local SA only or international shipping?
|
||||
- [ ] Currency: ZAR only or multi-currency?
|
||||
- [ ] Who registers as merchant — NSH or a new entity?
|
||||
- [ ] Cash on delivery — needed? (Still popular outside metros)
|
||||
- [ ] Payflex merchant approval — timeline?
|
||||
|
||||
---
|
||||
|
||||
## Architecture Summary
|
||||
|
||||
```
|
||||
Buyer → Spree Storefront (Next.js)
|
||||
↓
|
||||
Checkout
|
||||
↓
|
||||
┌────┴────┐
|
||||
│ Payfast │ → Cards, EFT, SnapScan
|
||||
│ Payflex │ → Buy now pay later
|
||||
└────┬────┘
|
||||
↓
|
||||
Order Created
|
||||
↓
|
||||
Bob Go API → auto-select cheapest courier
|
||||
↓
|
||||
┌────┴────┐
|
||||
│ Waybill │ → PDF generated
|
||||
│ Tracking│ → number assigned
|
||||
└────┬────┘
|
||||
↓
|
||||
Customer gets tracking link
|
||||
```
|
||||
@@ -0,0 +1,111 @@
|
||||
# 🛒 Storefront — Napkin Notes
|
||||
|
||||
Ideas and landmines for the shopper-facing side. Raised by abang 2026-09-15,
|
||||
after the night we wired the region anchor and put the cart on the customer.
|
||||
|
||||
---
|
||||
|
||||
## 1. Currency chosen after the total, not before 💡
|
||||
|
||||
**The idea:** let someone shop, reach the total, and *then* pick what currency to
|
||||
pay in. More natural than being asked up front, before they know what anything
|
||||
costs.
|
||||
|
||||
**Why it's hard as things stand:** Spree fixes a cart's currency **and** market at
|
||||
creation and neither can be changed afterwards. That is exactly why the anchor
|
||||
had to come first — see `switchCurrency` in `CartProvider`, which cannot convert
|
||||
a cart and instead builds a new one and re-adds every line.
|
||||
|
||||
So "choose at the end" is really "rebuild the basket at the end." That already
|
||||
works (the switcher does it), it just gets more expensive the fuller the basket,
|
||||
and the totals move under the customer at the worst moment.
|
||||
|
||||
**Worth trying:** show every currency's total on the cart/checkout page as a
|
||||
read-only comparison, and only rebuild when they commit. They get the "see it
|
||||
first" benefit without a silent re-price. The price rows already exist — all 160
|
||||
products carry ZAR, USD and MYR.
|
||||
|
||||
---
|
||||
|
||||
## 2. 🔴 LANDMINE — one person, addresses in several countries
|
||||
|
||||
**The case:** a customer lives in South Africa, has an office in Kuala Lumpur,
|
||||
and family in the US. Perfectly ordinary. Our model does not survive it.
|
||||
|
||||
**The mechanism.** The region anchor ties three things into one choice, because a
|
||||
Spree market *is* all three:
|
||||
|
||||
```
|
||||
currency -> market -> shippable countries -> shipping methods
|
||||
```
|
||||
|
||||
So a customer anchored to ZAR can only deliver to ZA. To send something to their
|
||||
KL office they must switch to MYR, which **rebuilds the cart and re-prices the
|
||||
whole basket** — even though the only thing they wanted to change was where the
|
||||
parcel goes.
|
||||
|
||||
Worse, it is silent about the real reason. They'd read it as "this shop won't
|
||||
deliver to my office," not "delivery country is welded to display currency."
|
||||
|
||||
**What is actually separable:** currency and delivery country are independent in
|
||||
principle — you can price in ZAR and ship to Malaysia. What welds them here is
|
||||
that shipping methods are per-zone AND their calculators are per-currency (see
|
||||
`MOCKS.md`, the bug that killed Malaysian checkout). A ZAR order finds no rate in
|
||||
the Malaysia zone because every method there quotes in MYR.
|
||||
|
||||
**Possible shapes, none built:**
|
||||
- **Delivery country separate from currency.** Give each zone a method in every
|
||||
supported currency. A ZAR order shipping to MY charges a ZAR rate. Most honest
|
||||
to how people actually shop; most rows to maintain.
|
||||
- **Ask on the address, not in the header.** The customer picks a delivery
|
||||
address first and the store says plainly: *"Delivering to Malaysia switches
|
||||
your prices to MYR."* Same restriction, but named instead of hidden.
|
||||
- **Single market, international rates.** Drop the multi-market idea, price
|
||||
everything in ZAR, treat non-SA as international shipping. Simplest by far, and
|
||||
it matches the actual NSH brief — a South African store.
|
||||
|
||||
⚠️ Decide this before more countries get added, not after. Every product carries
|
||||
three price rows already; that is three times the pricing surface to keep honest
|
||||
for a market nobody has confirmed we sell to.
|
||||
|
||||
---
|
||||
|
||||
## 3. Dark / light mode selection
|
||||
|
||||
The storefront is dark-only right now (`#0E0D0B` ground, `#EDE6DA` text,
|
||||
`#C8873A` ochre). Abang shops in Brave with a dark setup, so dark stays the
|
||||
default — this is about offering the choice, not flipping it.
|
||||
|
||||
- Respect `prefers-color-scheme` on first visit, then let an explicit toggle win
|
||||
- The palette is hardcoded as hex literals all through the components. A light
|
||||
theme means lifting those into CSS custom properties first — that refactor is
|
||||
most of the work, the theming itself is small after it
|
||||
- Store the choice next to the region anchor: localStorage for guests,
|
||||
`public_metadata` for signed-in customers (the plumbing for that exists now)
|
||||
|
||||
---
|
||||
|
||||
## 4. Admin — stock and restock, actually tested
|
||||
|
||||
Never exercised. Known state:
|
||||
|
||||
- Two stock locations: `Default` (no country) and `Shop location` (US), both
|
||||
active, both `propagate_all_variants = false`
|
||||
- 161 products, every one a single variant — the S/M/L/XL buttons on the product
|
||||
page are decorative, there is no `Spree::Variant` behind them
|
||||
- No restock flow has been run, and no out-of-stock path has been seen end to end
|
||||
|
||||
**Worth testing, in order:**
|
||||
1. Set a variant to 0 on hand → does the storefront hide it, or let it be added
|
||||
to a cart and fail at checkout?
|
||||
2. Backorderable on/off — which one do we want for Lisa's stock?
|
||||
3. Restock through admin → does the storefront pick it up, and how fast?
|
||||
4. Two locations with nothing propagated: which one is a new product stocked in?
|
||||
This is the likeliest silent trap.
|
||||
|
||||
Variants matter here too: real S/M/L/XL means per-size stock, which is the whole
|
||||
point of restocking apparel. Right now "medium out of stock" cannot be expressed.
|
||||
|
||||
---
|
||||
|
||||
*Started 2026-09-15 from abang's list. Add to it rather than remembering.*
|
||||
@@ -0,0 +1,346 @@
|
||||
# Pak Jabit Store — Project Manifest
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
Restructured 2026-09-15: backend and frontend now live on separate VMs, both
|
||||
under jux-lab.com. Vercel is no longer in the path.
|
||||
|
||||
```
|
||||
shop.jux-lab.com api.jux-lab.com + admin.jux-lab.com
|
||||
(celest-proto, 34.21.217.116) (pak-jabit-backend, 35.247.170.3)
|
||||
├── Next.js 16.3.5 storefront ├── Caddy (SSL)
|
||||
│ systemd: pak-jabit-store └── Docker Compose
|
||||
│ :3200 behind Caddy ├── Spree 5.6.1 (Rails 8.1.3)
|
||||
├── Tailwind 4 ├── PostgreSQL 18
|
||||
└── calls api.jux-lab.com └── Mailpit
|
||||
```
|
||||
|
||||
Also on celest-proto (unrelated): jux-lab.com landing, mmo.jux-lab.com,
|
||||
vibein.jux-lab.com, purge relay.
|
||||
|
||||
## 🔑 Credentials & Connections
|
||||
|
||||
| What | Value |
|
||||
|------|-------|
|
||||
| **Backend VM** | `pak-jabit-backend` · asia-southeast1-a · e2-medium · 35.247.170.3 |
|
||||
| **Frontend VM** | `celest-proto` · asia-southeast1-a · e2-custom-4-8192 · 34.21.217.116 |
|
||||
| **SSH** | `ssh -i /root/.ssh/google_compute_engine ijat@<ip>` |
|
||||
| **Storefront** | https://shop.jux-lab.com |
|
||||
| **Spree Admin** | https://admin.jux-lab.com (bare host redirects to the panel) |
|
||||
| **Admin Login** | juxtapo.opus@gmail.com / ayambasuhkaki |
|
||||
| **Storefront API** | https://api.jux-lab.com/api/v3/store/ |
|
||||
| **Admin API** | https://api.jux-lab.com/api/v3/admin/ |
|
||||
| **Publishable key** | pk_RVVf1fTUW1T4nW9FTFACm92D (header: X-Spree-Api-Key) |
|
||||
| **Secret key (admin)** | sk_ntDFxFE1KwiDwqGzpCBGTJ8N — all scopes |
|
||||
| **API Media** | `?expand=media` on product endpoint returns full gallery |
|
||||
| **Store Currency** | **ZAR** (switched 2026-09-15). Supported: ZAR, USD, MYR — one `spree_markets` row per currency; the `spree_stores.default_currency` COLUMN is ignored, `Spree::Stores::Markets` overrides the reader |
|
||||
| **Channel (Online Store)** | ch_UkLWZg9DAJ |
|
||||
| **GitHub Repo** | https://github.com/juxtapo9090/pak-jabit-store (private) |
|
||||
| **GCloud Project** | project-19f6479b-f9db-4588-92a |
|
||||
| **DNS** | Cloud DNS zone `jux-lab-com`, same project |
|
||||
| **Firewall Rule** | `allow-spree` (tcp:80,443) — applies to all instances. :3000 closed 2026-09-15; Caddy reaches Spree on 127.0.0.1:3000 |
|
||||
|
||||
## 🐳 Docker on the backend VM
|
||||
|
||||
```bash
|
||||
ssh -i /root/.ssh/google_compute_engine ijat@35.247.170.3
|
||||
cd ~/pak-jabit
|
||||
docker compose up -d # start
|
||||
docker compose down # stop
|
||||
docker logs pak-jabit-web-1 # spree logs
|
||||
docker exec pak-jabit-web-1 bin/rails runner "..." # rails console (~30s boot)
|
||||
```
|
||||
|
||||
⚠️ `docker compose restart` does NOT re-read `.env`. Use `docker compose up -d`
|
||||
after any env change.
|
||||
|
||||
## 🖥️ Storefront on celest-proto
|
||||
|
||||
```bash
|
||||
ssh ijat@34.21.217.116
|
||||
cd ~/pak-jabit-store
|
||||
npm run build && sudo systemctl restart pak-jabit-store
|
||||
sudo journalctl -u pak-jabit-store -f
|
||||
```
|
||||
|
||||
⚠️ Delete `.env.local` before building — Next.js gives it precedence over
|
||||
`.env.production`, and the Vercel-era copy points at the old backend.
|
||||
|
||||
## 📦 Products
|
||||
|
||||
- **160 products** live (30 original + 130 imported 2026-09-15)
|
||||
- Scraped from Zalora (source prices are **MYR**), 500+ images in ActiveStorage
|
||||
- Priced from source at the live rate 2026-09-15: ZAR R99.26-R3185.09 · USD $6.11-$196.02 · MYR RM24.90-RM799.00
|
||||
- Imported via the Spree Admin API v3 (`scraped-catalogue/bulk_import.py`)
|
||||
- Brands: G-Shock, H&M, Lacoste, Tommy Hilfiger, Cotton On, GAP, Giordano,
|
||||
Superdry, Under Armour, Calvin Klein, Camel Active, Nike, Urban Revivo,
|
||||
Trendyol, Swiss Polo, and more
|
||||
|
||||
### 🔴 Five gates a product must pass to appear on the storefront
|
||||
|
||||
Learned the hard way twice — 130 products invisible on 2026-09-15 morning, and
|
||||
again that evening when Image Studio created products that looked perfect and
|
||||
showed nothing. The scope is `Spree::Product.available(Time.current, currency)`,
|
||||
defined in `spree_core-5.6.1/app/models/concerns/spree/product_scopes.rb:315`.
|
||||
Read it rather than guessing; every gate below is a clause in it.
|
||||
|
||||
1. **`status: 'active'`** and not discontinued.
|
||||
2. **A price row in the store currency.** `POST /products` silently ignores a
|
||||
scalar `"price"`. It needs `"prices": [{"amount": N, "currency": "ZAR"}]`.
|
||||
The clause is `with_currency(currency)`, which joins
|
||||
`variants_including_master: :prices` — no row in that exact currency, no
|
||||
product.
|
||||
3. **A `Spree::ProductPublication` row for the channel.** Visibility is
|
||||
**per-channel**, not per-product: `for_channel` INNER JOINs
|
||||
`spree_product_publications`, so a product with no publication row is dropped
|
||||
whatever its status. Channels here are `1 Online Store` and `2 Wholesale`.
|
||||
Creating a product in Rails does **not** create this row.
|
||||
4. **Purchasable.** Either stock on hand, or `track_inventory: false` on the
|
||||
master **plus a stock item per active location**. `track_inventory: false`
|
||||
alone is not enough — with zero stock items `in_stock?` is false,
|
||||
`purchasable?` is false, and the product is excluded.
|
||||
5. **Timestamps at or before `cutoff.beginning_of_minute`.** The scope truncates
|
||||
the cutoff DOWN to the minute, so `published_at = Time.current` reads as the
|
||||
*future* and the product stays hidden until the next minute ticks over. Set
|
||||
`Time.current.beginning_of_minute` or it looks broken for up to 59 seconds
|
||||
and then mysteriously fixes itself.
|
||||
|
||||
`Spree::Admin::ImageStudioController#build_new_product` does all five and then
|
||||
re-checks `available?` before reporting success — copy that shape.
|
||||
|
||||
|
||||
## 🎨 Design Tokens (THANDO)
|
||||
|
||||
| Token | Value |
|
||||
|-------|-------|
|
||||
| ink | #0E0D0B |
|
||||
| ochre | #C8873A |
|
||||
| bone | #EDE6DA |
|
||||
| bone-light | #F5F1EB |
|
||||
| ochre-dark | #A86E2D |
|
||||
| Font | Instrument Sans (Google Fonts) |
|
||||
| Default theme | Dark |
|
||||
|
||||
## ☁️ NextCloudPi (Team File Sharing)
|
||||
|
||||
| What | Value |
|
||||
|------|-------|
|
||||
| **URL** | https://cloudmi/ |
|
||||
| **WebDAV** | https://cloudmi/remote.php/dav/files/juxtapo/ |
|
||||
| **User** | juxtapo |
|
||||
| **App Password** | Bk7qS-Pe5zk-7gWoY-kpc6E-KWYky |
|
||||
| **Box** | C-fu's mirah (192.168.1.22) |
|
||||
|
||||
## 🚚 EasyParcel (MY Shipping)
|
||||
|
||||
| What | Value |
|
||||
|------|-------|
|
||||
| **Developer Hub** | https://developer.easyparcel.com |
|
||||
| **App Name** | Pak Jabit Store |
|
||||
| **Client ID** | 5be15621-f711-485b-bc79-15256747ce65 |
|
||||
| **Client Secret** | 9a1e611e-c4e2-4d1a-87c8-f695c1323e63 |
|
||||
| **Account** | Muhammad Aizat (#10935549) |
|
||||
| **Auth** | OAuth 2.0 |
|
||||
| **API Docs** | https://developer.easyparcel.com (Open API Documentation) |
|
||||
| **Sandbox** | Available (connect from app config page) |
|
||||
| **Redirect URI** | `https://shop.jux-lab.com/api/easyparcel/callback` — must match the developer hub exactly |
|
||||
| **Token store** | `~/pak-jabit-store/.easyparcel-token.json` (chmod 600) |
|
||||
| **Sender address** | `STORE_POSTCODE=10150` / `STORE_STATE=MY-07` (Penang) in `.env.production` |
|
||||
| **Coverage** | All 16 MY states verified live — 20 couriers peninsular, 14-15 Sabah/Sarawak |
|
||||
| **Re-auth** | Open the callback URL in a browser; EasyParcel asks for login, then writes the token |
|
||||
|
||||
### EasyParcel token handling (fixed 2026-09-15)
|
||||
|
||||
The access token lives 10 hours and **cannot** be extended — that is EasyParcel's
|
||||
server-side policy, and it does not need extending: `getToken()` renews it with the
|
||||
refresh token automatically, and writes the rotated refresh token back.
|
||||
|
||||
🔴 **The original code kept the token in a module-level variable**, so every restart,
|
||||
redeploy or serverless cold start dropped it and the next quote died with "run OAuth
|
||||
flow first". On Vercel it would have looked random. Now persisted to disk — verified
|
||||
by restarting the service mid-session and getting 20 live quotes straight after.
|
||||
|
||||
🔴 **`req.nextUrl.origin` is wrong behind Caddy** — it resolved to `localhost:3200`
|
||||
and produced a redirect URI EasyParcel would reject. Use `APP_BASE_URL` instead.
|
||||
|
||||
## ⚠️ Known Issues / TODO
|
||||
|
||||
- [x] ~~Currency USD → ZAR~~ done 2026-09-15, all 160 products in ZAR/USD/MYR
|
||||
- [ ] Payment gateway not wired (Payfast — see napkin-payment-courier.md)
|
||||
- [ ] Courier: EasyParcel is in for MY; Bob Go still needed for SA
|
||||
- [ ] No product variants (size/colour) — every product is master-only
|
||||
- [ ] `track_inventory: false` on all products — fine for demo, not for real stock
|
||||
- [ ] Admin panel has no lockout or rate limiting — Devise `lockable` is off and
|
||||
there is no rack-attack, so login attempts are unlimited. Worth an IP
|
||||
allowlist or basic auth on the admin.jux-lab.com Caddy block before this
|
||||
goes anywhere real
|
||||
- [ ] Old Spree containers still on celest-proto (stopped, volumes intact) —
|
||||
delete once the new backend has proven itself for a few days
|
||||
- [ ] No backups on the backend VM — daily snapshots exist for celest-proto only
|
||||
|
||||
## 🗺️ Planned — customer data & image pipeline
|
||||
|
||||
### Customer account data (UI only — the data layer already exists)
|
||||
Spree ships this; we just haven't surfaced it in the storefront.
|
||||
|
||||
| Need | Where it already lives |
|
||||
|---|---|
|
||||
| Saved addresses | `spree_addresses` · `GET/POST/PATCH/DELETE /api/v3/store/customers/me/addresses` |
|
||||
| Saved cards | `spree_credit_cards` · `GET /api/v3/store/customers/me/credit_cards` |
|
||||
|
||||
🔴 **Never store raw card data.** `spree_credit_cards` holds `last_digits`,
|
||||
`cc_type`, `month`/`year`, and `gateway_customer_id` / `gateway_payment_profile_id`
|
||||
— a **gateway token**, not the PAN, and no CVV column at all. That is the
|
||||
PCI-correct design and it is already correct. Cards only populate once a gateway
|
||||
(Payfast) is wired, so this sequences after payment.
|
||||
|
||||
### Photoroom batch pipeline
|
||||
|
||||
✅ **Built 2026-09-15 evening.** Two ways in, same pipe:
|
||||
|
||||
- **Image Studio** — custom Spree admin page at `/admin/image_studio`. Upload
|
||||
photos from the device (multiple, `accept="image/*"` so a phone opens the
|
||||
camera roll) → Photoroom → before/after review → attach to an existing product
|
||||
or create a new listing. Nothing touches the catalogue until Apply; discarding
|
||||
is just navigating away, since the result is held as an unattached blob.
|
||||
- **CLI** — `scraped-catalogue/photoroom_to_spree.py --slug <slug> [--live]
|
||||
[--position 1]`. Same pipe, scriptable, with a read-back check.
|
||||
|
||||
🔴 **Batch is NOT built.** One at a time, capped at `MAX_PHOTOS = 8` per request
|
||||
because each image is a 3-5s synchronous round trip and more starts risking the
|
||||
web server timeout. A real batch needs a background job.
|
||||
|
||||
**Keys** live at `/root/.photoroom/credentials` (chmod 600) on the house box, and
|
||||
`PHOTOROOM_API_KEY` in the backend `.env`. Currently the **sandbox** key —
|
||||
1000/month free, every output watermarked. The live key has **10 images/month**
|
||||
on the free tier (9 left as of 2026-09-15); the 7-day trial is the *web app*, a
|
||||
separate meter from API credits, and its Batch tool does 250 images at once
|
||||
through the browser without touching API quota.
|
||||
|
||||
⚠️ Photoroom adds little to the scraped catalogue — those images are already
|
||||
studio shots on white, and the only real inconsistency is framing (2 aspect
|
||||
ratios, 4 sizes across 160 products). Its value is phone photos of new stock,
|
||||
which is what Image Studio is for. A free Pillow square-and-pad would fix the
|
||||
existing 160.
|
||||
|
||||
## 📜 Session Log — 2026-09-14 (midnight build)
|
||||
|
||||
1. Discovered old `spree-app` VM deleted, `celest-proto` available with jux-lab.com
|
||||
2. Installed Docker, cloned spree_starter, `docker compose up`
|
||||
3. Created admin user, loaded 30 products via Rails script
|
||||
4. Uploaded 76 product images to Spree ActiveStorage
|
||||
5. Published products to "Online Store" channel (`Spree::ProductPublication`)
|
||||
6. Scaffolded Next.js storefront with THANDO design tokens
|
||||
7. Wired storefront to Spree API v3 (`X-Spree-Api-Key`, `?expand=media`)
|
||||
8. Deployed to Vercel (pak-jabit-store-juxtapo9090.vercel.app)
|
||||
9. Pushed to GitHub (juxtapo9090/pak-jabit-store)
|
||||
10. Luc found `?expand=media` param for full image gallery
|
||||
11. Built Zalora-style vertical thumbnail gallery (ProductImages component)
|
||||
12. Hardcoded dark theme as default
|
||||
|
||||
## 📜 Session Log — 2026-09-15 (restructure)
|
||||
|
||||
1. Found the "deleted" `spree-app` VM alive in the `jux-mmo` project — stopped,
|
||||
then deleted it. Its admin key never matched because each Spree instance
|
||||
HMACs tokens with its own `SECRET_KEY_BASE`.
|
||||
2. Imported 130 products via the Spree Admin API v3 (proper API, not Rails
|
||||
runner — the presentation story is "automated import pipeline").
|
||||
3. Spent an hour on 130 invisible products. Three stacked causes, all silent:
|
||||
no `published_at`, not purchasable, and no price row at all.
|
||||
4. Built `pak-jabit-backend` VM, migrated Postgres + 207MB ActiveStorage.
|
||||
Kept `SECRET_KEY_BASE` so API keys and image URLs stayed valid.
|
||||
5. Split the domain: api + admin → backend VM, shop → celest-proto.
|
||||
6. Moved the storefront off Vercel onto celest-proto (systemd + Caddy).
|
||||
A leftover `.env.local` from Vercel silently built it against the old
|
||||
backend twice before we caught it.
|
||||
7. Deleted the Vercel project. One project, two VMs, one domain.
|
||||
8. Added `admin.jux-lab.com` — panel split from the API by hostname, so it can be
|
||||
locked down separately later. Tested in a real browser: no bounce back to api.
|
||||
9. Security pass: admin email off `spree@example.com`, password off `spree123`,
|
||||
and closed `:3000` at the firewall (it was open to the internet over plain
|
||||
HTTP, bypassing Caddy entirely).
|
||||
10. Cart proven end-to-end on the new stack: create → add 2x item → $5,154.24.
|
||||
|
||||
## 📜 Session Log — 2026-09-15 (evening: ZAR, currency switcher, Image Studio, fail2ban)
|
||||
|
||||
1. **Backups** — `Projects/PeakyBuildr/backup-pak-jabit.sh`, nightly 04:30 via
|
||||
`pak-jabit-backup.timer` on the house box → `/root/Backups/pak-jabit`.
|
||||
pg_dump + ActiveStorage rsync + `.env` + compose + **the bind-mounted custom
|
||||
code** (none of which is in the database). Restore proven into a throwaway
|
||||
PG18 cluster: 160 products, 3200 blobs, 0 blobs missing their file.
|
||||
`RESTORE.md` sits beside the dumps.
|
||||
2. **The 23x price bug** — `fix_prices.py:6` set `MYR_TO_USD = 4.8` and
|
||||
*multiplied*. 4.8 is the USD→MYR rate; MYR→USD is a divide. Every product was
|
||||
listed at 4.8² = **23.04x** its real price (a MYR 537 watch at $2,577).
|
||||
Repriced all 160 from source at the live rate.
|
||||
3. **USD → ZAR** — store now quotes rand. 160 rows each in ZAR/USD/MYR.
|
||||
4. **Currency switcher** on the storefront (ZAR · USD · MYR), URL-param driven so
|
||||
the server fetches real Spree price rows — nothing converted in the browser.
|
||||
The cart deliberately ignores it and stays on the store default.
|
||||
5. **Image Studio** — custom admin page, see the Photoroom section above.
|
||||
6. **fail2ban** — three jails, see Security below.
|
||||
|
||||
## 🔒 Security (2026-09-15 evening)
|
||||
|
||||
**fail2ban**, enabled at boot on the backend VM, banning at iptables so offenders
|
||||
never reach Caddy or Rails:
|
||||
|
||||
| Jail | Trigger | Ban |
|
||||
|---|---|---|
|
||||
| `caddy-admin-login` | 8 POSTs to `/admin_users/sign_in` in 10 min | 24h |
|
||||
| `caddy-scan` | 25 × 4xx in 10 min | 1h |
|
||||
| `sshd` | 5 failed SSH in 10 min | 24h |
|
||||
|
||||
`ignoreip = 127.0.0.1/8 ::1 34.21.217.116 27.125.248.0/24` — celest-proto must
|
||||
never be banned (it pulls product images constantly; banning it takes the shop
|
||||
down), and the /24 is abang's ISP block, which his public IP rotates inside.
|
||||
An allports ban on that range would also kill SSH to the box.
|
||||
|
||||
Caddy has **no `rate_limit` module** in the standard build, which is why this is
|
||||
fail2ban and not a Caddy directive.
|
||||
|
||||
🔴 **Caddy had no access log until tonight** — errors only, so successful
|
||||
requests left no trace whatsoever. Now JSON to journald (a file under
|
||||
`/var/log/caddy/` fails to open even with the directory writable, and Caddy
|
||||
fails the *whole service* on start rather than skipping the log — it dropped the
|
||||
site for about a minute finding that out).
|
||||
|
||||
⚠️ **Unresolved:** `42.188.122.106` (Windows, Brave 153, carrying a session
|
||||
cookie) requested `/admin/api_keys`, `/admin/store/edit` and `/admin/admin_users`.
|
||||
Abang's browser is Linux on `27.125.248.0/24` — different machine. Those specific
|
||||
requests 502'd mid-restart, but with no access logs before tonight any earlier
|
||||
success is invisible. Most likely a colleague, since abang expects colleagues to
|
||||
have access. `sign_in_count` is 0 but Devise trackable looks disabled, so that
|
||||
number proves nothing.
|
||||
|
||||
🔴 **There is exactly ONE admin account**, shared. Every session is
|
||||
indistinguishable, which is precisely why the above cannot be attributed. Give
|
||||
each person their own account (Settings → Users) before more people get access.
|
||||
|
||||
## 💣 Landmines hit today
|
||||
|
||||
Every one of these returned success while being wrong. Worth knowing before
|
||||
touching this stack again.
|
||||
|
||||
| Landmine | What it looked like | The actual cause |
|
||||
|---|---|---|
|
||||
| **Scalar `price` is silently dropped** | `POST /products` returns 200, product exists, price is `null` | Spree wants `prices: [{amount, currency}]`. No price row in the store currency → excluded from `available(Time.current, currency)` → invisible on the storefront, no error anywhere |
|
||||
| **`published_at` left null** | `add_products` returns `{product_count: 130}` | The publication row is created but not live unless you pass `published_at` |
|
||||
| **Not purchasable** | Product active, available, published | No stock and `track_inventory: true`. Needs stock or `track_inventory: false` |
|
||||
| **`.env.local` beats `.env.production`** | Build succeeds, only 30 of 160 pages | Next.js precedence. The Vercel-era file pointed at the old backend, which was still running and still serving 30 products — so the build read stale data and reported nothing wrong. Cost two full rebuilds |
|
||||
| **`docker compose restart` ignores `.env`** | Env edited, behaviour unchanged | `restart` reuses the existing container config. Needs `up -d` |
|
||||
| **Admin API key looked broken** | `find_by_secret_token` → no match, every time | The key was copied from a *different* Spree instance. Each one HMACs tokens with its own `SECRET_KEY_BASE` |
|
||||
| **"Deleted" VM was still running** | Manifest said deleted; nothing in the project list | It was in a different GCP project (`jux-mmo`), still billing |
|
||||
| **OAuth token in a module variable** | Shipping worked, then randomly didn't | Process memory dies on restart/redeploy/cold start. Persist the refresh token to disk or DB |
|
||||
| **`req.nextUrl.origin` behind a proxy** | OAuth redirect_uri came out as `localhost:3200` | Next.js sees the internal listener, not the public host. Use an explicit `APP_BASE_URL` |
|
||||
| **100KB API body cap** | Image upload → `request_too_large` on a 115KB jpg | `Spree::Api::Config[:max_request_body_size]` default. 🔴 **The fix recorded here was never live.** `config/initializers/api_body_limit.rb` existed on the host with the 10MB value but the container never loaded it — the compose file mounts no app code, so the file was dead. Fixed by bind-mounting it (2026-09-15 evening). A runtime assignment also does nothing, but not for the reason written before: it isn't read at boot, it just isn't *persisted*, so it only affects the process that ran it |
|
||||
| **Prebuilt image mounts no app code** | Initializer written, never ran | `ghcr.io/spree/spree` ships its own `/rails`. Anything custom needs an explicit bind mount in `docker-compose.yml`. This silently killed the 10MB body-limit fix for a whole day |
|
||||
| **`add_routes` from an initializer is a no-op** | Sidebar entry appears, path helper does not exist | Spree draws engine routes BEFORE `config/initializers` run. Routes must go in `config/routes.rb` |
|
||||
| **New asset defaults to position 1** | Appended an image, it became the product thumbnail | Spree defaults `position` to 1. A watermarked sandbox image went live as a public thumbnail this way. Always send an explicit position |
|
||||
| **`max(:position)` on `media` misses half the gallery** | Appended image landed mid-gallery at position 2 | A gallery is the UNION of product-level `media` and legacy assets on the master variant. Clear both |
|
||||
| **`product.stores << store`** | `NoMethodError` on a Relation | Products `belongs_to :store` (singular) in 5.6. Use `store:` on create |
|
||||
| **`published_at = Time.current` hides the product** | Created, active, priced, stocked — invisible for up to 59s | The scope compares against `cutoff.beginning_of_minute`, truncating DOWN. Use `Time.current.beginning_of_minute` |
|
||||
| **`curl -o` loses binary bodies here** | HTTP 200, `content-length: 758983`, zero-byte file | The rtk proxy layer eats it. Use Python for binary downloads on the house box |
|
||||
|
||||
Full write-up: `/mnt/playground/Server_files/Debug-Logs/2026-09-15_celeste_pak-jabit-restructure-and-invisible-products.md`
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"access_token":"eyJhbGciOiJIUzI1NiJ9.eyJhcHAiOnsiY2xpZW50X2lkIjoiNWJlMTU2MjEtZjcxMS00ODViLWJjNzktMTUyNTY3NDdjZTY1In0sInVzZXIiOnsiaWQiOiIyMGYyYjk0NTIzOWZhNmE2YjAwODI2ODM1ZmJhNWQ5YlI0Wlo3SVhTdS9BM0dLb2lzWUNFdEE9PSIsImVhc3lfYWNjb3VudF9pZCI6ImFhZDdhYTBjLWVmYjMtNDVkMi1iNzgwLTlkYTgzYmRiZDhlMiIsImFjY291bnRfaWQiOiIxMDkzNTU3MSJ9LCJhY2NvdW50X2FwcF91dWlkIjoiYzI5ZjI5YzYtY2M1My00YjE1LWE4NTEtNjY3OGUxN2JmMDk1IiwiaWF0IjoxNzg5NDcxMTQ5LCJpc3MiOiJlYXN5cGFyY2VsIiwiYXVkIjoiZWFzeXBhcmNlbCIsImV4cCI6MTc4OTUwNzE0OX0.fJ-INDL0sJb9bPqiJWlaiuZ08Aw9yhRYdPHHYn6rkNs","refresh_token":"de885b0754817f63c445f278703166c83535eb7455e149962cfda8305c9bab11","expires_at":1789507149231}
|
||||
@@ -0,0 +1,16 @@
|
||||
NEXT_PUBLIC_SPREE_API_URL=https://api.jux-lab.com
|
||||
NEXT_PUBLIC_SPREE_API_KEY=pk_RVVf1fTUW1T4nW9FTFACm92D
|
||||
# #mock — Malaysia-only courier, left over from the MY phase. The store sells to South Africa now — Bob Go replaces this.
|
||||
EASYPARCEL_CLIENT_ID=5be15621-f711-485b-bc79-15256747ce65
|
||||
# #mock — see EASYPARCEL_CLIENT_ID above.
|
||||
EASYPARCEL_CLIENT_SECRET=9a1e611e-c4e2-4d1a-87c8-f695c1323e63
|
||||
EASYPARCEL_TOKEN_PATH=/home/ijat/pak-jabit-store/.easyparcel-token.json
|
||||
APP_BASE_URL=https://shop.jux-lab.com
|
||||
# #mock — 10150 is Penang, Malaysia — placeholder origin for EasyParcel quotes. Needs the real SA warehouse postcode.
|
||||
STORE_POSTCODE=10150
|
||||
# #mock — MY-07 is Penang, Malaysia. Needs the real SA province.
|
||||
STORE_STATE=MY-07
|
||||
# #mock — prefixed id of the card gateway. Currently points at Spree::Gateway::Bogus
|
||||
# ("Test Card (sandbox)"), which approves any card and mints one ending 4242.
|
||||
# Repoint at the Payfast payment method once NSH merchant registration lands. See MOCKS.md.
|
||||
SPREE_CARD_PAYMENT_METHOD_ID=pm_EfhxLZ9ck8
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
.next/
|
||||
.vercel
|
||||
|
||||
# .env.production is committed on purpose — see ../LANDMINE.md. This repo lives
|
||||
# on the house's private Gitea; a clone that runs beats a secrets handover.
|
||||
# Local-only overrides still stay out:
|
||||
.env.local
|
||||
@@ -0,0 +1,9 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||
|
||||
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
@@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/types/root-params.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "api.jux-lab.com",
|
||||
},
|
||||
{
|
||||
protocol: "http",
|
||||
hostname: "api.jux-lab.com",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+6896
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "pak-jabit-store",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "16.3.5",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.3.5",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
@@ -0,0 +1,705 @@
|
||||
"use client";
|
||||
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCountries } from "@/lib/geo";
|
||||
import { useCart } from "@/components/CartProvider";
|
||||
import { CURRENCIES, parseCurrency, type CurrencyCode } from "@/lib/currency";
|
||||
import {
|
||||
listAddresses,
|
||||
createAddress,
|
||||
updateAddress,
|
||||
deleteAddress,
|
||||
listCards,
|
||||
addTestCard,
|
||||
deleteCard,
|
||||
updateProfile,
|
||||
Address,
|
||||
AddressInput,
|
||||
SavedCard,
|
||||
} from "@/lib/auth";
|
||||
|
||||
const LABEL_PRESETS = ["Home", "Office"];
|
||||
|
||||
const EMPTY_ADDRESS: AddressInput = {
|
||||
label: "Home",
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
address1: "",
|
||||
address2: "",
|
||||
city: "",
|
||||
state_name: "",
|
||||
postal_code: "",
|
||||
phone: "",
|
||||
country_iso: "ZA",
|
||||
};
|
||||
|
||||
const inputClass =
|
||||
"w-full bg-[#0E0D0B] border border-[#EDE6DA]/20 rounded-lg px-3 py-2 text-[#EDE6DA] text-sm placeholder:text-[#EDE6DA]/30 focus:border-[#C8873A] focus:outline-none";
|
||||
const labelClass = "block text-xs text-[#EDE6DA]/50 mb-1";
|
||||
|
||||
export default function AccountPage() {
|
||||
const { user, token, logout, isLoading, setSession } = useAuth();
|
||||
const { currency, switchCurrency, switching, cart } = useCart();
|
||||
const [regionError, setRegionError] = useState("");
|
||||
|
||||
const [addresses, setAddresses] = useState<Address[]>([]);
|
||||
const [cards, setCards] = useState<SavedCard[]>([]);
|
||||
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form, setForm] = useState({ first_name: "", last_name: "", phone: "" });
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
const [addrForm, setAddrForm] = useState<AddressInput>(EMPTY_ADDRESS);
|
||||
const [addrEditingId, setAddrEditingId] = useState<string | null>(null);
|
||||
const [addrOpen, setAddrOpen] = useState(false);
|
||||
const [addrSaving, setAddrSaving] = useState(false);
|
||||
const [addrError, setAddrError] = useState("");
|
||||
|
||||
const [cardBusy, setCardBusy] = useState(false);
|
||||
const [cardError, setCardError] = useState("");
|
||||
|
||||
// Countries come from Spree, already filtered to the store's checkout zone,
|
||||
// each carrying its own subdivisions. Picking a country repopulates the
|
||||
// province list from that country's real data.
|
||||
const {
|
||||
countries,
|
||||
states,
|
||||
statesRequired,
|
||||
error: geoError,
|
||||
} = useCountries(addrForm.country_iso);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setForm({
|
||||
first_name: user.first_name || "",
|
||||
last_name: user.last_name || "",
|
||||
phone: user.phone || "",
|
||||
});
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const refreshAddresses = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
setAddresses(await listAddresses(token));
|
||||
setAddrError("");
|
||||
} catch (e) {
|
||||
setAddrError(e instanceof Error ? e.message : "Could not load addresses");
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
const refreshCards = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
setCards(await listCards(token));
|
||||
setCardError("");
|
||||
} catch (e) {
|
||||
setCardError(e instanceof Error ? e.message : "Could not load cards");
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshAddresses();
|
||||
refreshCards();
|
||||
}, [refreshAddresses, refreshCards]);
|
||||
|
||||
// The profile is the durable half of the anchor; localStorage is the local
|
||||
// half. Writing both keeps a customer in their own currency on a new device.
|
||||
async function chooseCurrency(code: CurrencyCode) {
|
||||
if (!token || code === currency || switching) return;
|
||||
setRegionError("");
|
||||
|
||||
if (cart && cart.items.length > 0) {
|
||||
const ok = confirm(
|
||||
`Switch to ${code}? Your ${cart.items.length} item${
|
||||
cart.items.length === 1 ? "" : "s"
|
||||
} stay in the basket and are re-priced in ${code}.`
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
try {
|
||||
await switchCurrency(code);
|
||||
const updated = await updateProfile(token, {
|
||||
public_metadata: { ...(user?.public_metadata || {}), preferred_currency: code },
|
||||
});
|
||||
const auth = JSON.parse(localStorage.getItem("pak-jabit-auth") || "{}");
|
||||
setSession(auth.token, auth.refreshToken, updated);
|
||||
} catch (e) {
|
||||
setRegionError(e instanceof Error ? e.message : "Could not save that preference");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!token) return;
|
||||
setSaving(true);
|
||||
setMessage("");
|
||||
try {
|
||||
const updated = await updateProfile(token, form);
|
||||
const auth = JSON.parse(localStorage.getItem("pak-jabit-auth") || "{}");
|
||||
setSession(auth.token, auth.refreshToken, updated);
|
||||
setMessage("Profile updated!");
|
||||
setEditing(false);
|
||||
} catch (e) {
|
||||
setMessage(e instanceof Error ? e.message : "Failed to update profile");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openNewAddress() {
|
||||
setAddrForm({
|
||||
...EMPTY_ADDRESS,
|
||||
first_name: user?.first_name || "",
|
||||
last_name: user?.last_name || "",
|
||||
phone: user?.phone || "",
|
||||
// The first address a customer saves becomes their default automatically in
|
||||
// Spree; past that they have to say so.
|
||||
is_default_shipping: addresses.length === 0,
|
||||
});
|
||||
setAddrEditingId(null);
|
||||
setAddrError("");
|
||||
setAddrOpen(true);
|
||||
}
|
||||
|
||||
function openEditAddress(a: Address) {
|
||||
setAddrForm({
|
||||
label: a.label || "",
|
||||
first_name: a.first_name,
|
||||
last_name: a.last_name,
|
||||
address1: a.address1,
|
||||
address2: a.address2 || "",
|
||||
city: a.city,
|
||||
state_name: a.state_name || "",
|
||||
postal_code: a.postal_code,
|
||||
phone: a.phone || "",
|
||||
country_iso: a.country_iso,
|
||||
});
|
||||
setAddrEditingId(a.id);
|
||||
setAddrError("");
|
||||
setAddrOpen(true);
|
||||
}
|
||||
|
||||
async function handleAddressSave() {
|
||||
if (!token) return;
|
||||
setAddrSaving(true);
|
||||
setAddrError("");
|
||||
try {
|
||||
if (addrEditingId) {
|
||||
await updateAddress(token, addrEditingId, addrForm);
|
||||
} else {
|
||||
await createAddress(token, addrForm);
|
||||
}
|
||||
await refreshAddresses();
|
||||
setAddrOpen(false);
|
||||
setAddrEditingId(null);
|
||||
} catch (e) {
|
||||
setAddrError(e instanceof Error ? e.message : "Could not save address");
|
||||
} finally {
|
||||
setAddrSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMakeDefault(id: string) {
|
||||
if (!token) return;
|
||||
try {
|
||||
await updateAddress(token, id, { is_default_shipping: true, is_default_billing: true });
|
||||
await refreshAddresses();
|
||||
} catch (e) {
|
||||
setAddrError(e instanceof Error ? e.message : "Could not set default");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteAddress(id: string) {
|
||||
if (!token) return;
|
||||
if (!confirm("Delete this address?")) return;
|
||||
try {
|
||||
await deleteAddress(token, id);
|
||||
await refreshAddresses();
|
||||
} catch (e) {
|
||||
setAddrError(e instanceof Error ? e.message : "Could not delete address");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddCard() {
|
||||
if (!token) return;
|
||||
setCardBusy(true);
|
||||
setCardError("");
|
||||
try {
|
||||
await addTestCard(token);
|
||||
await refreshCards();
|
||||
} catch (e) {
|
||||
setCardError(e instanceof Error ? e.message : "Could not add card");
|
||||
} finally {
|
||||
setCardBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteCard(id: string) {
|
||||
if (!token) return;
|
||||
setCardBusy(true);
|
||||
setCardError("");
|
||||
try {
|
||||
await deleteCard(token, id);
|
||||
await refreshCards();
|
||||
} catch (e) {
|
||||
setCardError(e instanceof Error ? e.message : "Could not remove card");
|
||||
} finally {
|
||||
setCardBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="max-w-lg mx-auto px-4 py-16 text-center text-[#EDE6DA]/50">Loading…</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="max-w-lg mx-auto px-4 py-16 text-center">
|
||||
<p className="text-[#EDE6DA]/60 mb-4">You need to sign in first.</p>
|
||||
<a href="/login" className="text-[#C8873A] hover:underline">
|
||||
Sign in →
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const set = (field: string) => (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setForm((f) => ({ ...f, [field]: e.target.value }));
|
||||
|
||||
const setAddr =
|
||||
(field: keyof AddressInput) =>
|
||||
(e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) =>
|
||||
setAddrForm((f) => ({ ...f, [field]: e.target.value }));
|
||||
|
||||
// A province from the old country is worse than none: it saves silently and
|
||||
// the courier gets an address that cannot exist.
|
||||
const setCountry = (e: React.ChangeEvent<HTMLSelectElement>) =>
|
||||
setAddrForm((f) => ({ ...f, country_iso: e.target.value, state_name: "" }));
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto px-4 py-8">
|
||||
<h1 className="text-2xl font-bold text-[#EDE6DA] mb-6">My Account</h1>
|
||||
|
||||
{/* Profile */}
|
||||
<div className="bg-[#1a1917] rounded-lg p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-semibold text-[#EDE6DA]">Profile</h2>
|
||||
{!editing && (
|
||||
<button
|
||||
onClick={() => setEditing(true)}
|
||||
className="text-sm text-[#C8873A] hover:underline"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editing ? (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className={labelClass}>First Name</label>
|
||||
<input value={form.first_name} onChange={set("first_name")} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Last Name</label>
|
||||
<input value={form.last_name} onChange={set("last_name")} className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Phone</label>
|
||||
<input
|
||||
value={form.phone}
|
||||
onChange={set("phone")}
|
||||
type="tel"
|
||||
placeholder="071 234 5678"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Email</label>
|
||||
<p className="text-sm text-[#EDE6DA]/40 px-3 py-2">{user.email}</p>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="bg-[#C8873A] text-white px-4 py-2 rounded-lg text-sm font-semibold hover:bg-[#A86E2D] disabled:opacity-60"
|
||||
>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditing(false);
|
||||
setMessage("");
|
||||
}}
|
||||
className="text-[#EDE6DA]/50 text-sm hover:text-[#EDE6DA]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<span className="text-sm text-[#EDE6DA]/50 w-20">Name</span>
|
||||
<span className="text-sm text-[#EDE6DA]">
|
||||
{user.first_name && user.last_name
|
||||
? `${user.first_name} ${user.last_name}`
|
||||
: "Not set"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="text-sm text-[#EDE6DA]/50 w-20">Email</span>
|
||||
<span className="text-sm text-[#EDE6DA]">{user.email}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="text-sm text-[#EDE6DA]/50 w-20">Phone</span>
|
||||
<span className="text-sm text-[#EDE6DA]">{user.phone || "Not set"}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{message && (
|
||||
<p
|
||||
className={`text-sm mt-3 ${
|
||||
message === "Profile updated!" ? "text-green-400" : "text-red-400"
|
||||
}`}
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Region */}
|
||||
<div className="bg-[#1a1917] rounded-lg p-6 mb-6">
|
||||
<h2 className="font-semibold text-[#EDE6DA] mb-1">Region & Currency</h2>
|
||||
<p className="text-xs text-[#EDE6DA]/40 mb-4">
|
||||
Sets the prices you see, the countries we can deliver to, and the delivery
|
||||
options at checkout.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{CURRENCIES.map((c) => (
|
||||
<button
|
||||
key={c.code}
|
||||
type="button"
|
||||
disabled={switching}
|
||||
onClick={() => chooseCurrency(c.code)}
|
||||
className={`px-4 py-2 rounded-lg text-sm border transition-colors disabled:opacity-50 ${
|
||||
currency === c.code
|
||||
? "bg-[#C8873A] border-[#C8873A] text-white font-semibold"
|
||||
: "border-[#EDE6DA]/20 text-[#EDE6DA]/70 hover:border-[#C8873A]"
|
||||
}`}
|
||||
>
|
||||
{c.symbol} {c.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{regionError && <p className="text-sm text-red-400 mt-3">{regionError}</p>}
|
||||
</div>
|
||||
|
||||
{/* Addresses */}
|
||||
<div className="bg-[#1a1917] rounded-lg p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-semibold text-[#EDE6DA]">Saved Addresses</h2>
|
||||
{!addrOpen && (
|
||||
<button
|
||||
onClick={openNewAddress}
|
||||
className="text-sm text-[#C8873A] hover:underline"
|
||||
>
|
||||
+ Add address
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{addrError && <p className="text-sm text-red-400 mb-3">{addrError}</p>}
|
||||
|
||||
{addrOpen && (
|
||||
<div className="border border-[#C8873A]/30 rounded-lg p-4 mb-4 space-y-3">
|
||||
<div>
|
||||
<label className={labelClass}>Label</label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{LABEL_PRESETS.map((l) => (
|
||||
<button
|
||||
key={l}
|
||||
type="button"
|
||||
onClick={() => setAddrForm((f) => ({ ...f, label: l }))}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm border transition-colors ${
|
||||
addrForm.label === l
|
||||
? "bg-[#C8873A] border-[#C8873A] text-white"
|
||||
: "border-[#EDE6DA]/20 text-[#EDE6DA]/70 hover:border-[#C8873A]"
|
||||
}`}
|
||||
>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
<input
|
||||
value={LABEL_PRESETS.includes(addrForm.label || "") ? "" : addrForm.label || ""}
|
||||
onChange={setAddr("label")}
|
||||
placeholder="Or type your own"
|
||||
className={`${inputClass} flex-1 min-w-[10rem]`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className={labelClass}>First Name</label>
|
||||
<input value={addrForm.first_name} onChange={setAddr("first_name")} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Last Name</label>
|
||||
<input value={addrForm.last_name} onChange={setAddr("last_name")} className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Street Address</label>
|
||||
<input value={addrForm.address1} onChange={setAddr("address1")} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Apartment, suite, etc. (optional)</label>
|
||||
<input value={addrForm.address2 || ""} onChange={setAddr("address2")} className={inputClass} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className={labelClass}>City</label>
|
||||
<input value={addrForm.city} onChange={setAddr("city")} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Postal Code</label>
|
||||
<input value={addrForm.postal_code} onChange={setAddr("postal_code")} className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Country</label>
|
||||
<select
|
||||
value={addrForm.country_iso}
|
||||
onChange={setCountry}
|
||||
className={inputClass}
|
||||
>
|
||||
{countries.length === 0 && (
|
||||
<option value={addrForm.country_iso}>Loading…</option>
|
||||
)}
|
||||
{countries.map((c) => (
|
||||
<option key={c.iso} value={c.iso}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{geoError && <p className="text-sm text-red-400 mt-1">{geoError}</p>}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className={labelClass}>
|
||||
{statesRequired ? "State / Province" : "Region (optional)"}
|
||||
</label>
|
||||
{statesRequired ? (
|
||||
<select
|
||||
value={addrForm.state_name || ""}
|
||||
onChange={setAddr("state_name")}
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="">Select…</option>
|
||||
{states.map((s) => (
|
||||
<option key={s.abbr || s.name} value={s.name}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
// Not every country has subdivisions. Forcing a dropdown with
|
||||
// nothing in it would block an otherwise valid address.
|
||||
<input
|
||||
value={addrForm.state_name || ""}
|
||||
onChange={setAddr("state_name")}
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Phone</label>
|
||||
<input
|
||||
value={addrForm.phone || ""}
|
||||
onChange={setAddr("phone")}
|
||||
type="tel"
|
||||
placeholder="071 234 5678"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!addrForm.is_default_shipping}
|
||||
onChange={(e) =>
|
||||
setAddrForm((f) => ({
|
||||
...f,
|
||||
is_default_shipping: e.target.checked,
|
||||
is_default_billing: e.target.checked,
|
||||
}))
|
||||
}
|
||||
className="w-4 h-4 accent-[#C8873A]"
|
||||
/>
|
||||
<span className="text-sm text-[#EDE6DA]/80">Use as my default address</span>
|
||||
</label>
|
||||
|
||||
<div className="flex gap-3 pt-1">
|
||||
<button
|
||||
onClick={handleAddressSave}
|
||||
disabled={addrSaving}
|
||||
className="bg-[#C8873A] text-white px-4 py-2 rounded-lg text-sm font-semibold hover:bg-[#A86E2D] disabled:opacity-60"
|
||||
>
|
||||
{addrSaving ? "Saving…" : addrEditingId ? "Save changes" : "Add address"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setAddrOpen(false);
|
||||
setAddrEditingId(null);
|
||||
setAddrError("");
|
||||
}}
|
||||
className="text-[#EDE6DA]/50 text-sm hover:text-[#EDE6DA]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{addresses.length === 0 && !addrOpen ? (
|
||||
<p className="text-[#EDE6DA]/50 text-sm">
|
||||
No saved addresses yet. Add one here and checkout will fill itself in.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{addresses.map((addr) => (
|
||||
<div key={addr.id} className="border border-[#EDE6DA]/10 rounded-lg p-4">
|
||||
<div className="flex items-start justify-between gap-3 mb-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{addr.label && (
|
||||
<span className="text-xs font-semibold px-2 py-0.5 rounded bg-[#C8873A]/20 text-[#C8873A]">
|
||||
{addr.label}
|
||||
</span>
|
||||
)}
|
||||
{addr.is_default_shipping && (
|
||||
<span className="text-xs px-2 py-0.5 rounded border border-[#EDE6DA]/25 text-[#EDE6DA]/60">
|
||||
Default
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<button
|
||||
onClick={() => openEditAddress(addr)}
|
||||
className="text-xs text-[#C8873A] hover:underline"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteAddress(addr.id)}
|
||||
className="text-xs text-red-400 hover:text-red-300"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[#EDE6DA] font-medium">
|
||||
{addr.first_name} {addr.last_name}
|
||||
</p>
|
||||
<p className="text-sm text-[#EDE6DA]/60 mt-1">{addr.address1}</p>
|
||||
{addr.address2 && <p className="text-sm text-[#EDE6DA]/60">{addr.address2}</p>}
|
||||
<p className="text-sm text-[#EDE6DA]/60">
|
||||
{addr.city} {addr.postal_code}
|
||||
{addr.state_name ? `, ${addr.state_name}` : ""}
|
||||
</p>
|
||||
<p className="text-sm text-[#EDE6DA]/40">{addr.country_name}</p>
|
||||
{addr.phone && <p className="text-sm text-[#EDE6DA]/60">{addr.phone}</p>}
|
||||
|
||||
{!addr.is_default_shipping && (
|
||||
<button
|
||||
onClick={() => handleMakeDefault(addr.id)}
|
||||
className="text-xs text-[#C8873A] hover:underline mt-3"
|
||||
>
|
||||
Set as default
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Payment methods */}
|
||||
<div className="bg-[#1a1917] rounded-lg p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<h2 className="font-semibold text-[#EDE6DA]">Payment Methods</h2>
|
||||
<button
|
||||
onClick={handleAddCard}
|
||||
disabled={cardBusy}
|
||||
className="text-sm text-[#C8873A] hover:underline disabled:opacity-50"
|
||||
>
|
||||
{cardBusy ? "Working…" : "+ Add card"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* #mock — the store runs the Bogus gateway until Payfast is registered.
|
||||
This banner has to go with it. */}
|
||||
<p className="text-xs text-[#EDE6DA]/40 mb-4">
|
||||
Sandbox mode — cards here are simulated and no money moves.
|
||||
</p>
|
||||
|
||||
{cardError && <p className="text-sm text-red-400 mb-3">{cardError}</p>}
|
||||
|
||||
{cards.length === 0 ? (
|
||||
<p className="text-[#EDE6DA]/50 text-sm">No saved cards yet.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{cards.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className="border border-[#EDE6DA]/10 rounded-lg p-4 flex items-center justify-between gap-3"
|
||||
>
|
||||
<div>
|
||||
<p className="text-[#EDE6DA] font-medium">
|
||||
{c.brand || "Card"} •••• {c.last4}
|
||||
</p>
|
||||
<p className="text-sm text-[#EDE6DA]/50">
|
||||
Expires {String(c.month).padStart(2, "0")}/{c.year}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDeleteCard(c.id)}
|
||||
disabled={cardBusy}
|
||||
className="text-xs text-red-400 hover:text-red-300 disabled:opacity-50"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sign out */}
|
||||
<button
|
||||
onClick={() => {
|
||||
logout();
|
||||
window.location.href = "/";
|
||||
}}
|
||||
className="text-red-400 hover:text-red-300 text-sm"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { bearer, proxy, unauthorized } from "@/lib/spreeProxy";
|
||||
|
||||
// PATCH /api/auth/addresses/:id -> edit, or set as default (is_default_shipping)
|
||||
// DELETE /api/auth/addresses/:id
|
||||
|
||||
export async function PATCH(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||
const token = bearer(req);
|
||||
if (!token) return unauthorized();
|
||||
const { id } = await ctx.params;
|
||||
return proxy(`/customers/me/addresses/${id}`, token, {
|
||||
method: "PATCH",
|
||||
body: await req.json(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||
const token = bearer(req);
|
||||
if (!token) return unauthorized();
|
||||
const { id } = await ctx.params;
|
||||
return proxy(`/customers/me/addresses/${id}`, token, { method: "DELETE" });
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { bearer, proxy, unauthorized } from "@/lib/spreeProxy";
|
||||
|
||||
// GET /api/auth/addresses -> list the customer's saved addresses
|
||||
// POST /api/auth/addresses -> create one
|
||||
//
|
||||
// `label` ("Home", "Office") is not permitted by stock Spree — the store API
|
||||
// answers 201 and silently saves nil. The backend carries a decorator that
|
||||
// permits and serializes it: config/initializers/address_label.rb on the
|
||||
// pak-jabit-backend box, bind-mounted into the container.
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const token = bearer(req);
|
||||
if (!token) return unauthorized();
|
||||
return proxy("/customers/me/addresses", token);
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const token = bearer(req);
|
||||
if (!token) return unauthorized();
|
||||
return proxy("/customers/me/addresses", token, {
|
||||
method: "POST",
|
||||
body: await req.json(),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { bearer, proxy, unauthorized } from "@/lib/spreeProxy";
|
||||
|
||||
// DELETE /api/auth/cards/:id
|
||||
export async function DELETE(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||
const token = bearer(req);
|
||||
if (!token) return unauthorized();
|
||||
const { id } = await ctx.params;
|
||||
return proxy(`/customers/me/credit_cards/${id}`, token, { method: "DELETE" });
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { bearer, proxy, unauthorized } from "@/lib/spreeProxy";
|
||||
|
||||
// GET /api/auth/cards -> the customer's saved cards
|
||||
export async function GET(req: NextRequest) {
|
||||
const token = bearer(req);
|
||||
if (!token) return unauthorized();
|
||||
return proxy("/customers/me/credit_cards", token);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { bearer, call, unauthorized } from "@/lib/spreeProxy";
|
||||
|
||||
// POST /api/auth/cards/setup -> save a card on the customer's account.
|
||||
//
|
||||
// #mock — this route does BOTH halves of the setup in one request, which only
|
||||
// works because the Bogus gateway needs no card data: its
|
||||
// complete_payment_setup_session ignores params and mints a card ending 4242.
|
||||
//
|
||||
// With a real gateway (Payfast, Stripe) the two halves MUST split:
|
||||
// 1. server creates the setup session and returns external_client_secret
|
||||
// 2. the CLIENT collects the card in the gateway's own hosted field/iframe
|
||||
// and confirms against that secret — card numbers never touch our server,
|
||||
// which is the whole point (PCI scope)
|
||||
// 3. server PATCHes .../complete
|
||||
// So when the gateway swaps, this file splits into a "create" route and a
|
||||
// "complete" route. Do not carry the one-shot shape over to real cards.
|
||||
|
||||
const PAYMENT_METHOD_ID = process.env.SPREE_CARD_PAYMENT_METHOD_ID;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const token = bearer(req);
|
||||
if (!token) return unauthorized();
|
||||
|
||||
// No fallback id here on purpose. Guessing one would either 404 or, worse,
|
||||
// attach cards to whichever gateway happened to match — silently wrong.
|
||||
if (!PAYMENT_METHOD_ID) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
message:
|
||||
"SPREE_CARD_PAYMENT_METHOD_ID is not set. Add it to .env.production — it is the prefixed id of the card gateway (see MOCKS.md).",
|
||||
},
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const created = await call("/customers/me/payment_setup_sessions", token, {
|
||||
method: "POST",
|
||||
body: { payment_method_id: PAYMENT_METHOD_ID },
|
||||
});
|
||||
if (created.status >= 300) {
|
||||
return NextResponse.json(created.data, { status: created.status });
|
||||
}
|
||||
|
||||
const sessionId = created.data.id as string | undefined;
|
||||
if (!sessionId) {
|
||||
return NextResponse.json(
|
||||
{ error: { message: "Spree created a setup session with no id" } },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
|
||||
const completed = await call(
|
||||
`/customers/me/payment_setup_sessions/${sessionId}/complete`,
|
||||
token,
|
||||
{ method: "PATCH", body: {} }
|
||||
);
|
||||
return NextResponse.json(completed.data, { status: completed.status });
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const SPREE_URL = process.env.NEXT_PUBLIC_SPREE_API_URL || "https://api.jux-lab.com";
|
||||
const API_KEY = process.env.NEXT_PUBLIC_SPREE_API_KEY || "";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { email, password } = await req.json();
|
||||
|
||||
const res = await fetch(`${SPREE_URL}/api/v3/store/auth/login`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Spree-Api-Key": API_KEY,
|
||||
},
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const SPREE_URL = process.env.NEXT_PUBLIC_SPREE_API_URL || "https://api.jux-lab.com";
|
||||
const API_KEY = process.env.NEXT_PUBLIC_SPREE_API_KEY || "";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
||||
if (!token) return NextResponse.json({ error: "No token" }, { status: 401 });
|
||||
|
||||
// Spree's store API wants the store key AND the customer token. Sending only
|
||||
// the Bearer answers 401 "Valid API key required" — which is what this route
|
||||
// did from the day it was written, so the profile never loaded at all.
|
||||
const res = await fetch(`${SPREE_URL}/api/v3/store/customers/me`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"X-Spree-Api-Key": API_KEY,
|
||||
},
|
||||
});
|
||||
const data = await res.json();
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
}
|
||||
|
||||
export async function PATCH(req: NextRequest) {
|
||||
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
||||
if (!token) return NextResponse.json({ error: "No token" }, { status: 401 });
|
||||
|
||||
const body = await req.json();
|
||||
const res = await fetch(`${SPREE_URL}/api/v3/store/customers/me`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
"X-Spree-Api-Key": API_KEY,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json();
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const SPREE_URL = process.env.NEXT_PUBLIC_SPREE_API_URL || "https://api.jux-lab.com";
|
||||
const API_KEY = process.env.NEXT_PUBLIC_SPREE_API_KEY || "";
|
||||
|
||||
// POST /api/auth/refresh — trade a refresh token for a fresh access token.
|
||||
//
|
||||
// Spree's access tokens expire after 3600s (Spree::Api::Config[:jwt_expiration]).
|
||||
// Before this route existed the storefront had no way to renew one, so every
|
||||
// customer went dead exactly one hour after signing in: still "signed in" on
|
||||
// screen, every request 401.
|
||||
export async function POST(req: NextRequest) {
|
||||
// A missing or malformed body must not 500 — req.json() throws on an empty one.
|
||||
let refresh_token: string | undefined;
|
||||
try {
|
||||
({ refresh_token } = await req.json());
|
||||
} catch {
|
||||
refresh_token = undefined;
|
||||
}
|
||||
|
||||
if (!refresh_token) {
|
||||
return NextResponse.json(
|
||||
{ error: { message: "No refresh token" } },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const res = await fetch(`${SPREE_URL}/api/v3/store/auth/refresh`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-Spree-Api-Key": API_KEY },
|
||||
body: JSON.stringify({ refresh_token }),
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
try {
|
||||
return NextResponse.json(text ? JSON.parse(text) : {}, { status: res.status });
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: { message: `Spree returned non-JSON (HTTP ${res.status})` } },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const SPREE_URL = process.env.NEXT_PUBLIC_SPREE_API_URL || "https://api.jux-lab.com";
|
||||
const API_KEY = process.env.NEXT_PUBLIC_SPREE_API_KEY || "";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { email, password } = await req.json();
|
||||
|
||||
const res = await fetch(`${SPREE_URL}/api/v3/store/customers`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Spree-Api-Key": API_KEY,
|
||||
},
|
||||
body: JSON.stringify({ email, password, password_confirmation: password }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const SPREE_URL = process.env.NEXT_PUBLIC_SPREE_API_URL || "https://api.jux-lab.com";
|
||||
const API_KEY = process.env.NEXT_PUBLIC_SPREE_API_KEY || "";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { pathname, body, method, spreeToken, userToken } = await req.json();
|
||||
|
||||
// Shipping price is derived from cart metadata, and /api/shipping/select is
|
||||
// what verifies it against a live courier quote. Letting a body through here
|
||||
// with metadata in it would route around that check entirely.
|
||||
if (body && typeof body === "object" && "metadata" in body) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
message: "Cart metadata cannot be set from the browser. Use /api/shipping/select.",
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Spree-Api-Key": API_KEY,
|
||||
};
|
||||
if (spreeToken) {
|
||||
headers["X-Spree-Token"] = spreeToken;
|
||||
}
|
||||
// The cart token proves possession of a cart; the Bearer proves who is
|
||||
// holding it. Ownership, association and "my cart" all need the second one.
|
||||
if (userToken) {
|
||||
headers["Authorization"] = `Bearer ${userToken}`;
|
||||
}
|
||||
|
||||
const res = await fetch(`${SPREE_URL}/api/v3/store${pathname}`, {
|
||||
method: method || "POST",
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const SPREE_URL = process.env.NEXT_PUBLIC_SPREE_API_URL || "https://api.jux-lab.com";
|
||||
const API_KEY = process.env.NEXT_PUBLIC_SPREE_API_KEY || "";
|
||||
|
||||
// GET /api/countries — the store's checkout countries, each with its states.
|
||||
//
|
||||
// Spree already filters this to the store's checkout zone (3 countries, not the
|
||||
// 240 in the database) and `?expand=states` attaches the subdivisions, so one
|
||||
// ~3.5KB call feeds the whole country→province cascade with no further requests.
|
||||
//
|
||||
// Public on purpose: a signed-out guest fills the same address form.
|
||||
//
|
||||
// force-dynamic, NOT `export const revalidate`: that prerenders the whole route
|
||||
// at BUILD time, so a correction to the state data in Spree stays invisible
|
||||
// until the next deploy. The route runs per request; the fetch below is what
|
||||
// carries the hour of caching, so Spree is still only hit once an hour.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
const res = await fetch(`${SPREE_URL}/api/v3/store/countries?expand=states`, {
|
||||
headers: { "X-Spree-Api-Key": API_KEY },
|
||||
next: { revalidate: 3600 },
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
try {
|
||||
return NextResponse.json(text ? JSON.parse(text) : { data: [] }, { status: res.status });
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: { message: `Spree returned non-JSON (HTTP ${res.status})` } },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { EP_BASE, clientId, basicAuth, setToken } from "@/lib/easyparcel";
|
||||
|
||||
// Behind Caddy, req.nextUrl.origin resolves to the internal listener
|
||||
// (localhost:3200). OAuth needs the public URL, and it must match the redirect
|
||||
// URI registered in the EasyParcel developer hub exactly.
|
||||
function callbackUrl(): string {
|
||||
const base = process.env.APP_BASE_URL;
|
||||
if (!base) {
|
||||
throw new Error("APP_BASE_URL is not set — cannot build the OAuth redirect URI");
|
||||
}
|
||||
return `${base}/api/easyparcel/callback`;
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const code = req.nextUrl.searchParams.get("code");
|
||||
if (!code) {
|
||||
const authUrl = `${EP_BASE}/oauth/login?client_id=${clientId()}&redirect_uri=${encodeURIComponent(callbackUrl())}&state=pakjabit`;
|
||||
return NextResponse.redirect(authUrl);
|
||||
}
|
||||
|
||||
const res = await fetch(`${EP_BASE}/oauth/token`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Basic ${basicAuth()}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: callbackUrl(),
|
||||
code,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
return NextResponse.json({ error: "Token exchange failed", detail: text }, { status: 500 });
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
setToken(data.access_token, data.refresh_token, data.expires_in);
|
||||
|
||||
return NextResponse.json({
|
||||
message: "EasyParcel connected",
|
||||
expires_in: data.expires_in,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const SPREE_URL = process.env.NEXT_PUBLIC_SPREE_API_URL || "https://api.jux-lab.com";
|
||||
const API_KEY = process.env.NEXT_PUBLIC_SPREE_API_KEY || "";
|
||||
|
||||
// GET /api/markets — each market's id, currency and shippable countries.
|
||||
//
|
||||
// A market is the anchor for everything regional at once: which currency prices
|
||||
// are quoted in, which countries can be delivered to, and which shipping methods
|
||||
// produce a rate. Spree fixes a cart's market and currency at CREATION, so the
|
||||
// storefront needs these ids before it makes a cart, not after.
|
||||
//
|
||||
// force-dynamic, not `export const revalidate`: the latter prerenders the route
|
||||
// at BUILD time, so a market change stays invisible until the next deploy. The
|
||||
// hour of caching sits on the fetch below instead.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
const res = await fetch(`${SPREE_URL}/api/v3/store/markets`, {
|
||||
headers: { "X-Spree-Api-Key": API_KEY },
|
||||
next: { revalidate: 3600 },
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
try {
|
||||
return NextResponse.json(text ? JSON.parse(text) : { data: [] }, { status: res.status });
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: { message: `Spree returned non-JSON (HTTP ${res.status})` } },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { bookShipment } from "@/lib/easyparcel";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const result = await bookShipment(body);
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : "Booking failed" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getQuotes } from "@/lib/easyparcel";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { receiver_postcode, receiver_state, weight } = await req.json();
|
||||
|
||||
if (!receiver_postcode) {
|
||||
return NextResponse.json({ error: "receiver_postcode required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const quotes = await getQuotes({
|
||||
sender_postcode: process.env.STORE_POSTCODE || "10150",
|
||||
sender_state: process.env.STORE_STATE || "MY-07",
|
||||
receiver_postcode,
|
||||
receiver_state: receiver_state || "",
|
||||
weight: weight || 1,
|
||||
});
|
||||
|
||||
return NextResponse.json({ quotes });
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : "Quote failed" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getQuotes } from "@/lib/easyparcel";
|
||||
|
||||
const SPREE_URL = process.env.NEXT_PUBLIC_SPREE_API_URL || "https://api.jux-lab.com";
|
||||
const API_KEY = process.env.NEXT_PUBLIC_SPREE_API_KEY || "";
|
||||
|
||||
/**
|
||||
* POST /api/shipping/select — lock in a chosen EasyParcel courier.
|
||||
*
|
||||
* The price is re-fetched from EasyParcel here and matched against the chosen
|
||||
* service_id. The browser never gets to say what shipping costs: cart metadata
|
||||
* is PATCHable by the client, so trusting an amount sent from the page would let
|
||||
* a customer set their own shipping to one sen.
|
||||
*
|
||||
* The verified amount lands in the order's private_metadata, where the
|
||||
* EasyParcelQuote calculator reads it (config/initializers/easyparcel_calculator.rb
|
||||
* on the backend box). No selection means no rate and no fulfillment, so a
|
||||
* Malaysian order cannot complete without a real courier behind it.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { cart_id, cart_token, service_id, receiver_postcode, receiver_state, weight } =
|
||||
await req.json();
|
||||
|
||||
if (!cart_id || !cart_token) {
|
||||
return NextResponse.json({ error: "cart_id and cart_token required" }, { status: 400 });
|
||||
}
|
||||
if (!service_id) {
|
||||
return NextResponse.json({ error: "service_id required" }, { status: 400 });
|
||||
}
|
||||
if (!receiver_postcode) {
|
||||
return NextResponse.json({ error: "receiver_postcode required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const quotes = await getQuotes({
|
||||
sender_postcode: process.env.STORE_POSTCODE || "10150",
|
||||
sender_state: process.env.STORE_STATE || "MY-07",
|
||||
receiver_postcode,
|
||||
receiver_state: receiver_state || "",
|
||||
weight: weight || 1,
|
||||
});
|
||||
|
||||
const chosen = quotes.find((q) => String(q.service_id) === String(service_id));
|
||||
if (!chosen) {
|
||||
// The quote list is live and can change between rendering and choosing.
|
||||
return NextResponse.json(
|
||||
{ error: "That courier is no longer quoting for this address. Pick another." },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
const res = await fetch(`${SPREE_URL}/api/v3/store/carts/${cart_id}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Spree-Api-Key": API_KEY,
|
||||
"X-Spree-Token": cart_token,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
metadata: {
|
||||
easyparcel_service_id: String(chosen.service_id),
|
||||
easyparcel_amount: String(chosen.total_amount),
|
||||
easyparcel_courier: chosen.courier_name,
|
||||
easyparcel_service: chosen.service_name,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
return NextResponse.json(
|
||||
{ error: `Could not save the courier choice (HTTP ${res.status}): ${text.slice(0, 200)}` },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ selected: chosen });
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : "Selection failed" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { trackShipment } from "@/lib/easyparcel";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { awb_numbers } = await req.json();
|
||||
if (!awb_numbers?.length) {
|
||||
return NextResponse.json({ error: "awb_numbers required" }, { status: 400 });
|
||||
}
|
||||
const result = await trackShipment(awb_numbers);
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : "Tracking failed" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const payload = await req.json();
|
||||
console.log("[EasyParcel Webhook]", JSON.stringify(payload));
|
||||
// TODO: Update Spree order status based on payload.topic + payload.awb_number
|
||||
return NextResponse.json({ received: true });
|
||||
} catch {
|
||||
return NextResponse.json({ received: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,761 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCart } from "@/components/CartProvider";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import { listAddresses, Address } from "@/lib/auth";
|
||||
import { useCountries } from "@/lib/geo";
|
||||
import Image from "next/image";
|
||||
|
||||
type Step = "info" | "shipping" | "processing" | "done";
|
||||
|
||||
// EasyParcel quotes only exist for Malaysia — the account is MY-domestic. Every
|
||||
// other market uses Spree's own delivery rates.
|
||||
const EASYPARCEL_COUNTRY = "MY";
|
||||
|
||||
interface CourierOption {
|
||||
service_id: string;
|
||||
service_name: string;
|
||||
courier_name: string;
|
||||
delivery_duration: string | null;
|
||||
total_amount: string;
|
||||
currency: string;
|
||||
courier_logo: string | null;
|
||||
}
|
||||
|
||||
interface DeliveryRate {
|
||||
id: string;
|
||||
name: string;
|
||||
display_cost?: string;
|
||||
display_amount?: string;
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
interface OrderResult {
|
||||
number: string;
|
||||
display_total: string;
|
||||
completed_at: string;
|
||||
}
|
||||
|
||||
async function checkoutProxy(pathname: string, method: string, spreeToken: string, body?: unknown) {
|
||||
const res = await fetch("/api/cart", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ pathname, method, spreeToken, body }),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function rateCost(r: DeliveryRate) {
|
||||
return r.display_cost || r.display_amount || "";
|
||||
}
|
||||
|
||||
export default function CheckoutPage() {
|
||||
const { cart } = useCart();
|
||||
const { user, token } = useAuth();
|
||||
|
||||
const [step, setStep] = useState<Step>("info");
|
||||
const [order, setOrder] = useState<OrderResult | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [working, setWorking] = useState(false);
|
||||
|
||||
// Spree's own delivery rates — the ones the order is actually charged.
|
||||
const [rates, setRates] = useState<DeliveryRate[]>([]);
|
||||
const [fulfillmentId, setFulfillmentId] = useState<string | null>(null);
|
||||
const [selectedRateId, setSelectedRateId] = useState<string | null>(null);
|
||||
const [shipTotal, setShipTotal] = useState<string>("");
|
||||
|
||||
// Live EasyParcel couriers, Malaysia only.
|
||||
const [couriers, setCouriers] = useState<CourierOption[]>([]);
|
||||
const [selectedCourierId, setSelectedCourierId] = useState<string | null>(null);
|
||||
|
||||
const [saved, setSaved] = useState<Address[]>([]);
|
||||
const [pickedAddressId, setPickedAddressId] = useState<string | null>(null);
|
||||
|
||||
// A cart belongs to ONE market, and shipping only resolves for that market's
|
||||
// countries. Offering anything else guarantees a dead end: Spree answers 200
|
||||
// with zero fulfillments and there is nothing to charge.
|
||||
const marketIsos: string[] = cart?.market?.country_isos ?? [];
|
||||
// Empty means the cart carries no market. Guessing a country list here would
|
||||
// put the customer through a checkout nobody configured, so it is surfaced.
|
||||
const marketMissing = !!cart && marketIsos.length === 0;
|
||||
|
||||
const [form, setForm] = useState({
|
||||
email: "",
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
address1: "",
|
||||
city: "",
|
||||
postal_code: "",
|
||||
state_name: "",
|
||||
phone: "",
|
||||
country_iso: "",
|
||||
});
|
||||
|
||||
const { countries, states, statesRequired, zipRequired } = useCountries(form.country_iso);
|
||||
const shippable = useMemo(
|
||||
() => countries.filter((c) => marketIsos.includes(c.iso)),
|
||||
[countries, marketIsos.join(",")]
|
||||
);
|
||||
|
||||
// The <select> must never hold a value it has no option for — the browser then
|
||||
// renders the first option and the form quietly means something else.
|
||||
useEffect(() => {
|
||||
if (shippable.length === 0) return;
|
||||
if (!shippable.some((c) => c.iso === form.country_iso)) {
|
||||
setForm((f) => ({ ...f, country_iso: shippable[0].iso, state_name: "" }));
|
||||
}
|
||||
}, [shippable, form.country_iso]);
|
||||
|
||||
const set = (field: string) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) =>
|
||||
setForm((f) => ({ ...f, [field]: e.target.value }));
|
||||
|
||||
// A province belonging to the previous country is worse than none.
|
||||
const setCountry = (e: React.ChangeEvent<HTMLSelectElement>) =>
|
||||
setForm((f) => ({ ...f, country_iso: e.target.value, state_name: "" }));
|
||||
|
||||
const applyAddress = useCallback(
|
||||
(a: Address, deliverable: string[]) => {
|
||||
setPickedAddressId(a.id);
|
||||
// An empty list means the cart has not loaded yet, not that the address
|
||||
// is undeliverable. Treating those the same wiped the postcode on every
|
||||
// fill, so the customer retyped it into a field that blanked again.
|
||||
const ok = deliverable.length === 0 || deliverable.includes(a.country_iso);
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
first_name: a.first_name || "",
|
||||
last_name: a.last_name || "",
|
||||
address1: a.address1 || "",
|
||||
city: a.city || "",
|
||||
// Street, city, postcode and province all belong to the saved country.
|
||||
// Carrying them onto a different one produces an address that cannot
|
||||
// exist, so an undeliverable pick keeps only the person, not the place.
|
||||
postal_code: ok ? a.postal_code || "" : "",
|
||||
state_name: ok ? a.state_name || "" : "",
|
||||
phone: a.phone || "",
|
||||
country_iso: ok ? a.country_iso : f.country_iso,
|
||||
}));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const pickedAddress = saved.find((a) => a.id === pickedAddressId) || null;
|
||||
const pickedUndeliverable =
|
||||
!!pickedAddress && marketIsos.length > 0 && !marketIsos.includes(pickedAddress.country_iso);
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.email) setForm((f) => (f.email ? f : { ...f, email: user.email }));
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
// Wait for the basket's region. Choosing a "preferred" address without it
|
||||
// means choosing on incomplete information and then filling from it.
|
||||
if (marketIsos.length === 0) return;
|
||||
listAddresses(token)
|
||||
.then((list) => {
|
||||
setSaved(list);
|
||||
const deliverable = list.filter((a) => marketIsos.includes(a.country_iso));
|
||||
// Prefer an address we can actually ship to over the stored default.
|
||||
const preferred =
|
||||
deliverable.find((a) => a.is_default_shipping) ||
|
||||
deliverable[0] ||
|
||||
list.find((a) => a.is_default_shipping) ||
|
||||
list[0];
|
||||
if (preferred) applyAddress(preferred, marketIsos);
|
||||
})
|
||||
.catch(() => {
|
||||
// A guest, or an empty address book. The form stays blank; never block.
|
||||
});
|
||||
// marketIsos is rebuilt from cart on every render; keying on its contents
|
||||
// instead of its identity stops this refetching in a loop.
|
||||
}, [token, applyAddress, marketIsos.join(",")]);
|
||||
|
||||
// Exactly what is still missing, so a blocked button can say so.
|
||||
const missing: string[] = [];
|
||||
if (!form.email.trim()) missing.push("email");
|
||||
if (!form.first_name.trim()) missing.push("first name");
|
||||
if (!form.last_name.trim()) missing.push("last name");
|
||||
if (!form.address1.trim()) missing.push("street address");
|
||||
if (!form.city.trim()) missing.push("city");
|
||||
if (zipRequired && !form.postal_code.trim()) missing.push("postal code");
|
||||
if (statesRequired && !form.state_name.trim()) missing.push("province");
|
||||
if (!form.phone.trim()) missing.push("phone");
|
||||
|
||||
const countryShippable = marketIsos.includes(form.country_iso);
|
||||
const useEasyParcel = form.country_iso === EASYPARCEL_COUNTRY;
|
||||
const canContinue = missing.length === 0 && countryShippable && !working;
|
||||
|
||||
async function handleContinue() {
|
||||
if (!cart || !canContinue) return;
|
||||
setWorking(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const address = {
|
||||
first_name: form.first_name,
|
||||
last_name: form.last_name,
|
||||
address1: form.address1,
|
||||
city: form.city,
|
||||
postal_code: form.postal_code,
|
||||
state_name: form.state_name,
|
||||
phone: form.phone,
|
||||
country_iso: form.country_iso,
|
||||
};
|
||||
|
||||
const updated = await checkoutProxy(`/carts/${cart.id}`, "PATCH", cart.token, {
|
||||
email: form.email,
|
||||
shipping_address: address,
|
||||
billing_address: address,
|
||||
});
|
||||
if (updated.error) throw new Error(updated.error.message || "Could not save that address");
|
||||
|
||||
if (useEasyParcel) {
|
||||
// Real courier rates for this exact address, straight from EasyParcel.
|
||||
const res = await fetch("/api/shipping/quote", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
receiver_postcode: form.postal_code,
|
||||
receiver_state: form.state_name,
|
||||
weight: 1,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
|
||||
const list: CourierOption[] = data.quotes || [];
|
||||
if (list.length === 0) {
|
||||
throw new Error("No courier is quoting for that address. Nothing has been charged.");
|
||||
}
|
||||
|
||||
setCouriers(list);
|
||||
setSelectedCourierId(null);
|
||||
setShipTotal("");
|
||||
setStep("shipping");
|
||||
return;
|
||||
}
|
||||
|
||||
const fresh = await checkoutProxy(`/carts/${cart.id}`, "GET", cart.token);
|
||||
if (fresh.error) throw new Error(fresh.error.message || "Could not read the cart");
|
||||
|
||||
const fulfillment = fresh.fulfillments?.[0];
|
||||
const found: DeliveryRate[] = fulfillment?.delivery_rates || [];
|
||||
|
||||
if (!fulfillment || found.length === 0) {
|
||||
// Spree answers 200 with no fulfillment when nothing can ship there.
|
||||
// Saying that plainly beats a spinner that never resolves.
|
||||
throw new Error(
|
||||
"No delivery option covers that address yet. Nothing has been charged."
|
||||
);
|
||||
}
|
||||
|
||||
setFulfillmentId(fulfillment.id);
|
||||
setRates(found);
|
||||
setSelectedRateId((found.find((r) => r.selected) || found[0]).id);
|
||||
setShipTotal(fresh.display_delivery_total || "");
|
||||
setStep("shipping");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Could not get delivery options");
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function chooseCourier(c: CourierOption) {
|
||||
if (!cart || c.service_id === selectedCourierId) return;
|
||||
const previous = selectedCourierId;
|
||||
setSelectedCourierId(c.service_id);
|
||||
setWorking(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
// The price is confirmed against a fresh quote on the server; this page
|
||||
// only names which courier was picked.
|
||||
const res = await fetch("/api/shipping/select", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
cart_id: cart.id,
|
||||
cart_token: cart.token,
|
||||
service_id: c.service_id,
|
||||
receiver_postcode: form.postal_code,
|
||||
receiver_state: form.state_name,
|
||||
weight: 1,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || data.error) throw new Error(data.error || `HTTP ${res.status}`);
|
||||
|
||||
const fresh = await checkoutProxy(`/carts/${cart.id}`, "GET", cart.token);
|
||||
setShipTotal(fresh.display_delivery_total || "");
|
||||
|
||||
const fulfillment = fresh.fulfillments?.[0];
|
||||
if (!fulfillment || (fulfillment.delivery_rates || []).length === 0) {
|
||||
throw new Error("That courier could not be applied to the order. Pick another.");
|
||||
}
|
||||
setFulfillmentId(fulfillment.id);
|
||||
} catch (err) {
|
||||
// Put the radio back rather than showing a choice the server refused.
|
||||
setSelectedCourierId(previous);
|
||||
setShipTotal("");
|
||||
setError(err instanceof Error ? err.message : "Could not select that courier");
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function chooseRate(rateId: string) {
|
||||
if (!cart || !fulfillmentId || rateId === selectedRateId) return;
|
||||
const previous = selectedRateId;
|
||||
setSelectedRateId(rateId);
|
||||
setWorking(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const res = await checkoutProxy(
|
||||
`/carts/${cart.id}/fulfillments/${fulfillmentId}`,
|
||||
"PATCH",
|
||||
cart.token,
|
||||
{ delivery_rate_id: rateId }
|
||||
);
|
||||
if (res.error) throw new Error(res.error.message || "Could not select that option");
|
||||
|
||||
const fresh = await checkoutProxy(`/carts/${cart.id}`, "GET", cart.token);
|
||||
setShipTotal(fresh.display_delivery_total || "");
|
||||
setRates(fresh.fulfillments?.[0]?.delivery_rates || rates);
|
||||
} catch (err) {
|
||||
// Put the radio back where it was rather than showing a selection the
|
||||
// server did not accept.
|
||||
setSelectedRateId(previous);
|
||||
setError(err instanceof Error ? err.message : "Could not select that option");
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!cart) return;
|
||||
setError("");
|
||||
setStep("processing");
|
||||
|
||||
try {
|
||||
const fresh = await checkoutProxy(`/carts/${cart.id}`, "GET", cart.token);
|
||||
const paymentMethod = fresh.payment_methods?.[0];
|
||||
if (!paymentMethod) throw new Error("No payment method available");
|
||||
|
||||
const paid = await checkoutProxy(`/carts/${cart.id}/payments`, "POST", cart.token, {
|
||||
payment_method_id: paymentMethod.id,
|
||||
});
|
||||
if (paid.error) throw new Error(paid.error.message || "Could not attach payment");
|
||||
|
||||
const result = await checkoutProxy(`/carts/${cart.id}/complete`, "POST", cart.token);
|
||||
if (result.error) throw new Error(result.error.message || "Could not complete the order");
|
||||
|
||||
setOrder({
|
||||
number: result.number,
|
||||
display_total: result.display_total,
|
||||
completed_at: result.completed_at,
|
||||
});
|
||||
setStep("done");
|
||||
localStorage.removeItem("pak-jabit-cart");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Something went wrong");
|
||||
setStep("shipping");
|
||||
}
|
||||
}
|
||||
|
||||
if (!cart || cart.items.length === 0) {
|
||||
return (
|
||||
<div className="max-w-lg mx-auto px-4 py-16 text-center">
|
||||
<p className="text-[#EDE6DA]/60 text-lg">Your cart is empty</p>
|
||||
<a href="/" className="text-[#C8873A] hover:underline mt-4 inline-block">
|
||||
← Back to shop
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "done" && order) {
|
||||
return (
|
||||
<div className="max-w-lg mx-auto px-4 py-16 text-center">
|
||||
<div className="text-5xl mb-4">✓</div>
|
||||
<h1 className="text-2xl font-bold text-[#EDE6DA] mb-2">Order Confirmed!</h1>
|
||||
<p className="text-[#EDE6DA]/70">Order #{order.number}</p>
|
||||
<p className="text-2xl font-bold text-[#C8873A] mt-4">{order.display_total}</p>
|
||||
<p className="text-sm text-[#EDE6DA]/50 mt-1">Confirmation sent to {form.email}</p>
|
||||
<a
|
||||
href="/"
|
||||
className="inline-block mt-8 bg-[#C8873A] text-white px-6 py-3 rounded-lg font-semibold hover:bg-[#A86E2D] transition-colors"
|
||||
>
|
||||
Continue Shopping
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
"w-full bg-[#1a1917] border border-[#EDE6DA]/20 rounded-lg px-4 py-3 text-[#EDE6DA] placeholder:text-[#EDE6DA]/30 focus:border-[#C8873A] focus:outline-none";
|
||||
const labelClass = "block text-sm text-[#EDE6DA]/70 mb-1";
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||
<a href="/" className="text-sm text-[#C8873A] hover:underline mb-6 inline-block">
|
||||
← Back to shop
|
||||
</a>
|
||||
|
||||
<div className="flex items-center gap-2 mb-8 text-sm">
|
||||
<span className={step === "info" ? "text-[#C8873A] font-bold" : "text-[#EDE6DA]/50"}>
|
||||
1. Address
|
||||
</span>
|
||||
<span className="text-[#EDE6DA]/20">→</span>
|
||||
<span className={step === "shipping" ? "text-[#C8873A] font-bold" : "text-[#EDE6DA]/50"}>
|
||||
2. Delivery
|
||||
</span>
|
||||
<span className="text-[#EDE6DA]/20">→</span>
|
||||
<span className={step === "processing" ? "text-[#C8873A] font-bold" : "text-[#EDE6DA]/50"}>
|
||||
3. Confirm
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-5 gap-8">
|
||||
<div className="md:col-span-3">
|
||||
<h1 className="text-2xl font-bold text-[#EDE6DA] mb-6">Checkout</h1>
|
||||
|
||||
{step === "info" && (
|
||||
<div className="space-y-4">
|
||||
{saved.length > 0 && (
|
||||
<div>
|
||||
<label className={labelClass}>Deliver to</label>
|
||||
<div className="space-y-2">
|
||||
{saved.map((a) => (
|
||||
<button
|
||||
key={a.id}
|
||||
type="button"
|
||||
onClick={() => applyAddress(a, marketIsos)}
|
||||
className={`w-full text-left rounded-lg border px-4 py-3 transition-colors ${
|
||||
a.id === pickedAddressId
|
||||
? "border-[#C8873A] bg-[#C8873A]/10"
|
||||
: "border-[#EDE6DA]/20 hover:border-[#C8873A]/60"
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-2 flex-wrap">
|
||||
{a.label && (
|
||||
<span className="text-xs font-semibold px-2 py-0.5 rounded bg-[#C8873A]/20 text-[#C8873A]">
|
||||
{a.label}
|
||||
</span>
|
||||
)}
|
||||
{a.is_default_shipping && (
|
||||
<span className="text-xs px-2 py-0.5 rounded border border-[#EDE6DA]/25 text-[#EDE6DA]/60">
|
||||
Default
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm text-[#EDE6DA]">{a.full_name}</span>
|
||||
</span>
|
||||
<span className="block text-sm text-[#EDE6DA]/60 mt-1">
|
||||
{a.address1}, {a.city} {a.postal_code}
|
||||
{a.state_name ? `, ${a.state_name}` : ""} · {a.country_name}
|
||||
</span>
|
||||
{marketIsos.length > 0 && !marketIsos.includes(a.country_iso) && (
|
||||
<span className="block text-xs text-amber-400/90 mt-1">
|
||||
Outside our delivery area
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<a
|
||||
href="/account"
|
||||
className="inline-block text-xs text-[#C8873A] hover:underline mt-2"
|
||||
>
|
||||
Manage addresses →
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Email</label>
|
||||
<input
|
||||
required
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={set("email")}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelClass}>First Name</label>
|
||||
<input value={form.first_name} onChange={set("first_name")} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Last Name</label>
|
||||
<input value={form.last_name} onChange={set("last_name")} className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Street Address</label>
|
||||
<input
|
||||
value={form.address1}
|
||||
onChange={set("address1")}
|
||||
placeholder="12 Long Street"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Country</label>
|
||||
<select value={form.country_iso} onChange={setCountry} className={inputClass}>
|
||||
{shippable.length === 0 && (
|
||||
<option value={form.country_iso}>Loading…</option>
|
||||
)}
|
||||
{shippable.map((c) => (
|
||||
<option key={c.iso} value={c.iso}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelClass}>City</label>
|
||||
<input value={form.city} onChange={set("city")} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>
|
||||
{statesRequired ? "Province" : "Region"}
|
||||
</label>
|
||||
{statesRequired ? (
|
||||
<select
|
||||
value={form.state_name}
|
||||
onChange={set("state_name")}
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="">Select…</option>
|
||||
{states.map((s) => (
|
||||
<option key={s.abbr || s.name} value={s.name}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input value={form.state_name} onChange={set("state_name")} className={inputClass} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelClass}>Postal Code</label>
|
||||
<input
|
||||
value={form.postal_code}
|
||||
onChange={set("postal_code")}
|
||||
placeholder="8001"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Phone</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={form.phone}
|
||||
onChange={set("phone")}
|
||||
placeholder="071 234 5678"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-red-400 text-sm">{error}</p>}
|
||||
|
||||
{marketMissing && (
|
||||
<p className="text-red-400 text-sm">
|
||||
This cart has no delivery region set, so we can't work out where
|
||||
it ships. Please empty the cart and add the items again.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{pickedUndeliverable && (
|
||||
<p className="text-amber-400/90 text-sm">
|
||||
{pickedAddress?.label || "That saved address"} is in{" "}
|
||||
{pickedAddress?.country_name}, which we don't deliver to yet.
|
||||
Fill in a local address below, or add one under{" "}
|
||||
<a href="/account" className="underline">
|
||||
your account
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleContinue}
|
||||
disabled={!canContinue}
|
||||
className="w-full bg-[#C8873A] text-white py-4 rounded-lg font-semibold text-lg hover:bg-[#A86E2D] transition-colors disabled:opacity-60 disabled:cursor-not-allowed mt-4"
|
||||
>
|
||||
{working ? "Getting delivery options…" : "Continue to Delivery"}
|
||||
</button>
|
||||
|
||||
{/* A disabled button that will not say why reads as broken. It was
|
||||
showing a loading cursor too, which read as buffering. */}
|
||||
{missing.length > 0 && !working && (
|
||||
<p className="text-xs text-[#EDE6DA]/50 text-center">
|
||||
Still needed: {missing.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "shipping" && (
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
onClick={() => setStep("info")}
|
||||
className="text-sm text-[#C8873A] hover:underline mb-2"
|
||||
>
|
||||
← Back to address
|
||||
</button>
|
||||
|
||||
{useEasyParcel ? (
|
||||
<div className="space-y-3">
|
||||
{couriers.map((c) => (
|
||||
<label
|
||||
key={c.service_id}
|
||||
className={`flex items-center gap-4 p-4 rounded-lg border cursor-pointer transition-colors ${
|
||||
selectedCourierId === c.service_id
|
||||
? "border-[#C8873A] bg-[#C8873A]/10"
|
||||
: "border-[#EDE6DA]/20 bg-[#1a1917] hover:border-[#EDE6DA]/40"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="courier"
|
||||
checked={selectedCourierId === c.service_id}
|
||||
onChange={() => chooseCourier(c)}
|
||||
className="accent-[#C8873A]"
|
||||
/>
|
||||
{c.courier_logo && (
|
||||
<Image
|
||||
src={c.courier_logo}
|
||||
alt={c.courier_name}
|
||||
width={40}
|
||||
height={40}
|
||||
className="rounded"
|
||||
unoptimized
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-[#EDE6DA] truncate">{c.courier_name}</p>
|
||||
<p className="text-sm text-[#EDE6DA]/50 truncate">
|
||||
{c.service_name}
|
||||
{c.delivery_duration ? ` · ${c.delivery_duration}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<p className="font-bold text-[#C8873A] shrink-0">
|
||||
RM {c.total_amount}
|
||||
</p>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{rates.map((r) => (
|
||||
<label
|
||||
key={r.id}
|
||||
className={`flex items-center gap-4 p-4 rounded-lg border cursor-pointer transition-colors ${
|
||||
selectedRateId === r.id
|
||||
? "border-[#C8873A] bg-[#C8873A]/10"
|
||||
: "border-[#EDE6DA]/20 bg-[#1a1917] hover:border-[#EDE6DA]/40"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="delivery"
|
||||
checked={selectedRateId === r.id}
|
||||
onChange={() => chooseRate(r.id)}
|
||||
className="accent-[#C8873A]"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-[#EDE6DA]">{r.name}</p>
|
||||
</div>
|
||||
<p className="font-bold text-[#C8873A]">{rateCost(r)}</p>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-red-400 text-sm">{error}</p>}
|
||||
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={(useEasyParcel ? !selectedCourierId : !selectedRateId) || working}
|
||||
className="w-full bg-[#C8873A] text-white py-4 rounded-lg font-semibold text-lg hover:bg-[#A86E2D] transition-colors disabled:opacity-60 disabled:cursor-not-allowed mt-4"
|
||||
>
|
||||
{useEasyParcel && !selectedCourierId ? "Choose a courier" : "Place Order"}
|
||||
</button>
|
||||
|
||||
<p className="text-xs text-[#EDE6DA]/30 text-center mt-2">
|
||||
Test mode — no real payment will be charged
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "processing" && (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-4xl mb-4 animate-spin">⏳</div>
|
||||
<p className="text-[#EDE6DA]/70">Processing your order…</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Order Summary */}
|
||||
<div className="md:col-span-2">
|
||||
<div className="bg-[#1a1917] rounded-lg p-4">
|
||||
<h2 className="font-semibold text-[#EDE6DA] mb-4">Order Summary</h2>
|
||||
<div className="space-y-3">
|
||||
{cart.items.map((item) => (
|
||||
<div key={item.id} className="flex gap-3">
|
||||
<div className="w-14 h-16 relative rounded overflow-hidden bg-[#EDE6DA] shrink-0">
|
||||
{item.image_url ? (
|
||||
<Image
|
||||
src={item.image_url}
|
||||
alt={item.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="56px"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-sm">📷</div>
|
||||
)}
|
||||
<span className="absolute -top-1 -right-1 bg-[#C8873A] text-[#0E0D0B] text-[10px] w-4 h-4 rounded-full flex items-center justify-center font-bold">
|
||||
{item.quantity}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-[#EDE6DA] line-clamp-2 leading-tight">{item.name}</p>
|
||||
<p className="text-sm font-bold text-[#EDE6DA]/70 mt-1">{item.display_total}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t border-[#EDE6DA]/10 mt-4 pt-4 space-y-2">
|
||||
<div className="flex justify-between text-sm text-[#EDE6DA]/70">
|
||||
<span>Subtotal</span>
|
||||
<span>{cart.display_item_total}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm text-[#EDE6DA]/70">
|
||||
<span>Shipping</span>
|
||||
<span>{shipTotal || "Calculated next"}</span>
|
||||
</div>
|
||||
<div className="flex justify-between font-bold text-[#EDE6DA] pt-2 border-t border-[#EDE6DA]/10">
|
||||
<span>Total</span>
|
||||
<span>{cart.display_total}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,42 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@400;500;600;700&display=swap');
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--ink: #0E0D0B;
|
||||
--ochre: #C8873A;
|
||||
--bone: #EDE6DA;
|
||||
--bone-light: #F5F1EB;
|
||||
--ochre-dark: #A86E2D;
|
||||
--background: var(--ink);
|
||||
--foreground: var(--bone);
|
||||
--card-bg: #1a1917;
|
||||
--card-text: var(--bone);
|
||||
--section-bg: var(--ink);
|
||||
}
|
||||
|
||||
.light {
|
||||
--background: var(--bone-light);
|
||||
--foreground: var(--ink);
|
||||
--card-bg: #ffffff;
|
||||
--card-text: var(--ink);
|
||||
--section-bg: var(--bone-light);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-ink: var(--ink);
|
||||
--color-ochre: var(--ochre);
|
||||
--color-ochre-dark: var(--ochre-dark);
|
||||
--color-bone: var(--bone);
|
||||
--color-bone-light: var(--bone-light);
|
||||
--color-card-bg: var(--card-bg);
|
||||
--color-card-text: var(--card-text);
|
||||
--color-section-bg: var(--section-bg);
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: 'Instrument Sans', system-ui, sans-serif;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import "./globals.css";
|
||||
import { CartProvider } from "@/components/CartProvider";
|
||||
import { AuthProvider } from "@/components/AuthProvider";
|
||||
import CartButton from "@/components/CartButton";
|
||||
import CartDrawer from "@/components/CartDrawer";
|
||||
import AuthButton from "@/components/AuthButton";
|
||||
import CurrencySwitcher from "@/components/CurrencySwitcher";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: "Pak Jabit Store — Men's Apparel & Accessories",
|
||||
template: "%s | Pak Jabit",
|
||||
},
|
||||
description: "Curated men's apparel from brands that matter. G-Shock, Lacoste, Tommy Hilfiger, Calvin Klein and more.",
|
||||
metadataBase: new URL("https://shop.jux-lab.com"),
|
||||
openGraph: {
|
||||
siteName: "Pak Jabit Store",
|
||||
type: "website",
|
||||
locale: "en_US",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||
return (
|
||||
<html lang="en" className="h-full antialiased dark">
|
||||
<body className="min-h-full flex flex-col">
|
||||
{/* AuthProvider outside: the cart belongs to the customer, so
|
||||
CartProvider needs to know who is signed in. */}
|
||||
<AuthProvider>
|
||||
<CartProvider>
|
||||
<header className="bg-[#0E0D0B] text-[#EDE6DA] px-6 py-4 flex items-center justify-between">
|
||||
<a href="/" className="text-2xl font-bold tracking-tight">
|
||||
PAK JABIT<span className="text-[#C8873A]">.</span>
|
||||
</a>
|
||||
<nav className="flex items-center gap-6 text-sm font-medium">
|
||||
<a href="/" className="hover:text-[#C8873A] transition-colors">Shop</a>
|
||||
{/* useSearchParams needs a Suspense boundary during prerender */}
|
||||
<Suspense fallback={<div className="w-[150px] h-7" />}>
|
||||
<CurrencySwitcher />
|
||||
</Suspense>
|
||||
<AuthButton />
|
||||
<CartButton />
|
||||
</nav>
|
||||
</header>
|
||||
<main className="flex-1">
|
||||
{children}
|
||||
</main>
|
||||
<CartDrawer />
|
||||
</CartProvider>
|
||||
</AuthProvider>
|
||||
<footer className="bg-[#0E0D0B] text-[#EDE6DA]/60 px-6 py-8 text-center text-sm">
|
||||
<p>© 2026 Pak Jabit Store. Built with thread and needle.</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import { login, register } from "@/lib/auth";
|
||||
|
||||
export default function LoginPage() {
|
||||
const { setSession } = useAuth();
|
||||
const [isRegister, setIsRegister] = useState(false);
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const result = isRegister
|
||||
? await register(email, password)
|
||||
: await login(email, password);
|
||||
|
||||
if (result.error || !result.token) {
|
||||
setError(result.error?.message || "Login failed");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setSession(result.token, result.refresh_token, result.user);
|
||||
window.location.replace("/");
|
||||
} catch {
|
||||
setError("Something went wrong");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto px-4 py-16">
|
||||
<h1 className="text-2xl font-bold text-[#EDE6DA] mb-2">
|
||||
{isRegister ? "Create Account" : "Sign In"}
|
||||
</h1>
|
||||
<p className="text-[#EDE6DA]/50 mb-8">
|
||||
{isRegister
|
||||
? "Join Pak Jabit to save your addresses and track orders."
|
||||
: "Welcome back to Pak Jabit."}
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-[#EDE6DA]/70 mb-1">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
className="w-full bg-[#1a1917] border border-[#EDE6DA]/20 rounded-lg px-4 py-3 text-[#EDE6DA] placeholder:text-[#EDE6DA]/30 focus:border-[#C8873A] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-[#EDE6DA]/70 mb-1">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
minLength={6}
|
||||
className="w-full bg-[#1a1917] border border-[#EDE6DA]/20 rounded-lg px-4 py-3 text-[#EDE6DA] placeholder:text-[#EDE6DA]/30 focus:border-[#C8873A] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-red-400 text-sm">{error}</p>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-[#C8873A] text-white py-3 rounded-lg font-semibold hover:bg-[#A86E2D] transition-colors disabled:opacity-60"
|
||||
>
|
||||
{loading ? "Please wait…" : isRegister ? "Create Account" : "Sign In"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-sm text-[#EDE6DA]/50 mt-6">
|
||||
{isRegister ? "Already have an account?" : "Don't have an account?"}{" "}
|
||||
<button
|
||||
onClick={() => { setIsRegister(!isRegister); setError(""); }}
|
||||
className="text-[#C8873A] hover:underline"
|
||||
>
|
||||
{isRegister ? "Sign in" : "Create one"}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { getProducts } from "@/lib/spree";
|
||||
import { DEFAULT_CURRENCY, parseCurrency } from "@/lib/currency";
|
||||
import ProductGrid from "@/components/ProductGrid";
|
||||
|
||||
export default async function Home({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ currency?: string }>;
|
||||
}) {
|
||||
const { currency } = await searchParams;
|
||||
const selected = parseCurrency(currency) ?? DEFAULT_CURRENCY;
|
||||
const products = await getProducts(selected);
|
||||
|
||||
return (
|
||||
<div className="bg-background">
|
||||
{/* Hero */}
|
||||
<section className="bg-[#0E0D0B] text-[#EDE6DA] px-6 py-16 text-center">
|
||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tight mb-3">
|
||||
Dress Sharp<span className="text-[#C8873A]">.</span> Stay Real<span className="text-[#C8873A]">.</span>
|
||||
</h1>
|
||||
<p className="text-[#EDE6DA]/70 text-lg max-w-md mx-auto">
|
||||
Curated men's apparel from brands that matter.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Product Grid with Filters */}
|
||||
<ProductGrid products={products} currency={selected} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function ProductImages({ images, name }: { images: string[]; name: string }) {
|
||||
const [selected, setSelected] = useState(0);
|
||||
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
{images.length > 1 && (
|
||||
<div className="flex flex-col gap-2 w-16 md:w-20 shrink-0">
|
||||
{images.map((img, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setSelected(i)}
|
||||
className={`aspect-square relative rounded overflow-hidden ${
|
||||
i === selected ? "ring-2 ring-[#C8873A]" : "opacity-60 hover:opacity-100"
|
||||
} transition-all`}
|
||||
>
|
||||
<Image src={img} alt="" fill className="object-cover" sizes="80px" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 aspect-[3/4] relative rounded-lg overflow-hidden bg-[#EDE6DA]">
|
||||
<Image
|
||||
src={images[selected]}
|
||||
alt={name}
|
||||
fill
|
||||
className="object-cover"
|
||||
priority
|
||||
sizes="(max-width: 768px) 80vw, 40vw"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getProducts, getProduct } from "@/lib/spree";
|
||||
import { DEFAULT_CURRENCY, parseCurrency } from "@/lib/currency";
|
||||
import ProductImages from "./ProductImages";
|
||||
import AddToCartButton from "@/components/AddToCartButton";
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const products = await getProducts();
|
||||
return products.map((p) => ({ slug: p.slug }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
searchParams: Promise<{ currency?: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const { currency } = await searchParams;
|
||||
const product = await getProduct(slug, parseCurrency(currency) ?? DEFAULT_CURRENCY);
|
||||
if (!product) return {};
|
||||
|
||||
const title = `${product.name} — ${product.price.display_amount}`;
|
||||
const description = product.description || `Shop ${product.name} at Pak Jabit Store`;
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
images: product.thumbnail_url ? [product.thumbnail_url] : [],
|
||||
type: "website",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title,
|
||||
description,
|
||||
images: product.thumbnail_url ? [product.thumbnail_url] : [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ProductPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
searchParams: Promise<{ currency?: string }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const { currency } = await searchParams;
|
||||
const product = await getProduct(slug, parseCurrency(currency) ?? DEFAULT_CURRENCY);
|
||||
if (!product) notFound();
|
||||
|
||||
const images = product.media?.length
|
||||
? product.media.sort((a, b) => a.position - b.position).map((m) => m.original_url)
|
||||
: product.thumbnail_url
|
||||
? [product.thumbnail_url]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="bg-[#F5F1EB] min-h-screen">
|
||||
<div className="max-w-6xl mx-auto px-4 py-8">
|
||||
<a href="/" className="text-sm text-[#C8873A] hover:underline mb-6 inline-block">
|
||||
← Back to shop
|
||||
</a>
|
||||
<div className="grid md:grid-cols-2 gap-8">
|
||||
{images.length > 0 ? (
|
||||
<ProductImages images={images} name={product.name} />
|
||||
) : (
|
||||
<div className="aspect-square rounded-lg bg-[#EDE6DA] flex items-center justify-center text-[#0E0D0B]/20 text-6xl">📷</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col">
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-[#0E0D0B] mt-2">{product.name}</h1>
|
||||
<p className="text-3xl font-bold text-[#0E0D0B] mt-4">
|
||||
{product.price.display_amount}
|
||||
</p>
|
||||
<p className="text-sm text-[#0E0D0B]/60 mt-4">{product.description}</p>
|
||||
|
||||
<div className="mt-8 space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-[#0E0D0B]/70 block mb-2">Size</label>
|
||||
<div className="flex gap-2">
|
||||
{["S", "M", "L", "XL"].map((size) => (
|
||||
<button
|
||||
key={size}
|
||||
className="w-12 h-12 border-2 border-[#0E0D0B]/30 rounded-lg text-sm font-medium text-[#0E0D0B] hover:border-[#C8873A] hover:text-[#C8873A] transition-colors"
|
||||
>
|
||||
{size}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AddToCartButton variantId={product.default_variant_id} />
|
||||
</div>
|
||||
|
||||
<div className="mt-8 pt-6 border-t border-[#0E0D0B]/10 text-sm text-[#0E0D0B]/60 space-y-2">
|
||||
<p>✓ Free shipping on orders over R500</p>
|
||||
<p>✓ 30-day returns</p>
|
||||
<p>✓ Secure checkout</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: {
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
disallow: ["/api/", "/checkout"],
|
||||
},
|
||||
sitemap: "https://pak-jabit-store.vercel.app/sitemap.xml",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { getProducts } from "@/lib/spree";
|
||||
|
||||
const BASE = "https://pak-jabit-store.vercel.app";
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const products = await getProducts();
|
||||
|
||||
const productUrls = products.map((p) => ({
|
||||
url: `${BASE}/product/${p.slug}`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "weekly" as const,
|
||||
priority: 0.8,
|
||||
}));
|
||||
|
||||
return [
|
||||
{
|
||||
url: BASE,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "daily",
|
||||
priority: 1,
|
||||
},
|
||||
...productUrls,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useCart } from "./CartProvider";
|
||||
|
||||
export default function AddToCartButton({ variantId }: { variantId: string }) {
|
||||
const { add, loading } = useCart();
|
||||
|
||||
return (
|
||||
<button
|
||||
disabled={loading}
|
||||
onClick={() => add(variantId)}
|
||||
className="w-full bg-[#C8873A] text-white py-4 rounded-lg font-semibold text-lg hover:bg-[#A86E2D] transition-colors mt-6 disabled:opacity-60 disabled:cursor-wait"
|
||||
>
|
||||
{loading ? "Adding…" : "Add to Cart"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useAuth } from "./AuthProvider";
|
||||
|
||||
export default function AuthButton() {
|
||||
const { user, isLoading } = useAuth();
|
||||
|
||||
if (isLoading) return null;
|
||||
|
||||
if (user) {
|
||||
return (
|
||||
<a href="/account" className="hover:text-[#C8873A] transition-colors">
|
||||
Account
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a href="/login" className="hover:text-[#C8873A] transition-colors">
|
||||
Sign in
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, useEffect, useCallback } from "react";
|
||||
import { User, loadAuth, saveAuth, clearAuth, getProfile, AUTH_REFRESHED } from "@/lib/auth";
|
||||
import { saveRegion } from "@/lib/region";
|
||||
import { parseCurrency } from "@/lib/currency";
|
||||
|
||||
// A signed-in customer's stored choice outranks whatever this browser happens to
|
||||
// hold, so signing in on a new device lands you in your own currency.
|
||||
function adoptRegion(u: User | null | undefined) {
|
||||
const code = parseCurrency(u?.public_metadata?.preferred_currency);
|
||||
if (code) saveRegion(code);
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
isLoading: boolean;
|
||||
setSession: (token: string, refreshToken: string, user: User) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType>({
|
||||
user: null,
|
||||
token: null,
|
||||
isLoading: true,
|
||||
setSession: () => {},
|
||||
logout: () => {},
|
||||
});
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = loadAuth();
|
||||
if (stored?.token) {
|
||||
setToken(stored.token);
|
||||
if (stored.user) setUser(stored.user);
|
||||
// getProfile renews the token on its own if it has expired. If it still
|
||||
// fails, the session really is dead — drop it. Keeping the cached
|
||||
// stored.user here is what made an expired login look signed in.
|
||||
getProfile(stored.token)
|
||||
.then((u) => {
|
||||
setUser(u);
|
||||
adoptRegion(u);
|
||||
const now = loadAuth();
|
||||
if (now?.token) setToken(now.token);
|
||||
})
|
||||
.catch(() => {
|
||||
clearAuth();
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// A renewal can happen inside any API call; adopt the new token so the next
|
||||
// request does not start from the expired one.
|
||||
useEffect(() => {
|
||||
function onRefreshed(e: Event) {
|
||||
const d = (e as CustomEvent).detail as { token?: string; user?: User };
|
||||
if (d?.token) setToken(d.token);
|
||||
if (d?.user) setUser(d.user);
|
||||
}
|
||||
window.addEventListener(AUTH_REFRESHED, onRefreshed);
|
||||
return () => window.removeEventListener(AUTH_REFRESHED, onRefreshed);
|
||||
}, []);
|
||||
|
||||
const setSession = useCallback((t: string, rt: string, u: User) => {
|
||||
setToken(t);
|
||||
setUser(u);
|
||||
adoptRegion(u);
|
||||
saveAuth(t, rt, u);
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
clearAuth();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, token, isLoading, setSession, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
return useContext(AuthContext);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useCart } from "./CartProvider";
|
||||
|
||||
export default function CartButton() {
|
||||
const { cart, setDrawerOpen } = useCart();
|
||||
const count = cart?.total_quantity || 0;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
className="relative hover:text-[#C8873A] transition-colors"
|
||||
>
|
||||
Cart
|
||||
<span className="absolute -top-2 -right-4 bg-[#C8873A] text-[#0E0D0B] text-xs w-5 h-5 rounded-full flex items-center justify-center font-bold">
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useCart } from "./CartProvider";
|
||||
|
||||
export default function CartDrawer() {
|
||||
const { cart, loading, drawerOpen, setDrawerOpen, updateQuantity, remove } = useCart();
|
||||
|
||||
if (!drawerOpen) return null;
|
||||
|
||||
const items = cart?.items || [];
|
||||
const totalQty = cart?.total_quantity || 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-40"
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
/>
|
||||
<div className="fixed right-0 top-0 h-full w-full max-w-md bg-[#1a1917] z-50 shadow-2xl flex flex-col">
|
||||
<div className="flex items-center justify-between p-4 border-b border-[#EDE6DA]/10">
|
||||
<h2 className="text-lg font-semibold text-[#EDE6DA]">
|
||||
Cart ({totalQty})
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
className="text-[#EDE6DA]/60 hover:text-[#EDE6DA] text-2xl leading-none"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{items.length === 0 ? (
|
||||
<p className="text-[#EDE6DA]/40 text-center mt-12">Your cart is empty</p>
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<div key={item.id} className="flex gap-3 bg-[#0E0D0B] rounded-lg p-3">
|
||||
<div className="w-20 h-24 relative rounded overflow-hidden bg-[#EDE6DA] shrink-0">
|
||||
{item.image_url ? (
|
||||
<Image src={item.image_url} alt={item.name} fill className="object-cover" sizes="80px" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-2xl">📷</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm text-[#EDE6DA] line-clamp-2 leading-tight">{item.name}</h3>
|
||||
<p className="text-sm font-bold text-[#C8873A] mt-1">{item.display_price}</p>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<button
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
if (item.quantity <= 1) remove(item.id);
|
||||
else updateQuantity(item.id, item.quantity - 1);
|
||||
}}
|
||||
className="w-7 h-7 rounded bg-[#EDE6DA]/10 text-[#EDE6DA] hover:bg-[#EDE6DA]/20 flex items-center justify-center text-sm disabled:opacity-40"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="text-sm text-[#EDE6DA] w-6 text-center">{item.quantity}</span>
|
||||
<button
|
||||
disabled={loading}
|
||||
onClick={() => updateQuantity(item.id, item.quantity + 1)}
|
||||
className="w-7 h-7 rounded bg-[#EDE6DA]/10 text-[#EDE6DA] hover:bg-[#EDE6DA]/20 flex items-center justify-center text-sm disabled:opacity-40"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
disabled={loading}
|
||||
onClick={() => remove(item.id)}
|
||||
className="ml-auto text-xs text-[#EDE6DA]/40 hover:text-red-400"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{items.length > 0 && (
|
||||
<div className="p-4 border-t border-[#EDE6DA]/10 space-y-3">
|
||||
<div className="flex justify-between text-[#EDE6DA]">
|
||||
<span>Subtotal</span>
|
||||
<span className="font-bold">{cart?.display_item_total}</span>
|
||||
</div>
|
||||
<a
|
||||
href="/checkout"
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
className="block w-full bg-[#C8873A] text-white py-3 rounded-lg font-semibold hover:bg-[#A86E2D] transition-colors text-center"
|
||||
>
|
||||
Checkout
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from "react";
|
||||
import {
|
||||
createCart,
|
||||
getCart,
|
||||
getMyCart,
|
||||
associateCart,
|
||||
addItem,
|
||||
updateItemQuantity,
|
||||
removeItem,
|
||||
type Cart,
|
||||
} from "@/lib/cart";
|
||||
import { loadMarkets, loadRegion, saveRegion, cartOptionsFor } from "@/lib/region";
|
||||
import { DEFAULT_CURRENCY, type CurrencyCode } from "@/lib/currency";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
|
||||
interface CartContextValue {
|
||||
cart: Cart | null;
|
||||
loading: boolean;
|
||||
drawerOpen: boolean;
|
||||
setDrawerOpen: (open: boolean) => void;
|
||||
add: (variantId: string, quantity?: number) => Promise<void>;
|
||||
updateQuantity: (itemId: string, quantity: number) => Promise<void>;
|
||||
remove: (itemId: string) => Promise<void>;
|
||||
/** The region anchor. Changing it rebuilds the cart in the new currency. */
|
||||
currency: CurrencyCode;
|
||||
switchCurrency: (code: CurrencyCode) => Promise<void>;
|
||||
switching: boolean;
|
||||
}
|
||||
|
||||
const CartContext = createContext<CartContextValue | null>(null);
|
||||
|
||||
export function useCart() {
|
||||
const ctx = useContext(CartContext);
|
||||
if (!ctx) throw new Error("useCart must be used within CartProvider");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "pak-jabit-cart";
|
||||
|
||||
function loadStored(): { id: string; token: string } | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed.id && parsed.token) return parsed;
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
function saveStored(id: string, token: string) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ id, token }));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function clearStored() {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function CartProvider({ children }: { children: ReactNode }) {
|
||||
const { token: userToken } = useAuth();
|
||||
|
||||
const [cart, setCart] = useState<Cart | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [switching, setSwitching] = useState(false);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [currency, setCurrency] = useState<CurrencyCode>(DEFAULT_CURRENCY);
|
||||
|
||||
// Spree fixes a cart's currency at creation. A cart from before the customer
|
||||
// chose a different region still prices itself in the old money and would
|
||||
// check out in it, so it is dropped rather than carried.
|
||||
const staleFor = useCallback((c: Cart, want: CurrencyCode) => {
|
||||
return !!c.currency && c.currency !== want;
|
||||
}, []);
|
||||
|
||||
// Guest path: whatever this browser is holding.
|
||||
useEffect(() => {
|
||||
const saved = loadRegion();
|
||||
if (saved) setCurrency(saved);
|
||||
|
||||
const stored = loadStored();
|
||||
if (!stored) return;
|
||||
|
||||
getCart(stored.id, stored.token)
|
||||
.then((c) => {
|
||||
if (staleFor(c, saved || DEFAULT_CURRENCY)) {
|
||||
clearStored();
|
||||
setCart(null);
|
||||
return;
|
||||
}
|
||||
setCart(c);
|
||||
if (c.currency) setCurrency(c.currency as CurrencyCode);
|
||||
})
|
||||
.catch(() => clearStored());
|
||||
}, [staleFor]);
|
||||
|
||||
// Signed-in path: the cart belongs to the customer, not to this browser. A
|
||||
// guest basket built before signing in is claimed rather than abandoned.
|
||||
useEffect(() => {
|
||||
if (!userToken) return;
|
||||
let live = true;
|
||||
|
||||
(async () => {
|
||||
const stored = loadStored();
|
||||
if (stored) {
|
||||
try {
|
||||
await associateCart(stored.id, stored.token, userToken);
|
||||
} catch {
|
||||
// Already claimed, or claimed by someone else. The server copy below
|
||||
// is the authority either way.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const mine = await getMyCart(userToken);
|
||||
if (!live || !mine) return;
|
||||
|
||||
const want = loadRegion() || DEFAULT_CURRENCY;
|
||||
if (staleFor(mine, want)) {
|
||||
// Their saved basket is in a currency they no longer shop in. Leave it
|
||||
// on the server untouched rather than silently checking out old money.
|
||||
clearStored();
|
||||
setCart(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setCart(mine);
|
||||
saveStored(mine.id, mine.token);
|
||||
if (mine.currency) setCurrency(mine.currency as CurrencyCode);
|
||||
} catch {
|
||||
// No server cart, or the lookup failed — keep whatever is local.
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [userToken, staleFor]);
|
||||
|
||||
const makeCart = useCallback(
|
||||
async (code: CurrencyCode): Promise<Cart> => {
|
||||
const markets = await loadMarkets();
|
||||
const opts = cartOptionsFor(markets, code);
|
||||
// No fallback to a default market: a cart built on a guessed region prices
|
||||
// and ships somewhere nobody chose, and Spree will not let it be corrected
|
||||
// afterwards.
|
||||
if (!opts) {
|
||||
throw new Error(`No market quotes in ${code}. Pick another currency.`);
|
||||
}
|
||||
const created = await createCart(opts, userToken ?? undefined);
|
||||
saveStored(created.id, created.token);
|
||||
return created;
|
||||
},
|
||||
[userToken]
|
||||
);
|
||||
|
||||
const ensureCart = useCallback(async (): Promise<Cart> => {
|
||||
if (cart) return cart;
|
||||
const created = await makeCart(currency);
|
||||
setCart(created);
|
||||
return created;
|
||||
}, [cart, currency, makeCart]);
|
||||
|
||||
const add = useCallback(
|
||||
async (variantId: string, quantity = 1) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const c = await ensureCart();
|
||||
const updated = await addItem(c.id, c.token, variantId, quantity);
|
||||
updated.token = c.token;
|
||||
setCart(updated);
|
||||
saveStored(updated.id, c.token);
|
||||
setDrawerOpen(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[ensureCart]
|
||||
);
|
||||
|
||||
const updateQuantity = useCallback(
|
||||
async (itemId: string, quantity: number) => {
|
||||
if (!cart) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const updated = await updateItemQuantity(cart.id, cart.token, itemId, quantity);
|
||||
updated.token = cart.token;
|
||||
setCart(updated);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[cart]
|
||||
);
|
||||
|
||||
const remove = useCallback(
|
||||
async (itemId: string) => {
|
||||
if (!cart) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const updated = await removeItem(cart.id, cart.token, itemId);
|
||||
updated.token = cart.token;
|
||||
setCart(updated);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[cart]
|
||||
);
|
||||
|
||||
/**
|
||||
* Move the whole session to another currency.
|
||||
*
|
||||
* The cart cannot be converted, so it is rebuilt: a new cart in the new market
|
||||
* and every line re-added. The basket survives the switch, which is the point —
|
||||
* otherwise changing currency silently empties it.
|
||||
*/
|
||||
const switchCurrency = useCallback(
|
||||
async (code: CurrencyCode) => {
|
||||
if (code === currency) return;
|
||||
setSwitching(true);
|
||||
try {
|
||||
const carry =
|
||||
cart?.items.map((i) => ({ variant_id: i.variant_id, quantity: i.quantity })) ?? [];
|
||||
|
||||
const fresh = await makeCart(code);
|
||||
let latest = fresh;
|
||||
for (const line of carry) {
|
||||
latest = await addItem(fresh.id, fresh.token, line.variant_id, line.quantity);
|
||||
latest.token = fresh.token;
|
||||
}
|
||||
|
||||
setCart(carry.length ? latest : fresh);
|
||||
setCurrency(code);
|
||||
saveRegion(code);
|
||||
} finally {
|
||||
setSwitching(false);
|
||||
}
|
||||
},
|
||||
[cart, currency, makeCart]
|
||||
);
|
||||
|
||||
return (
|
||||
<CartContext
|
||||
value={{
|
||||
cart,
|
||||
loading,
|
||||
drawerOpen,
|
||||
setDrawerOpen,
|
||||
add,
|
||||
updateQuantity,
|
||||
remove,
|
||||
currency,
|
||||
switchCurrency,
|
||||
switching,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</CartContext>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { useSearchParams, useRouter, usePathname } from "next/navigation";
|
||||
import { useTransition } from "react";
|
||||
import { CURRENCIES, DEFAULT_CURRENCY, parseCurrency, type CurrencyCode } from "@/lib/currency";
|
||||
import { useCart } from "@/components/CartProvider";
|
||||
|
||||
/**
|
||||
* Sliding segmented control for the region anchor.
|
||||
*
|
||||
* The choice does two jobs. It stays in the URL (?currency=USD) so the server
|
||||
* component re-fetches real Spree prices — nothing is converted in the browser.
|
||||
* And it rebuilds the cart in the matching market, because Spree fixes a cart's
|
||||
* currency at creation: without the rebuild the basket would keep quoting money
|
||||
* the order can never be placed in.
|
||||
*/
|
||||
export default function CurrencySwitcher() {
|
||||
const params = useSearchParams();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [pending, startTransition] = useTransition();
|
||||
const { switchCurrency, switching, cart } = useCart();
|
||||
|
||||
const active = parseCurrency(params.get("currency")) ?? DEFAULT_CURRENCY;
|
||||
const index = CURRENCIES.findIndex((c) => c.code === active);
|
||||
const busy = pending || switching;
|
||||
|
||||
async function select(code: CurrencyCode) {
|
||||
if (code === active || busy) return;
|
||||
|
||||
// Ask before discarding a basket's pricing. The items are carried over, but
|
||||
// the totals change, and a price changing under someone without warning is
|
||||
// its own kind of broken.
|
||||
if (cart && cart.items.length > 0) {
|
||||
const ok = confirm(
|
||||
`Switch to ${code}? Your ${cart.items.length} item${
|
||||
cart.items.length === 1 ? "" : "s"
|
||||
} stay in the basket and are re-priced in ${code}.`
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
await switchCurrency(code);
|
||||
|
||||
const next = new URLSearchParams(params.toString());
|
||||
if (code === DEFAULT_CURRENCY) next.delete("currency");
|
||||
else next.set("currency", code);
|
||||
const qs = next.toString();
|
||||
startTransition(() => {
|
||||
router.push(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Display currency"
|
||||
className={`relative flex items-center rounded-full bg-[#EDE6DA]/10 p-0.5 transition-opacity ${
|
||||
busy ? "opacity-60" : "opacity-100"
|
||||
}`}
|
||||
>
|
||||
{/* sliding indicator */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute top-0.5 bottom-0.5 rounded-full bg-[#C8873A] transition-transform duration-200 ease-out"
|
||||
style={{
|
||||
width: `calc((100% - 0.25rem) / ${CURRENCIES.length})`,
|
||||
transform: `translateX(calc(${index} * 100%))`,
|
||||
left: "0.125rem",
|
||||
}}
|
||||
/>
|
||||
{CURRENCIES.map((c) => {
|
||||
const isActive = c.code === active;
|
||||
return (
|
||||
<button
|
||||
key={c.code}
|
||||
role="radio"
|
||||
aria-checked={isActive}
|
||||
disabled={busy}
|
||||
onClick={() => select(c.code)}
|
||||
className={`relative z-10 px-3 py-1 text-xs font-semibold tracking-wide rounded-full transition-colors disabled:cursor-wait ${
|
||||
isActive ? "text-[#0E0D0B]" : "text-[#EDE6DA]/70 hover:text-[#EDE6DA]"
|
||||
}`}
|
||||
style={{ flex: "1 0 0" }}
|
||||
>
|
||||
{c.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import Image from "next/image";
|
||||
import { DEFAULT_CURRENCY, symbolFor, type CurrencyCode } from "@/lib/currency";
|
||||
|
||||
interface Product {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
thumbnail_url: string | null;
|
||||
price: { amount: string; currency: string; display_amount: string };
|
||||
}
|
||||
|
||||
function extractBrand(description: string, name: string): string {
|
||||
if (description && description.includes(" - ")) {
|
||||
return description.split(" - ")[0].trim();
|
||||
}
|
||||
const known = [
|
||||
"G-Shock", "H&M", "Lacoste", "Tommy Hilfiger", "Cotton On", "GAP",
|
||||
"Giordano", "Superdry", "Under Armour", "Calvin Klein", "Camel Active",
|
||||
"Electro Denim Lab", "Trendyol", "Swiss Polo", "Adidas", "Nike",
|
||||
"New Balance", "Levi's", "Mango Man", "Urban Revivo", "Puma",
|
||||
"Jack & Jones", "Harley-Davidson", "Clarks", "Ben Sherman",
|
||||
"Connor", "Jordan", "Desigual", "Dr. Cardin", "Veja", "Hunter Boots",
|
||||
];
|
||||
for (const b of known) {
|
||||
if (name.toLowerCase().includes(b.toLowerCase())) return b;
|
||||
}
|
||||
return "Other";
|
||||
}
|
||||
|
||||
type SortOption = "default" | "price-asc" | "price-desc" | "name";
|
||||
|
||||
// 24, not 25: the grid is 2 columns on mobile and 3 on desktop, so 24 is the
|
||||
// largest round page size that fills every row in both layouts.
|
||||
const PER_PAGE = 24;
|
||||
|
||||
export default function ProductGrid({
|
||||
products,
|
||||
currency = DEFAULT_CURRENCY,
|
||||
}: {
|
||||
products: Product[];
|
||||
currency?: CurrencyCode;
|
||||
}) {
|
||||
const [selectedBrands, setSelectedBrands] = useState<Set<string>>(new Set());
|
||||
const [sort, setSort] = useState<SortOption>("default");
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const symbol = symbolFor(currency);
|
||||
|
||||
const productsWithBrand = useMemo(
|
||||
() => products.map((p) => ({ ...p, brand: extractBrand(p.description, p.name) })),
|
||||
[products]
|
||||
);
|
||||
|
||||
const brands = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const p of productsWithBrand) {
|
||||
counts.set(p.brand, (counts.get(p.brand) || 0) + 1);
|
||||
}
|
||||
return Array.from(counts.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([name, count]) => ({ name, count }));
|
||||
}, [productsWithBrand]);
|
||||
|
||||
const maxPrice = useMemo(() => {
|
||||
const amounts = productsWithBrand.map((p) => parseFloat(p.price.amount));
|
||||
if (!amounts.length) return 0;
|
||||
return Math.ceil(Math.max(...amounts) / 100) * 100;
|
||||
}, [productsWithBrand]);
|
||||
|
||||
// Roughly 100 notches across whatever range this currency spans. A fixed
|
||||
// step of 50 was unusable in MYR (max ~800) and too coarse in ZAR.
|
||||
const step = useMemo(() => Math.max(1, Math.round(maxPrice / 100)), [maxPrice]);
|
||||
|
||||
const [priceRange, setPriceRange] = useState<[number, number]>([0, maxPrice]);
|
||||
|
||||
// Prices change scale entirely when the currency changes (R3,185 vs RM799 vs
|
||||
// $196), so a range carried over from the previous currency would silently
|
||||
// hide most of the catalogue. Reset it whenever the ceiling moves.
|
||||
useEffect(() => {
|
||||
setPriceRange([0, maxPrice]);
|
||||
}, [maxPrice, currency]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let result = productsWithBrand;
|
||||
|
||||
if (selectedBrands.size > 0) {
|
||||
result = result.filter((p) => selectedBrands.has(p.brand));
|
||||
}
|
||||
|
||||
const price = parseFloat;
|
||||
result = result.filter(
|
||||
(p) => price(p.price.amount) >= priceRange[0] && price(p.price.amount) <= priceRange[1]
|
||||
);
|
||||
|
||||
switch (sort) {
|
||||
case "price-asc":
|
||||
result = [...result].sort((a, b) => price(a.price.amount) - price(b.price.amount));
|
||||
break;
|
||||
case "price-desc":
|
||||
result = [...result].sort((a, b) => price(b.price.amount) - price(a.price.amount));
|
||||
break;
|
||||
case "name":
|
||||
result = [...result].sort((a, b) => a.name.localeCompare(b.name));
|
||||
break;
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [productsWithBrand, selectedBrands, sort, priceRange]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / PER_PAGE));
|
||||
|
||||
// A filter that shrinks the result set can strand the viewer on a page that no
|
||||
// longer exists (page 6 of 2), which renders an empty grid with no explanation.
|
||||
// Snap back to the first page whenever the selection changes.
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [selectedBrands, sort, priceRange, currency]);
|
||||
|
||||
const paged = useMemo(
|
||||
() => filtered.slice((page - 1) * PER_PAGE, page * PER_PAGE),
|
||||
[filtered, page]
|
||||
);
|
||||
|
||||
const firstShown = filtered.length === 0 ? 0 : (page - 1) * PER_PAGE + 1;
|
||||
const lastShown = Math.min(page * PER_PAGE, filtered.length);
|
||||
|
||||
function goToPage(next: number) {
|
||||
setPage(Math.min(Math.max(1, next), totalPages));
|
||||
if (typeof window !== "undefined") {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
}
|
||||
|
||||
// Up to 7 page buttons; longer runs collapse the middle to an ellipsis so the
|
||||
// row never wraps on a phone.
|
||||
const pageNumbers = useMemo<(number | "gap")[]>(() => {
|
||||
if (totalPages <= 7) {
|
||||
return Array.from({ length: totalPages }, (_, i) => i + 1);
|
||||
}
|
||||
const out: (number | "gap")[] = [1];
|
||||
const from = Math.max(2, page - 1);
|
||||
const to = Math.min(totalPages - 1, page + 1);
|
||||
if (from > 2) out.push("gap");
|
||||
for (let i = from; i <= to; i++) out.push(i);
|
||||
if (to < totalPages - 1) out.push("gap");
|
||||
out.push(totalPages);
|
||||
return out;
|
||||
}, [page, totalPages]);
|
||||
|
||||
function toggleBrand(brand: string) {
|
||||
setSelectedBrands((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(brand)) next.delete(brand);
|
||||
else next.add(brand);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
setSelectedBrands(new Set());
|
||||
setPriceRange([0, maxPrice]);
|
||||
setSort("default");
|
||||
}
|
||||
|
||||
// Keep the chosen currency when following a product link.
|
||||
function productHref(slug: string) {
|
||||
return currency === DEFAULT_CURRENCY
|
||||
? `/product/${slug}`
|
||||
: `/product/${slug}?currency=${currency}`;
|
||||
}
|
||||
|
||||
const activeFilterCount = selectedBrands.size + (priceRange[0] > 0 || priceRange[1] < maxPrice ? 1 : 0);
|
||||
|
||||
return (
|
||||
<section className="max-w-7xl mx-auto px-4 py-12">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-xl font-semibold text-foreground">All Products</h2>
|
||||
<span className="text-sm text-foreground/50">
|
||||
{filtered.length === 0
|
||||
? "0 items"
|
||||
: `${firstShown}–${lastShown} of ${filtered.length} items`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className="md:hidden flex items-center gap-2 px-3 py-2 rounded-lg border border-foreground/20 text-sm text-foreground hover:border-ochre transition-colors"
|
||||
>
|
||||
<span>Filters</span>
|
||||
{activeFilterCount > 0 && (
|
||||
<span className="bg-ochre text-[#0E0D0B] text-xs font-bold w-5 h-5 rounded-full flex items-center justify-center">
|
||||
{activeFilterCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<select
|
||||
value={sort}
|
||||
onChange={(e) => setSort(e.target.value as SortOption)}
|
||||
className="bg-card-bg text-foreground text-sm border border-foreground/20 rounded-lg px-3 py-2 focus:outline-none focus:border-ochre"
|
||||
>
|
||||
<option value="default">Sort by</option>
|
||||
<option value="price-asc">Price: Low → High</option>
|
||||
<option value="price-desc">Price: High → Low</option>
|
||||
<option value="name">Name: A → Z</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-8">
|
||||
{/* Sidebar Filters */}
|
||||
<aside
|
||||
className={`${
|
||||
showFilters ? "block" : "hidden"
|
||||
} md:block w-full md:w-56 shrink-0`}
|
||||
>
|
||||
<div className="sticky top-4 space-y-6">
|
||||
{/* Clear filters */}
|
||||
{activeFilterCount > 0 && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="text-sm text-ochre hover:text-ochre-dark transition-colors"
|
||||
>
|
||||
Clear all filters ✕
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Brand Filter */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground mb-3">Brand</h3>
|
||||
<div className="space-y-1.5 max-h-64 overflow-y-auto pr-1">
|
||||
{brands.map(({ name, count }) => (
|
||||
<label
|
||||
key={name}
|
||||
className="flex items-center gap-2 cursor-pointer group"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedBrands.has(name)}
|
||||
onChange={() => toggleBrand(name)}
|
||||
className="w-4 h-4 rounded border-foreground/30 accent-[#C8873A]"
|
||||
/>
|
||||
<span className="text-sm text-foreground/80 group-hover:text-foreground transition-colors flex-1">
|
||||
{name}
|
||||
</span>
|
||||
<span className="text-xs text-foreground/40">{count}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price Filter */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground mb-3">
|
||||
Price <span className="text-xs font-normal text-foreground/40">({currency})</span>
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={maxPrice}
|
||||
step={step}
|
||||
value={priceRange[1]}
|
||||
onChange={(e) => setPriceRange([priceRange[0], parseInt(e.target.value)])}
|
||||
className="w-full accent-[#C8873A]"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-foreground/50">
|
||||
<span>{symbol}{priceRange[0].toLocaleString()}</span>
|
||||
<span>{symbol}{priceRange[1].toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Product Grid */}
|
||||
<div className="flex-1">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="text-center py-16 text-foreground/50">
|
||||
<p className="text-lg">No products match your filters</p>
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="mt-3 text-ochre hover:underline text-sm"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6">
|
||||
{paged.map((product) => (
|
||||
<a
|
||||
key={product.id}
|
||||
href={productHref(product.slug)}
|
||||
className="group bg-card-bg rounded-lg overflow-hidden hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<div className="aspect-[3/4] relative overflow-hidden bg-[#EDE6DA]">
|
||||
{product.thumbnail_url ? (
|
||||
<Image
|
||||
src={product.thumbnail_url}
|
||||
alt={product.name}
|
||||
fill
|
||||
className="object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
sizes="(max-width: 768px) 50vw, (max-width: 1200px) 33vw, 25vw"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-foreground/20 text-4xl">
|
||||
📷
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<p className="text-xs text-ochre font-medium">{product.brand}</p>
|
||||
<h3 className="text-sm font-medium text-card-text mt-1 line-clamp-2 leading-tight">
|
||||
{product.name}
|
||||
</h3>
|
||||
<p className="text-sm font-bold text-card-text mt-2">
|
||||
{product.price.display_amount}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<nav
|
||||
aria-label="Pagination"
|
||||
className="flex items-center justify-center gap-1.5 mt-10 flex-wrap"
|
||||
>
|
||||
<button
|
||||
onClick={() => goToPage(page - 1)}
|
||||
disabled={page === 1}
|
||||
aria-label="Previous page"
|
||||
className="px-3 py-2 rounded-lg border border-foreground/20 text-sm text-foreground hover:border-ochre transition-colors disabled:opacity-30 disabled:hover:border-foreground/20 disabled:cursor-not-allowed"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
|
||||
{pageNumbers.map((n, i) =>
|
||||
n === "gap" ? (
|
||||
<span
|
||||
key={`gap-${i}`}
|
||||
className="px-2 text-sm text-foreground/30 select-none"
|
||||
>
|
||||
…
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
key={n}
|
||||
onClick={() => goToPage(n)}
|
||||
aria-current={n === page ? "page" : undefined}
|
||||
className={`min-w-[2.25rem] px-3 py-2 rounded-lg border text-sm transition-colors ${
|
||||
n === page
|
||||
? "bg-ochre border-ochre text-[#0E0D0B] font-semibold"
|
||||
: "border-foreground/20 text-foreground hover:border-ochre"
|
||||
}`}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => goToPage(page + 1)}
|
||||
disabled={page === totalPages}
|
||||
aria-label="Next page"
|
||||
className="px-3 py-2 rounded-lg border border-foreground/20 text-sm text-foreground hover:border-ochre transition-colors disabled:opacity-30 disabled:hover:border-foreground/20 disabled:cursor-not-allowed"
|
||||
>
|
||||
→
|
||||
</button>
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "DW-5600STT-1 Stranger Things Men Digital Watch Black Resin Band",
|
||||
"brand": "G-Shock",
|
||||
"price": 2577.12,
|
||||
"currency": "ZAR",
|
||||
"slug": "01-g-shock-dw-5600stt-1-stranger-things-men-digital-watch-black-resin-b",
|
||||
"image_count": 4,
|
||||
"image": "/products/01-g-shock-dw-5600stt-1-stranger-things-men-digital-watch-black-resin-b/image-1.jpg"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Relaxed Fit Printed T-shirt",
|
||||
"brand": "H&M",
|
||||
"price": 143.52,
|
||||
"currency": "ZAR",
|
||||
"slug": "02-h-m-relaxed-fit-printed-t-shirt",
|
||||
"image_count": 5,
|
||||
"image": "/products/02-h-m-relaxed-fit-printed-t-shirt/image-1.jpg"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "Regular Jeans",
|
||||
"brand": "H&M",
|
||||
"price": 479.52,
|
||||
"currency": "ZAR",
|
||||
"slug": "03-h-m-regular-jeans",
|
||||
"image_count": 7,
|
||||
"image": "/products/03-h-m-regular-jeans/image-1.jpg"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "Cotton T-shirt with Breast Pocket",
|
||||
"brand": "Lacoste",
|
||||
"price": 885.6,
|
||||
"currency": "ZAR",
|
||||
"slug": "04-lacoste-cotton-t-shirt-with-breast-pocket",
|
||||
"image_count": 2,
|
||||
"image": "/products/04-lacoste-cotton-t-shirt-with-breast-pocket/image-1.jpg"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"name": "Heritage Logo Washed Baseball Cap - Tommy Jeans",
|
||||
"brand": "Tommy Hilfiger",
|
||||
"price": 558.96,
|
||||
"currency": "ZAR",
|
||||
"slug": "05-tommy-hilfiger-heritage-logo-washed-baseball-cap-tommy-jeans",
|
||||
"image_count": 1,
|
||||
"image": "/products/05-tommy-hilfiger-heritage-logo-washed-baseball-cap-tommy-jeans/image-1.jpg"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"name": "Baggy Worker Pants",
|
||||
"brand": "Cotton On",
|
||||
"price": 549.36,
|
||||
"currency": "ZAR",
|
||||
"slug": "06-cotton-on-baggy-worker-pants",
|
||||
"image_count": 1,
|
||||
"image": "/products/06-cotton-on-baggy-worker-pants/image-1.jpg"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"name": "Arch Logo T-Shirt",
|
||||
"brand": "GAP",
|
||||
"price": 292.56,
|
||||
"currency": "ZAR",
|
||||
"slug": "07-gap-arch-logo-t-shirt",
|
||||
"image_count": 1,
|
||||
"image": "/products/07-gap-arch-logo-t-shirt/image-1.jpg"
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"name": "Men's Cotton Short Sleeve Print Tee 13096224",
|
||||
"brand": "Giordano",
|
||||
"price": 134.54,
|
||||
"currency": "ZAR",
|
||||
"slug": "08-giordano-men-s-cotton-short-sleeve-print-tee-13096224",
|
||||
"image_count": 5,
|
||||
"image": "/products/08-giordano-men-s-cotton-short-sleeve-print-tee-13096224/image-1.jpg"
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"name": "Square Buckle Leather Belt - TH Mainline",
|
||||
"brand": "Tommy Hilfiger",
|
||||
"price": 788.83,
|
||||
"currency": "ZAR",
|
||||
"slug": "09-tommy-hilfiger-square-buckle-leather-belt-th-mainline",
|
||||
"image_count": 1,
|
||||
"image": "/products/09-tommy-hilfiger-square-buckle-leather-belt-th-mainline/image-1.jpg"
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"name": "Vintage Script T-Shirt",
|
||||
"brand": "Superdry",
|
||||
"price": 387.5,
|
||||
"currency": "ZAR",
|
||||
"slug": "10-superdry-vintage-script-t-shirt",
|
||||
"image_count": 8,
|
||||
"image": "/products/10-superdry-vintage-script-t-shirt/image-1.jpg"
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"name": "Tech\u2122 Short Sleeve T-Shirt",
|
||||
"brand": "Under Armour",
|
||||
"price": 212.35,
|
||||
"currency": "ZAR",
|
||||
"slug": "11-under-armour-tech-short-sleeve-t-shirt",
|
||||
"image_count": 1,
|
||||
"image": "/products/11-under-armour-tech-short-sleeve-t-shirt/image-1.jpg"
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"name": "Tech\u2122 Polo Shirt",
|
||||
"brand": "Under Armour",
|
||||
"price": 549.89,
|
||||
"currency": "ZAR",
|
||||
"slug": "12-under-armour-tech-polo-shirt",
|
||||
"image_count": 1,
|
||||
"image": "/products/12-under-armour-tech-polo-shirt/image-1.jpg"
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"name": "Six-Panel Baseball Cap - Th Mainline",
|
||||
"brand": "Tommy Hilfiger",
|
||||
"price": 688.03,
|
||||
"currency": "ZAR",
|
||||
"slug": "13-tommy-hilfiger-six-panel-baseball-cap-th-mainline",
|
||||
"image_count": 1,
|
||||
"image": "/products/13-tommy-hilfiger-six-panel-baseball-cap-th-mainline/image-1.jpg"
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"name": "1985 Regular Polo Shirt - Th Mainline",
|
||||
"brand": "Tommy Hilfiger",
|
||||
"price": 1143.07,
|
||||
"currency": "ZAR",
|
||||
"slug": "14-tommy-hilfiger-1985-regular-polo-shirt-th-mainline",
|
||||
"image_count": 1,
|
||||
"image": "/products/14-tommy-hilfiger-1985-regular-polo-shirt-th-mainline/image-1.jpg"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"name": "Box Fit Text T-Shirt",
|
||||
"brand": "Cotton On",
|
||||
"price": 375.98,
|
||||
"currency": "ZAR",
|
||||
"slug": "15-cotton-on-box-fit-text-t-shirt",
|
||||
"image_count": 1,
|
||||
"image": "/products/15-cotton-on-box-fit-text-t-shirt/image-1.jpg"
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"name": "Men's Cotton Short Sleeve Print Tee 13096299",
|
||||
"brand": "Giordano",
|
||||
"price": 134.54,
|
||||
"currency": "ZAR",
|
||||
"slug": "16-giordano-men-s-cotton-short-sleeve-print-tee-13096299",
|
||||
"image_count": 5,
|
||||
"image": "/products/16-giordano-men-s-cotton-short-sleeve-print-tee-13096299/image-1.jpg"
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"name": "Gap Original Arch Crew",
|
||||
"brand": "GAP",
|
||||
"price": 688.13,
|
||||
"currency": "ZAR",
|
||||
"slug": "17-gap-gap-original-arch-crew",
|
||||
"image_count": 1,
|
||||
"image": "/products/17-gap-gap-original-arch-crew/image-1.jpg"
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"name": "Performance Tech\u2122 Unisex 3-Pack Crew Socks",
|
||||
"brand": "Under Armour",
|
||||
"price": 251.66,
|
||||
"currency": "ZAR",
|
||||
"slug": "18-under-armour-performance-tech-unisex-3-pack-crew-socks",
|
||||
"image_count": 3,
|
||||
"image": "/products/18-under-armour-performance-tech-unisex-3-pack-crew-socks/image-1.jpg"
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"name": "Chino Pants",
|
||||
"brand": "Electro Denim Lab",
|
||||
"price": 312.0,
|
||||
"currency": "ZAR",
|
||||
"slug": "19-electro-denim-lab-chino-pants",
|
||||
"image_count": 5,
|
||||
"image": "/products/19-electro-denim-lab-chino-pants/image-1.jpg"
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"name": "Flag Raglan Sleeves Relaxed Jersey T-Shirt - Th Mainline",
|
||||
"brand": "Tommy Hilfiger",
|
||||
"price": 863.71,
|
||||
"currency": "ZAR",
|
||||
"slug": "20-tommy-hilfiger-flag-raglan-sleeves-relaxed-jersey-t-shirt-th-mainline",
|
||||
"image_count": 1,
|
||||
"image": "/products/20-tommy-hilfiger-flag-raglan-sleeves-relaxed-jersey-t-shirt-th-mainline/image-1.jpg"
|
||||
},
|
||||
{
|
||||
"id": 21,
|
||||
"name": "3-Pack Essential Crew Socks",
|
||||
"brand": "Cotton On",
|
||||
"price": 158.64,
|
||||
"currency": "ZAR",
|
||||
"slug": "21-cotton-on-3-pack-essential-crew-socks",
|
||||
"image_count": 1,
|
||||
"image": "/products/21-cotton-on-3-pack-essential-crew-socks/image-1.jpg"
|
||||
},
|
||||
{
|
||||
"id": 22,
|
||||
"name": "Everyday Fleece Shorts",
|
||||
"brand": "Cotton On",
|
||||
"price": 292.56,
|
||||
"currency": "ZAR",
|
||||
"slug": "22-cotton-on-everyday-fleece-shorts",
|
||||
"image_count": 1,
|
||||
"image": "/products/22-cotton-on-everyday-fleece-shorts/image-1.jpg"
|
||||
},
|
||||
{
|
||||
"id": 23,
|
||||
"name": "Box Fit Graphic Hoodie",
|
||||
"brand": "Cotton On",
|
||||
"price": 681.74,
|
||||
"currency": "ZAR",
|
||||
"slug": "23-cotton-on-box-fit-graphic-hoodie",
|
||||
"image_count": 1,
|
||||
"image": "/products/23-cotton-on-box-fit-graphic-hoodie/image-1.jpg"
|
||||
},
|
||||
{
|
||||
"id": 24,
|
||||
"name": "Grimelanj Regular Fit Plaid Knitted Shorts Pajama Set TMNSS25PT00034",
|
||||
"brand": "Trendyol",
|
||||
"price": 379.34,
|
||||
"currency": "ZAR",
|
||||
"slug": "24-trendyol-grimelanj-regular-fit-plaid-knitted-shorts-pajama-set-tmnss2",
|
||||
"image_count": 1,
|
||||
"image": "/products/24-trendyol-grimelanj-regular-fit-plaid-knitted-shorts-pajama-set-tmnss2/image-1.jpg"
|
||||
},
|
||||
{
|
||||
"id": 25,
|
||||
"name": "C BY CAMEL ACTIVE OUTDOOR HIKING TRAVEL BACKPACK - 51250081",
|
||||
"brand": "Camel Active",
|
||||
"price": 1291.2,
|
||||
"currency": "ZAR",
|
||||
"slug": "25-camel-active-c-by-camel-active-outdoor-hiking-travel-backpack-51250081",
|
||||
"image_count": 7,
|
||||
"image": "/products/25-camel-active-c-by-camel-active-outdoor-hiking-travel-backpack-51250081/image-1.jpg"
|
||||
},
|
||||
{
|
||||
"id": 26,
|
||||
"name": "Cotton T-shirt",
|
||||
"brand": "Lacoste",
|
||||
"price": 1239.84,
|
||||
"currency": "ZAR",
|
||||
"slug": "26-lacoste-cotton-t-shirt",
|
||||
"image_count": 1,
|
||||
"image": "/products/26-lacoste-cotton-t-shirt/image-1.jpg"
|
||||
},
|
||||
{
|
||||
"id": 27,
|
||||
"name": "Under Armour UA Tech\u2122 Vent Men's Short Sleeve",
|
||||
"brand": "Under Armour",
|
||||
"price": 400.51,
|
||||
"currency": "ZAR",
|
||||
"slug": "27-under-armour-under-armour-ua-tech-vent-men-s-short-sleeve",
|
||||
"image_count": 5,
|
||||
"image": "/products/27-under-armour-under-armour-ua-tech-vent-men-s-short-sleeve/image-1.jpg"
|
||||
},
|
||||
{
|
||||
"id": 28,
|
||||
"name": "Pool Slide Sandals - TH Mainline",
|
||||
"brand": "Tommy Hilfiger",
|
||||
"price": 921.31,
|
||||
"currency": "ZAR",
|
||||
"slug": "28-tommy-hilfiger-pool-slide-sandals-th-mainline",
|
||||
"image_count": 1,
|
||||
"image": "/products/28-tommy-hilfiger-pool-slide-sandals-th-mainline/image-1.jpg"
|
||||
},
|
||||
{
|
||||
"id": 29,
|
||||
"name": "Low Rise Trunks 3 Pack - Calvin Klein Underwear",
|
||||
"brand": "Calvin Klein",
|
||||
"price": 866.16,
|
||||
"currency": "ZAR",
|
||||
"slug": "29-calvin-klein-low-rise-trunks-3-pack-calvin-klein-underwear",
|
||||
"image_count": 1,
|
||||
"image": "/products/29-calvin-klein-low-rise-trunks-3-pack-calvin-klein-underwear/image-1.jpg"
|
||||
},
|
||||
{
|
||||
"id": 30,
|
||||
"name": "Men's Backpack - Black",
|
||||
"brand": "Swiss Polo",
|
||||
"price": 497.04,
|
||||
"currency": "ZAR",
|
||||
"slug": "30-swiss-polo-men-s-backpack-black",
|
||||
"image_count": 2,
|
||||
"image": "/products/30-swiss-polo-men-s-backpack-black/image-1.jpg"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,283 @@
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
phone: string | null;
|
||||
full_name: string;
|
||||
addresses: Address[];
|
||||
default_shipping_address: Address | null;
|
||||
// Where the region anchor lives for a signed-in customer. Stock Spree neither
|
||||
// permits nor serializes this — the backend carries a decorator for it
|
||||
// (config/initializers/customer_preferences.rb on pak-jabit-backend).
|
||||
public_metadata?: { preferred_currency?: string } | null;
|
||||
}
|
||||
|
||||
export interface Address {
|
||||
id: string;
|
||||
label: string | null;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
full_name: string;
|
||||
address1: string;
|
||||
address2: string | null;
|
||||
company: string | null;
|
||||
city: string;
|
||||
state_name: string | null;
|
||||
postal_code: string;
|
||||
phone: string | null;
|
||||
country_iso: string;
|
||||
country_name: string;
|
||||
is_default_shipping: boolean;
|
||||
is_default_billing: boolean;
|
||||
}
|
||||
|
||||
export interface AddressInput {
|
||||
label?: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
address1: string;
|
||||
address2?: string;
|
||||
city: string;
|
||||
state_name?: string;
|
||||
postal_code: string;
|
||||
phone?: string;
|
||||
country_iso: string;
|
||||
is_default_shipping?: boolean;
|
||||
is_default_billing?: boolean;
|
||||
}
|
||||
|
||||
export interface SavedCard {
|
||||
id: string;
|
||||
brand: string | null;
|
||||
last4: string;
|
||||
month: number;
|
||||
year: number;
|
||||
name: string | null;
|
||||
default: boolean;
|
||||
}
|
||||
|
||||
export interface AuthResult {
|
||||
token: string;
|
||||
refresh_token: string;
|
||||
user: User;
|
||||
error?: { message: string };
|
||||
}
|
||||
|
||||
export async function register(email: string, password: string): Promise<AuthResult> {
|
||||
const res = await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function login(email: string, password: string): Promise<AuthResult> {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Returning null on error hid a 401 behind an empty-looking profile page for the
|
||||
// whole life of this route. Throw with whatever Spree actually said so the caller
|
||||
// can show it.
|
||||
async function readError(res: Response): Promise<string> {
|
||||
try {
|
||||
const body = await res.json();
|
||||
return body?.error?.message || body?.error || body?.message || `HTTP ${res.status}`;
|
||||
} catch {
|
||||
return `HTTP ${res.status}`;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProfile(token: string): Promise<User> {
|
||||
const res = await authFetch("/api/auth/me", token);
|
||||
if (!res.ok) throw new Error(await readError(res));
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateProfile(
|
||||
token: string,
|
||||
data: {
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
phone?: string;
|
||||
public_metadata?: Record<string, unknown>;
|
||||
}
|
||||
): Promise<User> {
|
||||
const res = await authFetch("/api/auth/me", token, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error(await readError(res));
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// An empty array here is indistinguishable from "this customer has no addresses",
|
||||
// so a failed request used to read as a legitimately empty address book.
|
||||
export async function getAddresses(token: string): Promise<Address[]> {
|
||||
const user = await getProfile(token);
|
||||
return user.addresses || [];
|
||||
}
|
||||
|
||||
export function saveAuth(token: string, refreshToken: string, user?: User) {
|
||||
try {
|
||||
localStorage.setItem("pak-jabit-auth", JSON.stringify({ token, refreshToken, user }));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function loadAuth(): { token: string; refreshToken: string; user?: User } | null {
|
||||
try {
|
||||
const raw = localStorage.getItem("pak-jabit-auth");
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearAuth() {
|
||||
try {
|
||||
localStorage.removeItem("pak-jabit-auth");
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Address book
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function authHeaders(token: string): HeadersInit {
|
||||
return { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
|
||||
}
|
||||
|
||||
/** Fired after a silent token renewal so AuthProvider can pick up the new one. */
|
||||
export const AUTH_REFRESHED = "pak-jabit-auth-refreshed";
|
||||
|
||||
// In-flight renewal, shared. Without this, a page that loads addresses and cards
|
||||
// at once fires two refreshes on the same expired token; the second spends an
|
||||
// already-rotated refresh token and fails, signing the customer out mid-load.
|
||||
let refreshing: Promise<string | null> | null = null;
|
||||
|
||||
async function renewToken(): Promise<string | null> {
|
||||
if (refreshing) return refreshing;
|
||||
|
||||
refreshing = (async () => {
|
||||
const stored = loadAuth();
|
||||
if (!stored?.refreshToken) return null;
|
||||
|
||||
const res = await fetch("/api/auth/refresh", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ refresh_token: stored.refreshToken }),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
|
||||
const body = await res.json();
|
||||
if (!body?.token) return null;
|
||||
|
||||
saveAuth(body.token, body.refresh_token || stored.refreshToken, body.user || stored.user);
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(AUTH_REFRESHED, {
|
||||
detail: { token: body.token, refreshToken: body.refresh_token, user: body.user },
|
||||
})
|
||||
);
|
||||
}
|
||||
return body.token as string;
|
||||
})().finally(() => {
|
||||
refreshing = null;
|
||||
});
|
||||
|
||||
return refreshing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call the storefront's auth API. On 401 the access token is renewed once and
|
||||
* the request replayed. If renewal fails the session is cleared — staying
|
||||
* "signed in" with a dead token is what made this invisible for so long.
|
||||
*/
|
||||
async function authFetch(
|
||||
path: string,
|
||||
token: string,
|
||||
init: RequestInit = {}
|
||||
): Promise<Response> {
|
||||
const send = (t: string) =>
|
||||
fetch(path, { ...init, headers: { ...authHeaders(t), ...(init.headers || {}) } });
|
||||
|
||||
let res = await send(token);
|
||||
if (res.status !== 401) return res;
|
||||
|
||||
const fresh = await renewToken();
|
||||
if (!fresh) {
|
||||
clearAuth();
|
||||
throw new Error("Your session expired. Please sign in again.");
|
||||
}
|
||||
|
||||
res = await send(fresh);
|
||||
if (res.status === 401) {
|
||||
clearAuth();
|
||||
throw new Error("Your session expired. Please sign in again.");
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function listAddresses(token: string): Promise<Address[]> {
|
||||
const res = await authFetch("/api/auth/addresses", token);
|
||||
if (!res.ok) throw new Error(await readError(res));
|
||||
const body = await res.json();
|
||||
return body.data || [];
|
||||
}
|
||||
|
||||
export async function createAddress(token: string, data: AddressInput): Promise<Address> {
|
||||
const res = await authFetch("/api/auth/addresses", token, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error(await readError(res));
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateAddress(
|
||||
token: string,
|
||||
id: string,
|
||||
data: Partial<AddressInput>
|
||||
): Promise<Address> {
|
||||
const res = await authFetch(`/api/auth/addresses/${id}`, token, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error(await readError(res));
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteAddress(token: string, id: string): Promise<void> {
|
||||
const res = await authFetch(`/api/auth/addresses/${id}`, token, { method: "DELETE" });
|
||||
if (!res.ok) throw new Error(await readError(res));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Saved cards
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function listCards(token: string): Promise<SavedCard[]> {
|
||||
const res = await authFetch("/api/auth/cards", token);
|
||||
if (!res.ok) throw new Error(await readError(res));
|
||||
const body = await res.json();
|
||||
return body.data || [];
|
||||
}
|
||||
|
||||
// #mock — one-shot because the Bogus gateway needs no card entry. A real gateway
|
||||
// splits this: create session here, collect the card in the gateway's own hosted
|
||||
// field, then complete. See src/app/api/auth/cards/setup/route.ts.
|
||||
export async function addTestCard(token: string): Promise<void> {
|
||||
const res = await authFetch("/api/auth/cards/setup", token, { method: "POST" });
|
||||
if (!res.ok) throw new Error(await readError(res));
|
||||
}
|
||||
|
||||
export async function deleteCard(token: string, id: string): Promise<void> {
|
||||
const res = await authFetch(`/api/auth/cards/${id}`, token, { method: "DELETE" });
|
||||
if (!res.ok) throw new Error(await readError(res));
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
async function cartProxy(
|
||||
pathname: string,
|
||||
method: string,
|
||||
spreeToken?: string,
|
||||
body?: unknown,
|
||||
userToken?: string
|
||||
) {
|
||||
const res = await fetch("/api/cart", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ pathname, method, spreeToken, body, userToken }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Cart API ${res.status}: ${text}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export interface CartItem {
|
||||
id: string;
|
||||
name: string;
|
||||
quantity: number;
|
||||
price: string;
|
||||
display_price: string;
|
||||
total: string;
|
||||
display_total: string;
|
||||
variant_id: string;
|
||||
image_url: string | null;
|
||||
}
|
||||
|
||||
export interface Market {
|
||||
id: string;
|
||||
name: string;
|
||||
currency: string;
|
||||
country_isos: string[];
|
||||
}
|
||||
|
||||
export interface Cart {
|
||||
id: string;
|
||||
token: string;
|
||||
total_quantity: number;
|
||||
display_total: string;
|
||||
display_item_total: string;
|
||||
items: CartItem[];
|
||||
// Fixed at creation by Spree. A cart older than a currency change keeps the
|
||||
// old one, so it is compared against the market's to spot a stale cart.
|
||||
currency: string;
|
||||
// A cart belongs to ONE market, and shipping only resolves for that market's
|
||||
// countries. Checkout uses this to offer exactly what can actually ship.
|
||||
market: Market | null;
|
||||
}
|
||||
|
||||
function parseCart(data: Record<string, unknown>): Cart {
|
||||
const items = (data.items as Record<string, unknown>[]) || [];
|
||||
return {
|
||||
id: data.id as string,
|
||||
token: data.token as string,
|
||||
total_quantity: data.total_quantity as number,
|
||||
display_total: data.display_total as string,
|
||||
display_item_total: data.display_item_total as string,
|
||||
market: (data.market as Market) ?? null,
|
||||
currency: (data.currency as string) ?? "",
|
||||
items: items.map((item) => ({
|
||||
id: item.id as string,
|
||||
name: item.name as string,
|
||||
quantity: item.quantity as number,
|
||||
price: item.price as string,
|
||||
display_price: item.display_price as string,
|
||||
total: item.total as string,
|
||||
display_total: item.display_total as string,
|
||||
variant_id: item.variant_id as string,
|
||||
image_url: (item.thumbnail_url as string) || null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Spree fixes a cart's market AND currency at creation - neither can be changed
|
||||
* afterwards. Passing them here is the only chance to get the region right.
|
||||
*/
|
||||
export async function createCart(
|
||||
opts?: { market_id: string; currency: string },
|
||||
userToken?: string
|
||||
): Promise<Cart> {
|
||||
// Passing the customer's token makes Spree attach the cart to them at
|
||||
// creation, so it is theirs on every device from the first item.
|
||||
const data = await cartProxy("/carts", "POST", undefined, opts, userToken);
|
||||
return parseCart(data);
|
||||
}
|
||||
|
||||
/** The signed-in customer's own cart, held server-side. Null when they have none. */
|
||||
export async function getMyCart(userToken: string): Promise<Cart | null> {
|
||||
const data = await cartProxy("/carts", "GET", undefined, undefined, userToken);
|
||||
const list = (data?.data as Record<string, unknown>[]) || [];
|
||||
if (list.length === 0) return null;
|
||||
// Newest first: a customer can end up with more than one open cart, and the
|
||||
// last one they touched is the one they mean.
|
||||
const newest = [...list].sort((a, b) =>
|
||||
String(b.created_at ?? "").localeCompare(String(a.created_at ?? ""))
|
||||
)[0];
|
||||
return parseCart(newest);
|
||||
}
|
||||
|
||||
/** Claim a guest cart for the signed-in customer. */
|
||||
export async function associateCart(
|
||||
id: string,
|
||||
cartToken: string,
|
||||
userToken: string
|
||||
): Promise<Cart> {
|
||||
const data = await cartProxy(`/carts/${id}/associate`, "PATCH", cartToken, {}, userToken);
|
||||
return parseCart(data);
|
||||
}
|
||||
|
||||
export async function getCart(id: string, token: string): Promise<Cart> {
|
||||
const data = await cartProxy(`/carts/${id}`, "GET", token);
|
||||
return parseCart(data);
|
||||
}
|
||||
|
||||
export async function addItem(
|
||||
cartId: string,
|
||||
token: string,
|
||||
variantId: string,
|
||||
quantity: number = 1
|
||||
): Promise<Cart> {
|
||||
const data = await cartProxy(`/carts/${cartId}/items`, "POST", token, {
|
||||
variant_id: variantId,
|
||||
quantity,
|
||||
});
|
||||
return parseCart(data);
|
||||
}
|
||||
|
||||
export async function updateItemQuantity(
|
||||
cartId: string,
|
||||
token: string,
|
||||
itemId: string,
|
||||
quantity: number
|
||||
): Promise<Cart> {
|
||||
const data = await cartProxy(`/carts/${cartId}/items/${itemId}`, "PATCH", token, {
|
||||
quantity,
|
||||
});
|
||||
return parseCart(data);
|
||||
}
|
||||
|
||||
export async function removeItem(
|
||||
cartId: string,
|
||||
token: string,
|
||||
itemId: string
|
||||
): Promise<Cart> {
|
||||
const data = await cartProxy(`/carts/${cartId}/items/${itemId}`, "DELETE", token);
|
||||
return parseCart(data);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// The currencies the store can display. Each one needs BOTH a Spree market
|
||||
// (markets are what make a currency selectable — the store's
|
||||
// supported_currencies column is not read by the v3 API) and a price row per
|
||||
// product in that currency. Adding a code here without both is a silent
|
||||
// no-op: Spree rejects the unsupported currency and serves the default.
|
||||
export const CURRENCIES = [
|
||||
{ code: "ZAR", symbol: "R", label: "ZAR" },
|
||||
{ code: "USD", symbol: "$", label: "USD" },
|
||||
{ code: "MYR", symbol: "RM", label: "MYR" },
|
||||
] as const;
|
||||
|
||||
export type CurrencyCode = (typeof CURRENCIES)[number]["code"];
|
||||
|
||||
export const DEFAULT_CURRENCY: CurrencyCode = "ZAR";
|
||||
|
||||
/**
|
||||
* Narrow an arbitrary value (a URL query param) to a supported currency.
|
||||
* Returns null when it isn't one, so callers decide what to do rather than
|
||||
* inheriting a guessed default.
|
||||
*/
|
||||
export function parseCurrency(value: unknown): CurrencyCode | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const upper = value.toUpperCase();
|
||||
const hit = CURRENCIES.find((c) => c.code === upper);
|
||||
return hit ? hit.code : null;
|
||||
}
|
||||
|
||||
export function symbolFor(code: string): string {
|
||||
return CURRENCIES.find((c) => c.code === code)?.symbol ?? "";
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const EP_BASE = "https://api.easyparcel.com";
|
||||
const EP_API_VERSION = "2026-06";
|
||||
|
||||
// Read lazily, not at module load: `next build` imports this file without the
|
||||
// deployment env, and a module-level throw would break the build.
|
||||
function requireEnv(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
throw new Error(`${name} is not set — EasyParcel cannot authenticate`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
const clientId = () => requireEnv("EASYPARCEL_CLIENT_ID");
|
||||
|
||||
// The refresh token has to outlive the process. It used to live only in a
|
||||
// module variable, so every restart, redeploy or serverless cold start silently
|
||||
// dropped it and the next quote failed with "run OAuth flow first".
|
||||
const TOKEN_PATH =
|
||||
process.env.EASYPARCEL_TOKEN_PATH || path.join(process.cwd(), ".easyparcel-token.json");
|
||||
|
||||
interface StoredToken {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_at: number;
|
||||
}
|
||||
|
||||
let cachedToken: StoredToken | null = null;
|
||||
|
||||
function readToken(): StoredToken | null {
|
||||
if (cachedToken) return cachedToken;
|
||||
try {
|
||||
cachedToken = JSON.parse(fs.readFileSync(TOKEN_PATH, "utf8")) as StoredToken;
|
||||
return cachedToken;
|
||||
} catch (err) {
|
||||
// Absent is normal before the first OAuth run; unreadable or corrupt is not.
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
|
||||
throw new Error(`EasyParcel token at ${TOKEN_PATH} is unreadable: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function writeToken(token: StoredToken): void {
|
||||
cachedToken = token;
|
||||
fs.writeFileSync(TOKEN_PATH, JSON.stringify(token), { mode: 0o600 });
|
||||
}
|
||||
|
||||
function basicAuth(): string {
|
||||
return Buffer.from(
|
||||
`${requireEnv("EASYPARCEL_CLIENT_ID")}:${requireEnv("EASYPARCEL_CLIENT_SECRET")}`
|
||||
).toString("base64");
|
||||
}
|
||||
|
||||
export async function getToken(): Promise<string> {
|
||||
const stored = readToken();
|
||||
|
||||
if (stored && Date.now() < stored.expires_at - 60000) {
|
||||
return stored.access_token;
|
||||
}
|
||||
|
||||
if (stored?.refresh_token) {
|
||||
const res = await fetch(`${EP_BASE}/oauth/token`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Basic ${basicAuth()}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: stored.refresh_token,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
writeToken({
|
||||
access_token: data.access_token,
|
||||
refresh_token: data.refresh_token,
|
||||
expires_at: Date.now() + data.expires_in * 1000,
|
||||
});
|
||||
return data.access_token;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("No valid EasyParcel token — run OAuth flow first");
|
||||
}
|
||||
|
||||
export function setToken(access_token: string, refresh_token: string, expires_in: number) {
|
||||
writeToken({
|
||||
access_token,
|
||||
refresh_token,
|
||||
expires_at: Date.now() + expires_in * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
async function ep(path: string, body: unknown): Promise<unknown> {
|
||||
const token = await getToken();
|
||||
const res = await fetch(`${EP_BASE}/open_api/${EP_API_VERSION}${path}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`EasyParcel ${res.status}: ${text}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export interface QuoteRequest {
|
||||
sender_postcode: string;
|
||||
sender_state: string;
|
||||
receiver_postcode: string;
|
||||
receiver_state: string;
|
||||
weight: number;
|
||||
}
|
||||
|
||||
export interface CourierOption {
|
||||
service_id: string;
|
||||
service_name: string;
|
||||
courier_name: string;
|
||||
courier_logo: string;
|
||||
delivery_duration: string;
|
||||
is_pickup: boolean;
|
||||
is_dropoff: boolean;
|
||||
total_amount: string;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export async function getQuotes(req: QuoteRequest): Promise<CourierOption[]> {
|
||||
const data = (await ep("/shipment/quotations", {
|
||||
shipment: [
|
||||
{
|
||||
sender: {
|
||||
postcode: req.sender_postcode,
|
||||
subdivision_code: req.sender_state,
|
||||
country: "MY",
|
||||
},
|
||||
receiver: {
|
||||
postcode: req.receiver_postcode,
|
||||
subdivision_code: req.receiver_state,
|
||||
country: "MY",
|
||||
},
|
||||
weight: req.weight,
|
||||
},
|
||||
],
|
||||
})) as { data: Array<{ quotations: Array<{ courier: Record<string, unknown>; pricing: Record<string, unknown> }> }> };
|
||||
|
||||
const shipment = data.data?.[0];
|
||||
if (!shipment?.quotations) return [];
|
||||
|
||||
return shipment.quotations.map((q) => {
|
||||
let duration = q.courier.delivery_duration as string;
|
||||
try {
|
||||
const parsed = JSON.parse(duration);
|
||||
if (parsed?.type === "days" && parsed?.value) {
|
||||
duration = `${parsed.value} day${parsed.value === "1" ? "" : "s"}`;
|
||||
}
|
||||
} catch { /* already a string */ }
|
||||
|
||||
return {
|
||||
service_id: q.courier.service_id as string,
|
||||
service_name: q.courier.service_name as string,
|
||||
courier_name: q.courier.courier_name as string,
|
||||
courier_logo: q.courier.courier_logo as string,
|
||||
delivery_duration: duration,
|
||||
is_pickup: q.courier.is_pickup as boolean,
|
||||
is_dropoff: q.courier.is_dropoff as boolean,
|
||||
total_amount: (q.pricing as Record<string, string>).total_amount,
|
||||
currency: (q.pricing as Record<string, string>).currency,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export interface BookRequest {
|
||||
service_id: string;
|
||||
reference: string;
|
||||
collection_date: string;
|
||||
weight: number;
|
||||
sender: {
|
||||
name: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
address: string;
|
||||
postcode: string;
|
||||
city: string;
|
||||
state: string;
|
||||
};
|
||||
receiver: {
|
||||
name: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
address: string;
|
||||
postcode: string;
|
||||
city: string;
|
||||
state: string;
|
||||
};
|
||||
items: Array<{
|
||||
content: string;
|
||||
weight: number;
|
||||
value: number;
|
||||
quantity: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function bookShipment(req: BookRequest) {
|
||||
return ep("/shipment/submit_orders", {
|
||||
shipment: [
|
||||
{
|
||||
reference: req.reference,
|
||||
service_id: req.service_id,
|
||||
collection_date: req.collection_date,
|
||||
weight: req.weight,
|
||||
height: 10,
|
||||
length: 10,
|
||||
width: 10,
|
||||
item: req.items.map((i) => ({
|
||||
content: i.content,
|
||||
weight: i.weight,
|
||||
height: 10,
|
||||
length: 10,
|
||||
width: 10,
|
||||
currency_code: "MYR",
|
||||
value: i.value,
|
||||
quantity: i.quantity,
|
||||
})),
|
||||
sender: {
|
||||
name: req.sender.name,
|
||||
phone_number_country_code: "MY",
|
||||
phone_number: req.sender.phone,
|
||||
email: req.sender.email,
|
||||
address_1: req.sender.address,
|
||||
postcode: req.sender.postcode,
|
||||
city: req.sender.city,
|
||||
subdivision_code: req.sender.state,
|
||||
country_code: "MY",
|
||||
},
|
||||
receiver: {
|
||||
name: req.receiver.name,
|
||||
phone_number_country_code: "MY",
|
||||
phone_number: req.receiver.phone,
|
||||
email: req.receiver.email,
|
||||
address_1: req.receiver.address,
|
||||
postcode: req.receiver.postcode,
|
||||
city: req.receiver.city,
|
||||
subdivision_code: req.receiver.state,
|
||||
country_code: "MY",
|
||||
},
|
||||
feature: {
|
||||
sms_tracking: false,
|
||||
email_tracking: true,
|
||||
whatsapp_tracking: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export async function trackShipment(awbNumbers: string[]) {
|
||||
return ep("/shipment/tracking_status", { awb_numbers: awbNumbers });
|
||||
}
|
||||
|
||||
export { EP_BASE, clientId, basicAuth };
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export interface GeoState {
|
||||
abbr: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface GeoCountry {
|
||||
iso: string;
|
||||
iso3: string;
|
||||
name: string;
|
||||
states_required: boolean;
|
||||
zipcode_required: boolean;
|
||||
states?: GeoState[];
|
||||
}
|
||||
|
||||
// One fetch per page load, shared by every component that asks. The list is the
|
||||
// store's checkout countries and changes about never, so the route caches it for
|
||||
// an hour server-side too.
|
||||
let cache: Promise<GeoCountry[]> | null = null;
|
||||
|
||||
export function loadCountries(): Promise<GeoCountry[]> {
|
||||
if (!cache) {
|
||||
cache = fetch("/api/countries")
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
|
||||
.then((b) => (b.data || []) as GeoCountry[])
|
||||
.catch((e) => {
|
||||
// Let the next caller try again rather than caching the failure forever.
|
||||
cache = null;
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Countries for the address form, plus whichever states belong to the selected
|
||||
* one. `statesRequired` says whether the country has subdivisions at all —
|
||||
* several do not, and demanding a province for those blocks a valid address.
|
||||
*/
|
||||
export function useCountries(selectedIso: string) {
|
||||
const [countries, setCountries] = useState<GeoCountry[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
loadCountries()
|
||||
.then((c) => live && setCountries(c))
|
||||
.catch((e) => live && setError(e instanceof Error ? e.message : "Could not load countries"));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const country = countries.find((c) => c.iso === selectedIso) || null;
|
||||
const states = country?.states || [];
|
||||
|
||||
return {
|
||||
countries,
|
||||
country,
|
||||
states,
|
||||
statesRequired: !!country?.states_required && states.length > 0,
|
||||
zipRequired: country?.zipcode_required ?? true,
|
||||
error,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { CurrencyCode, DEFAULT_CURRENCY, parseCurrency } from "@/lib/currency";
|
||||
|
||||
/**
|
||||
* The region anchor.
|
||||
*
|
||||
* Spree fixes a cart's currency AND market at creation and neither can be
|
||||
* changed afterwards — switching currency mid-basket leaves items priced in
|
||||
* money the order will never use. So the choice has to exist before the cart
|
||||
* does, and it has to outlive any single cart.
|
||||
*
|
||||
* It lives in two places on purpose: localStorage so a guest keeps it, and the
|
||||
* customer's public_metadata so it follows a signed-in person to another device.
|
||||
* The profile wins when both exist.
|
||||
*/
|
||||
|
||||
export interface Market {
|
||||
id: string;
|
||||
name: string;
|
||||
currency: string;
|
||||
country_isos: string[];
|
||||
default: boolean;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "pak-jabit-region";
|
||||
|
||||
export function loadRegion(): CurrencyCode | null {
|
||||
try {
|
||||
return parseCurrency(localStorage.getItem(STORAGE_KEY));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveRegion(code: CurrencyCode) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, code);
|
||||
} catch {
|
||||
// A browser with site data blocked still shops; the choice just resets on
|
||||
// reload, and a signed-in customer's profile carries it anyway.
|
||||
}
|
||||
}
|
||||
|
||||
let cache: Promise<Market[]> | null = null;
|
||||
|
||||
export function loadMarkets(): Promise<Market[]> {
|
||||
if (!cache) {
|
||||
cache = fetch("/api/markets")
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
|
||||
.then((b) => (b.data || []) as Market[])
|
||||
.catch((e) => {
|
||||
cache = null;
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
|
||||
/** The market quoting in this currency, or null when none does. */
|
||||
export function marketFor(markets: Market[], currency: string): Market | null {
|
||||
return markets.find((m) => m.currency === currency) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a new cart should be created with. Returns null rather than guessing when
|
||||
* no market quotes the requested currency — a cart built on a guessed market
|
||||
* prices and ships somewhere nobody chose.
|
||||
*/
|
||||
export function cartOptionsFor(
|
||||
markets: Market[],
|
||||
currency: CurrencyCode
|
||||
): { market_id: string; currency: string } | null {
|
||||
const m = marketFor(markets, currency);
|
||||
return m ? { market_id: m.id, currency: m.currency } : null;
|
||||
}
|
||||
|
||||
export { DEFAULT_CURRENCY };
|
||||
@@ -0,0 +1,86 @@
|
||||
import { DEFAULT_CURRENCY, type CurrencyCode } from "./currency";
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_SPREE_API_URL || "https://api.jux-lab.com";
|
||||
const API_KEY = process.env.NEXT_PUBLIC_SPREE_API_KEY || "";
|
||||
|
||||
// Spree builds media URLs from RAILS_HOST, which carries no scheme, so they
|
||||
// always come back http://. Caddy serves the same paths over TLS.
|
||||
export function fixImageUrl(url: string | null): string {
|
||||
if (!url) return "";
|
||||
return url.replace(/^http:\/\//, "https://");
|
||||
}
|
||||
|
||||
async function spree(path: string, currency: CurrencyCode = DEFAULT_CURRENCY) {
|
||||
const sep = path.includes("?") ? "&" : "?";
|
||||
const res = await fetch(`${API_URL}/api/v3/store${path}${sep}currency=${currency}`, {
|
||||
headers: { "X-Spree-Api-Key": API_KEY },
|
||||
next: { revalidate: 60 },
|
||||
});
|
||||
if (!res.ok) throw new Error(`Spree API ${res.status}: ${path}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export interface SpreeMedia {
|
||||
id: string;
|
||||
position: number;
|
||||
original_url: string;
|
||||
mini_url: string;
|
||||
small_url?: string;
|
||||
large_url?: string;
|
||||
}
|
||||
|
||||
export interface SpreeProduct {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
available: boolean;
|
||||
default_variant_id: string;
|
||||
price: { amount: string; currency: string; display_amount: string };
|
||||
thumbnail_url: string | null;
|
||||
tags: string[];
|
||||
media?: SpreeMedia[];
|
||||
}
|
||||
|
||||
function fixProductUrls(p: SpreeProduct): SpreeProduct {
|
||||
return {
|
||||
...p,
|
||||
thumbnail_url: fixImageUrl(p.thumbnail_url),
|
||||
media: p.media?.map((m) => ({
|
||||
...m,
|
||||
original_url: fixImageUrl(m.original_url),
|
||||
mini_url: fixImageUrl(m.mini_url),
|
||||
small_url: m.small_url ? fixImageUrl(m.small_url) : undefined,
|
||||
large_url: m.large_url ? fixImageUrl(m.large_url) : undefined,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getProducts(
|
||||
currency: CurrencyCode = DEFAULT_CURRENCY
|
||||
): Promise<SpreeProduct[]> {
|
||||
const allProducts: SpreeProduct[] = [];
|
||||
let page = 1;
|
||||
while (true) {
|
||||
const data = await spree(`/products?per_page=25&page=${page}`, currency);
|
||||
const products = (data.data || []).map(fixProductUrls);
|
||||
allProducts.push(...products);
|
||||
const meta = data.meta;
|
||||
if (!meta?.next || page >= (meta.pages || 1)) break;
|
||||
page++;
|
||||
}
|
||||
return allProducts;
|
||||
}
|
||||
|
||||
export async function getProduct(
|
||||
slug: string,
|
||||
currency: CurrencyCode = DEFAULT_CURRENCY
|
||||
): Promise<SpreeProduct | null> {
|
||||
try {
|
||||
const data = await spree(`/products/${slug}?expand=media`, currency);
|
||||
const product = data.data || data || null;
|
||||
return product ? fixProductUrls(product) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Thin proxy to Spree's store API.
|
||||
*
|
||||
* Every store-API call needs BOTH the store key and the customer's Bearer token.
|
||||
* Sending only the Bearer answers 401 "Valid API key required" — which is exactly
|
||||
* what /api/auth/me did from the day it was written, so the account page never
|
||||
* loaded a profile at all. Routing every call through here means that can only
|
||||
* be got wrong in one place.
|
||||
*/
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const SPREE_URL = process.env.NEXT_PUBLIC_SPREE_API_URL || "https://api.jux-lab.com";
|
||||
const API_KEY = process.env.NEXT_PUBLIC_SPREE_API_KEY || "";
|
||||
|
||||
export function bearer(req: NextRequest): string | null {
|
||||
return req.headers.get("Authorization")?.replace("Bearer ", "") || null;
|
||||
}
|
||||
|
||||
export function unauthorized() {
|
||||
return NextResponse.json({ error: { message: "Not signed in" } }, { status: 401 });
|
||||
}
|
||||
|
||||
/** Call the Spree store API as the signed-in customer and pass the answer straight back. */
|
||||
export async function proxy(
|
||||
path: string,
|
||||
token: string,
|
||||
init: { method?: string; body?: unknown } = {}
|
||||
) {
|
||||
const res = await fetch(`${SPREE_URL}/api/v3/store${path}`, {
|
||||
method: init.method || "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
"X-Spree-Api-Key": API_KEY,
|
||||
},
|
||||
body: init.body === undefined ? undefined : JSON.stringify(init.body),
|
||||
});
|
||||
|
||||
// 204 No Content has no body to parse — DELETE answers with one.
|
||||
if (res.status === 204) return new NextResponse(null, { status: 204 });
|
||||
|
||||
const text = await res.text();
|
||||
if (!text) return new NextResponse(null, { status: res.status });
|
||||
|
||||
try {
|
||||
return NextResponse.json(JSON.parse(text), { status: res.status });
|
||||
} catch {
|
||||
// Spree answered with something that isn't JSON (a proxy error page, an HTML
|
||||
// 500). Say so plainly instead of throwing an opaque parse error.
|
||||
return NextResponse.json(
|
||||
{ error: { message: `Spree returned non-JSON (HTTP ${res.status})` } },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Raw variant for routes that need to inspect the parsed body before answering. */
|
||||
export async function call(
|
||||
path: string,
|
||||
token: string,
|
||||
init: { method?: string; body?: unknown } = {}
|
||||
): Promise<{ status: number; data: Record<string, unknown> }> {
|
||||
const res = await fetch(`${SPREE_URL}/api/v3/store${path}`, {
|
||||
method: init.method || "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
"X-Spree-Api-Key": API_KEY,
|
||||
},
|
||||
body: init.body === undefined ? undefined : JSON.stringify(init.body),
|
||||
});
|
||||
const text = await res.text();
|
||||
let data: Record<string, unknown> = {};
|
||||
try {
|
||||
data = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
data = { error: { message: `Spree returned non-JSON (HTTP ${res.status})` } };
|
||||
}
|
||||
return { status: res.status, data };
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user