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
+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"]
}