import type { CreateOrderDto } from '@gold-bank/shared-types'; const BASE = '/api'; const TOKEN_KEY = 'gold-bank:token'; /** * Called when the API rejects our token. Wired up in main.ts rather than * imported from the store, so this module stays free of a Pinia dependency * (the client is used before Pinia is active during app bootstrap). */ let onUnauthorized: (() => void) | null = null; export function setUnauthorizedHandler(fn: () => void) { onUnauthorized = fn; } /** Thrown for a 401 so callers can tell "session gone" from any other failure. */ export class UnauthorizedError extends Error { constructor(path: string) { super(`API ${path} rejected the session (401)`); this.name = 'UnauthorizedError'; } } function readToken(): string | null { try { return localStorage.getItem(TOKEN_KEY); } catch { // Private mode / blocked storage — treat as logged out rather than throwing. return null; } } async function request(path: string, options?: RequestInit): Promise { const token = readToken(); const res = await fetch(`${BASE}${path}`, { ...options, headers: { 'Content-Type': 'application/json', // Customer JWT from POST /customer/auth/verify-otp. The API derives the // customer from this, never from an id in the URL. ...(token ? { Authorization: `Bearer ${token}` } : {}), ...options?.headers, }, }); if (res.status === 401) { // Expired or revoked: drop the session and send the user to login, so no // stale personal data stays on screen behind a dead token. onUnauthorized?.(); throw new UnauthorizedError(path); } if (!res.ok) { const body = await res.text(); throw new Error(`API ${path} failed (${res.status}): ${body}`); } return res.json(); } export interface ProductImageSummary { id: string; url: string; sortOrder: number; role: 'FEATURED' | 'FEATURED_MOBILE' | 'GALLERY' | 'GALLERY_MOBILE'; } export interface ProductVariantSummary { id: string; title: string; rashWeightGrams: string; defaultWagePercent: string; /// On-hand stock and the "orderable at zero stock" flag. Both exist on /// ProductVariant and are already returned by GET /catalog/products (the /// catalog service includes every variant field) — declared here so the UI /// can read them. qty?: number; isPreOrder?: boolean; /// The gallery image chosen for this variant in the admin panel. It is one of /// the product's OWN images — the same ProductImage row, not a copy — so /// several variants (e.g. different weights of one design) may share it. /// Null when no picture was picked; callers fall back to the product image. imageId?: string | null; image?: ProductImageSummary | null; } export interface CategorySummary { id: string; title: string; parentId: string | null; } export interface BrandSummary { id: string; title: string; } export interface CollectionSummary { id: string; title: string; description: string | null; isActive: boolean; } export interface ProductSummary { id: string; title: string; description: string | null; /// Legacy Product.product_code — the human-facing code. Already returned by /// GET /catalog/products. productCode?: string | null; variants: ProductVariantSummary[]; /// Returned sorted by sortOrder, so images[0] really is the product's main /// photo rather than whichever row the database happened to hand back. images: ProductImageSummary[]; category: CategorySummary | null; brand: BrandSummary | null; collections: Array<{ collectionId: string; collection: CollectionSummary }>; } export interface LedgerEntrySummary { id: string; currency: string; direction: string; sourceType: string; amount: string; confirmed: boolean; createdAt: string; note: string | null; } export interface OrderSummary { id: string; orderNumber: string; status: string; totalGoldWeightGrams: string; isSuspendedOverCreditLimit: boolean; } export interface OrderItemDetail { id: string; productVariantId: string; quantity: number; rashWeightGramsSnapshot: string; wagePercentSnapshot: string; discountPercentSnapshot: string; computedWeightGrams: string; flatRialCharge: string; } export interface OrderDetail { id: string; orderNumber: string; status: string; totalGoldWeightGrams: string; wageWeightGrams: string; flatRialCharge: string; isSuspendedOverCreditLimit: boolean; deliveredAt: string | null; createdAt: string; items: OrderItemDetail[]; } export interface CustomerDetail { id: string; fullName: string; mobile: string; nationalCode: string; cityId: string | null; goldCreditLimitGrams: string; goldDebtGrams: string; fiatDebtRial: string; verificationStep: string; createdAt: string; } export interface GoldPriceQuote { id: string; karatPerMille: number; pricePerGramRial: string; source: string; quotedAt: string; } /** One admin-uploaded home banner slide — raw shape as saved by the admin * panel's manage/Const.vue "اسلایدر موبایل" tab (image + optional link). */ export interface AdminSlide { image: string; link: string; } /** * D21 — a sales expert signed into the app. Ordinary customers get * `isSalesExpert: false` and neither expert screen is shown to them. */ export interface ExpertIdentity { isSalesExpert: boolean; type: 'customer' | 'sales-expert'; } /** One of the expert's referred customers ("انتخاب مشتری"). */ export interface ExpertCustomer { id: string; /** Legacy key the reference's card bound to. */ name: string; fullName: string; mobile: string; phone: string | null; store_name: string | null; pasazh_name: string | null; gram_level: string; remain: string; rial_remain: string; active: number; createdAt: string; } /** One row of "سفارش مشتریان". */ export interface ExpertChildOrder { id: string; orderNumber: string; status: string; /** Total order weight — the reference's `item.w` (ChildOrders.vue:55). */ w: string; totalGoldWeightGrams: string; createdAt: string; items: OrderItemDetail[]; customer: { id: string; name: string; mobile: string } | null; } export const api = { listProducts: () => request('/catalog/products'), /** * Step 1 of login — ports henza_server ApiMobileController::userAuth. * Always resolves `{ sent: true }`, even for an unregistered number, so the * endpoint cannot be used to discover which numbers are customers. */ requestOtp: (username: string) => request<{ sent: boolean }>('/customer/auth/request-otp', { method: 'POST', body: JSON.stringify({ username }), }), /** * Step 2 — ports ApiMobileController::userVerfiy. The server compares the * code and returns a token plus that one customer. A wrong, expired or * already-used code is a 401. */ verifyOtp: (username: string, password: string) => request<{ token: string; customer: { id: string; mobile: string } }>( '/customer/auth/verify-otp', { method: 'POST', body: JSON.stringify({ username, password }) }, ), // The three reads below identify the customer by TOKEN. They replaced // /customers/:id, /:id/orders and /:id/ledger-entries, which took the id from // the URL and let anyone read anyone (SEC-08). getMe: () => request('/customer/me'), getMyOrders: () => request('/customer/me/orders'), getMyLedgerEntries: () => request('/customer/me/ledger-entries'), createOrder: (dto: CreateOrderDto) => request('/orders', { method: 'POST', body: JSON.stringify(dto) }), getLatestGoldPrice: (karatPerMille = 750) => request(`/pricing/latest?karatPerMille=${karatPerMille}`), /** GET /home/slider — public, no auth. Empty array when the admin hasn't * configured any slides (the caller falls back to a derived hero). */ getHomeSlider: () => request('/home/slider'), // ── D21: sales-expert screens ──────────────────────────────────────── // All three scope themselves on the AdminUser id inside the token; no id // ever travels from the client. An ordinary customer gets 403 on the first // two, and `isSalesExpert: false` from whoami so the menu entries stay hidden. getExpertIdentity: () => request('/expert/whoami'), getExpertCustomers: (search?: string | null) => request('/expert/my-customers', { method: 'POST', body: JSON.stringify({ search: search ?? null }), }), getExpertChildOrders: (page = 1, search?: string | null) => request<{ rows: ExpertChildOrder[]; total: number; page: number; limit: number }>( '/expert/child-orders', { method: 'POST', body: JSON.stringify({ page, search: search ?? null }) }, ), };