2026-09-22 01:20:00 +03:30
|
|
|
import type { CreateOrderDto } from '@gold-bank/shared-types';
|
2026-09-18 12:54:45 +03:30
|
|
|
|
|
|
|
|
const BASE = '/api';
|
2026-09-22 01:20:00 +03:30
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-18 12:54:45 +03:30
|
|
|
|
|
|
|
|
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
2026-09-22 01:20:00 +03:30
|
|
|
const token = readToken();
|
|
|
|
|
|
2026-09-18 12:54:45 +03:30
|
|
|
const res = await fetch(`${BASE}${path}`, {
|
|
|
|
|
...options,
|
2026-09-22 01:20:00 +03:30
|
|
|
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,
|
|
|
|
|
},
|
2026-09-18 12:54:45 +03:30
|
|
|
});
|
2026-09-22 01:20:00 +03:30
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-18 12:54:45 +03:30
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-23 01:12:53 +03:30
|
|
|
/** 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;
|
|
|
|
|
}
|
2026-09-18 12:54:45 +03:30
|
|
|
export const api = {
|
|
|
|
|
listProducts: () => request<ProductSummary[]>('/catalog/products'),
|
2026-09-22 01:20:00 +03:30
|
|
|
/**
|
|
|
|
|
* 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<CustomerDetail>('/customer/me'),
|
|
|
|
|
getMyOrders: () => request<OrderDetail[]>('/customer/me/orders'),
|
|
|
|
|
getMyLedgerEntries: () => request<LedgerEntrySummary[]>('/customer/me/ledger-entries'),
|
2026-09-18 12:54:45 +03:30
|
|
|
createOrder: (dto: CreateOrderDto) => request<OrderSummary>('/orders', { method: 'POST', body: JSON.stringify(dto) }),
|
|
|
|
|
getLatestGoldPrice: (karatPerMille = 750) => request<GoldPriceQuote>(`/pricing/latest?karatPerMille=${karatPerMille}`),
|
2026-09-23 01:12:53 +03:30
|
|
|
/** 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 }) },
|
|
|
|
|
),
|
2026-09-18 12:54:45 +03:30
|
|
|
};
|