feat: catalog read cache (api + user apps) and clearer Zarrin accounting errors

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
s.esmaeili
2026-10-11 14:31:32 +03:30
co-authored by Cursor
parent 561addc676
commit 574fa1b4aa
53 changed files with 1241 additions and 401 deletions
@@ -119,42 +119,110 @@
</div>
<div class="hint mb-4">
اسناد سفارش همیشه «موقت» ثبت می‌شوند و تا حسابدار نهایی نکند روی مانده‌ی حسابداری اثر ندارند.
سقف اعتبار سفارش همچنان با دفتر داخلی اپ چک می‌شود.
سقف اعتبار سفارش همچنان با دفتر داخلی منتزر چک می‌شود.
</div>
<a-button type="primary" @click="save">ذخیره</a-button>
</a-tab-pane>
<!-- ── نگاشت ─────────────────────────────────────────── -->
<a-tab-pane key="mapping" tab="نگاشت">
<a-collapse ghost class="mb-4">
<a-collapse-panel key="guide" header="راهنمای این تب: نگاشت چیست و چطور پر می‌شود؟">
<p>
در این صفحه «منتزر» یعنی همین سامانه: <b>اپ مشتری</b> (جایی که مشتری ثبت‌نام، احراز هویت و سفارش می‌کند و مانده‌اش را می‌بیند) و
<b>پنل مدیریت</b> (همین پنل، جایی که محصول و دسته‌بندی تعریف می‌شود). «حسابداری» یعنی نرم‌افزار حسابداری مغازه (ته‌حساب یا زرین).
</p>
<p>
منتزر و حسابداری هر کدام گروه‌بندی خودشان را دارند. این تب می‌گوید هر چیز در منتزر با کدام گروه در حسابداری متناظر است؛
خودِ مشتری‌ها و محصول‌ها اینجا وصل نمی‌شوند (آن‌ها در تب «لینک‌ها» دیده می‌شوند).
</p>
<ol>
<li>تنظیمات تب «اتصال» را ذخیره کنید، بعد «دریافت گروه‌ها از حسابداری» را بزنید تا فهرست‌ها از حسابداری پر شوند.</li>
<li>
<b>گروه‌های حسابی که مشتری منتزر حساب می‌شوند:</b> در حسابداری صندوق، کارمند و سرمایه‌گذار هم «حساب» هستند. فقط گروه‌هایی را انتخاب کنید که
واقعاً مشتری‌اند؛ سینک مشتری فقط حساب‌های این گروه‌ها را با شماره موبایل به کاربران اپ مشتری وصل می‌کند. اگر خالی بماند سینک مشتری اجرا نمی‌شود.
</li>
<li>
<b>گروه مشتری جدید:</b> وقتی کاربری در اپ مشتری احراز هویت شود و در حسابداری حساب نداشته باشد، حسابش در این گروه ساخته می‌شود.
اگر تعیین نشود، ساخت حساب در «لاگ ارسال» با وضعیت «نیاز به بررسی» می‌ماند.
</li>
<li>
<b>عیار پایه:</b> عیار وزن‌هایی که در منتزر ثبت می‌شود (وزن محصول‌ها و سفارش‌ها؛ معمولاً ۷۵۰ = ۱۸ عیار). در سند سفارش، وزن با همین عیار به
حسابداری می‌رود؛ در ته‌حساب اجرت هم روی آن اعمال می‌شود (مثلاً ۷۵۰ با اجرت ۱۰٪ می‌شود ۸۲۵).
</li>
<li v-if="form.provider === 'zarrin'">
<b>کارساخته (فقط زرین):</b> وقتی در پنل مدیریت منتزر محصول جدیدی تعریف شود، برایش یک «کارساخته» در زرین ساخته می‌شود. «نوع» تعیین می‌کند
گرمی است یا تعدادی، و «گروه ساخته» جای آن در زرین است. در جدول پایین برای هر دسته‌بندی محصولِ منتزر می‌توانید گروه جدا بدهید؛ دسته‌ای که
«پیش‌فرض» بماند در «گروه ساخته‌ی پیش‌فرض» می‌رود. اگر نوع یا گروه خالی باشد، ساخت کارساخته در «لاگ ارسال» منتظر بررسی می‌ماند.
</li>
</ol>
<p class="hint">
تغییر این تنظیمات فقط روی موارد بعدی اثر دارد؛ حساب‌ها و کارساخته‌هایی که قبلاً ساخته شده‌اند جابه‌جا نمی‌شوند. در آخر «ذخیره» را بزنید.
</p>
</a-collapse-panel>
</a-collapse>
<a-space class="mb-4">
<a-button :disabled="form.provider === 'none'" :loading="optionsLoading" @click="loadOptions">دریافت گروه‌ها از حسابداری</a-button>
<span v-for="e in optionErrors" :key="e" class="warn">{{ e }}</span>
</a-space>
<a-form layout="vertical">
<a-divider orientation="right">حساب مشتری‌ها</a-divider>
<a-alert type="info" show-icon class="mb-4" message="این بخش تعیین می‌کند کدام حساب‌های حسابداری به کاربران اپ مشتری منتزر وصل شوند">
<template #description>
<div>
هر حسابی که در گروه‌های انتخاب‌شده باشد و موبایلش با یک کاربر اپ مشتری منتزر یکی باشد، به آن کاربر وصل می‌شود و مانده‌اش در اپ مشتری
نمایش داده می‌شود. صندوق، کارمند و سرمایه‌گذار را انتخاب نکنید.
</div>
<div>
مثال: «بنکدار» و «مغازه‌دار» را در فیلد اول انتخاب کنید و در فیلد دوم «مغازه‌دار» را بگذارید. در این حالت کسی که در اپ مشتری ثبت‌نام و
احراز هویت کند و در حسابداری حساب نداشته باشد، در گروه «مغازه‌دار» برایش حساب ساخته می‌شود.
</div>
</template>
</a-alert>
<div class="grid-2">
<a-form-item label="گروه‌های حسابی که وارد اپ می‌شوند (خالی = همه)">
<a-select v-model:value="importGroupKeys" mode="multiple" :options="customerGroupOptions" allow-clear placeholder="همه‌ی گروه‌ها" />
<a-form-item label="گروه‌های حسابی که مشتری منتزر حساب می‌شوند" extra="فقط گروه‌هایی که مشتری‌اند؛ خالی = سینک مشتری اجرا نمی‌شود">
<a-select v-model:value="importGroupKeys" mode="multiple" :options="customerGroupOptions" allow-clear placeholder="هیچ گروهی انتخاب نشده" />
</a-form-item>
<a-form-item label="گروه مشتری جدیدی که از اپ ساخته می‌شود">
<a-form-item
label="گروه حسابداریِ مشتری تازه‌ی اپ مشتری"
extra="کاربری که در اپ مشتری احراز هویت شده و در حسابداری حساب ندارد، در این گروه برایش حساب ساخته می‌شود"
>
<a-select v-model:value="newCustomerGroupKey" :options="customerGroupOptions" allow-clear />
</a-form-item>
<a-form-item label="عیار پایه‌ی گرم‌های اپ">
<a-form-item label="عیار پایه‌ی وزن‌های منتزر" extra="عیار وزن محصول‌ها و سفارش‌ها در منتزر؛ ۷۵۰ یعنی ۱۸ عیار">
<a-input-number v-model:value="form.mapping.karat" :min="1" :max="1000" class="w-full" />
</a-form-item>
</div>
<template v-if="form.provider === 'zarrin'">
<a-divider orientation="right">کارساخته (زرین)</a-divider>
<a-alert type="info" show-icon class="mb-4" message="هر محصولی که در پنل مدیریت منتزر (بخش محصولات) تعریف می‌کنید، در زرین یک «کارساخته» می‌شود">
<template #description>
<div>
زرین برای ساختن کارساخته دو چیز لازم دارد: <b>نوع</b> (گرمی یا تعدادی) و <b>گروه ساخته</b> (کارساخته در کدام پوشه‌ی زرین قرار بگیرد).
این بخش این دو را به‌جای شما انتخاب می‌کند تا لازم نباشد برای هر محصول جداگانه در زرین کاری انجام دهید.
</div>
<div>
ستون «دسته‌بندی محصول در منتزر» همان دسته‌بندی‌هایی است که در پنل مدیریت برای محصولات تعریف کرده‌اید. مثال: اگر روبه‌روی «انگشتر»
گروه «انگشتر» زرین را بگذارید، هر محصول تازه در دسته‌ی انگشتر منتزر، در گروه انگشترِ زرین ساخته می‌شود.
دسته‌هایی که «پیش‌فرض» بمانند در «گروه ساخته‌ی پیش‌فرض» می‌روند.
</div>
<div>
اگر نوع یا گروه خالی باشد، محصول در منتزر ساخته می‌شود ولی ساخت کارساخته در «لاگ ارسال» منتظر می‌ماند تا آن را تعیین کنید و «تلاش دوباره» بزنید.
محصول‌هایی که از قبل به کارساخته وصل شده‌اند تغییری نمی‌کنند.
</div>
</template>
</a-alert>
<div class="grid-2">
<a-form-item label="نوع کارساخته‌ی جدید (گرمی/تعدادی…)">
<a-form-item label="نوع کارساخته‌ی جدید (گرمی/تعدادی…)" extra="برای محصول تازه‌ای که در پنل مدیریت منتزر تعریف می‌شود">
<a-select v-model:value="newTypeKey" :options="typeOptions" allow-clear />
</a-form-item>
<a-form-item label="گروه ساخته‌ی پیش‌فرض">
<a-form-item label="گروه ساخته‌ی پیش‌فرض" extra="برای دسته‌هایی که در جدول زیر «پیش‌فرض» مانده‌اند">
<a-select v-model:value="newItemGroupKey" :options="itemGroupOptions" allow-clear />
</a-form-item>
</div>
<a-table :data-source="categories" :pagination="false" row-key="id" size="small" class="mb-4">
<a-table-column title="دسته‌بندی اپ" data-index="title" />
<a-table-column title="دسته‌بندی محصول در منتزر" data-index="title" />
<a-table-column title="گروه ساخته در زرین">
<template #default="{ record }">
<a-select v-model:value="categoryGroupKeys[record.id]" :options="itemGroupOptions" allow-clear class="w-full" placeholder="پیش‌فرض" />
@@ -165,6 +233,9 @@
<div v-else-if="form.provider === 'tahesab'" class="hint mb-4">
ته‌حساب در API خود ساخت کارساخته ندارد: نام کار با اولین سند سفارش در ته‌حساب ساخته می‌شود و از آن به بعد به محصول لینک می‌شود.
</div>
<div v-if="optionErrors.length" class="hint mb-4">
اگر فهرستی خالی است، یعنی دریافت آن از حسابداری خطا داده (پیام قرمز بالای صفحه). اتصال را بررسی کنید و دوباره «دریافت گروه‌ها» را بزنید.
</div>
<a-button type="primary" @click="save">ذخیره</a-button>
</a-form>
</a-tab-pane>
@@ -272,7 +343,7 @@
>
<a-table-column title="کد حسابداری" data-index="externalCode" />
<a-table-column title="نام در حسابداری" data-index="externalName" />
<a-table-column title="شناسه‌ی اپ" data-index="entityId" />
<a-table-column title="شناسه در منتزر" data-index="entityId" />
<a-table-column title="منشأ">
<template #default="{ record }">{{ ORIGIN[record.origin] ?? record.origin }}</template>
</a-table-column>
@@ -367,7 +438,7 @@ const ISSUE: Record<string, string> = {
const ORIGIN: Record<string, string> = {
LINKED: 'جفت با موبایل/نام',
IMPORTED: 'وارد شده از حسابداری',
PUSHED: 'ساخته شده از اپ',
PUSHED: 'ساخته شده از منتزر',
MANUAL: 'دستی',
};
+2
View File
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from './prisma/prisma.module';
import { CatalogModule } from './modules/catalog/catalog.module';
import { CatalogCacheModule } from './modules/catalog/catalog-cache.module';
import { CustomersModule } from './modules/customers/customers.module';
import { CustomerAuthModule } from './modules/customer-auth/customer-auth.module';
import { SalesExpertAppModule } from './modules/sales-expert-app/sales-expert-app.module';
@@ -41,6 +42,7 @@ import { SalesAgentModule } from './modules/sales-agent/sales-agent.module';
IamModule,
AdminSessionModule,
PrismaModule,
CatalogCacheModule,
AuthModule,
AdminTableModule,
AdminOrdersModule,
@@ -18,6 +18,7 @@ import {
import { ACCOUNTING_PROVIDERS, AccountingProvider, ProviderName, ProviderSettings, isProviderName } from './providers';
import { openSecret, sealSecret, secretKeySource } from './secret-box';
import { parsePemCertificates } from './accounting-http';
import { CatalogCacheService } from '../catalog/catalog-cache.service';
/** The first certificate of a pasted bundle, for display; null when none parses. */
function describeCertificate(pem: string | null) {
@@ -59,7 +60,10 @@ export class AccountingConfigService implements OnModuleInit {
private readonly logger = new Logger('Accounting');
private cache: { at: number; config: AccountingConfig } | null = null;
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly catalogCache: CatalogCacheService,
) {}
onModuleInit() {
if (secretKeySource() !== 'ACCOUNTING_SECRET_KEY') {
@@ -203,6 +207,8 @@ export class AccountingConfigService implements OnModuleInit {
create: { key: ACCOUNTING_CONFIG_KEY, value: next as unknown as Prisma.InputJsonValue },
});
this.cache = null;
// The provider and product-sync switch decide which products read as out of stock.
this.catalogCache.invalidate();
return { providerChanged: current.provider !== next.provider };
}
@@ -9,6 +9,12 @@ import { ACCOUNTING_PROVIDERS } from './providers';
type Tx = Prisma.TransactionClient | PrismaService;
type StockFields = {
accountingStockAt: Date | null;
accountingStockWeight: Prisma.Decimal | null;
accountingStockCount: Prisma.Decimal | null;
};
const OPEN: AccountingOutboxStatus[] = ['PENDING', 'SENDING'];
/**
@@ -150,15 +156,21 @@ export class AccountingHooksService {
* meaningful while the product sync is on; stock fields of products that are
* not linked under the active provider are cleared by every product pull.
*/
async stockBlocked(product: {
accountingStockAt: Date | null;
accountingStockWeight: Prisma.Decimal | null;
accountingStockCount: Prisma.Decimal | null;
}) {
if (!product.accountingStockAt) return false;
async stockBlocked(product: StockFields) {
return (await this.stockBlocker())(product);
}
/**
* The same rule as stockBlocked, with the configuration read once — for
* checking a whole list without an await per product.
*/
async stockBlocker(): Promise<(product: StockFields) => boolean> {
const c = await this.config.get();
if (c.provider === 'none' || !c.features.products) return false;
const stock = product.accountingStockWeight ?? product.accountingStockCount;
return stock !== null && new Prisma.Decimal(stock).lte(0);
const active = c.provider !== 'none' && c.features.products;
return (product) => {
if (!active || !product.accountingStockAt) return false;
const stock = product.accountingStockWeight ?? product.accountingStockCount;
return stock !== null && new Prisma.Decimal(stock).lte(0);
};
}
}
@@ -59,6 +59,17 @@ describe('wire format', () => {
await expect(z.listCustomerGroups(s)).rejects.toThrow('دسترسی مجاز نیست');
});
it('shows the server reason on a non-2xx reply, without the token', async () => {
http.mockReturnValueOnce(reply('<html><body><h1>Server Error</h1><p>kind=listGorooheHesabha&token=SECRET failed</p></body></html>', 500));
const err = await z.listCustomerGroups(s).catch((e) => e);
expect(err.message).toContain('HTTP 500 — Server Error');
expect(err.message).toContain('failed');
expect(err.message).not.toContain('SECRET');
http.mockReturnValueOnce(reply({ state: false, msg: 'شناسه نامعتبر' }, 400));
await expect(z.listCustomerGroups(s)).rejects.toThrow('HTTP 400 — شناسه نامعتبر');
});
it('lists the different envelopes Zarrin uses', () => {
const { listOf, fullJalali } = __zarrinInternals;
expect(listOf({ data: [1] })).toEqual([1]);
@@ -70,17 +81,76 @@ describe('wire format', () => {
});
describe('customers', () => {
it('pages with Get100Account and fills groups from listhesabha', async () => {
const page = Array.from({ length: 100 }, (_, i) => ({ code: 81 + i, name: 'n', namKhanevadegi: `f${i}`, mobile: i === 0 ? '09121112233' : '' }));
http
.mockReturnValueOnce(reply({ state: true, msg: '', date: page }))
.mockReturnValueOnce(reply({ data: [{ id: '81', nam: 'حسین اصغری', gorooh: 'مشتری (خریدار)', tell: '021', mobile: '09121112233', codeMeli: '' }] }));
it('lists every account with its group from one listhesabha call', async () => {
http.mockReturnValueOnce(
reply({
data: [
{ id: '81', nam: 'حسین اصغری', gorooh: 'مشتری (خریدار)', tell: '021', mobile: '09121112233', codeMeli: '' },
{ id: '82', nam: 'آرین یوسفی', gorooh: 'سازنده (تامین کننده)', tell: '', mobile: '', codeMeli: '' },
],
}),
);
const res = await z.listCustomers(s, null);
expect(url(0).searchParams.get('id')).toBe('0');
expect(url(1).searchParams.get('az')).toBe('81');
expect(url(1).searchParams.get('ta')).toBe('180');
expect(http).toHaveBeenCalledTimes(1);
expect(url(0).searchParams.get('kind')).toBe('listhesabha');
expect(url(0).searchParams.get('az')).toBe('1');
expect(res.items).toHaveLength(2);
expect(res.items[0]).toMatchObject({ code: '81', name: 'حسین اصغری', mobile: '09121112233', groupName: 'مشتری (خریدار)', phone: '021' });
expect(res.next).toBe('181');
expect(res.items[1].mobile).toBeNull();
expect(res.next).toBeNull();
});
it('finds by mobile with searchAccount, keeping exact matches only', async () => {
http.mockReturnValueOnce(
reply({
state: true,
msg: '',
date: [
{ code: 84, name: 'سعید', namKhanevadegi: 'رجایی', mobile: '09121995971' },
{ code: 90, name: 'x', namKhanevadegi: 'y', mobile: '09121995972' },
],
}),
);
const found = await z.findCustomersByMobile(s, '09121995971');
expect(url(0).searchParams.get('kind')).toBe('searchAccount');
expect(found.map((c) => c.code)).toEqual(['84']);
expect(found[0].name).toBe('سعید رجایی');
});
it('falls back to listhesabha when searchAccount is missing (bare 400)', async () => {
http
.mockReturnValueOnce(reply('', 400))
.mockReturnValueOnce(reply({ data: [{ id: '84', nam: 'رجایی * سعید', gorooh: 'مشتری (خریدار)', mobile: '09121995971' }, { id: '85', nam: 'z', mobile: '' }] }));
const found = await z.findCustomersByMobile(s, '09121995971');
expect(url(1).searchParams.get('kind')).toBe('listhesabha');
expect(found.map((c) => c.code)).toEqual(['84']);
});
it('does not hide other errors behind the fallback', async () => {
http.mockReturnValueOnce(reply('', 500));
await expect(z.findCustomersByMobile(s, '09121995971')).rejects.toThrow('HTTP 500');
});
it('lists karsakhte groups with goroohSakhte when the installation has it', async () => {
http.mockReturnValueOnce(reply({ state: true, detail: 'OK', data: [{ id: '26', name: 'النگو' }] }));
expect(await z.listKarSakhteGroups!(s)).toEqual([{ id: '26', name: 'النگو' }]);
expect(http).toHaveBeenCalledTimes(1);
});
it('scans karsakhte groups with infogorooh when goroohSakhte is missing, skipping ids that fail', async () => {
http.mockImplementation((req) => {
const u = new URL(req.url);
if (u.searchParams.get('kind') === 'goroohSakhte') return reply('', 400);
const id = u.searchParams.get('id');
if (id === '26') return reply({ data: [{ nam: 'النگو', code: 26 }] });
if (id === '115') return reply({ data: [{ nam: 'سکه', code: 115 }] });
if (id === '3') return reply('', 500);
return reply('{\n "data": \n ]\n}');
});
expect(await z.listKarSakhteGroups!(s)).toEqual([
{ id: '26', name: 'النگو' },
{ id: '115', name: 'سکه' },
]);
});
it('keeps the account in its group on edit', async () => {
@@ -40,11 +40,51 @@ import {
const STATEMENT_PAGE = 250;
const CHANGE_WINDOW = 50;
const MAX_STATEMENT_PAGES = 20;
const ALL_CODES = { az: 1, ta: 999999 };
const GROUP_SCAN_MAX = 300;
const GROUP_SCAN_CONCURRENCY = 10;
function endpoint(s: ProviderSettings) {
return s.baseUrl.trim();
}
class ZarrinHttpError extends AccountingProviderError {
constructor(
message: string,
body: string,
readonly status: number,
) {
super(message, false, body, status === 404);
}
}
/**
* Installations lack some documented kinds (v228 has no Get100Account,
* goroohSakhte or getinfoByPhone) and answer them with a bare 400.
*/
function isMissingKind(err: unknown) {
return err instanceof ZarrinHttpError && err.status === 400;
}
/**
* What the server said on a non-2xx reply, short enough for the admin screen.
* Error pages may echo the request URL, so the token is scrubbed.
*/
function errorBodyText(body: string, token: string) {
let text = body;
try {
const j = parseLooseJson(body) as any;
const o = typeof j === 'string' && /^\s*[{[]/.test(j) ? parseLooseJson(j) : j;
if (o && typeof o === 'object') text = str(o.msg) ?? str(o.detail) ?? str(o.message) ?? str(o.Message) ?? str(o.title) ?? JSON.stringify(o);
else if (typeof o === 'string') text = o;
} catch {
text = body.replace(/<(script|style)[\s\S]*?<\/\1>/gi, ' ').replace(/<[^>]+>/g, ' ');
}
if (token) for (const t of new Set([token, encodeURIComponent(token)])) text = text.split(t).join('***');
text = text.replace(/\s+/g, ' ').trim();
return text.length > 200 ? `${text.slice(0, 200)}…` : text;
}
async function call(
s: ProviderSettings,
kind: string,
@@ -73,7 +113,8 @@ async function call(
}
if (res.status < 200 || res.status >= 300) {
throw new AccountingProviderError(`زرین (${kind}): HTTP ${res.status}`, false, res.body.slice(0, 500), res.status === 404);
const reason = errorBodyText(res.body, s.token);
throw new ZarrinHttpError(`زرین (${kind}): HTTP ${res.status}${reason ? ` — ${reason}` : ''}`, res.body.slice(0, 500), res.status);
}
let json: unknown;
@@ -96,7 +137,7 @@ async function call(
return json as Record<string, unknown> | unknown[];
}
/** Zarrin wraps lists as `data`, once as `date` (Get100Account), or not at all. */
/** Zarrin wraps lists as `data`, sometimes as `date` (searchAccount), or not at all. */
function listOf(json: unknown): Record<string, unknown>[] {
if (Array.isArray(json)) return json as Record<string, unknown>[];
if (json && typeof json === 'object') {
@@ -128,6 +169,21 @@ function fullJalali(v: unknown): string {
return `14${m[1]}/${m[2].padStart(2, '0')}/${m[3].padStart(2, '0')}`;
}
async function allAccounts(s: ProviderSettings): Promise<ExternalCustomer[]> {
return listOf(await call(s, 'listhesabha', ALL_CODES))
.filter((r) => r.id !== undefined)
.map((r) => ({
code: String(r.id),
name: str(r.nam) ?? String(r.id),
mobile: normalizeMobile(r.mobile),
phone: str(r.tell),
nationalCode: str(r.codeMeli),
address: str(r.address),
groupId: null,
groupName: str(r.gorooh),
}));
}
function signed(s: ProviderSettings, v: Prisma.Decimal) {
// Until checked on a real installation (plan §9) the sign is a setting.
return (s.balanceOwesWhen ?? 'negative') === 'negative' ? v.negated() : v;
@@ -152,35 +208,10 @@ export const zarrinProvider: AccountingProvider = {
}));
},
async listCustomers(s, cursor) {
const from = Number(cursor ?? 0) || 0;
// Get100Account pages by code (100 accounts from `id` on) but carries no
// group or address; listhesabha over the same code span fills those in.
const page = listOf(await call(s, 'Get100Account', { id: from }));
if (page.length === 0) return { items: [], next: null };
const codes = page.map((r) => Number(r.code)).filter(Number.isFinite);
const lo = Math.min(...codes);
const hi = Math.max(...codes);
const details = new Map<string, Record<string, unknown>>();
for (const r of listOf(await call(s, 'listhesabha', { az: lo, ta: hi }))) details.set(String(r.id), r);
const items: ExternalCustomer[] = page.map((r) => {
const code = String(r.code);
const d = details.get(code) ?? {};
return {
code,
name: str(d.nam) ?? joinName(r.name, r.namKhanevadegi),
mobile: normalizeMobile(d.mobile ?? r.mobile),
phone: str(d.tell),
nationalCode: str(d.codeMeli),
address: str(d.address),
groupId: null,
groupName: str(d.gorooh),
};
});
const next = page.length < 100 ? null : String(hi + 1);
return { items, next };
async listCustomers(s) {
// listhesabha carries everything (group, mobile, address) and returns the
// whole code span in one reply (~865 accounts in under 2 s).
return { items: await allAccounts(s), next: null };
},
async findCustomersByMobile(s, mobile) {
@@ -188,20 +219,22 @@ export const zarrinProvider: AccountingProvider = {
if (!target) return [];
let rows: Record<string, unknown>[];
try {
rows = listOf(await call(s, 'getinfoByPhone', { number: target }));
// Matches on name or mobile, possibly partially; filtered exactly below.
rows = listOf(await call(s, 'searchAccount', { search: target }));
} catch (err) {
if (err instanceof AccountingProviderError && err.notFound) return [];
throw err;
if (!isMissingKind(err)) throw err;
return (await allAccounts(s)).filter((c) => c.mobile === target);
}
return rows
.filter((r) => r.code !== undefined && normalizeMobile(r.mobile) === target)
.map((r) => ({
code: String(r.code),
name: joinName(r.nam, r.namKhanevadegi),
name: joinName(r.name, r.namKhanevadegi),
mobile: target,
phone: str(r.tel),
phone: null,
nationalCode: null,
address: str(r.address),
address: null,
groupId: null,
groupName: null,
}));
@@ -333,7 +366,30 @@ export const zarrinProvider: AccountingProvider = {
},
async listKarSakhteGroups(s) {
return listOf(await call(s, 'goroohSakhte')).map((g) => ({ id: String(g.id), name: str(g.name) ?? String(g.id) }));
try {
return listOf(await call(s, 'goroohSakhte')).map((g) => ({ id: String(g.id), name: str(g.name) ?? String(g.id) }));
} catch (err) {
if (!isMissingKind(err)) throw err;
}
// Without goroohSakhte the only way is one infogorooh per id. Ids are
// sparse and unknown ids fail (some with a 500), so failures are skipped.
const found: { id: string; name: string }[] = [];
let next = 1;
const worker = async () => {
while (next <= GROUP_SCAN_MAX) {
const id = next++;
try {
for (const g of listOf(await call(s, 'infogorooh', { id }))) {
const name = str(g.nam);
if (name) found.push({ id: String(g.code ?? id), name });
}
} catch {
// not a group
}
}
};
await Promise.all(Array.from({ length: GROUP_SCAN_CONCURRENCY }, worker));
return found.sort((a, b) => Number(a.id) - Number(b.id));
},
async listKarSakhteTypes(s) {
@@ -0,0 +1,14 @@
import { Global, Module } from '@nestjs/common';
import { CatalogCacheService } from './catalog-cache.service';
/**
* Global so the accounting module can invalidate the storefront catalogue
* without importing CatalogModule (which itself depends on the accounting
* module).
*/
@Global()
@Module({
providers: [CatalogCacheService],
exports: [CatalogCacheService],
})
export class CatalogCacheModule {}
@@ -0,0 +1,97 @@
import { CATALOG_CACHE_MS, CatalogCacheService } from './catalog-cache.service';
type Middleware = (params: Record<string, unknown>, next: (p: unknown) => Promise<unknown>) => Promise<unknown>;
function setup() {
let middleware: Middleware | undefined;
const prisma = { $use: jest.fn((fn: Middleware) => (middleware = fn)) };
const cache = new CatalogCacheService(prisma as never);
cache.onModuleInit();
const runQuery = (params: Record<string, unknown>) => middleware!(params, async () => 'ok');
return { cache, runQuery };
}
describe('CatalogCacheService', () => {
afterEach(() => jest.useRealTimers());
it('serves a fresh copy without rebuilding', async () => {
const { cache } = setup();
const build = jest.fn().mockResolvedValue(['a']);
expect(await cache.get(build)).toEqual(['a']);
expect(await cache.get(build)).toEqual(['a']);
expect(build).toHaveBeenCalledTimes(1);
});
it('rebuilds once the TTL has passed', async () => {
jest.useFakeTimers({ now: 1_000_000 });
const { cache } = setup();
const build = jest.fn().mockResolvedValueOnce(['old']).mockResolvedValueOnce(['new']);
await cache.get(build);
jest.setSystemTime(1_000_000 + CATALOG_CACHE_MS);
expect(await cache.get(build)).toEqual(['new']);
expect(build).toHaveBeenCalledTimes(2);
});
it('shares one build between concurrent callers', async () => {
const { cache } = setup();
let resolve!: (v: string[]) => void;
const build = jest.fn(() => new Promise<string[]>((r) => (resolve = r)));
const a = cache.get(build);
const b = cache.get(build);
resolve(['x']);
expect(await a).toEqual(['x']);
expect(await b).toEqual(['x']);
expect(build).toHaveBeenCalledTimes(1);
});
it('invalidate() forces the next read to rebuild', async () => {
const { cache } = setup();
const build = jest.fn().mockResolvedValueOnce(['old']).mockResolvedValueOnce(['new']);
await cache.get(build);
cache.invalidate();
expect(await cache.get(build)).toEqual(['new']);
});
it('does not keep a build that started before an invalidation', async () => {
const { cache } = setup();
let resolve!: (v: string[]) => void;
const slow = jest.fn(() => new Promise<string[]>((r) => (resolve = r)));
const pending = cache.get(slow);
cache.invalidate();
resolve(['stale']);
await pending;
const fresh = jest.fn().mockResolvedValue(['fresh']);
expect(await cache.get(fresh)).toEqual(['fresh']);
});
it('invalidates on a write to a catalogue model', async () => {
const { cache, runQuery } = setup();
const build = jest.fn().mockResolvedValue(['x']);
await cache.get(build);
await runQuery({ model: 'ProductVariant', action: 'updateMany', runInTransaction: false });
await cache.get(build);
expect(build).toHaveBeenCalledTimes(2);
});
it('ignores reads and writes to other models', async () => {
const { cache, runQuery } = setup();
const build = jest.fn().mockResolvedValue(['x']);
await cache.get(build);
await runQuery({ model: 'Product', action: 'findMany', runInTransaction: false });
await runQuery({ model: 'Setting', action: 'upsert', runInTransaction: false });
await cache.get(build);
expect(build).toHaveBeenCalledTimes(1);
});
it('invalidates again after a transactional write has had time to commit', async () => {
jest.useFakeTimers();
const { cache, runQuery } = setup();
await runQuery({ model: 'Product', action: 'update', runInTransaction: true });
// Rebuilt from the pre-commit state before the transaction finished.
const build = jest.fn().mockResolvedValue(['x']);
await cache.get(build);
jest.advanceTimersByTime(2_000);
await cache.get(build);
expect(build).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,83 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
/** How long a built storefront catalogue is served before it is rebuilt. */
export const CATALOG_CACHE_MS = 30_000;
/** Models whose rows appear in GET /catalog/products. */
const CATALOG_MODELS = new Set(['Product', 'ProductVariant', 'ProductImage', 'Category', 'Brand', 'Collection', 'CollectionProduct']);
const WRITE_ACTIONS = new Set([
'create',
'createMany',
'createManyAndReturn',
'update',
'updateMany',
'upsert',
'delete',
'deleteMany',
]);
/**
* A write inside an interactive transaction is not visible until the commit,
* which the middleware cannot see. A second invalidation this long after the
* write drops anything rebuilt from the pre-commit state in between.
*/
const TX_SETTLE_MS = 2_000;
/**
* In-memory copy of the storefront catalogue (GET /catalog/products).
*
* Building it is one wide findMany plus a stock check per product, and every
* app screen used to ask for it. Any Prisma write to a catalogue model — admin
* product/category/brand/collection screens, the accounting product and stock
* pull, wage changes — invalidates it through the middleware below, wherever
* the write comes from. Accounting settings (which decide the stock rule) are
* not a catalogue model; AccountingConfigService invalidates on save.
*
* Per process: correct while the API runs as a single instance. With several
* instances this moves to a shared store (Redis) behind the same two methods.
*/
@Injectable()
export class CatalogCacheService implements OnModuleInit {
private value: { at: number; data: unknown } | null = null;
private inflight: Promise<unknown> | null = null;
/** Bumped by invalidate(), so a build started before it is never stored. */
private generation = 0;
constructor(private readonly prisma: PrismaService) {}
onModuleInit() {
this.prisma.$use(async (params, next) => {
const result = await next(params);
if (params.model && CATALOG_MODELS.has(params.model) && WRITE_ACTIONS.has(params.action)) {
this.invalidate();
if (params.runInTransaction) setTimeout(() => this.invalidate(), TX_SETTLE_MS).unref?.();
}
return result;
});
}
async get<T>(build: () => Promise<T>): Promise<T> {
if (this.value && Date.now() - this.value.at < CATALOG_CACHE_MS) return this.value.data as T;
if (this.inflight) return this.inflight as Promise<T>;
const gen = this.generation;
const p = build()
.then((data) => {
if (gen === this.generation) this.value = { at: Date.now(), data };
return data;
})
.finally(() => {
if (this.inflight === p) this.inflight = null;
});
this.inflight = p;
return p;
}
invalidate() {
this.generation++;
this.value = null;
this.inflight = null;
}
}
@@ -1,15 +1,22 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { AccountingHooksService } from '../accounting-sync/accounting-hooks.service';
import { CatalogCacheService } from './catalog-cache.service';
@Injectable()
export class CatalogService {
constructor(
private readonly prisma: PrismaService,
private readonly accounting: AccountingHooksService,
private readonly catalogCache: CatalogCacheService,
) {}
async listProducts() {
/** Served from CatalogCacheService; see there for what invalidates it. */
listProducts() {
return this.catalogCache.get(() => this.buildProductList());
}
private async buildProductList() {
const products = await this.prisma.product.findMany({
// D27 — a product with no image is never shown in the app. Products now
// also arrive from the accounting software, which carries no pictures;
@@ -34,6 +41,7 @@ export class CatalogService {
},
});
const stockBlocked = await this.accounting.stockBlocker();
const out = [];
for (const p of products) {
const { accountingStockWeight, accountingStockCount, accountingStockAt, ...product } = p;
@@ -41,7 +49,7 @@ export class CatalogService {
// qty 0, which the apps already render as «ناموجود» and refuse to add to
// the cart unless it is a pre-order variant. The stock figures themselves
// stay server-side; this endpoint is public.
const blocked = await this.accounting.stockBlocked({ accountingStockWeight, accountingStockCount, accountingStockAt });
const blocked = stockBlocked({ accountingStockWeight, accountingStockCount, accountingStockAt });
out.push(blocked ? { ...product, variants: product.variants.map((v) => (v.isPreOrder ? v : { ...v, qty: 0 })) } : product);
}
return out;
+18
View File
@@ -0,0 +1,18 @@
// Cached versions of the reads screens repeat on every visit. Each one goes
// through queryCache under its own key; writes stay on `api` directly.
import { api } from './client'
import { KEYS, useQueryCache, type FetchOptions } from '../stores/queryCache'
export function useCachedReads() {
const cache = useQueryCache()
return {
me: (o?: FetchOptions) => cache.fetch(KEYS.profile, api.getMe, o),
myOrders: (o?: FetchOptions) => cache.fetch(KEYS.orders, api.getMyOrders, o),
accountingBalance: (o?: FetchOptions) => cache.fetch(KEYS.balance, api.getAccountingBalance, o),
myLedgerEntries: (o?: FetchOptions) => cache.fetch(KEYS.ledger, api.getMyLedgerEntries, o),
goldPrice: (o?: FetchOptions) => cache.fetch(KEYS.goldPrice, () => api.getLatestGoldPrice(), o),
homeSlider: (o?: FetchOptions) => cache.fetch(KEYS.homeSlider, api.getHomeSlider, o),
peek: cache.peek,
invalidate: cache.invalidate,
}
}
@@ -1,39 +1,16 @@
// The real GET /customers/:id/orders endpoint returns OrderItem rows with
// only `productVariantId` (no nested product/variant title) — this joins
// against the already-existing GET /catalog/products list client-side rather
// against the shared catalogue (stores/catalogStore.ts) client-side rather
// than touching apps/api to add an include.
import { ref } from 'vue'
import { api } from '../api/client'
import { useCatalogStore, type VariantLookup } from '../stores/catalogStore'
export interface VariantLookup {
productTitle: string
variantTitle: string
imageUrl?: string
}
const cache = ref<Map<string, VariantLookup> | null>(null)
async function ensureLoaded() {
if (cache.value) return cache.value
const products = await api.listProducts()
const map = new Map<string, VariantLookup>()
for (const product of products) {
for (const variant of product.variants) {
map.set(variant.id, {
productTitle: product.title,
variantTitle: variant.title,
imageUrl: product.images[0]?.url,
})
}
}
cache.value = map
return map
}
export type { VariantLookup }
export function useOrderItemProduct() {
const catalog = useCatalogStore()
const lookup = async (productVariantId: string): Promise<VariantLookup | undefined> => {
const map = await ensureLoaded()
return map.get(productVariantId)
if (!catalog.loaded) await catalog.load()
return catalog.variantLookup.get(productVariantId)
}
return { lookup }
}
@@ -1,25 +1,26 @@
// Adapted from legacy singleProduct.vue's own fetch (GET /product/:id).
// The new API has no single-product-detail endpoint (only GET
// /catalog/products, the flat list) — apps/api is out of scope for this port,
// so this fetches the full list and finds the matching product client-side.
// Wasteful for a large catalog, but honest: no fabricated endpoint, no silent
// behavior change.
// /catalog/products, the flat list), so the product is found in the shared
// catalogue (stores/catalogStore.ts). When the catalogue is already cached,
// opening a product costs no request at all.
//
// `initial` seeds the page with the ProductSummary the tapped card already
// holds — the same shape, from the same endpoint — so the detail view renders
// complete on its first frame instead of mounting blank and jumping when the
// fetch lands. The fetch still runs, silently, and only replaces the product
// if the server's copy actually differs (stock moved since the list loaded).
// fetch lands. The lookup still runs, silently, and only replaces the product
// if the catalogue's copy actually differs (stock moved since the list loaded).
//
// The silent refresh waits until the opening morph (ProductMorph.vue, ≤420ms)
// is over: the response is the whole catalogue, and parsing it — or swapping
// the product and re-rendering — mid-animation drops frames.
// is over: if the catalogue has to come from the server, parsing it — or
// swapping the product and re-rendering — mid-animation drops frames.
import { ref } from 'vue'
import { api, type ProductSummary } from '../api/client'
import type { ProductSummary } from '../api/client'
import { useCatalogStore } from '../stores/catalogStore'
const SEEDED_REFRESH_DELAY_MS = 600
export function useProductDetail(id: string, initial?: ProductSummary | null) {
const catalog = useCatalogStore()
const loading = ref(false)
const flag = ref(Boolean(initial))
const notFound = ref(false)
@@ -30,7 +31,7 @@ export function useProductDetail(id: string, initial?: ProductSummary | null) {
if (seeded) await new Promise((resolve) => setTimeout(resolve, SEEDED_REFRESH_DELAY_MS))
else loading.value = true
try {
const products = await api.listProducts()
const products = await catalog.load()
const found = products.find((p) => p.id === id) ?? null
if (seeded) {
if (found && JSON.stringify(found) !== JSON.stringify(productRef.value)) productRef.value = found
+21 -10
View File
@@ -1,12 +1,13 @@
// Adapted from legacy henza_pwa/src/Mixins/getProducts.ts. Legacy paginated
// against POST /get-products (page/limit/count). The new API's GET
// /catalog/products returns the full active catalog in one flat response with
// no pagination — so `pull()` (legacy's infinite-scroll "next page" trigger)
// is a no-op after the first successful fetch here, and `totalPages` is
// always 0. This keeps the ScrollFetchSearch infinite-scroll wiring intact
// (it still calls fetch on scroll) without fabricating fake pages.
import { reactive } from 'vue'
import { api, type ProductSummary } from '../api/client'
// no pagination — so `isPull` (legacy's infinite-scroll "next page" trigger)
// is a no-op here, and `totalPages` is always 0. A caller that wants new
// products at the end of the list passes `force` to refresh the cached
// catalogue instead of fabricating fake pages.
import { reactive, watch } from 'vue'
import type { ProductSummary } from '../api/client'
import { useCatalogStore } from '../stores/catalogStore'
export interface ProductsData {
totalPages: number
@@ -15,17 +16,27 @@ export interface ProductsData {
}
export function useProducts() {
const catalog = useCatalogStore()
const productsData: ProductsData = reactive({
totalPages: 0,
productList: [],
productList: catalog.products,
flag: false,
})
const getProducts = async (loading: { value: boolean }, isPull = false) => {
// A refresh from another screen (pull-to-refresh) lands here too.
watch(
() => catalog.products,
(list) => {
productsData.productList = list
productsData.flag = list.length < 1
},
)
const getProducts = async (loading: { value: boolean }, isPull = false, force = false) => {
if (isPull) return // no further pages exist server-side yet
loading.value = true
loading.value = force || !catalog.loaded
try {
productsData.productList = await api.listProducts()
productsData.productList = await catalog.load(force)
productsData.flag = productsData.productList.length < 1
} catch (e) {
console.log(e)
+11 -6
View File
@@ -1,16 +1,17 @@
<!--
Ported from legacy henza_pwa/src/layout/master.vue.
NOTE (existing issue, ported as-is — not fixed here): this layout renders
its own <router-view/> rather than a <slot/>, so the keep-alive/suspense
wrapper App.vue builds around its own <router-view> is never actually used
by this layout — the route component here is resolved fresh, independently.
Preserved exactly; flagged separately.
This layout renders its own <router-view/> rather than a <slot/>, so the
keep-alive/suspense wrapper App.vue builds around its own <router-view> never
reaches these routes. The keep-alive below is the one that applies: the main
tabs stay mounted between switches instead of refetching on every visit.
-->
<template>
<section ref="content">
<router-view v-slot="{ Component, route }">
<transition name="page" mode="out-in">
<component :is="Component" :key="route.name" />
<keep-alive :include="KEEP_ALIVE">
<component :is="Component" :key="route.name" />
</keep-alive>
</transition>
</router-view>
<Navigation />
@@ -24,6 +25,10 @@ import { useResizeObserver } from '@vueuse/core'
import Navigation from '../components/ui/Navigation.vue'
import { useWindowDataStore } from '../stores/windowDataStore'
// Component names (from the SFC file names) of the bottom-nav tabs. Forms and
// the product page are left out: they should open fresh every time.
const KEEP_ALIVE = ['HomeView', 'SearchView', 'OrdersView', 'DashboardView']
const windowData = useWindowDataStore()
const content = ref<HTMLElement | null>(null)
+4 -3
View File
@@ -3,6 +3,7 @@ import { useSessionStore } from '../stores/session';
import { useNavStore } from '../stores/navStore';
import { useTransitionStore } from '../stores/transitionStore';
import { api } from '../api/client';
import { useCachedReads } from '../api/cachedReads';
// Legacy lazy-loaded every route component (`component: () => import(...)`) so
// the initial bundle only carries the entry route. Matched here.
@@ -126,13 +127,13 @@ router.beforeEach(async (to) => {
// Ported from henza_pwa router/index.ts:266 — once the documents are in,
// /complete_profile is not reachable any more and redirects home. The
// reference read its cached store; this asks the server, so a profile
// completed on another device is respected too. A failed lookup lets the
// reference read its cached store; this asks the server (through the shared
// short-lived cache), so a profile completed on another device is respected too. A failed lookup lets the
// navigation through: the page itself is harmless, and the server is the
// one that decides whether the write is allowed.
if (to.meta.requiresIncompleteProfile && session.isLoggedIn) {
try {
const me = await api.getMe();
const me = await useCachedReads().me();
if (me.verificationStep === 'VERIFIED' || me.verificationStep === 'STEP_TWO_SUBMITTED') {
return { name: 'home' };
}
+4 -3
View File
@@ -3,7 +3,8 @@
// localStorage key), just via Pinia's own state instead of @vueuse's
// useStorage wrapping a hand-rolled class.
import { defineStore } from 'pinia'
import { api, type ProductSummary } from '../api/client'
import type { ProductSummary } from '../api/client'
import { useCatalogStore } from './catalogStore'
export interface CartItem {
pid: string
@@ -36,9 +37,9 @@ export const useCartStore = defineStore('cart', {
// Re-syncs cart entries against fresh product data (e.g. price/weight
// changes since the item was added), dropping entries whose
// product/variant no longer exists — same intent as legacy's init().
async init() {
async init(force = false) {
try {
const products = await api.listProducts()
const products = await useCatalogStore().load(force)
const refreshed: CartItem[] = []
for (const item of this.items) {
const product = products.find((p) => p.id === item.pid)
@@ -0,0 +1,65 @@
// The storefront catalogue (GET /catalog/products), shared by every screen.
//
// The endpoint returns the whole active catalogue in one response, so one copy
// serves home, search, collections, favourites, the product page, the cart
// re-sync and the order-item lookup. Reads go through queryCache and keep the
// copy until pull-to-refresh, the end of the newest-products list, or an order.
import { defineStore } from 'pinia'
import { computed, ref, shallowRef } from 'vue'
import { api, type ProductSummary } from '../api/client'
import { KEYS, useQueryCache } from './queryCache'
export interface VariantLookup {
productTitle: string
variantTitle: string
imageUrl?: string
}
export const useCatalogStore = defineStore('catalog', () => {
const cache = useQueryCache()
// shallowRef: the list is replaced wholesale, never mutated in place.
const cached = cache.peek<ProductSummary[]>(KEYS.catalog)
const products = shallowRef<ProductSummary[]>(cached ?? [])
/** True once a response has arrived — an empty catalogue is still "loaded". */
const loaded = ref(cached !== undefined)
const categories = computed(() => {
const seen = new Map<string, { id: string; title: string }>()
for (const p of products.value) {
if (p.category && !seen.has(p.category.id)) seen.set(p.category.id, { id: p.category.id, title: p.category.title })
}
return [...seen.values()]
})
const variantLookup = computed(() => {
const map = new Map<string, VariantLookup>()
for (const product of products.value) {
for (const variant of product.variants) {
map.set(variant.id, {
productTitle: product.title,
variantTitle: variant.title,
imageUrl: product.images[0]?.url,
})
}
}
return map
})
async function load(force = false): Promise<ProductSummary[]> {
const list = await cache.fetch(KEYS.catalog, api.listProducts, { force })
products.value = list
loaded.value = true
return list
}
function byId(id: string): ProductSummary | null {
return products.value.find((p) => p.id === id) ?? null
}
/** Drops the cached copy; the next load() goes to the server. */
function invalidate() {
cache.invalidate(KEYS.catalog)
}
return { products, loaded, categories, variantLookup, load, byId, invalidate }
})
+7 -14
View File
@@ -18,7 +18,7 @@
//
// Not ported (no backend): promotionList, ignoreList, priceList, isLoaded.
import { defineStore } from 'pinia'
import { api } from '../api/client'
import { useCatalogStore } from './catalogStore'
import shippingArt from '../assets/img/shipping-and-delivery.png'
import competitionArt from '../assets/img/sports-and-competition.png'
import brandArt from '../assets/img/montzar-logo.png'
@@ -70,20 +70,13 @@ export const useConfigStore = defineStore('config', {
// Legacy's store.init() fanned out to /config, /promotion, /ignore, /price.
// Here the only live source is the product catalogue, from which the real
// category list is derived.
async init() {
// The catalogue comes from catalogStore, so calling this next to another
// catalogue read costs no second request.
async init(force = false) {
try {
const products = await api.listProducts()
const seen = new Map<string, HomeCategory>()
for (const p of products) {
if (p.category && !seen.has(p.category.id)) {
seen.set(p.category.id, {
id: p.category.id,
title: p.category.title,
icon: { image: brandArt },
})
}
}
this.cats = [...seen.values()]
const catalog = useCatalogStore()
await catalog.load(force)
this.cats = catalog.categories.map((c) => ({ ...c, icon: { image: brandArt } }))
this.loaded = true
} catch (e) {
console.log(e)
+100
View File
@@ -0,0 +1,100 @@
// Shared read cache for API responses.
//
// Every screen used to fetch on mount, so each tab switch repeated the same
// requests — the whole catalogue alone was fetched from ten places. Reads now go
// through `fetch(key, …)`:
//
// - cached entry → returned at once, no request
// - request in flight → the same promise is shared, never a second request
// - `force` → always asks the server (pull-to-refresh, the
// profile tab's balance, reaching the end of a list)
//
// Entries never expire on a timer: data changes when the user asks for it or
// when a write here makes it wrong, and those writes call `invalidate`.
//
// Entries live in a plain Map, not reactive state: the catalogue is large and
// making it deeply reactive would cost more than the request it saves.
//
// Keys under `me:` hold one customer's data. They are dropped whenever the
// session token changes, so the next user never sees the previous one's data.
import { defineStore } from 'pinia'
import { watch } from 'vue'
import { useSessionStore } from './session'
export const KEYS = {
catalog: 'catalog',
goldPrice: 'goldPrice',
homeSlider: 'homeSlider',
profile: 'me:profile',
orders: 'me:orders',
balance: 'me:balance',
ledger: 'me:ledger',
} as const
const USER_PREFIX = 'me:'
interface Entry {
data?: unknown
/** False until the first successful response. */
has: boolean
inflight?: Promise<unknown>
}
export interface FetchOptions {
/** Skip the cached value. An identical request already in flight is still shared. */
force?: boolean
}
export const useQueryCache = defineStore('queryCache', () => {
const entries = new Map<string, Entry>()
const session = useSessionStore()
function request<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
const entry = entries.get(key)
if (entry?.inflight) return entry.inflight as Promise<T>
const p: Promise<T> = fetcher()
.then((data) => {
// Invalidated (or the user changed) while this was in flight: the
// response may predate the change, so it is handed back but not kept.
if (entries.get(key)?.inflight === p) entries.set(key, { data, has: true })
return data
})
.finally(() => {
const current = entries.get(key)
if (current?.inflight === p) current.inflight = undefined
})
entries.set(key, { ...(entry ?? { has: false }), inflight: p })
return p
}
async function fetch<T>(key: string, fetcher: () => Promise<T>, opts: FetchOptions = {}): Promise<T> {
const entry = entries.get(key)
if (!opts.force && entry?.has) return entry.data as T
return request(key, fetcher)
}
/** The cached value, without fetching. */
function peek<T>(key: string): T | undefined {
const entry = entries.get(key)
return entry?.has ? (entry.data as T) : undefined
}
function invalidate(...keys: string[]) {
for (const key of keys) entries.delete(key)
}
function clearUserScoped() {
for (const key of [...entries.keys()]) {
if (key.startsWith(USER_PREFIX)) entries.delete(key)
}
}
watch(
() => session.token,
() => clearUserScoped(),
)
return { fetch, peek, invalidate, clearUserScoped }
})
+6 -1
View File
@@ -150,6 +150,8 @@ import CartItem from '../components/product/CartItem.vue'
import { useCartStore, type CartItem as CartItemType } from '../stores/cartStore'
import { useSessionStore } from '../stores/session'
import { api } from '../api/client'
import { useCatalogStore } from '../stores/catalogStore'
import { KEYS, useQueryCache } from '../stores/queryCache'
// Module scope, so it survives this view being unmounted and remounted as the
// user moves between tabs, and resets only on a full app reload — the same
@@ -231,6 +233,9 @@ const submitOrder = async () => {
cartData.orderID = order.orderNumber
cartData.step = 2
cart.clear()
// The order shows up in the list and the balance, and may have taken stock.
useQueryCache().invalidate(KEYS.orders, KEYS.balance, KEYS.profile, KEYS.ledger)
useCatalogStore().invalidate()
} catch (e) {
console.log(e)
} finally {
@@ -239,7 +244,7 @@ const submitOrder = async () => {
}
async function onRefresh() {
await cart.init()
await cart.init(true)
refreshing.value = false
}
+28 -30
View File
@@ -94,8 +94,10 @@ import ProductCarousel from '../components/home/ProductCarousel.vue'
import { useConfigStore } from '../stores/configStore'
import type { HomeCategory } from '../stores/configStore'
import { useFiltersStore } from '../stores/filtersStore'
import { api } from '../api/client'
import type { AdminSlide, GoldPriceQuote, ProductSummary } from '../api/client'
import { useCachedReads } from '../api/cachedReads'
import { useCatalogStore } from '../stores/catalogStore'
import { KEYS } from '../stores/queryCache'
// Module scope, so it survives this view being unmounted and remounted as the
// user moves between tabs, and resets only on a full app reload.
@@ -104,27 +106,29 @@ let hasRevealed = false
const router = useRouter()
const memory = useConfigStore()
const filters = useFiltersStore()
const catalog = useCatalogStore()
const reads = useCachedReads()
const loading = ref(true)
const loading = ref(!catalog.loaded)
const refreshing = ref(false)
const products = ref<ProductSummary[]>([])
const goldPrice = ref<GoldPriceQuote | null>(null)
const products = computed(() => catalog.products)
const goldPrice = ref<GoldPriceQuote | null>(reads.peek<GoldPriceQuote>(KEYS.goldPrice) ?? null)
// Admin-managed banner slides (manage/Const.vue "اسلایدر موبایل"). null while
// unfetched/unavailable — distinct from "fetched, empty" — so `slides` below
// knows whether to wait or fall back to the category-derived hero.
// Kept in step with user-app, which has had this since the admin screen was
// wired up; without it, slides configured in the admin panel never showed here.
const adminSlides = ref<AdminSlide[] | null>(null)
const adminSlides = ref<AdminSlide[] | null>(reads.peek<AdminSlide[]>(KEYS.homeSlider) ?? null)
// First-paint entrance. Flipped once, after the first data load, so the
// sections settle into place instead of appearing all at once; pull-to-refresh
// does not replay it.
//
// MasterLayout renders its route without keep-alive, so this view remounts on
// every return to the Home tab. The flag therefore lives outside the component
// — the choreography is a first-run flourish, and replaying it on each tab
// switch would fight the page transition rather than add to it.
// MasterLayout keeps this view alive between tab switches, but it still
// remounts after a visit to a full-page route. The flag therefore lives outside
// the component — the choreography is a first-run flourish, and replaying it
// would fight the page transition rather than add to it.
const revealed = ref(hasRevealed)
// Each section starts a beat after the one above it. Kept small — the whole
@@ -208,31 +212,25 @@ function goNewest() {
router.push({ name: 'newestProduct' })
}
async function loadData() {
try {
const [list] = await Promise.all([api.listProducts(), memory.init()])
products.value = list
} catch (e) {
console.log(e)
}
// The three reads are independent, so they run side by side. Each one is
// cached until pull-to-refresh asks the server again.
async function loadData(force = false) {
const [catalogResult, priceResult, slidesResult] = await Promise.allSettled([
// memory.init() reads the same cached catalogue, so this is one request.
memory.init(force),
reads.goldPrice({ force }),
reads.homeSlider({ force }),
])
try {
goldPrice.value = await api.getLatestGoldPrice()
} catch {
goldPrice.value = null
}
try {
adminSlides.value = await api.getHomeSlider()
} catch {
// No admin slides configured, or the endpoint failed — the category-
// derived hero covers this silently, same as before this existed.
adminSlides.value = null
}
if (catalogResult.status === 'rejected') console.log(catalogResult.reason)
goldPrice.value = priceResult.status === 'fulfilled' ? priceResult.value : null
// No admin slides configured, or the endpoint failed — the category-derived
// hero covers this silently, same as before this existed.
adminSlides.value = slidesResult.status === 'fulfilled' ? slidesResult.value : null
}
async function onRefresh() {
await loadData()
await loadData(true)
refreshing.value = false
}
+4 -2
View File
@@ -71,12 +71,14 @@ onBeforeMount(async () => {
const pull = async () => {
// Reveal more of what is already loaded first; only go back to the server
// once the whole fetched list is on screen.
// once the whole fetched list is on screen. Reaching the end is how this
// page asks for new products: the cached catalogue is refreshed.
if (visibleCount.value < productsData.productList.length) {
visibleCount.value += PAGE_SIZE
return
}
await getProducts(loadingPull, true)
if (loadingPull.value) return
await getProducts(loadingPull, false, true)
}
</script>
@@ -40,12 +40,13 @@ import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue'
import SectionHead from '../../components/home/SectionHead.vue'
import ProductCard from '../../components/product/ProductCard.vue'
import { api, type ProductSummary } from '../../api/client'
import { useCatalogStore } from '../../stores/catalogStore'
const route = useRoute()
const catalog = useCatalogStore()
const loadingPage = ref(false)
const allProducts = ref<ProductSummary[]>([])
const allProducts = computed(() => catalog.products)
// legacy passed the collection's title through as ?page=<title>; kept, with a
// neutral fallback so the bar is never blank on a direct link.
@@ -56,9 +57,11 @@ const collectionProducts = computed(() =>
)
onMounted(async () => {
loadingPage.value = true
loadingPage.value = !catalog.loaded
try {
allProducts.value = await api.listProducts()
await catalog.load()
} catch (e) {
console.log(e)
} finally {
loadingPage.value = false
}
@@ -108,13 +108,14 @@ import Filters from '../../components/search/Filters.vue'
import SectionHead from '../../components/home/SectionHead.vue'
import ProductCard from '../../components/product/ProductCard.vue'
import { useFiltersStore } from '../../stores/filtersStore'
import { api, type ProductSummary } from '../../api/client'
import { useCatalogStore } from '../../stores/catalogStore'
const filters = useFiltersStore()
const catalog = useCatalogStore()
const loadingPage = ref(false)
const refreshing = ref(false)
const allProducts = ref<ProductSummary[]>([])
const allProducts = computed(() => catalog.products)
const showFilter = ref(false)
const filterKeys = ref<string[]>([])
@@ -187,17 +188,20 @@ watch(sentinel, mountObserver)
onBeforeUnmount(() => observer?.disconnect())
async function loadProducts() {
loadingPage.value = true
async function loadProducts(force = false) {
// Cached data renders at once; the spinner is only for a cold start.
loadingPage.value = !catalog.loaded
try {
allProducts.value = await api.listProducts()
await catalog.load(force)
} catch (e) {
console.log(e)
} finally {
loadingPage.value = false
}
}
async function onRefresh() {
await loadProducts()
await loadProducts(true)
refreshing.value = false
}
@@ -211,7 +215,7 @@ function showFilters() {
showFilter.value = true
}
onMounted(loadProducts)
onMounted(() => loadProducts())
</script>
<style scoped lang="less">
@@ -82,6 +82,7 @@ import Spinner from '../../components/Spinner.vue'
import IslandSheet from '../../components/ui/IslandSheet.vue'
import SingleImageUploader from '../../components/upload/SingleImageUploader.vue'
import { api } from '../../api/client'
import { KEYS, useQueryCache } from '../../stores/queryCache'
const router = useRouter()
@@ -112,6 +113,7 @@ async function submit() {
shop_image_id: form.shop_image_id,
ncode_image_id: form.ncode_image_id,
})
useQueryCache().invalidate(KEYS.profile)
done.value = true
} catch {
error.value = 'ثبت اطلاعات انجام نشد. دوباره تلاش کنید.'
@@ -241,7 +241,7 @@
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { computed, nextTick, onActivated, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Loading, PullRefresh } from 'vant'
import Icon from '../../components/icon/Icon.vue'
@@ -250,12 +250,15 @@ import IslandSheet from '../../components/ui/IslandSheet.vue'
import { price } from '../../utils/filters'
import { useSessionStore } from '../../stores/session'
import { api, type CustomerDetail, type GoldPriceQuote } from '../../api/client'
import { useCachedReads } from '../../api/cachedReads'
import { KEYS } from '../../stores/queryCache'
const router = useRouter()
const session = useSessionStore()
const reads = useCachedReads()
const customer = ref<CustomerDetail | null>(null)
const goldPrice = ref<GoldPriceQuote | null>(null)
const customer = ref<CustomerDetail | null>(reads.peek<CustomerDetail>(KEYS.profile) ?? null)
const goldPrice = ref<GoldPriceQuote | null>(reads.peek<GoldPriceQuote>(KEYS.goldPrice) ?? null)
const refreshing = ref(false)
// The ledger stores the gram position as a signed figure — positive means the
@@ -300,30 +303,34 @@ async function logOut() {
router.push({ name: 'home' })
}
async function loadDashboard() {
if (session.isLoggedIn) {
// Not wrapped before: a failure here (expired token, network blip) left
// the exception uncaught, so onRefresh's refreshing.value = false never
// ran and PullRefresh stayed stuck in its loading state — a working pull
// gesture never dismissed it again until a full page reload.
try {
customer.value = await api.getMe()
} catch (e) {
console.log(e)
}
}
try {
goldPrice.value = await api.getLatestGoldPrice()
} catch {
goldPrice.value = null
}
async function loadDashboard(force = false) {
// Not wrapped before: a failure here (expired token, network blip) left
// the exception uncaught, so onRefresh's refreshing.value = false never
// ran and PullRefresh stayed stuck in its loading state — a working pull
// gesture never dismissed it again until a full page reload.
const [me, price] = await Promise.allSettled([
// The balance on this tab is the one figure read fresh on every visit;
// the cached copy only fills the screen until it arrives.
session.isLoggedIn ? reads.me({ force: true }) : Promise.resolve(null),
reads.goldPrice({ force }),
])
if (me.status === 'fulfilled') customer.value = me.value
else console.log(me.reason)
goldPrice.value = price.status === 'fulfilled' ? price.value : null
}
async function onRefresh() {
await loadDashboard()
await loadDashboard(true)
refreshing.value = false
}
let activatedOnce = false
onActivated(() => {
// Kept alive by MasterLayout, so every later visit lands here, not in onMounted.
if (activatedOnce) void loadDashboard()
activatedOnce = true
})
// First-paint entrance, mirroring HomeView's choreography exactly (module-scope
// flag so it only replays on a full app reload, not on every tab switch).
let hasRevealed = false
@@ -43,19 +43,22 @@ import Spinner from '../../components/Spinner.vue'
import SectionHead from '../../components/home/SectionHead.vue'
import ProductCard from '../../components/product/ProductCard.vue'
import { useFavoritesStore } from '../../stores/favoritesStore'
import { api, type ProductSummary } from '../../api/client'
import { useCatalogStore } from '../../stores/catalogStore'
const router = useRouter()
const favorites = useFavoritesStore()
const catalog = useCatalogStore()
const loading = ref(false)
const allProducts = ref<ProductSummary[]>([])
const allProducts = computed(() => catalog.products)
const favoriteProducts = computed(() => allProducts.value.filter((p) => favorites.isFavorite(p.id)))
onMounted(async () => {
loading.value = true
loading.value = !catalog.loaded
try {
allProducts.value = await api.listProducts()
await catalog.load()
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
+16 -8
View File
@@ -164,9 +164,12 @@ import {
} from '../../utils/orderStatus'
import { jdate } from '../../utils/filters'
import { useSessionStore } from '../../stores/session'
import { api, type OrderDetail } from '../../api/client'
import type { OrderDetail } from '../../api/client'
import { useCachedReads } from '../../api/cachedReads'
import { KEYS } from '../../stores/queryCache'
const session = useSessionStore()
const reads = useCachedReads()
const tabs = [
{ title: 'همه', key: 'ALL' },
@@ -183,7 +186,7 @@ const activeTab = ref('ALL')
const loading = ref(false)
const refreshing = ref(false)
const allOrders = ref<OrderDetail[]>([])
const allOrders = ref<OrderDetail[]>(reads.peek<OrderDetail[]>(KEYS.orders) ?? [])
const searchTerm = ref<string | undefined>(undefined)
const filteredOrders = computed(() => {
@@ -197,11 +200,16 @@ function readSearch(value: string | undefined) {
searchTerm.value = value
}
async function loadOrders() {
if (!session.isLoggedIn) return
loading.value = true
async function loadOrders(force = false) {
if (!session.isLoggedIn) {
allOrders.value = []
return
}
loading.value = reads.peek(KEYS.orders) === undefined
try {
allOrders.value = await api.getMyOrders()
allOrders.value = await reads.myOrders({ force })
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
@@ -220,11 +228,11 @@ function openDetail(item: OrderDetail) {
}
async function onRefresh() {
await loadOrders()
await loadOrders(true)
refreshing.value = false
}
onMounted(loadOrders)
onMounted(() => loadOrders())
</script>
<style scoped lang="less">
@@ -104,7 +104,9 @@ import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
import SectionHead from '../../components/home/SectionHead.vue'
import { useSessionStore } from '../../stores/session'
import { api, type AccountingBalance, type CustomerDetail } from '../../api/client'
import type { AccountingBalance, CustomerDetail } from '../../api/client'
import { useCachedReads } from '../../api/cachedReads'
import { KEYS } from '../../stores/queryCache'
const VERIFICATION_LABELS: Record<string, string> = {
UNVERIFIED: 'احراز نشده',
@@ -115,7 +117,8 @@ const VERIFICATION_LABELS: Record<string, string> = {
}
const session = useSessionStore()
const customer = ref<CustomerDetail | null>(null)
const reads = useCachedReads()
const customer = ref<CustomerDetail | null>(reads.peek<CustomerDetail>(KEYS.profile) ?? null)
const loading = ref(false)
const isVerified = computed(() => customer.value?.verificationStep === 'VERIFIED')
@@ -133,8 +136,9 @@ const canCompleteProfile = computed(() => {
const router = useRouter()
// D27 §12 — fetched on its own, every time the page opens, so a slow
// accounting server never holds up the profile itself.
const balance = ref<AccountingBalance | null>(null)
// accounting server never holds up the profile itself. The last copy fills the
// screen until the fresh one arrives.
const balance = ref<AccountingBalance | null>(reads.peek<AccountingBalance>(KEYS.balance) ?? null)
const balanceLoading = ref(false)
const num = (v: string | null) => Number(v ?? 0)
@@ -151,9 +155,9 @@ const balanceSubtitle = computed(() => {
})
async function loadBalance() {
balanceLoading.value = true
balanceLoading.value = !balance.value
try {
balance.value = await api.getAccountingBalance()
balance.value = await reads.accountingBalance({ force: true })
} catch {
balance.value = null
} finally {
@@ -164,9 +168,9 @@ async function loadBalance() {
onMounted(async () => {
if (!session.isLoggedIn) return
void loadBalance()
loading.value = true
loading.value = !customer.value
try {
customer.value = await api.getMe()
customer.value = await reads.me({ force: true })
} finally {
loading.value = false
}
@@ -107,7 +107,9 @@ import Spinner from '../../components/Spinner.vue'
import SectionHead from '../../components/home/SectionHead.vue'
import { jdate, price } from '../../utils/filters'
import { useSessionStore } from '../../stores/session'
import { api, type LedgerEntrySummary } from '../../api/client'
import type { LedgerEntrySummary } from '../../api/client'
import { useCachedReads } from '../../api/cachedReads'
import { KEYS } from '../../stores/queryCache'
const SOURCE_LABELS: Record<string, string> = {
ORDER_STOCK: 'سفارش',
@@ -121,9 +123,10 @@ const SOURCE_LABELS: Record<string, string> = {
}
const session = useSessionStore()
const reads = useCachedReads()
const loading = ref(false)
const entries = ref<LedgerEntrySummary[]>([])
const entries = ref<LedgerEntrySummary[]>(reads.peek<LedgerEntrySummary[]>(KEYS.ledger) ?? [])
const showPopUp = ref(false)
const activeEntry = ref<LedgerEntrySummary | null>(null)
@@ -141,9 +144,9 @@ const openEntry = (item: LedgerEntrySummary) => {
onMounted(async () => {
if (!session.isLoggedIn) return
loading.value = true
loading.value = reads.peek(KEYS.ledger) === undefined
try {
entries.value = await api.getMyLedgerEntries()
entries.value = await reads.myLedgerEntries()
} finally {
loading.value = false
}
+18
View File
@@ -0,0 +1,18 @@
// Cached versions of the reads screens repeat on every visit. Each one goes
// through queryCache under its own key; writes stay on `api` directly.
import { api } from './client'
import { KEYS, useQueryCache, type FetchOptions } from '../stores/queryCache'
export function useCachedReads() {
const cache = useQueryCache()
return {
me: (o?: FetchOptions) => cache.fetch(KEYS.profile, api.getMe, o),
myOrders: (o?: FetchOptions) => cache.fetch(KEYS.orders, api.getMyOrders, o),
accountingBalance: (o?: FetchOptions) => cache.fetch(KEYS.balance, api.getAccountingBalance, o),
myLedgerEntries: (o?: FetchOptions) => cache.fetch(KEYS.ledger, api.getMyLedgerEntries, o),
goldPrice: (o?: FetchOptions) => cache.fetch(KEYS.goldPrice, () => api.getLatestGoldPrice(), o),
homeSlider: (o?: FetchOptions) => cache.fetch(KEYS.homeSlider, api.getHomeSlider, o),
peek: cache.peek,
invalidate: cache.invalidate,
}
}
@@ -1,39 +1,16 @@
// The real GET /customers/:id/orders endpoint returns OrderItem rows with
// only `productVariantId` (no nested product/variant title) — this joins
// against the already-existing GET /catalog/products list client-side rather
// against the shared catalogue (stores/catalogStore.ts) client-side rather
// than touching apps/api to add an include.
import { ref } from 'vue'
import { api } from '../api/client'
import { useCatalogStore, type VariantLookup } from '../stores/catalogStore'
export interface VariantLookup {
productTitle: string
variantTitle: string
imageUrl?: string
}
const cache = ref<Map<string, VariantLookup> | null>(null)
async function ensureLoaded() {
if (cache.value) return cache.value
const products = await api.listProducts()
const map = new Map<string, VariantLookup>()
for (const product of products) {
for (const variant of product.variants) {
map.set(variant.id, {
productTitle: product.title,
variantTitle: variant.title,
imageUrl: product.images[0]?.url,
})
}
}
cache.value = map
return map
}
export type { VariantLookup }
export function useOrderItemProduct() {
const catalog = useCatalogStore()
const lookup = async (productVariantId: string): Promise<VariantLookup | undefined> => {
const map = await ensureLoaded()
return map.get(productVariantId)
if (!catalog.loaded) await catalog.load()
return catalog.variantLookup.get(productVariantId)
}
return { lookup }
}
@@ -1,25 +1,26 @@
// Adapted from legacy singleProduct.vue's own fetch (GET /product/:id).
// The new API has no single-product-detail endpoint (only GET
// /catalog/products, the flat list) — apps/api is out of scope for this port,
// so this fetches the full list and finds the matching product client-side.
// Wasteful for a large catalog, but honest: no fabricated endpoint, no silent
// behavior change.
// /catalog/products, the flat list), so the product is found in the shared
// catalogue (stores/catalogStore.ts). When the catalogue is already cached,
// opening a product costs no request at all.
//
// `initial` seeds the page with the ProductSummary the tapped card already
// holds — the same shape, from the same endpoint — so the detail view renders
// complete on its first frame instead of mounting blank and jumping when the
// fetch lands. The fetch still runs, silently, and only replaces the product
// if the server's copy actually differs (stock moved since the list loaded).
// fetch lands. The lookup still runs, silently, and only replaces the product
// if the catalogue's copy actually differs (stock moved since the list loaded).
//
// The silent refresh waits until the opening morph (ProductMorph.vue, ≤420ms)
// is over: the response is the whole catalogue, and parsing it — or swapping
// the product and re-rendering — mid-animation drops frames.
// is over: if the catalogue has to come from the server, parsing it — or
// swapping the product and re-rendering — mid-animation drops frames.
import { ref } from 'vue'
import { api, type ProductSummary } from '../api/client'
import type { ProductSummary } from '../api/client'
import { useCatalogStore } from '../stores/catalogStore'
const SEEDED_REFRESH_DELAY_MS = 600
export function useProductDetail(id: string, initial?: ProductSummary | null) {
const catalog = useCatalogStore()
const loading = ref(false)
const flag = ref(Boolean(initial))
const notFound = ref(false)
@@ -30,7 +31,7 @@ export function useProductDetail(id: string, initial?: ProductSummary | null) {
if (seeded) await new Promise((resolve) => setTimeout(resolve, SEEDED_REFRESH_DELAY_MS))
else loading.value = true
try {
const products = await api.listProducts()
const products = await catalog.load()
const found = products.find((p) => p.id === id) ?? null
if (seeded) {
if (found && JSON.stringify(found) !== JSON.stringify(productRef.value)) productRef.value = found
+21 -10
View File
@@ -1,12 +1,13 @@
// Adapted from legacy henza_pwa/src/Mixins/getProducts.ts. Legacy paginated
// against POST /get-products (page/limit/count). The new API's GET
// /catalog/products returns the full active catalog in one flat response with
// no pagination — so `pull()` (legacy's infinite-scroll "next page" trigger)
// is a no-op after the first successful fetch here, and `totalPages` is
// always 0. This keeps the ScrollFetchSearch infinite-scroll wiring intact
// (it still calls fetch on scroll) without fabricating fake pages.
import { reactive } from 'vue'
import { api, type ProductSummary } from '../api/client'
// no pagination — so `isPull` (legacy's infinite-scroll "next page" trigger)
// is a no-op here, and `totalPages` is always 0. A caller that wants new
// products at the end of the list passes `force` to refresh the cached
// catalogue instead of fabricating fake pages.
import { reactive, watch } from 'vue'
import type { ProductSummary } from '../api/client'
import { useCatalogStore } from '../stores/catalogStore'
export interface ProductsData {
totalPages: number
@@ -15,17 +16,27 @@ export interface ProductsData {
}
export function useProducts() {
const catalog = useCatalogStore()
const productsData: ProductsData = reactive({
totalPages: 0,
productList: [],
productList: catalog.products,
flag: false,
})
const getProducts = async (loading: { value: boolean }, isPull = false) => {
// A refresh from another screen (pull-to-refresh) lands here too.
watch(
() => catalog.products,
(list) => {
productsData.productList = list
productsData.flag = list.length < 1
},
)
const getProducts = async (loading: { value: boolean }, isPull = false, force = false) => {
if (isPull) return // no further pages exist server-side yet
loading.value = true
loading.value = force || !catalog.loaded
try {
productsData.productList = await api.listProducts()
productsData.productList = await catalog.load(force)
productsData.flag = productsData.productList.length < 1
} catch (e) {
console.log(e)
+5 -5
View File
@@ -6,11 +6,11 @@ import { nextTick, ref } from 'vue'
* after another. The styles are the global `.reveal` / `.is-revealed` pair in
* assets/styles/style.less.
*
* MasterLayout renders its routes without keep-alive, so a screen remounts on
* every return to its tab. Which screens have already played is kept here in
* module scope, so the entrance is a first-run flourish that resets only on a
* full reload — replaying it on each tab switch would fight the page
* transition rather than add to it. (A `let` at the top of <script setup>
* MasterLayout keeps the main tabs alive, but a screen still remounts after a
* full-page route or if it is not in the keep-alive list. Which screens have
* already played is kept here in module scope, so the entrance is a first-run
* flourish that resets only on a full reload — replaying it on each return
* would fight the page transition rather than add to it. (A `let` at the top of <script setup>
* would not do this: that code runs once per mount.)
*/
const played = new Set<string>()
+11 -6
View File
@@ -1,10 +1,9 @@
<!--
Ported from legacy henza_pwa/src/layout/master.vue.
NOTE (existing issue, ported as-is — not fixed here): this layout renders
its own <router-view/> rather than a <slot/>, so the keep-alive/suspense
wrapper App.vue builds around its own <router-view> is never actually used
by this layout — the route component here is resolved fresh, independently.
Preserved exactly; flagged separately.
This layout renders its own <router-view/> rather than a <slot/>, so the
keep-alive/suspense wrapper App.vue builds around its own <router-view> never
reaches these routes. The keep-alive below is the one that applies: the main
tabs stay mounted between switches instead of refetching on every visit.
-->
<template>
<section ref="content">
@@ -16,7 +15,9 @@
@after-leave="pageLeaveEnded"
@leave-cancelled="pageLeaveEnded"
>
<component :is="Component" :key="route.name" />
<keep-alive :include="KEEP_ALIVE">
<component :is="Component" :key="route.name" />
</keep-alive>
</transition>
</router-view>
<Navigation />
@@ -31,6 +32,10 @@ import Navigation from '../components/ui/Navigation.vue'
import { useWindowDataStore } from '../stores/windowDataStore'
import { pageLeaveStarted, pageLeaveEnded } from '../composables/usePageTransition'
// Component names (from the SFC file names) of the bottom-nav tabs. Forms and
// the product page are left out: they should open fresh every time.
const KEEP_ALIVE = ['HomeView', 'SearchView', 'OrdersView', 'DashboardView']
const windowData = useWindowDataStore()
const content = ref<HTMLElement | null>(null)
+4 -3
View File
@@ -3,6 +3,7 @@ import { useSessionStore } from '../stores/session';
import { useNavStore } from '../stores/navStore';
import { useTransitionStore } from '../stores/transitionStore';
import { api } from '../api/client';
import { useCachedReads } from '../api/cachedReads';
import { afterPageLeave } from '../composables/usePageTransition';
// Legacy lazy-loaded every route component (`component: () => import(...)`) so
@@ -131,13 +132,13 @@ router.beforeEach(async (to) => {
// Ported from henza_pwa router/index.ts:266 — once the documents are in,
// /complete_profile is not reachable any more and redirects home. The
// reference read its cached store; this asks the server, so a profile
// completed on another device is respected too. A failed lookup lets the
// reference read its cached store; this asks the server (through the shared
// short-lived cache), so a profile completed on another device is respected too. A failed lookup lets the
// navigation through: the page itself is harmless, and the server is the
// one that decides whether the write is allowed.
if (to.meta.requiresIncompleteProfile && session.isLoggedIn) {
try {
const me = await api.getMe();
const me = await useCachedReads().me();
if (me.verificationStep === 'VERIFIED' || me.verificationStep === 'STEP_TWO_SUBMITTED') {
return { name: 'home' };
}
+4 -3
View File
@@ -3,7 +3,8 @@
// localStorage key), just via Pinia's own state instead of @vueuse's
// useStorage wrapping a hand-rolled class.
import { defineStore } from 'pinia'
import { api, type ProductSummary } from '../api/client'
import type { ProductSummary } from '../api/client'
import { useCatalogStore } from './catalogStore'
export interface CartItem {
pid: string
@@ -36,9 +37,9 @@ export const useCartStore = defineStore('cart', {
// Re-syncs cart entries against fresh product data (e.g. price/weight
// changes since the item was added), dropping entries whose
// product/variant no longer exists — same intent as legacy's init().
async init() {
async init(force = false) {
try {
const products = await api.listProducts()
const products = await useCatalogStore().load(force)
const refreshed: CartItem[] = []
for (const item of this.items) {
const product = products.find((p) => p.id === item.pid)
+65
View File
@@ -0,0 +1,65 @@
// The storefront catalogue (GET /catalog/products), shared by every screen.
//
// The endpoint returns the whole active catalogue in one response, so one copy
// serves home, search, collections, favourites, the product page, the cart
// re-sync and the order-item lookup. Reads go through queryCache and keep the
// copy until pull-to-refresh, the end of the newest-products list, or an order.
import { defineStore } from 'pinia'
import { computed, ref, shallowRef } from 'vue'
import { api, type ProductSummary } from '../api/client'
import { KEYS, useQueryCache } from './queryCache'
export interface VariantLookup {
productTitle: string
variantTitle: string
imageUrl?: string
}
export const useCatalogStore = defineStore('catalog', () => {
const cache = useQueryCache()
// shallowRef: the list is replaced wholesale, never mutated in place.
const cached = cache.peek<ProductSummary[]>(KEYS.catalog)
const products = shallowRef<ProductSummary[]>(cached ?? [])
/** True once a response has arrived — an empty catalogue is still "loaded". */
const loaded = ref(cached !== undefined)
const categories = computed(() => {
const seen = new Map<string, { id: string; title: string }>()
for (const p of products.value) {
if (p.category && !seen.has(p.category.id)) seen.set(p.category.id, { id: p.category.id, title: p.category.title })
}
return [...seen.values()]
})
const variantLookup = computed(() => {
const map = new Map<string, VariantLookup>()
for (const product of products.value) {
for (const variant of product.variants) {
map.set(variant.id, {
productTitle: product.title,
variantTitle: variant.title,
imageUrl: product.images[0]?.url,
})
}
}
return map
})
async function load(force = false): Promise<ProductSummary[]> {
const list = await cache.fetch(KEYS.catalog, api.listProducts, { force })
products.value = list
loaded.value = true
return list
}
function byId(id: string): ProductSummary | null {
return products.value.find((p) => p.id === id) ?? null
}
/** Drops the cached copy; the next load() goes to the server. */
function invalidate() {
cache.invalidate(KEYS.catalog)
}
return { products, loaded, categories, variantLookup, load, byId, invalidate }
})
+7 -14
View File
@@ -18,7 +18,7 @@
//
// Not ported (no backend): promotionList, ignoreList, priceList, isLoaded.
import { defineStore } from 'pinia'
import { api } from '../api/client'
import { useCatalogStore } from './catalogStore'
import shippingArt from '../assets/img/shipping-and-delivery.png'
import competitionArt from '../assets/img/sports-and-competition.png'
import brandArt from '../assets/img/montzar-logo.png'
@@ -70,20 +70,13 @@ export const useConfigStore = defineStore('config', {
// Legacy's store.init() fanned out to /config, /promotion, /ignore, /price.
// Here the only live source is the product catalogue, from which the real
// category list is derived.
async init() {
// The catalogue comes from catalogStore, so calling this next to another
// catalogue read costs no second request.
async init(force = false) {
try {
const products = await api.listProducts()
const seen = new Map<string, HomeCategory>()
for (const p of products) {
if (p.category && !seen.has(p.category.id)) {
seen.set(p.category.id, {
id: p.category.id,
title: p.category.title,
icon: { image: brandArt },
})
}
}
this.cats = [...seen.values()]
const catalog = useCatalogStore()
await catalog.load(force)
this.cats = catalog.categories.map((c) => ({ ...c, icon: { image: brandArt } }))
this.loaded = true
} catch (e) {
console.log(e)
+100
View File
@@ -0,0 +1,100 @@
// Shared read cache for API responses.
//
// Every screen used to fetch on mount, so each tab switch repeated the same
// requests — the whole catalogue alone was fetched from ten places. Reads now go
// through `fetch(key, …)`:
//
// - cached entry → returned at once, no request
// - request in flight → the same promise is shared, never a second request
// - `force` → always asks the server (pull-to-refresh, the
// profile tab's balance, reaching the end of a list)
//
// Entries never expire on a timer: data changes when the user asks for it or
// when a write here makes it wrong, and those writes call `invalidate`.
//
// Entries live in a plain Map, not reactive state: the catalogue is large and
// making it deeply reactive would cost more than the request it saves.
//
// Keys under `me:` hold one customer's data. They are dropped whenever the
// session token changes, so the next user never sees the previous one's data.
import { defineStore } from 'pinia'
import { watch } from 'vue'
import { useSessionStore } from './session'
export const KEYS = {
catalog: 'catalog',
goldPrice: 'goldPrice',
homeSlider: 'homeSlider',
profile: 'me:profile',
orders: 'me:orders',
balance: 'me:balance',
ledger: 'me:ledger',
} as const
const USER_PREFIX = 'me:'
interface Entry {
data?: unknown
/** False until the first successful response. */
has: boolean
inflight?: Promise<unknown>
}
export interface FetchOptions {
/** Skip the cached value. An identical request already in flight is still shared. */
force?: boolean
}
export const useQueryCache = defineStore('queryCache', () => {
const entries = new Map<string, Entry>()
const session = useSessionStore()
function request<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
const entry = entries.get(key)
if (entry?.inflight) return entry.inflight as Promise<T>
const p: Promise<T> = fetcher()
.then((data) => {
// Invalidated (or the user changed) while this was in flight: the
// response may predate the change, so it is handed back but not kept.
if (entries.get(key)?.inflight === p) entries.set(key, { data, has: true })
return data
})
.finally(() => {
const current = entries.get(key)
if (current?.inflight === p) current.inflight = undefined
})
entries.set(key, { ...(entry ?? { has: false }), inflight: p })
return p
}
async function fetch<T>(key: string, fetcher: () => Promise<T>, opts: FetchOptions = {}): Promise<T> {
const entry = entries.get(key)
if (!opts.force && entry?.has) return entry.data as T
return request(key, fetcher)
}
/** The cached value, without fetching. */
function peek<T>(key: string): T | undefined {
const entry = entries.get(key)
return entry?.has ? (entry.data as T) : undefined
}
function invalidate(...keys: string[]) {
for (const key of keys) entries.delete(key)
}
function clearUserScoped() {
for (const key of [...entries.keys()]) {
if (key.startsWith(USER_PREFIX)) entries.delete(key)
}
}
watch(
() => session.token,
() => clearUserScoped(),
)
return { fetch, peek, invalidate, clearUserScoped }
})
+6 -1
View File
@@ -157,6 +157,8 @@ import { useCartStore, type CartItem as CartItemType } from '../stores/cartStore
import { useSessionStore } from '../stores/session'
import { useReveal } from '../composables/useReveal'
import { api } from '../api/client'
import { useCatalogStore } from '../stores/catalogStore'
import { KEYS, useQueryCache } from '../stores/queryCache'
const router = useRouter()
const cart = useCartStore()
@@ -222,6 +224,9 @@ const submitOrder = async () => {
cartData.orderID = order.orderNumber
cartData.step = 2
cart.clear()
// The order shows up in the list and the balance, and may have taken stock.
useQueryCache().invalidate(KEYS.orders, KEYS.balance, KEYS.profile, KEYS.ledger)
useCatalogStore().invalidate()
} catch (e) {
console.log(e)
} finally {
@@ -230,7 +235,7 @@ const submitOrder = async () => {
}
async function onRefresh() {
await cart.init()
await cart.init(true)
refreshing.value = false
}
+24 -26
View File
@@ -133,22 +133,26 @@ import type { HomeCategory } from '../stores/configStore'
import { useCartStore } from '../stores/cartStore'
import { useFiltersStore } from '../stores/filtersStore'
import { useReveal } from '../composables/useReveal'
import { api } from '../api/client'
import type { AdminSlide, GoldPriceQuote, ProductSummary } from '../api/client'
import { useCachedReads } from '../api/cachedReads'
import { useCatalogStore } from '../stores/catalogStore'
import { KEYS } from '../stores/queryCache'
const router = useRouter()
const memory = useConfigStore()
const cart = useCartStore()
const filters = useFiltersStore()
const catalog = useCatalogStore()
const reads = useCachedReads()
const loading = ref(true)
const loading = ref(!catalog.loaded)
const refreshing = ref(false)
const products = ref<ProductSummary[]>([])
const goldPrice = ref<GoldPriceQuote | null>(null)
const products = computed(() => catalog.products)
const goldPrice = ref<GoldPriceQuote | null>(reads.peek<GoldPriceQuote>(KEYS.goldPrice) ?? null)
// Admin-managed banner slides (manage/Const.vue "اسلایدر موبایل"). null while
// unfetched/unavailable — distinct from "fetched, empty" — so `slides` below
// knows whether to wait or fall back to the category-derived hero.
const adminSlides = ref<AdminSlide[] | null>(null)
const adminSlides = ref<AdminSlide[] | null>(reads.peek<AdminSlide[]>(KEYS.homeSlider) ?? null)
// First-paint entrance: played once, after the first data load, so the
@@ -240,31 +244,25 @@ function goSearch() {
function goNewest() {
router.push({ name: 'newestProduct' })
}
async function loadData() {
try {
const [list] = await Promise.all([api.listProducts(), memory.init()])
products.value = list
} catch (e) {
console.log(e)
}
// The three reads are independent, so they run side by side. Each one is
// cached until pull-to-refresh asks the server again.
async function loadData(force = false) {
const [catalogResult, priceResult, slidesResult] = await Promise.allSettled([
// memory.init() reads the same cached catalogue, so this is one request.
memory.init(force),
reads.goldPrice({ force }),
reads.homeSlider({ force }),
])
try {
goldPrice.value = await api.getLatestGoldPrice()
} catch {
goldPrice.value = null
}
try {
adminSlides.value = await api.getHomeSlider()
} catch {
// No admin slides configured, or the endpoint failed — the category-
// derived hero below covers this silently, same as before this existed.
adminSlides.value = null
}
if (catalogResult.status === 'rejected') console.log(catalogResult.reason)
goldPrice.value = priceResult.status === 'fulfilled' ? priceResult.value : null
// No admin slides configured, or the endpoint failed — the category-derived
// hero below covers this silently, same as before this existed.
adminSlides.value = slidesResult.status === 'fulfilled' ? slidesResult.value : null
}
async function onRefresh() {
await loadData()
await loadData(true)
refreshing.value = false
}
+4 -2
View File
@@ -62,12 +62,14 @@ onBeforeMount(async () => {
const pull = async () => {
// Reveal more of what is already loaded first; only go back to the server
// once the whole fetched list is on screen.
// once the whole fetched list is on screen. Reaching the end is how this
// page asks for new products: the cached catalogue is refreshed.
if (visibleCount.value < productsData.productList.length) {
visibleCount.value += PAGE_SIZE
return
}
await getProducts(loadingPull, true)
if (loadingPull.value) return
await getProducts(loadingPull, false, true)
}
</script>
@@ -40,12 +40,13 @@ import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue'
import SectionHead from '../../components/home/SectionHead.vue'
import ProductCard from '../../components/product/ProductCard.vue'
import { api, type ProductSummary } from '../../api/client'
import { useCatalogStore } from '../../stores/catalogStore'
const route = useRoute()
const catalog = useCatalogStore()
const loadingPage = ref(false)
const allProducts = ref<ProductSummary[]>([])
const allProducts = computed(() => catalog.products)
// legacy passed the collection's title through as ?page=<title>; kept, with a
// neutral fallback so the bar is never blank on a direct link.
@@ -56,9 +57,11 @@ const collectionProducts = computed(() =>
)
onMounted(async () => {
loadingPage.value = true
loadingPage.value = !catalog.loaded
try {
allProducts.value = await api.listProducts()
await catalog.load()
} catch (e) {
console.log(e)
} finally {
loadingPage.value = false
}
+14 -11
View File
@@ -148,18 +148,19 @@ import Filters from '../../components/search/Filters.vue'
import SectionHead from '../../components/home/SectionHead.vue'
import ProductCard from '../../components/product/ProductCard.vue'
import { useFiltersStore } from '../../stores/filtersStore'
import { api, type ProductSummary } from '../../api/client'
import { useCatalogStore } from '../../stores/catalogStore'
const filters = useFiltersStore()
const catalog = useCatalogStore()
// Starts true: the first render happens before onMounted kicks off the fetch,
// and with false the "nothing found" box flashed up for a frame before the
// loading state replaced it.
const loadingPage = ref(true)
// Starts true on a cold start: the first render happens before onMounted kicks
// off the fetch, and with false the "nothing found" box flashed up for a frame
// before the loading state replaced it. With the catalogue cached it renders at once.
const loadingPage = ref(!catalog.loaded)
// Two rows of the two-column grid — enough to fill the screen under the bar.
const SKELETON_CARDS = 6
const refreshing = ref(false)
const allProducts = ref<ProductSummary[]>([])
const allProducts = computed(() => catalog.products)
const showFilter = ref(false)
const filterKeys = ref<string[]>([])
@@ -232,17 +233,19 @@ watch(sentinel, mountObserver)
onBeforeUnmount(() => observer?.disconnect())
async function loadProducts() {
loadingPage.value = true
async function loadProducts(force = false) {
loadingPage.value = !catalog.loaded
try {
allProducts.value = await api.listProducts()
await catalog.load(force)
} catch (e) {
console.log(e)
} finally {
loadingPage.value = false
}
}
async function onRefresh() {
await loadProducts()
await loadProducts(true)
refreshing.value = false
}
@@ -256,7 +259,7 @@ function showFilters() {
showFilter.value = true
}
onMounted(loadProducts)
onMounted(() => loadProducts())
</script>
<style scoped lang="less">
@@ -87,6 +87,7 @@ import Spinner from '../../components/Spinner.vue'
import DialogVantOneButton from '../../components/DialogVantOneButton.vue'
import SingleImageUploader from '../../components/upload/SingleImageUploader.vue'
import { api } from '../../api/client'
import { KEYS, useQueryCache } from '../../stores/queryCache'
const router = useRouter()
@@ -117,6 +118,7 @@ async function submit() {
shop_image_id: form.shop_image_id,
ncode_image_id: form.ncode_image_id,
})
useQueryCache().invalidate(KEYS.profile)
done.value = true
} catch {
error.value = 'ثبت اطلاعات انجام نشد. دوباره تلاش کنید.'
+28 -21
View File
@@ -154,7 +154,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, onActivated, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Loading, PullRefresh } from 'vant'
import Icon from '../../components/icon/Icon.vue'
@@ -167,12 +167,15 @@ import { price } from '../../utils/filters'
import { useSessionStore } from '../../stores/session'
import { useReveal } from '../../composables/useReveal'
import { api, type CustomerDetail, type GoldPriceQuote } from '../../api/client'
import { useCachedReads } from '../../api/cachedReads'
import { KEYS } from '../../stores/queryCache'
const router = useRouter()
const session = useSessionStore()
const reads = useCachedReads()
const customer = ref<CustomerDetail | null>(null)
const goldPrice = ref<GoldPriceQuote | null>(null)
const customer = ref<CustomerDetail | null>(reads.peek<CustomerDetail>(KEYS.profile) ?? null)
const goldPrice = ref<GoldPriceQuote | null>(reads.peek<GoldPriceQuote>(KEYS.goldPrice) ?? null)
const refreshing = ref(false)
// The ledger stores the gram position as a signed figure — positive means the
@@ -254,30 +257,34 @@ async function logOut() {
router.push({ name: 'home' })
}
async function loadDashboard() {
if (session.isLoggedIn) {
// Not wrapped before: a failure here (expired token, network blip) left
// the exception uncaught, so onRefresh's refreshing.value = false never
// ran and PullRefresh stayed stuck in its loading state — a working pull
// gesture never dismissed it again until a full page reload.
try {
customer.value = await api.getMe()
} catch (e) {
console.log(e)
}
}
try {
goldPrice.value = await api.getLatestGoldPrice()
} catch {
goldPrice.value = null
}
async function loadDashboard(force = false) {
// Not wrapped before: a failure here (expired token, network blip) left
// the exception uncaught, so onRefresh's refreshing.value = false never
// ran and PullRefresh stayed stuck in its loading state — a working pull
// gesture never dismissed it again until a full page reload.
const [me, price] = await Promise.allSettled([
// The balance on this tab is the one figure read fresh on every visit;
// the cached copy only fills the screen until it arrives.
session.isLoggedIn ? reads.me({ force: true }) : Promise.resolve(null),
reads.goldPrice({ force }),
])
if (me.status === 'fulfilled') customer.value = me.value
else console.log(me.reason)
goldPrice.value = price.status === 'fulfilled' ? price.value : null
}
async function onRefresh() {
await loadDashboard()
await loadDashboard(true)
refreshing.value = false
}
let activatedOnce = false
onActivated(() => {
// Kept alive by MasterLayout, so every later visit lands here, not in onMounted.
if (activatedOnce) void loadDashboard()
activatedOnce = true
})
// First-paint entrance, the same choreography HomeView uses: played once,
// after the first load; pull-to-refresh and tab returns don't replay it.
const { revealed, revealDelay, reveal } = useReveal('dashboard')
@@ -43,19 +43,22 @@ import Spinner from '../../components/Spinner.vue'
import SectionHead from '../../components/home/SectionHead.vue'
import ProductCard from '../../components/product/ProductCard.vue'
import { useFavoritesStore } from '../../stores/favoritesStore'
import { api, type ProductSummary } from '../../api/client'
import { useCatalogStore } from '../../stores/catalogStore'
const router = useRouter()
const favorites = useFavoritesStore()
const catalog = useCatalogStore()
const loading = ref(false)
const allProducts = ref<ProductSummary[]>([])
const allProducts = computed(() => catalog.products)
const favoriteProducts = computed(() => allProducts.value.filter((p) => favorites.isFavorite(p.id)))
onMounted(async () => {
loading.value = true
loading.value = !catalog.loaded
try {
allProducts.value = await api.listProducts()
await catalog.load()
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
+16 -8
View File
@@ -164,9 +164,12 @@ import {
} from '../../utils/orderStatus'
import { jdate } from '../../utils/filters'
import { useSessionStore } from '../../stores/session'
import { api, type OrderDetail } from '../../api/client'
import type { OrderDetail } from '../../api/client'
import { useCachedReads } from '../../api/cachedReads'
import { KEYS } from '../../stores/queryCache'
const session = useSessionStore()
const reads = useCachedReads()
const tabs = [
{ title: 'همه', key: 'ALL' },
@@ -183,7 +186,7 @@ const activeTab = ref('ALL')
const loading = ref(false)
const refreshing = ref(false)
const allOrders = ref<OrderDetail[]>([])
const allOrders = ref<OrderDetail[]>(reads.peek<OrderDetail[]>(KEYS.orders) ?? [])
const searchTerm = ref<string | undefined>(undefined)
const filteredOrders = computed(() => {
@@ -197,11 +200,16 @@ function readSearch(value: string | undefined) {
searchTerm.value = value
}
async function loadOrders() {
if (!session.isLoggedIn) return
loading.value = true
async function loadOrders(force = false) {
if (!session.isLoggedIn) {
allOrders.value = []
return
}
loading.value = reads.peek(KEYS.orders) === undefined
try {
allOrders.value = await api.getMyOrders()
allOrders.value = await reads.myOrders({ force })
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
@@ -220,11 +228,11 @@ function openDetail(item: OrderDetail) {
}
async function onRefresh() {
await loadOrders()
await loadOrders(true)
refreshing.value = false
}
onMounted(loadOrders)
onMounted(() => loadOrders())
</script>
<style scoped lang="less">
+12 -8
View File
@@ -104,7 +104,9 @@ import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
import SectionHead from '../../components/home/SectionHead.vue'
import { useSessionStore } from '../../stores/session'
import { api, type AccountingBalance, type CustomerDetail } from '../../api/client'
import type { AccountingBalance, CustomerDetail } from '../../api/client'
import { useCachedReads } from '../../api/cachedReads'
import { KEYS } from '../../stores/queryCache'
const VERIFICATION_LABELS: Record<string, string> = {
UNVERIFIED: 'احراز نشده',
@@ -115,7 +117,8 @@ const VERIFICATION_LABELS: Record<string, string> = {
}
const session = useSessionStore()
const customer = ref<CustomerDetail | null>(null)
const reads = useCachedReads()
const customer = ref<CustomerDetail | null>(reads.peek<CustomerDetail>(KEYS.profile) ?? null)
const loading = ref(false)
const isVerified = computed(() => customer.value?.verificationStep === 'VERIFIED')
@@ -133,8 +136,9 @@ const canCompleteProfile = computed(() => {
const router = useRouter()
// D27 §12 — fetched on its own, every time the page opens, so a slow
// accounting server never holds up the profile itself.
const balance = ref<AccountingBalance | null>(null)
// accounting server never holds up the profile itself. The last copy fills the
// screen until the fresh one arrives.
const balance = ref<AccountingBalance | null>(reads.peek<AccountingBalance>(KEYS.balance) ?? null)
const balanceLoading = ref(false)
const num = (v: string | null) => Number(v ?? 0)
@@ -151,9 +155,9 @@ const balanceSubtitle = computed(() => {
})
async function loadBalance() {
balanceLoading.value = true
balanceLoading.value = !balance.value
try {
balance.value = await api.getAccountingBalance()
balance.value = await reads.accountingBalance({ force: true })
} catch {
balance.value = null
} finally {
@@ -164,9 +168,9 @@ async function loadBalance() {
onMounted(async () => {
if (!session.isLoggedIn) return
void loadBalance()
loading.value = true
loading.value = !customer.value
try {
customer.value = await api.getMe()
customer.value = await reads.me({ force: true })
} finally {
loading.value = false
}
@@ -107,7 +107,9 @@ import Spinner from '../../components/Spinner.vue'
import SectionHead from '../../components/home/SectionHead.vue'
import { jdate, price } from '../../utils/filters'
import { useSessionStore } from '../../stores/session'
import { api, type LedgerEntrySummary } from '../../api/client'
import type { LedgerEntrySummary } from '../../api/client'
import { useCachedReads } from '../../api/cachedReads'
import { KEYS } from '../../stores/queryCache'
const SOURCE_LABELS: Record<string, string> = {
ORDER_STOCK: 'سفارش',
@@ -121,9 +123,10 @@ const SOURCE_LABELS: Record<string, string> = {
}
const session = useSessionStore()
const reads = useCachedReads()
const loading = ref(false)
const entries = ref<LedgerEntrySummary[]>([])
const entries = ref<LedgerEntrySummary[]>(reads.peek<LedgerEntrySummary[]>(KEYS.ledger) ?? [])
const showPopUp = ref(false)
const activeEntry = ref<LedgerEntrySummary | null>(null)
@@ -141,9 +144,9 @@ const openEntry = (item: LedgerEntrySummary) => {
onMounted(async () => {
if (!session.isLoggedIn) return
loading.value = true
loading.value = reads.peek(KEYS.ledger) === undefined
try {
entries.value = await api.getMyLedgerEntries()
entries.value = await reads.myLedgerEntries()
} finally {
loading.value = false
}