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:
2026-09-15 22:09:55 +08:00
co-authored by Claude Opus 5
commit a58a61b98c
98 changed files with 15524 additions and 0 deletions
+14
View File
@@ -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
View File
@@ -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.*
+149
View File
@@ -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.
+5
View File
@@ -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 &middot; 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 &rarr;
</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 &middot; 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 &middot; Review</h5>
<p class="text-muted small">
<%= @processed %> processed<%= ", #{@failed} failed" if @failed.to_i.positive? %>
&middot; <%= @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 &middot; <%= 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 &middot; <%= 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 &middot; 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
+49
View File
@@ -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
+106
View File
@@ -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:
+6
View File
@@ -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
+79
View File
@@ -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)"
+236
View File
@@ -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()
+33
View File
@@ -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")
+65
View File
@@ -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")
+57
View File
@@ -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}")
+48
View File
@@ -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}")
+164
View File
@@ -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}
+42
View File
@@ -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}"
+186
View File
@@ -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()
+218
View File
@@ -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()
+53
View File
@@ -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"]}
+77
View File
@@ -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"]}
+30
View File
@@ -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"]}
+157
View File
@@ -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()
+185
View File
@@ -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
View File
@@ -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.*
+169
View File
@@ -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`.
+109
View File
@@ -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
```
+111
View File
@@ -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.*
+346
View File
@@ -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`
+1
View File
@@ -0,0 +1 @@
{"access_token":"eyJhbGciOiJIUzI1NiJ9.eyJhcHAiOnsiY2xpZW50X2lkIjoiNWJlMTU2MjEtZjcxMS00ODViLWJjNzktMTUyNTY3NDdjZTY1In0sInVzZXIiOnsiaWQiOiIyMGYyYjk0NTIzOWZhNmE2YjAwODI2ODM1ZmJhNWQ5YlI0Wlo3SVhTdS9BM0dLb2lzWUNFdEE9PSIsImVhc3lfYWNjb3VudF9pZCI6ImFhZDdhYTBjLWVmYjMtNDVkMi1iNzgwLTlkYTgzYmRiZDhlMiIsImFjY291bnRfaWQiOiIxMDkzNTU3MSJ9LCJhY2NvdW50X2FwcF91dWlkIjoiYzI5ZjI5YzYtY2M1My00YjE1LWE4NTEtNjY3OGUxN2JmMDk1IiwiaWF0IjoxNzg5NDcxMTQ5LCJpc3MiOiJlYXN5cGFyY2VsIiwiYXVkIjoiZWFzeXBhcmNlbCIsImV4cCI6MTc4OTUwNzE0OX0.fJ-INDL0sJb9bPqiJWlaiuZ08Aw9yhRYdPHHYn6rkNs","refresh_token":"de885b0754817f63c445f278703166c83535eb7455e149962cfda8305c9bab11","expires_at":1789507149231}
+16
View File
@@ -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
+8
View File
@@ -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
+9
View File
@@ -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 -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+36
View File
@@ -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.
+18
View File
@@ -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;
+7
View File
@@ -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.
+18
View File
@@ -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;
+6896
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -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"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+705
View File
@@ -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 &amp; 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 });
}
+39
View File
@@ -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 });
}
+44
View File
@@ -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 });
}
+35
View File
@@ -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,
});
}
+33
View File
@@ -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 });
}
}
+761
View File
@@ -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&apos;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&apos;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

+42
View File
@@ -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;
}
+66
View File
@@ -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>
);
}
+99
View File
@@ -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>
);
}
+30
View File
@@ -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&apos;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>
);
}
+112
View File
@@ -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>
);
}
+12
View File
@@ -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",
};
}
+25
View File
@@ -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>
);
}
+23
View File
@@ -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);
}
+20
View File
@@ -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>
);
}
+100
View File
@@ -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>
</>
);
}
+265
View File
@@ -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>
);
}
+383
View File
@@ -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>
);
}
+302
View File
@@ -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"
}
]
+283
View File
@@ -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));
}
+152
View File
@@ -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);
}
+30
View File
@@ -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 ?? "";
}
+267
View File
@@ -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 };
+68
View File
@@ -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,
};
}
+78
View File
@@ -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 };
+86
View File
@@ -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;
}
}
+80
View File
@@ -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 };
}
+34
View File
@@ -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"]
}