Files
tehran_taj/apps/user-app/src/api/client.ts
T
s.esmaeiliandClaude Opus 5 f71fd5bfe1 feat(apps): sales-expert screens in both apps + v2 admin home slider (D21, APP-02)
Both expert screens are built in user-app and user-app-v2, with byte-identical
template and script and a per-app style block only — verified by diffing
everything above <style>:

  ChildOrdersView  "سفارش مشتریان"  — legacy ChildOrders.vue. Keeps the
    reference's title, search, infinite paging, and per-card item count, total
    weight, customer name and the fixed «تحویل مسئول فروش» chip (the endpoint
    only returns that status, ApiMobileController:766).
  CustomerChoiceView "لیست مشتریان" — legacy customerChoice.vue. Keeps its
    title, per-search re-query and the reference's choice to LIST inactive
    customers (its active() scope is commented out at :187).

Reachable from inside the app, not just by URL: routes carry the reference's own
paths and names, the dashboard grows the two entries only for an expert (v1 as
tiles, v2 as its own rows), and a router guard sends an ordinary customer back
to their dashboard. None of that is relied on for safety — the server scopes
both endpoints on the AdminUser id in the token and answers 403 otherwise.

Not ported, both recorded in D21: the act-as api_token minting (:197-201) and
the child-order edit drawer, which recomputed an order's money from client-sent
values. The card opens a read-only summary instead.

APP-02 fixed: user-app-v2 gained getHomeSlider and now prefers admin-configured
slides, keeping its category-derived hero as the fallback and its own design.

Session stores stay byte-identical across the apps.
build 5/5, jest 45/45, expert login 36/36, apps smoke 43/43, typecheck clean.

[ref: henza_pwa/src/views/user/src/components/ChildOrders.vue, customerChoice.vue]
[ref: henza_pwa/src/router/index.ts:171-188]

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 01:12:53 +03:30

285 lines
8.8 KiB
TypeScript

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 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<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) =>
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'),
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 }) },
),
};