Files
hedena_gold/apps/user-app-v2/src/api/client.ts
T
s.esmaeiliandCursor afb068c643 feat: paginate customer orders and polish the dashboard hero
Add paged orders API and infinite scroll, short order numbers, and align the guest invite card with the logged-in hero shell.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 13:11:42 +03:30

655 lines
22 KiB
TypeScript

import type { CreateOrderDto } from '@gold-bank/shared-types';
const BASE = '/api';
const TOKEN_KEY = 'gold-bank:token';
/** Must match stores/session.ts — the act-as target, if any. */
const ACTING_AS_KEY = 'gold-bank:actingAs';
/** Must match MAX_IDS on the server's ListProductsDto. */
const ID_CHUNK = 100;
/**
* 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;
}
}
/**
* «ورود به حساب مشتری» — the customer a sales expert is working inside.
*
* Read from storage rather than the store for the same reason as the token: this
* module must not depend on Pinia. The server treats this as a request, not a
* fact: it re-checks on every call that the customer is actually referred by
* this expert, so a forged value is a 403 and reveals nothing.
*/
function readActingAsId(): string | null {
try {
const raw = sessionStorage.getItem(ACTING_AS_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as { id?: unknown };
return typeof parsed?.id === 'string' && parsed.id ? parsed.id : null;
} catch {
return null;
}
}
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const token = readToken();
const actingAs = readActingAsId();
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}` } : {}),
// Sent on every request while impersonating, which is what makes the whole
// app render from the customer's point of view — the reference achieved
// the same by swapping the bearer token in its axios interceptor
// (henza_pwa plugins/axios.ts:18 via getUserToken()).
...(actingAs ? { 'X-Act-As-Customer': actingAs } : {}),
...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 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;
}
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[];
images: Array<{ url: string }>;
category: CategorySummary | null;
brand: BrandSummary | null;
collections: Array<{ collectionId: string; collection: CollectionSummary }>;
}
/** Query for the paged storefront list; every field is optional. */
export interface ProductQuery {
page?: number;
limit?: number;
search?: string;
categoryId?: string;
brandId?: string;
collectionId?: string;
minGram?: number;
maxGram?: number;
sort?: 'newest' | 'oldest' | 'title';
}
/**
* One page of products. `total` is the size of the FILTERED set, not of the
* page, so it both drives the "N محصول" count and tells the list when to stop
* asking for more.
*/
export interface ProductPage {
rows: ProductSummary[];
total: number;
page: number;
limit: number;
}
/** Filter options, built from the catalogue server-side. */
export interface CatalogFacets {
categories: CategorySummary[];
brands: BrandSummary[];
collections: CollectionSummary[];
/** Decimal strings, like every other weight this API returns. */
gramRange: { min: string; max: string };
}
/**
* Builds `?a=1&b=2`, dropping anything unset so the server sees a bare request
* rather than `?search=undefined` — which would be a filter for the literal
* text "undefined".
*/
function toQueryString(query: object): string {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
if (value !== undefined && value !== '') params.set(key, String(value));
}
const s = params.toString();
return s ? `?${s}` : '';
}
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;
/**
* The product and variant this line is for, included by the server.
*
* Optional because an order fetched by an older client — or a cached response
* from before the server included it — has no such field. The apps fall back
* to a plain label rather than showing nothing.
*
* It used to be looked up in the browser against the whole catalogue, which
* both downloaded ~16k products for a few row labels and rendered blank for a
* product that had since left the storefront.
*/
productVariant?: {
id: string;
title: string;
product: { id: string; title: string; images: Array<{ url: string }> };
} | null;
}
export interface OrderStatusEvent {
id: string;
fromStatus: string | null;
toStatus: string;
note: string | null;
createdAt: 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[];
/** Chronological status changes (oldest first). May be empty on older rows. */
statusEvents?: OrderStatusEvent[];
}
/** One page of GET /customer/me/orders — same envelope as the report endpoints. */
export interface OrdersPage {
result: OrderDetail[];
count: number;
page: number;
limit: number;
}
export interface OrdersQuery {
page?: number;
limit?: number;
/** OrderStatus, or omit / ALL for every status. */
status?: string;
/** Substring of orderNumber. */
q?: string;
}
export interface CustomerDetail {
id: string;
fullName: string;
mobile: string;
nationalCode: string | null;
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;
}
/** Body of POST customer/auth/register — the app register form, field for field. */
export interface RegisterBody {
fname: string;
lname: string;
mobile: string;
store_name: string;
phone: string;
address: string;
city: 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;
}
/**
* D24 — "گزارش", the account statement.
*
* The field names are the reference's (henza_pwa/src/type/accounting.ts), so
* the ported components bind exactly as they did. Money and weights travel as
* decimal STRINGS, never as JS numbers, so no figure is rounded in transit.
*/
export interface ReportRow {
id: number;
/** The ledger entry id; what the detail call is keyed on. */
factor_Code: string;
sh_factor: string;
tarikh: string;
/** Gold in / out, as separate positive columns. */
geram_v: string;
geram_kh: string;
rial_v: string;
rial_kh: string;
/** Running balances AFTER this row. Signed: negative means in credit. */
tahesab_T: string;
tahesab_P: string;
/** No foreign-currency ledger in the target — always zero (REP-01). */
rial_V2: string;
rial_Kh2: string;
tahesabP2: string;
sourceType: string;
note: string | null;
}
export interface ReportPage {
result: ReportRow[];
count: number;
page: number;
limit: number;
}
/** One document's detail. Fields the target cannot know are null, not invented. */
export interface ReportDetailRow {
factor_Code: string;
sh_factor: string;
tarikh: string;
vazn: string;
ayar: number | null;
no: string;
sh_sharti: string | null;
name_az: string | null;
tabdil750_v: string;
tabdil750_kh: string;
fi: string | null;
fiF: string | null;
Darsad_Talayee: string | null;
accountName: string | null;
bankName: string | null;
transferTo: string | null;
receivedAt: string | null;
note: string | null;
nO_kar: null;
jens_felez: null;
Darsad: null;
maliatDarsad: null;
}
export interface ReportBalance {
arz: string;
mandeh: string;
}
/** GET /customer/me/accounting-balance. Signed like the app: positive = owes. */
export interface AccountingBalance {
source: 'accounting' | 'stored' | 'none'
gold: string | null
rial: string | null
at: string | null
provider: string | null
}
/** POST /customer/me/wage-quote — one variant's making charge for the signed-in customer. */
export interface WageQuote {
variantId: string
variantPercent: string
customerPercent: string
promotionPercent: string
finalPercent: string
}
function iamAppUrl(path: string) {
const base = import.meta.env.BASE_URL || '/'
const prefix = base.endsWith('/') ? base : `${base}/`
return `${window.location.origin}${prefix}${path.replace(/^\//, '')}`
}
export const api = {
/**
* The WHOLE active catalogue in one response.
*
* The real catalogue reaches ~16k products, so prefer the paged/targeted
* calls below; this stays for the screens that genuinely work over the full
* list and is what the route answers when asked for nothing in particular.
*/
listProducts: () => request<ProductSummary[]>('/catalog/products'),
/** One page of the storefront, filtered and counted server-side. */
listProductsPaged: (query: ProductQuery = {}) =>
request<ProductPage>(`/catalog/products${toQueryString(query)}`),
/**
* One product, instead of downloading the catalogue to `find()` it.
* 404s for a product that is not on the storefront (inactive, or no photo).
*/
getProduct: (id: string) => request<ProductSummary>(`/catalog/products/${encodeURIComponent(id)}`),
/**
* Specific products by id — the cart, favourites, order items.
*
* Chunked because the route caps `ids` at 100 and drops the rest: a caller
* that asked for more would silently get a partial answer, and callers treat
* "missing from the answer" as "no longer on the storefront". Favourites
* already run to the high hundreds in the imported data (874 rows, FAV-01),
* so this is a live case, not a theoretical one.
*/
getProductsByIds: async (ids: string[]): Promise<ProductSummary[]> => {
const unique = [...new Set(ids)].filter(Boolean);
if (unique.length === 0) return [];
const chunks = Array.from({ length: Math.ceil(unique.length / ID_CHUNK) }, (_, i) =>
unique.slice(i * ID_CHUNK, (i + 1) * ID_CHUNK),
);
const pages = await Promise.all(
chunks.map((chunk) =>
request<ProductPage>(`/catalog/products?ids=${chunk.map(encodeURIComponent).join(',')}`),
),
);
return pages.flatMap((p) => p.rows);
},
/**
* The filter sheet's options (categories, brands, collections, gram range),
* which used to be derived by scanning the whole catalogue in the browser.
*/
getCatalogFacets: () => request<CatalogFacets>('/catalog/products/facets'),
/** App self-registration. The new customer waits for admin approval before signing in. */
register: (body: RegisterBody) =>
request<{ ok: boolean }>('/customer/auth/register', {
method: 'POST',
body: JSON.stringify(body),
}),
/**
* 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 }; iamBootstrapUrl?: string }>(
'/customer/auth/verify-otp',
{ method: 'POST', body: JSON.stringify({ username, password }) },
),
ssoStart: () =>
request<{ enabled: boolean; authorize_url: string | null }>(
`/customer/auth/sso/start?redirect_uri=${encodeURIComponent(iamAppUrl('auth/iam/callback'))}`,
),
ssoCallback: (code: string, state: string) =>
request<{ token: string; customer: { id: string; mobile: string } }>('/customer/auth/sso/callback', {
method: 'POST',
body: JSON.stringify({ code, state }),
}),
ssoLogoutUrl: () =>
request<{ logout_url: string | null }>(
`/customer/auth/sso/logout-url?return_url=${encodeURIComponent(iamAppUrl(''))}`,
),
endSharedSession: () =>
request<{ ended: number }>('/customer/auth/logout', { method: 'POST', body: '{}' }),
// 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<CustomerDetail>('/customer/me'),
/** D27 §12 — this customer's balance, read from the accounting software on every call. */
getAccountingBalance: () => request<AccountingBalance>('/customer/me/accounting-balance'),
getMyOrders: (query: OrdersQuery = {}) => {
const params = new URLSearchParams();
if (query.page) params.set('page', String(query.page));
if (query.limit) params.set('limit', String(query.limit));
if (query.status && query.status !== 'ALL') params.set('status', query.status);
if (query.q?.trim()) params.set('q', query.q.trim());
const qs = params.toString();
return request<OrdersPage>(`/customer/me/orders${qs ? `?${qs}` : ''}`);
},
getMyLedgerEntries: () => request<LedgerEntrySummary[]>('/customer/me/ledger-entries'),
/** The making charge the server will charge for these variants (MON-08). */
getWageQuotes: (variantIds: string[]) =>
request<WageQuote[]>('/customer/me/wage-quote', { method: 'POST', body: JSON.stringify({ variantIds }) }),
/** Favourite product ids, newest first — legacy get-favorites (FAV-01). */
getFavorites: () => request<{ ids: string[] }>('/customer/me/favorites'),
/** Legacy add-to-fav: adds or removes; answers the new state. */
toggleFavorite: (productId: string) =>
request<{ favorite: boolean }>('/customer/me/favorites/toggle', {
method: 'POST',
body: JSON.stringify({ productId }),
}),
createOrder: (dto: CreateOrderDto) => request<OrderSummary>('/orders', { method: 'POST', body: JSON.stringify(dto) }),
getLatestGoldPrice: (karatPerMille = 750) => request<GoldPriceQuote>(`/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<AdminSlide[]>('/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<ExpertIdentity>('/expert/whoami'),
getExpertCustomers: (search?: string | null) =>
request<ExpertCustomer[]>('/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 }) },
),
// ── complete_profile ─────────────────────────────────────────────────
// Ports POST /major/complete (henza_server Major/CustomerController::complete)
// and the PWA's document uploads, which went through the same file/create the
// admin used but behind the customer guard (routes/api.php:95).
/**
* Uploads one document and resolves its stored id, which is what
* completeProfile sends. multipart/form-data, so it bypasses `request()` —
* that helper forces a JSON content-type, and setting it here would break
* the multipart boundary.
*/
uploadDocument: async (file: File, purpose: string): Promise<{ id: string; url: string }> => {
const body = new FormData();
body.append('file', file);
body.append('purpose', purpose);
const token = readToken();
// This route bypasses `request()`, so the act-as header has to be repeated
// here — without it an expert uploading a customer's documents would file
// them against their OWN account, silently and with no error.
const actingAs = readActingAsId();
const res = await fetch(`${BASE}/customer/me/documents`, {
method: 'POST',
// No Content-Type: the browser sets it with the boundary.
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(actingAs ? { 'X-Act-As-Customer': actingAs } : {}),
},
body,
});
if (res.status === 401) {
onUnauthorized?.();
throw new UnauthorizedError('/customer/me/documents');
}
if (!res.ok) {
throw new Error(`آپلود فایل ناموفق بود (${res.status})`);
}
return res.json();
},
/**
* The three ids keep the reference's snake_case keys so the payload matches
* what henza_pwa sent (completeProfile.vue:136-140).
*/
completeProfile: (payload: {
business_license_image_id: string;
shop_image_id: string;
ncode_image_id: string;
}) =>
request<{ ok: boolean }>('/customer/me/complete-profile', {
method: 'POST',
body: JSON.stringify(payload),
}),
// ── report / report detail (D24) ─────────────────────────────────────
// The reference proxied an external accounting system
// (henza_server Customer/TahesabController). These read the target's own
// ledger instead — no external call — and the customer comes from the token.
getReport: (page = 1) =>
request<ReportPage>(`/customer/me/report?page=${page}`),
getReportDetailList: (page = 1) =>
request<{ result: ReportDetailRow[]; page: number; limit: number }>(
`/customer/me/report/detail?page=${page}`,
),
getReportBalances: () =>
request<{ status: number; message: string; result: ReportBalance[] }>(
'/customer/me/report/balances',
),
getReportDocument: (id: string) =>
request<{ result: ReportDetailRow[]; page: number; limit: number }>(
`/customer/me/report/${encodeURIComponent(id)}`,
),
};