Files
tehran_taj/apps/user-app-v2/src/api/client.ts
T

422 lines
13 KiB
TypeScript
Raw Normal View History

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<T>(path: string, options?: RequestInit): Promise<T> {
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 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 }>;
}
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;
}
/**
* 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;
}
2026-10-07 16:45:36 +03:30
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 = {
listProducts: () => request<ProductSummary[]>('/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) =>
2026-10-07 16:45:36 +03:30
request<{ token: string; customer: { id: string; mobile: string }; iamBootstrapUrl?: string }>(
'/customer/auth/verify-otp',
{ method: 'POST', body: JSON.stringify({ username, password }) },
),
2026-10-07 16:45:36 +03:30
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(''))}`,
),
// 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'),
getMyOrders: () => request<OrderDetail[]>('/customer/me/orders'),
getMyLedgerEntries: () => request<LedgerEntrySummary[]>('/customer/me/ledger-entries'),
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();
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}` } : {},
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)}`,
),
};