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>
<div class="hint mb-4"> <div class="hint mb-4">
اسناد سفارش همیشه «موقت» ثبت می‌شوند و تا حسابدار نهایی نکند روی مانده‌ی حسابداری اثر ندارند. اسناد سفارش همیشه «موقت» ثبت می‌شوند و تا حسابدار نهایی نکند روی مانده‌ی حسابداری اثر ندارند.
سقف اعتبار سفارش همچنان با دفتر داخلی اپ چک می‌شود. سقف اعتبار سفارش همچنان با دفتر داخلی منتزر چک می‌شود.
</div> </div>
<a-button type="primary" @click="save">ذخیره</a-button> <a-button type="primary" @click="save">ذخیره</a-button>
</a-tab-pane> </a-tab-pane>
<!-- ── نگاشت ─────────────────────────────────────────── --> <!-- ── نگاشت ─────────────────────────────────────────── -->
<a-tab-pane key="mapping" tab="نگاشت"> <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-space class="mb-4">
<a-button :disabled="form.provider === 'none'" :loading="optionsLoading" @click="loadOptions">دریافت گروه‌ها از حسابداری</a-button> <a-button :disabled="form.provider === 'none'" :loading="optionsLoading" @click="loadOptions">دریافت گروه‌ها از حسابداری</a-button>
<span v-for="e in optionErrors" :key="e" class="warn">{{ e }}</span> <span v-for="e in optionErrors" :key="e" class="warn">{{ e }}</span>
</a-space> </a-space>
<a-form layout="vertical"> <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"> <div class="grid-2">
<a-form-item label="گروه‌های حسابی که وارد اپ می‌شوند (خالی = همه)"> <a-form-item label="گروه‌های حسابی که مشتری منتزر حساب می‌شوند" extra="فقط گروه‌هایی که مشتری‌اند؛ خالی = سینک مشتری اجرا نمی‌شود">
<a-select v-model:value="importGroupKeys" mode="multiple" :options="customerGroupOptions" allow-clear placeholder="همه‌ی گروه‌ها" /> <a-select v-model:value="importGroupKeys" mode="multiple" :options="customerGroupOptions" allow-clear placeholder="هیچ گروهی انتخاب نشده" />
</a-form-item> </a-form-item>
<a-form-item label="گروه مشتری جدیدی که از اپ ساخته می‌شود"> <a-form-item
label="گروه حسابداریِ مشتری تازه‌ی اپ مشتری"
extra="کاربری که در اپ مشتری احراز هویت شده و در حسابداری حساب ندارد، در این گروه برایش حساب ساخته می‌شود"
>
<a-select v-model:value="newCustomerGroupKey" :options="customerGroupOptions" allow-clear /> <a-select v-model:value="newCustomerGroupKey" :options="customerGroupOptions" allow-clear />
</a-form-item> </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-input-number v-model:value="form.mapping.karat" :min="1" :max="1000" class="w-full" />
</a-form-item> </a-form-item>
</div> </div>
<template v-if="form.provider === 'zarrin'"> <template v-if="form.provider === 'zarrin'">
<a-divider orientation="right">کارساخته (زرین)</a-divider> <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"> <div class="grid-2">
<a-form-item label="نوع کارساخته‌ی جدید (گرمی/تعدادی…)"> <a-form-item label="نوع کارساخته‌ی جدید (گرمی/تعدادی…)" extra="برای محصول تازه‌ای که در پنل مدیریت منتزر تعریف می‌شود">
<a-select v-model:value="newTypeKey" :options="typeOptions" allow-clear /> <a-select v-model:value="newTypeKey" :options="typeOptions" allow-clear />
</a-form-item> </a-form-item>
<a-form-item label="گروه ساخته‌ی پیش‌فرض"> <a-form-item label="گروه ساخته‌ی پیش‌فرض" extra="برای دسته‌هایی که در جدول زیر «پیش‌فرض» مانده‌اند">
<a-select v-model:value="newItemGroupKey" :options="itemGroupOptions" allow-clear /> <a-select v-model:value="newItemGroupKey" :options="itemGroupOptions" allow-clear />
</a-form-item> </a-form-item>
</div> </div>
<a-table :data-source="categories" :pagination="false" row-key="id" size="small" class="mb-4"> <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="گروه ساخته در زرین"> <a-table-column title="گروه ساخته در زرین">
<template #default="{ record }"> <template #default="{ record }">
<a-select v-model:value="categoryGroupKeys[record.id]" :options="itemGroupOptions" allow-clear class="w-full" placeholder="پیش‌فرض" /> <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"> <div v-else-if="form.provider === 'tahesab'" class="hint mb-4">
ته‌حساب در API خود ساخت کارساخته ندارد: نام کار با اولین سند سفارش در ته‌حساب ساخته می‌شود و از آن به بعد به محصول لینک می‌شود. ته‌حساب در API خود ساخت کارساخته ندارد: نام کار با اولین سند سفارش در ته‌حساب ساخته می‌شود و از آن به بعد به محصول لینک می‌شود.
</div> </div>
<div v-if="optionErrors.length" class="hint mb-4">
اگر فهرستی خالی است، یعنی دریافت آن از حسابداری خطا داده (پیام قرمز بالای صفحه). اتصال را بررسی کنید و دوباره «دریافت گروه‌ها» را بزنید.
</div>
<a-button type="primary" @click="save">ذخیره</a-button> <a-button type="primary" @click="save">ذخیره</a-button>
</a-form> </a-form>
</a-tab-pane> </a-tab-pane>
@@ -272,7 +343,7 @@
> >
<a-table-column title="کد حسابداری" data-index="externalCode" /> <a-table-column title="کد حسابداری" data-index="externalCode" />
<a-table-column title="نام در حسابداری" data-index="externalName" /> <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="منشأ"> <a-table-column title="منشأ">
<template #default="{ record }">{{ ORIGIN[record.origin] ?? record.origin }}</template> <template #default="{ record }">{{ ORIGIN[record.origin] ?? record.origin }}</template>
</a-table-column> </a-table-column>
@@ -367,7 +438,7 @@ const ISSUE: Record<string, string> = {
const ORIGIN: Record<string, string> = { const ORIGIN: Record<string, string> = {
LINKED: 'جفت با موبایل/نام', LINKED: 'جفت با موبایل/نام',
IMPORTED: 'وارد شده از حسابداری', IMPORTED: 'وارد شده از حسابداری',
PUSHED: 'ساخته شده از اپ', PUSHED: 'ساخته شده از منتزر',
MANUAL: 'دستی', MANUAL: 'دستی',
}; };
+2
View File
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { PrismaModule } from './prisma/prisma.module'; import { PrismaModule } from './prisma/prisma.module';
import { CatalogModule } from './modules/catalog/catalog.module'; import { CatalogModule } from './modules/catalog/catalog.module';
import { CatalogCacheModule } from './modules/catalog/catalog-cache.module';
import { CustomersModule } from './modules/customers/customers.module'; import { CustomersModule } from './modules/customers/customers.module';
import { CustomerAuthModule } from './modules/customer-auth/customer-auth.module'; import { CustomerAuthModule } from './modules/customer-auth/customer-auth.module';
import { SalesExpertAppModule } from './modules/sales-expert-app/sales-expert-app.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, IamModule,
AdminSessionModule, AdminSessionModule,
PrismaModule, PrismaModule,
CatalogCacheModule,
AuthModule, AuthModule,
AdminTableModule, AdminTableModule,
AdminOrdersModule, AdminOrdersModule,
@@ -18,6 +18,7 @@ import {
import { ACCOUNTING_PROVIDERS, AccountingProvider, ProviderName, ProviderSettings, isProviderName } from './providers'; import { ACCOUNTING_PROVIDERS, AccountingProvider, ProviderName, ProviderSettings, isProviderName } from './providers';
import { openSecret, sealSecret, secretKeySource } from './secret-box'; import { openSecret, sealSecret, secretKeySource } from './secret-box';
import { parsePemCertificates } from './accounting-http'; 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. */ /** The first certificate of a pasted bundle, for display; null when none parses. */
function describeCertificate(pem: string | null) { function describeCertificate(pem: string | null) {
@@ -59,7 +60,10 @@ export class AccountingConfigService implements OnModuleInit {
private readonly logger = new Logger('Accounting'); private readonly logger = new Logger('Accounting');
private cache: { at: number; config: AccountingConfig } | null = null; private cache: { at: number; config: AccountingConfig } | null = null;
constructor(private readonly prisma: PrismaService) {} constructor(
private readonly prisma: PrismaService,
private readonly catalogCache: CatalogCacheService,
) {}
onModuleInit() { onModuleInit() {
if (secretKeySource() !== 'ACCOUNTING_SECRET_KEY') { 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 }, create: { key: ACCOUNTING_CONFIG_KEY, value: next as unknown as Prisma.InputJsonValue },
}); });
this.cache = null; 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 }; return { providerChanged: current.provider !== next.provider };
} }
@@ -9,6 +9,12 @@ import { ACCOUNTING_PROVIDERS } from './providers';
type Tx = Prisma.TransactionClient | PrismaService; type Tx = Prisma.TransactionClient | PrismaService;
type StockFields = {
accountingStockAt: Date | null;
accountingStockWeight: Prisma.Decimal | null;
accountingStockCount: Prisma.Decimal | null;
};
const OPEN: AccountingOutboxStatus[] = ['PENDING', 'SENDING']; 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 * 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. * not linked under the active provider are cleared by every product pull.
*/ */
async stockBlocked(product: { async stockBlocked(product: StockFields) {
accountingStockAt: Date | null; return (await this.stockBlocker())(product);
accountingStockWeight: Prisma.Decimal | null; }
accountingStockCount: Prisma.Decimal | null;
}) { /**
if (!product.accountingStockAt) return false; * 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(); const c = await this.config.get();
if (c.provider === 'none' || !c.features.products) return false; const active = c.provider !== 'none' && c.features.products;
const stock = product.accountingStockWeight ?? product.accountingStockCount; return (product) => {
return stock !== null && new Prisma.Decimal(stock).lte(0); 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('دسترسی مجاز نیست'); 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', () => { it('lists the different envelopes Zarrin uses', () => {
const { listOf, fullJalali } = __zarrinInternals; const { listOf, fullJalali } = __zarrinInternals;
expect(listOf({ data: [1] })).toEqual([1]); expect(listOf({ data: [1] })).toEqual([1]);
@@ -70,17 +81,76 @@ describe('wire format', () => {
}); });
describe('customers', () => { describe('customers', () => {
it('pages with Get100Account and fills groups from listhesabha', async () => { it('lists every account with its group from one listhesabha call', async () => {
const page = Array.from({ length: 100 }, (_, i) => ({ code: 81 + i, name: 'n', namKhanevadegi: `f${i}`, mobile: i === 0 ? '09121112233' : '' })); http.mockReturnValueOnce(
http reply({
.mockReturnValueOnce(reply({ state: true, msg: '', date: page })) data: [
.mockReturnValueOnce(reply({ data: [{ id: '81', nam: 'حسین اصغری', gorooh: 'مشتری (خریدار)', tell: '021', mobile: '09121112233', codeMeli: '' }] })); { id: '81', nam: 'حسین اصغری', gorooh: 'مشتری (خریدار)', tell: '021', mobile: '09121112233', codeMeli: '' },
{ id: '82', nam: 'آرین یوسفی', gorooh: 'سازنده (تامین کننده)', tell: '', mobile: '', codeMeli: '' },
],
}),
);
const res = await z.listCustomers(s, null); const res = await z.listCustomers(s, null);
expect(url(0).searchParams.get('id')).toBe('0'); expect(http).toHaveBeenCalledTimes(1);
expect(url(1).searchParams.get('az')).toBe('81'); expect(url(0).searchParams.get('kind')).toBe('listhesabha');
expect(url(1).searchParams.get('ta')).toBe('180'); 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.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 () => { it('keeps the account in its group on edit', async () => {
@@ -40,11 +40,51 @@ import {
const STATEMENT_PAGE = 250; const STATEMENT_PAGE = 250;
const CHANGE_WINDOW = 50; const CHANGE_WINDOW = 50;
const MAX_STATEMENT_PAGES = 20; 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) { function endpoint(s: ProviderSettings) {
return s.baseUrl.trim(); 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( async function call(
s: ProviderSettings, s: ProviderSettings,
kind: string, kind: string,
@@ -73,7 +113,8 @@ async function call(
} }
if (res.status < 200 || res.status >= 300) { 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; let json: unknown;
@@ -96,7 +137,7 @@ async function call(
return json as Record<string, unknown> | unknown[]; 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>[] { function listOf(json: unknown): Record<string, unknown>[] {
if (Array.isArray(json)) return json as Record<string, unknown>[]; if (Array.isArray(json)) return json as Record<string, unknown>[];
if (json && typeof json === 'object') { 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')}`; 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) { function signed(s: ProviderSettings, v: Prisma.Decimal) {
// Until checked on a real installation (plan §9) the sign is a setting. // Until checked on a real installation (plan §9) the sign is a setting.
return (s.balanceOwesWhen ?? 'negative') === 'negative' ? v.negated() : v; return (s.balanceOwesWhen ?? 'negative') === 'negative' ? v.negated() : v;
@@ -152,35 +208,10 @@ export const zarrinProvider: AccountingProvider = {
})); }));
}, },
async listCustomers(s, cursor) { async listCustomers(s) {
const from = Number(cursor ?? 0) || 0; // listhesabha carries everything (group, mobile, address) and returns the
// Get100Account pages by code (100 accounts from `id` on) but carries no // whole code span in one reply (~865 accounts in under 2 s).
// group or address; listhesabha over the same code span fills those in. return { items: await allAccounts(s), next: null };
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 findCustomersByMobile(s, mobile) { async findCustomersByMobile(s, mobile) {
@@ -188,20 +219,22 @@ export const zarrinProvider: AccountingProvider = {
if (!target) return []; if (!target) return [];
let rows: Record<string, unknown>[]; let rows: Record<string, unknown>[];
try { 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) { } catch (err) {
if (err instanceof AccountingProviderError && err.notFound) return []; 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 return rows
.filter((r) => r.code !== undefined && normalizeMobile(r.mobile) === target) .filter((r) => r.code !== undefined && normalizeMobile(r.mobile) === target)
.map((r) => ({ .map((r) => ({
code: String(r.code), code: String(r.code),
name: joinName(r.nam, r.namKhanevadegi), name: joinName(r.name, r.namKhanevadegi),
mobile: target, mobile: target,
phone: str(r.tel), phone: null,
nationalCode: null, nationalCode: null,
address: str(r.address), address: null,
groupId: null, groupId: null,
groupName: null, groupName: null,
})); }));
@@ -333,7 +366,30 @@ export const zarrinProvider: AccountingProvider = {
}, },
async listKarSakhteGroups(s) { 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) { 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 { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { AccountingHooksService } from '../accounting-sync/accounting-hooks.service'; import { AccountingHooksService } from '../accounting-sync/accounting-hooks.service';
import { CatalogCacheService } from './catalog-cache.service';
@Injectable() @Injectable()
export class CatalogService { export class CatalogService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly accounting: AccountingHooksService, 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({ const products = await this.prisma.product.findMany({
// D27 — a product with no image is never shown in the app. Products now // D27 — a product with no image is never shown in the app. Products now
// also arrive from the accounting software, which carries no pictures; // 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 = []; const out = [];
for (const p of products) { for (const p of products) {
const { accountingStockWeight, accountingStockCount, accountingStockAt, ...product } = p; 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 // 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 // the cart unless it is a pre-order variant. The stock figures themselves
// stay server-side; this endpoint is public. // 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); out.push(blocked ? { ...product, variants: product.variants.map((v) => (v.isPreOrder ? v : { ...v, qty: 0 })) } : product);
} }
return out; 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 // The real GET /customers/:id/orders endpoint returns OrderItem rows with
// only `productVariantId` (no nested product/variant title) — this joins // 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. // than touching apps/api to add an include.
import { ref } from 'vue' import { useCatalogStore, type VariantLookup } from '../stores/catalogStore'
import { api } from '../api/client'
export interface VariantLookup { export type { 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 function useOrderItemProduct() { export function useOrderItemProduct() {
const catalog = useCatalogStore()
const lookup = async (productVariantId: string): Promise<VariantLookup | undefined> => { const lookup = async (productVariantId: string): Promise<VariantLookup | undefined> => {
const map = await ensureLoaded() if (!catalog.loaded) await catalog.load()
return map.get(productVariantId) return catalog.variantLookup.get(productVariantId)
} }
return { lookup } return { lookup }
} }
@@ -1,25 +1,26 @@
// Adapted from legacy singleProduct.vue's own fetch (GET /product/:id). // Adapted from legacy singleProduct.vue's own fetch (GET /product/:id).
// The new API has no single-product-detail endpoint (only GET // 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, // /catalog/products, the flat list), so the product is found in the shared
// so this fetches the full list and finds the matching product client-side. // catalogue (stores/catalogStore.ts). When the catalogue is already cached,
// Wasteful for a large catalog, but honest: no fabricated endpoint, no silent // opening a product costs no request at all.
// behavior change.
// //
// `initial` seeds the page with the ProductSummary the tapped card already // `initial` seeds the page with the ProductSummary the tapped card already
// holds — the same shape, from the same endpoint — so the detail view renders // 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 // 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 // fetch lands. The lookup still runs, silently, and only replaces the product
// if the server's copy actually differs (stock moved since the list loaded). // if the catalogue's copy actually differs (stock moved since the list loaded).
// //
// The silent refresh waits until the opening morph (ProductMorph.vue, ≤420ms) // The silent refresh waits until the opening morph (ProductMorph.vue, ≤420ms)
// is over: the response is the whole catalogue, and parsing it — or swapping // is over: if the catalogue has to come from the server, parsing it — or
// the product and re-rendering — mid-animation drops frames. // swapping the product and re-rendering — mid-animation drops frames.
import { ref } from 'vue' 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 const SEEDED_REFRESH_DELAY_MS = 600
export function useProductDetail(id: string, initial?: ProductSummary | null) { export function useProductDetail(id: string, initial?: ProductSummary | null) {
const catalog = useCatalogStore()
const loading = ref(false) const loading = ref(false)
const flag = ref(Boolean(initial)) const flag = ref(Boolean(initial))
const notFound = ref(false) 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)) if (seeded) await new Promise((resolve) => setTimeout(resolve, SEEDED_REFRESH_DELAY_MS))
else loading.value = true else loading.value = true
try { try {
const products = await api.listProducts() const products = await catalog.load()
const found = products.find((p) => p.id === id) ?? null const found = products.find((p) => p.id === id) ?? null
if (seeded) { if (seeded) {
if (found && JSON.stringify(found) !== JSON.stringify(productRef.value)) productRef.value = found 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 // Adapted from legacy henza_pwa/src/Mixins/getProducts.ts. Legacy paginated
// against POST /get-products (page/limit/count). The new API's GET // against POST /get-products (page/limit/count). The new API's GET
// /catalog/products returns the full active catalog in one flat response with // /catalog/products returns the full active catalog in one flat response with
// no pagination — so `pull()` (legacy's infinite-scroll "next page" trigger) // no pagination — so `isPull` (legacy's infinite-scroll "next page" trigger)
// is a no-op after the first successful fetch here, and `totalPages` is // is a no-op here, and `totalPages` is always 0. A caller that wants new
// always 0. This keeps the ScrollFetchSearch infinite-scroll wiring intact // products at the end of the list passes `force` to refresh the cached
// (it still calls fetch on scroll) without fabricating fake pages. // catalogue instead of fabricating fake pages.
import { reactive } from 'vue' import { reactive, watch } from 'vue'
import { api, type ProductSummary } from '../api/client' import type { ProductSummary } from '../api/client'
import { useCatalogStore } from '../stores/catalogStore'
export interface ProductsData { export interface ProductsData {
totalPages: number totalPages: number
@@ -15,17 +16,27 @@ export interface ProductsData {
} }
export function useProducts() { export function useProducts() {
const catalog = useCatalogStore()
const productsData: ProductsData = reactive({ const productsData: ProductsData = reactive({
totalPages: 0, totalPages: 0,
productList: [], productList: catalog.products,
flag: false, 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 if (isPull) return // no further pages exist server-side yet
loading.value = true loading.value = force || !catalog.loaded
try { try {
productsData.productList = await api.listProducts() productsData.productList = await catalog.load(force)
productsData.flag = productsData.productList.length < 1 productsData.flag = productsData.productList.length < 1
} catch (e) { } catch (e) {
console.log(e) console.log(e)
+11 -6
View File
@@ -1,16 +1,17 @@
<!-- <!--
Ported from legacy henza_pwa/src/layout/master.vue. Ported from legacy henza_pwa/src/layout/master.vue.
NOTE (existing issue, ported as-is — not fixed here): this layout renders This layout renders its own <router-view/> rather than a <slot/>, so the
its own <router-view/> rather than a <slot/>, so the keep-alive/suspense keep-alive/suspense wrapper App.vue builds around its own <router-view> never
wrapper App.vue builds around its own <router-view> is never actually used reaches these routes. The keep-alive below is the one that applies: the main
by this layout — the route component here is resolved fresh, independently. tabs stay mounted between switches instead of refetching on every visit.
Preserved exactly; flagged separately.
--> -->
<template> <template>
<section ref="content"> <section ref="content">
<router-view v-slot="{ Component, route }"> <router-view v-slot="{ Component, route }">
<transition name="page" mode="out-in"> <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> </transition>
</router-view> </router-view>
<Navigation /> <Navigation />
@@ -24,6 +25,10 @@ import { useResizeObserver } from '@vueuse/core'
import Navigation from '../components/ui/Navigation.vue' import Navigation from '../components/ui/Navigation.vue'
import { useWindowDataStore } from '../stores/windowDataStore' 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 windowData = useWindowDataStore()
const content = ref<HTMLElement | null>(null) 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 { useNavStore } from '../stores/navStore';
import { useTransitionStore } from '../stores/transitionStore'; import { useTransitionStore } from '../stores/transitionStore';
import { api } from '../api/client'; import { api } from '../api/client';
import { useCachedReads } from '../api/cachedReads';
// Legacy lazy-loaded every route component (`component: () => import(...)`) so // Legacy lazy-loaded every route component (`component: () => import(...)`) so
// the initial bundle only carries the entry route. Matched here. // 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, // Ported from henza_pwa router/index.ts:266 — once the documents are in,
// /complete_profile is not reachable any more and redirects home. The // /complete_profile is not reachable any more and redirects home. The
// reference read its cached store; this asks the server, so a profile // reference read its cached store; this asks the server (through the shared
// completed on another device is respected too. A failed lookup lets the // 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 // navigation through: the page itself is harmless, and the server is the
// one that decides whether the write is allowed. // one that decides whether the write is allowed.
if (to.meta.requiresIncompleteProfile && session.isLoggedIn) { if (to.meta.requiresIncompleteProfile && session.isLoggedIn) {
try { try {
const me = await api.getMe(); const me = await useCachedReads().me();
if (me.verificationStep === 'VERIFIED' || me.verificationStep === 'STEP_TWO_SUBMITTED') { if (me.verificationStep === 'VERIFIED' || me.verificationStep === 'STEP_TWO_SUBMITTED') {
return { name: 'home' }; return { name: 'home' };
} }
+4 -3
View File
@@ -3,7 +3,8 @@
// localStorage key), just via Pinia's own state instead of @vueuse's // localStorage key), just via Pinia's own state instead of @vueuse's
// useStorage wrapping a hand-rolled class. // useStorage wrapping a hand-rolled class.
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { api, type ProductSummary } from '../api/client' import type { ProductSummary } from '../api/client'
import { useCatalogStore } from './catalogStore'
export interface CartItem { export interface CartItem {
pid: string pid: string
@@ -36,9 +37,9 @@ export const useCartStore = defineStore('cart', {
// Re-syncs cart entries against fresh product data (e.g. price/weight // Re-syncs cart entries against fresh product data (e.g. price/weight
// changes since the item was added), dropping entries whose // changes since the item was added), dropping entries whose
// product/variant no longer exists — same intent as legacy's init(). // product/variant no longer exists — same intent as legacy's init().
async init() { async init(force = false) {
try { try {
const products = await api.listProducts() const products = await useCatalogStore().load(force)
const refreshed: CartItem[] = [] const refreshed: CartItem[] = []
for (const item of this.items) { for (const item of this.items) {
const product = products.find((p) => p.id === item.pid) 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. // Not ported (no backend): promotionList, ignoreList, priceList, isLoaded.
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { api } from '../api/client' import { useCatalogStore } from './catalogStore'
import shippingArt from '../assets/img/shipping-and-delivery.png' import shippingArt from '../assets/img/shipping-and-delivery.png'
import competitionArt from '../assets/img/sports-and-competition.png' import competitionArt from '../assets/img/sports-and-competition.png'
import brandArt from '../assets/img/montzar-logo.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. // Legacy's store.init() fanned out to /config, /promotion, /ignore, /price.
// Here the only live source is the product catalogue, from which the real // Here the only live source is the product catalogue, from which the real
// category list is derived. // 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 { try {
const products = await api.listProducts() const catalog = useCatalogStore()
const seen = new Map<string, HomeCategory>() await catalog.load(force)
for (const p of products) { this.cats = catalog.categories.map((c) => ({ ...c, icon: { image: brandArt } }))
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()]
this.loaded = true this.loaded = true
} catch (e) { } catch (e) {
console.log(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 { useCartStore, type CartItem as CartItemType } from '../stores/cartStore'
import { useSessionStore } from '../stores/session' import { useSessionStore } from '../stores/session'
import { api } from '../api/client' 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 // 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 // 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.orderID = order.orderNumber
cartData.step = 2 cartData.step = 2
cart.clear() 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) { } catch (e) {
console.log(e) console.log(e)
} finally { } finally {
@@ -239,7 +244,7 @@ const submitOrder = async () => {
} }
async function onRefresh() { async function onRefresh() {
await cart.init() await cart.init(true)
refreshing.value = false refreshing.value = false
} }
+28 -30
View File
@@ -94,8 +94,10 @@ import ProductCarousel from '../components/home/ProductCarousel.vue'
import { useConfigStore } from '../stores/configStore' import { useConfigStore } from '../stores/configStore'
import type { HomeCategory } from '../stores/configStore' import type { HomeCategory } from '../stores/configStore'
import { useFiltersStore } from '../stores/filtersStore' import { useFiltersStore } from '../stores/filtersStore'
import { api } from '../api/client'
import type { AdminSlide, GoldPriceQuote, ProductSummary } 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 // 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. // user moves between tabs, and resets only on a full app reload.
@@ -104,27 +106,29 @@ let hasRevealed = false
const router = useRouter() const router = useRouter()
const memory = useConfigStore() const memory = useConfigStore()
const filters = useFiltersStore() const filters = useFiltersStore()
const catalog = useCatalogStore()
const reads = useCachedReads()
const loading = ref(true) const loading = ref(!catalog.loaded)
const refreshing = ref(false) const refreshing = ref(false)
const products = ref<ProductSummary[]>([]) const products = computed(() => catalog.products)
const goldPrice = ref<GoldPriceQuote | null>(null) const goldPrice = ref<GoldPriceQuote | null>(reads.peek<GoldPriceQuote>(KEYS.goldPrice) ?? null)
// Admin-managed banner slides (manage/Const.vue "اسلایدر موبایل"). null while // Admin-managed banner slides (manage/Const.vue "اسلایدر موبایل"). null while
// unfetched/unavailable — distinct from "fetched, empty" — so `slides` below // unfetched/unavailable — distinct from "fetched, empty" — so `slides` below
// knows whether to wait or fall back to the category-derived hero. // 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 // 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. // 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 // 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 // sections settle into place instead of appearing all at once; pull-to-refresh
// does not replay it. // does not replay it.
// //
// MasterLayout renders its route without keep-alive, so this view remounts on // MasterLayout keeps this view alive between tab switches, but it still
// every return to the Home tab. The flag therefore lives outside the component // remounts after a visit to a full-page route. The flag therefore lives outside
// — the choreography is a first-run flourish, and replaying it on each tab // the component — the choreography is a first-run flourish, and replaying it
// switch would fight the page transition rather than add to it. // would fight the page transition rather than add to it.
const revealed = ref(hasRevealed) const revealed = ref(hasRevealed)
// Each section starts a beat after the one above it. Kept small — the whole // Each section starts a beat after the one above it. Kept small — the whole
@@ -208,31 +212,25 @@ function goNewest() {
router.push({ name: 'newestProduct' }) router.push({ name: 'newestProduct' })
} }
async function loadData() { // The three reads are independent, so they run side by side. Each one is
try { // cached until pull-to-refresh asks the server again.
const [list] = await Promise.all([api.listProducts(), memory.init()]) async function loadData(force = false) {
products.value = list const [catalogResult, priceResult, slidesResult] = await Promise.allSettled([
} catch (e) { // memory.init() reads the same cached catalogue, so this is one request.
console.log(e) memory.init(force),
} reads.goldPrice({ force }),
reads.homeSlider({ force }),
])
try { if (catalogResult.status === 'rejected') console.log(catalogResult.reason)
goldPrice.value = await api.getLatestGoldPrice() goldPrice.value = priceResult.status === 'fulfilled' ? priceResult.value : null
} catch { // No admin slides configured, or the endpoint failed — the category-derived
goldPrice.value = null // hero covers this silently, same as before this existed.
} adminSlides.value = slidesResult.status === 'fulfilled' ? slidesResult.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
}
} }
async function onRefresh() { async function onRefresh() {
await loadData() await loadData(true)
refreshing.value = false refreshing.value = false
} }
+4 -2
View File
@@ -71,12 +71,14 @@ onBeforeMount(async () => {
const pull = async () => { const pull = async () => {
// Reveal more of what is already loaded first; only go back to the server // 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) { if (visibleCount.value < productsData.productList.length) {
visibleCount.value += PAGE_SIZE visibleCount.value += PAGE_SIZE
return return
} }
await getProducts(loadingPull, true) if (loadingPull.value) return
await getProducts(loadingPull, false, true)
} }
</script> </script>
@@ -40,12 +40,13 @@ import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue' import Spinner from '../../components/Spinner.vue'
import SectionHead from '../../components/home/SectionHead.vue' import SectionHead from '../../components/home/SectionHead.vue'
import ProductCard from '../../components/product/ProductCard.vue' import ProductCard from '../../components/product/ProductCard.vue'
import { api, type ProductSummary } from '../../api/client' import { useCatalogStore } from '../../stores/catalogStore'
const route = useRoute() const route = useRoute()
const catalog = useCatalogStore()
const loadingPage = ref(false) 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 // 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. // neutral fallback so the bar is never blank on a direct link.
@@ -56,9 +57,11 @@ const collectionProducts = computed(() =>
) )
onMounted(async () => { onMounted(async () => {
loadingPage.value = true loadingPage.value = !catalog.loaded
try { try {
allProducts.value = await api.listProducts() await catalog.load()
} catch (e) {
console.log(e)
} finally { } finally {
loadingPage.value = false loadingPage.value = false
} }
@@ -108,13 +108,14 @@ import Filters from '../../components/search/Filters.vue'
import SectionHead from '../../components/home/SectionHead.vue' import SectionHead from '../../components/home/SectionHead.vue'
import ProductCard from '../../components/product/ProductCard.vue' import ProductCard from '../../components/product/ProductCard.vue'
import { useFiltersStore } from '../../stores/filtersStore' import { useFiltersStore } from '../../stores/filtersStore'
import { api, type ProductSummary } from '../../api/client' import { useCatalogStore } from '../../stores/catalogStore'
const filters = useFiltersStore() const filters = useFiltersStore()
const catalog = useCatalogStore()
const loadingPage = ref(false) const loadingPage = ref(false)
const refreshing = ref(false) const refreshing = ref(false)
const allProducts = ref<ProductSummary[]>([]) const allProducts = computed(() => catalog.products)
const showFilter = ref(false) const showFilter = ref(false)
const filterKeys = ref<string[]>([]) const filterKeys = ref<string[]>([])
@@ -187,17 +188,20 @@ watch(sentinel, mountObserver)
onBeforeUnmount(() => observer?.disconnect()) onBeforeUnmount(() => observer?.disconnect())
async function loadProducts() { async function loadProducts(force = false) {
loadingPage.value = true // Cached data renders at once; the spinner is only for a cold start.
loadingPage.value = !catalog.loaded
try { try {
allProducts.value = await api.listProducts() await catalog.load(force)
} catch (e) {
console.log(e)
} finally { } finally {
loadingPage.value = false loadingPage.value = false
} }
} }
async function onRefresh() { async function onRefresh() {
await loadProducts() await loadProducts(true)
refreshing.value = false refreshing.value = false
} }
@@ -211,7 +215,7 @@ function showFilters() {
showFilter.value = true showFilter.value = true
} }
onMounted(loadProducts) onMounted(() => loadProducts())
</script> </script>
<style scoped lang="less"> <style scoped lang="less">
@@ -82,6 +82,7 @@ import Spinner from '../../components/Spinner.vue'
import IslandSheet from '../../components/ui/IslandSheet.vue' import IslandSheet from '../../components/ui/IslandSheet.vue'
import SingleImageUploader from '../../components/upload/SingleImageUploader.vue' import SingleImageUploader from '../../components/upload/SingleImageUploader.vue'
import { api } from '../../api/client' import { api } from '../../api/client'
import { KEYS, useQueryCache } from '../../stores/queryCache'
const router = useRouter() const router = useRouter()
@@ -112,6 +113,7 @@ async function submit() {
shop_image_id: form.shop_image_id, shop_image_id: form.shop_image_id,
ncode_image_id: form.ncode_image_id, ncode_image_id: form.ncode_image_id,
}) })
useQueryCache().invalidate(KEYS.profile)
done.value = true done.value = true
} catch { } catch {
error.value = 'ثبت اطلاعات انجام نشد. دوباره تلاش کنید.' error.value = 'ثبت اطلاعات انجام نشد. دوباره تلاش کنید.'
@@ -241,7 +241,7 @@
</template> </template>
<script setup lang="ts"> <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 { useRouter } from 'vue-router'
import { Loading, PullRefresh } from 'vant' import { Loading, PullRefresh } from 'vant'
import Icon from '../../components/icon/Icon.vue' import Icon from '../../components/icon/Icon.vue'
@@ -250,12 +250,15 @@ import IslandSheet from '../../components/ui/IslandSheet.vue'
import { price } from '../../utils/filters' import { price } from '../../utils/filters'
import { useSessionStore } from '../../stores/session' import { useSessionStore } from '../../stores/session'
import { api, type CustomerDetail, type GoldPriceQuote } from '../../api/client' import { api, type CustomerDetail, type GoldPriceQuote } from '../../api/client'
import { useCachedReads } from '../../api/cachedReads'
import { KEYS } from '../../stores/queryCache'
const router = useRouter() const router = useRouter()
const session = useSessionStore() const session = useSessionStore()
const reads = useCachedReads()
const customer = ref<CustomerDetail | null>(null) const customer = ref<CustomerDetail | null>(reads.peek<CustomerDetail>(KEYS.profile) ?? null)
const goldPrice = ref<GoldPriceQuote | null>(null) const goldPrice = ref<GoldPriceQuote | null>(reads.peek<GoldPriceQuote>(KEYS.goldPrice) ?? null)
const refreshing = ref(false) const refreshing = ref(false)
// The ledger stores the gram position as a signed figure — positive means the // The ledger stores the gram position as a signed figure — positive means the
@@ -300,30 +303,34 @@ async function logOut() {
router.push({ name: 'home' }) router.push({ name: 'home' })
} }
async function loadDashboard() { async function loadDashboard(force = false) {
if (session.isLoggedIn) { // Not wrapped before: a failure here (expired token, network blip) left
// Not wrapped before: a failure here (expired token, network blip) left // the exception uncaught, so onRefresh's refreshing.value = false never
// the exception uncaught, so onRefresh's refreshing.value = false never // ran and PullRefresh stayed stuck in its loading state — a working pull
// ran and PullRefresh stayed stuck in its loading state — a working pull // gesture never dismissed it again until a full page reload.
// gesture never dismissed it again until a full page reload. const [me, price] = await Promise.allSettled([
try { // The balance on this tab is the one figure read fresh on every visit;
customer.value = await api.getMe() // the cached copy only fills the screen until it arrives.
} catch (e) { session.isLoggedIn ? reads.me({ force: true }) : Promise.resolve(null),
console.log(e) reads.goldPrice({ force }),
} ])
} if (me.status === 'fulfilled') customer.value = me.value
try { else console.log(me.reason)
goldPrice.value = await api.getLatestGoldPrice() goldPrice.value = price.status === 'fulfilled' ? price.value : null
} catch {
goldPrice.value = null
}
} }
async function onRefresh() { async function onRefresh() {
await loadDashboard() await loadDashboard(true)
refreshing.value = false 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 // 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). // flag so it only replays on a full app reload, not on every tab switch).
let hasRevealed = false let hasRevealed = false
@@ -43,19 +43,22 @@ import Spinner from '../../components/Spinner.vue'
import SectionHead from '../../components/home/SectionHead.vue' import SectionHead from '../../components/home/SectionHead.vue'
import ProductCard from '../../components/product/ProductCard.vue' import ProductCard from '../../components/product/ProductCard.vue'
import { useFavoritesStore } from '../../stores/favoritesStore' import { useFavoritesStore } from '../../stores/favoritesStore'
import { api, type ProductSummary } from '../../api/client' import { useCatalogStore } from '../../stores/catalogStore'
const router = useRouter() const router = useRouter()
const favorites = useFavoritesStore() const favorites = useFavoritesStore()
const catalog = useCatalogStore()
const loading = ref(false) const loading = ref(false)
const allProducts = ref<ProductSummary[]>([]) const allProducts = computed(() => catalog.products)
const favoriteProducts = computed(() => allProducts.value.filter((p) => favorites.isFavorite(p.id))) const favoriteProducts = computed(() => allProducts.value.filter((p) => favorites.isFavorite(p.id)))
onMounted(async () => { onMounted(async () => {
loading.value = true loading.value = !catalog.loaded
try { try {
allProducts.value = await api.listProducts() await catalog.load()
} catch (e) {
console.log(e)
} finally { } finally {
loading.value = false loading.value = false
} }
+16 -8
View File
@@ -164,9 +164,12 @@ import {
} from '../../utils/orderStatus' } from '../../utils/orderStatus'
import { jdate } from '../../utils/filters' import { jdate } from '../../utils/filters'
import { useSessionStore } from '../../stores/session' 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 session = useSessionStore()
const reads = useCachedReads()
const tabs = [ const tabs = [
{ title: 'همه', key: 'ALL' }, { title: 'همه', key: 'ALL' },
@@ -183,7 +186,7 @@ const activeTab = ref('ALL')
const loading = ref(false) const loading = ref(false)
const refreshing = 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 searchTerm = ref<string | undefined>(undefined)
const filteredOrders = computed(() => { const filteredOrders = computed(() => {
@@ -197,11 +200,16 @@ function readSearch(value: string | undefined) {
searchTerm.value = value searchTerm.value = value
} }
async function loadOrders() { async function loadOrders(force = false) {
if (!session.isLoggedIn) return if (!session.isLoggedIn) {
loading.value = true allOrders.value = []
return
}
loading.value = reads.peek(KEYS.orders) === undefined
try { try {
allOrders.value = await api.getMyOrders() allOrders.value = await reads.myOrders({ force })
} catch (e) {
console.log(e)
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -220,11 +228,11 @@ function openDetail(item: OrderDetail) {
} }
async function onRefresh() { async function onRefresh() {
await loadOrders() await loadOrders(true)
refreshing.value = false refreshing.value = false
} }
onMounted(loadOrders) onMounted(() => loadOrders())
</script> </script>
<style scoped lang="less"> <style scoped lang="less">
@@ -104,7 +104,9 @@ import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue' import PageBar from '../../components/ui/PageBar.vue'
import SectionHead from '../../components/home/SectionHead.vue' import SectionHead from '../../components/home/SectionHead.vue'
import { useSessionStore } from '../../stores/session' 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> = { const VERIFICATION_LABELS: Record<string, string> = {
UNVERIFIED: 'احراز نشده', UNVERIFIED: 'احراز نشده',
@@ -115,7 +117,8 @@ const VERIFICATION_LABELS: Record<string, string> = {
} }
const session = useSessionStore() 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 loading = ref(false)
const isVerified = computed(() => customer.value?.verificationStep === 'VERIFIED') const isVerified = computed(() => customer.value?.verificationStep === 'VERIFIED')
@@ -133,8 +136,9 @@ const canCompleteProfile = computed(() => {
const router = useRouter() const router = useRouter()
// D27 §12 — fetched on its own, every time the page opens, so a slow // D27 §12 — fetched on its own, every time the page opens, so a slow
// accounting server never holds up the profile itself. // accounting server never holds up the profile itself. The last copy fills the
const balance = ref<AccountingBalance | null>(null) // screen until the fresh one arrives.
const balance = ref<AccountingBalance | null>(reads.peek<AccountingBalance>(KEYS.balance) ?? null)
const balanceLoading = ref(false) const balanceLoading = ref(false)
const num = (v: string | null) => Number(v ?? 0) const num = (v: string | null) => Number(v ?? 0)
@@ -151,9 +155,9 @@ const balanceSubtitle = computed(() => {
}) })
async function loadBalance() { async function loadBalance() {
balanceLoading.value = true balanceLoading.value = !balance.value
try { try {
balance.value = await api.getAccountingBalance() balance.value = await reads.accountingBalance({ force: true })
} catch { } catch {
balance.value = null balance.value = null
} finally { } finally {
@@ -164,9 +168,9 @@ async function loadBalance() {
onMounted(async () => { onMounted(async () => {
if (!session.isLoggedIn) return if (!session.isLoggedIn) return
void loadBalance() void loadBalance()
loading.value = true loading.value = !customer.value
try { try {
customer.value = await api.getMe() customer.value = await reads.me({ force: true })
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -107,7 +107,9 @@ import Spinner from '../../components/Spinner.vue'
import SectionHead from '../../components/home/SectionHead.vue' import SectionHead from '../../components/home/SectionHead.vue'
import { jdate, price } from '../../utils/filters' import { jdate, price } from '../../utils/filters'
import { useSessionStore } from '../../stores/session' 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> = { const SOURCE_LABELS: Record<string, string> = {
ORDER_STOCK: 'سفارش', ORDER_STOCK: 'سفارش',
@@ -121,9 +123,10 @@ const SOURCE_LABELS: Record<string, string> = {
} }
const session = useSessionStore() const session = useSessionStore()
const reads = useCachedReads()
const loading = ref(false) const loading = ref(false)
const entries = ref<LedgerEntrySummary[]>([]) const entries = ref<LedgerEntrySummary[]>(reads.peek<LedgerEntrySummary[]>(KEYS.ledger) ?? [])
const showPopUp = ref(false) const showPopUp = ref(false)
const activeEntry = ref<LedgerEntrySummary | null>(null) const activeEntry = ref<LedgerEntrySummary | null>(null)
@@ -141,9 +144,9 @@ const openEntry = (item: LedgerEntrySummary) => {
onMounted(async () => { onMounted(async () => {
if (!session.isLoggedIn) return if (!session.isLoggedIn) return
loading.value = true loading.value = reads.peek(KEYS.ledger) === undefined
try { try {
entries.value = await api.getMyLedgerEntries() entries.value = await reads.myLedgerEntries()
} finally { } finally {
loading.value = false 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 // The real GET /customers/:id/orders endpoint returns OrderItem rows with
// only `productVariantId` (no nested product/variant title) — this joins // 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. // than touching apps/api to add an include.
import { ref } from 'vue' import { useCatalogStore, type VariantLookup } from '../stores/catalogStore'
import { api } from '../api/client'
export interface VariantLookup { export type { 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 function useOrderItemProduct() { export function useOrderItemProduct() {
const catalog = useCatalogStore()
const lookup = async (productVariantId: string): Promise<VariantLookup | undefined> => { const lookup = async (productVariantId: string): Promise<VariantLookup | undefined> => {
const map = await ensureLoaded() if (!catalog.loaded) await catalog.load()
return map.get(productVariantId) return catalog.variantLookup.get(productVariantId)
} }
return { lookup } return { lookup }
} }
@@ -1,25 +1,26 @@
// Adapted from legacy singleProduct.vue's own fetch (GET /product/:id). // Adapted from legacy singleProduct.vue's own fetch (GET /product/:id).
// The new API has no single-product-detail endpoint (only GET // 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, // /catalog/products, the flat list), so the product is found in the shared
// so this fetches the full list and finds the matching product client-side. // catalogue (stores/catalogStore.ts). When the catalogue is already cached,
// Wasteful for a large catalog, but honest: no fabricated endpoint, no silent // opening a product costs no request at all.
// behavior change.
// //
// `initial` seeds the page with the ProductSummary the tapped card already // `initial` seeds the page with the ProductSummary the tapped card already
// holds — the same shape, from the same endpoint — so the detail view renders // 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 // 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 // fetch lands. The lookup still runs, silently, and only replaces the product
// if the server's copy actually differs (stock moved since the list loaded). // if the catalogue's copy actually differs (stock moved since the list loaded).
// //
// The silent refresh waits until the opening morph (ProductMorph.vue, ≤420ms) // The silent refresh waits until the opening morph (ProductMorph.vue, ≤420ms)
// is over: the response is the whole catalogue, and parsing it — or swapping // is over: if the catalogue has to come from the server, parsing it — or
// the product and re-rendering — mid-animation drops frames. // swapping the product and re-rendering — mid-animation drops frames.
import { ref } from 'vue' 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 const SEEDED_REFRESH_DELAY_MS = 600
export function useProductDetail(id: string, initial?: ProductSummary | null) { export function useProductDetail(id: string, initial?: ProductSummary | null) {
const catalog = useCatalogStore()
const loading = ref(false) const loading = ref(false)
const flag = ref(Boolean(initial)) const flag = ref(Boolean(initial))
const notFound = ref(false) 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)) if (seeded) await new Promise((resolve) => setTimeout(resolve, SEEDED_REFRESH_DELAY_MS))
else loading.value = true else loading.value = true
try { try {
const products = await api.listProducts() const products = await catalog.load()
const found = products.find((p) => p.id === id) ?? null const found = products.find((p) => p.id === id) ?? null
if (seeded) { if (seeded) {
if (found && JSON.stringify(found) !== JSON.stringify(productRef.value)) productRef.value = found 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 // Adapted from legacy henza_pwa/src/Mixins/getProducts.ts. Legacy paginated
// against POST /get-products (page/limit/count). The new API's GET // against POST /get-products (page/limit/count). The new API's GET
// /catalog/products returns the full active catalog in one flat response with // /catalog/products returns the full active catalog in one flat response with
// no pagination — so `pull()` (legacy's infinite-scroll "next page" trigger) // no pagination — so `isPull` (legacy's infinite-scroll "next page" trigger)
// is a no-op after the first successful fetch here, and `totalPages` is // is a no-op here, and `totalPages` is always 0. A caller that wants new
// always 0. This keeps the ScrollFetchSearch infinite-scroll wiring intact // products at the end of the list passes `force` to refresh the cached
// (it still calls fetch on scroll) without fabricating fake pages. // catalogue instead of fabricating fake pages.
import { reactive } from 'vue' import { reactive, watch } from 'vue'
import { api, type ProductSummary } from '../api/client' import type { ProductSummary } from '../api/client'
import { useCatalogStore } from '../stores/catalogStore'
export interface ProductsData { export interface ProductsData {
totalPages: number totalPages: number
@@ -15,17 +16,27 @@ export interface ProductsData {
} }
export function useProducts() { export function useProducts() {
const catalog = useCatalogStore()
const productsData: ProductsData = reactive({ const productsData: ProductsData = reactive({
totalPages: 0, totalPages: 0,
productList: [], productList: catalog.products,
flag: false, 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 if (isPull) return // no further pages exist server-side yet
loading.value = true loading.value = force || !catalog.loaded
try { try {
productsData.productList = await api.listProducts() productsData.productList = await catalog.load(force)
productsData.flag = productsData.productList.length < 1 productsData.flag = productsData.productList.length < 1
} catch (e) { } catch (e) {
console.log(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 * after another. The styles are the global `.reveal` / `.is-revealed` pair in
* assets/styles/style.less. * assets/styles/style.less.
* *
* MasterLayout renders its routes without keep-alive, so a screen remounts on * MasterLayout keeps the main tabs alive, but a screen still remounts after a
* every return to its tab. Which screens have already played is kept here in * full-page route or if it is not in the keep-alive list. Which screens have
* module scope, so the entrance is a first-run flourish that resets only on a * already played is kept here in module scope, so the entrance is a first-run
* full reload — replaying it on each tab switch would fight the page * flourish that resets only on a full reload — replaying it on each return
* transition rather than add to it. (A `let` at the top of <script setup> * 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.) * would not do this: that code runs once per mount.)
*/ */
const played = new Set<string>() const played = new Set<string>()
+11 -6
View File
@@ -1,10 +1,9 @@
<!-- <!--
Ported from legacy henza_pwa/src/layout/master.vue. Ported from legacy henza_pwa/src/layout/master.vue.
NOTE (existing issue, ported as-is — not fixed here): this layout renders This layout renders its own <router-view/> rather than a <slot/>, so the
its own <router-view/> rather than a <slot/>, so the keep-alive/suspense keep-alive/suspense wrapper App.vue builds around its own <router-view> never
wrapper App.vue builds around its own <router-view> is never actually used reaches these routes. The keep-alive below is the one that applies: the main
by this layout — the route component here is resolved fresh, independently. tabs stay mounted between switches instead of refetching on every visit.
Preserved exactly; flagged separately.
--> -->
<template> <template>
<section ref="content"> <section ref="content">
@@ -16,7 +15,9 @@
@after-leave="pageLeaveEnded" @after-leave="pageLeaveEnded"
@leave-cancelled="pageLeaveEnded" @leave-cancelled="pageLeaveEnded"
> >
<component :is="Component" :key="route.name" /> <keep-alive :include="KEEP_ALIVE">
<component :is="Component" :key="route.name" />
</keep-alive>
</transition> </transition>
</router-view> </router-view>
<Navigation /> <Navigation />
@@ -31,6 +32,10 @@ import Navigation from '../components/ui/Navigation.vue'
import { useWindowDataStore } from '../stores/windowDataStore' import { useWindowDataStore } from '../stores/windowDataStore'
import { pageLeaveStarted, pageLeaveEnded } from '../composables/usePageTransition' 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 windowData = useWindowDataStore()
const content = ref<HTMLElement | null>(null) 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 { useNavStore } from '../stores/navStore';
import { useTransitionStore } from '../stores/transitionStore'; import { useTransitionStore } from '../stores/transitionStore';
import { api } from '../api/client'; import { api } from '../api/client';
import { useCachedReads } from '../api/cachedReads';
import { afterPageLeave } from '../composables/usePageTransition'; import { afterPageLeave } from '../composables/usePageTransition';
// Legacy lazy-loaded every route component (`component: () => import(...)`) so // 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, // Ported from henza_pwa router/index.ts:266 — once the documents are in,
// /complete_profile is not reachable any more and redirects home. The // /complete_profile is not reachable any more and redirects home. The
// reference read its cached store; this asks the server, so a profile // reference read its cached store; this asks the server (through the shared
// completed on another device is respected too. A failed lookup lets the // 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 // navigation through: the page itself is harmless, and the server is the
// one that decides whether the write is allowed. // one that decides whether the write is allowed.
if (to.meta.requiresIncompleteProfile && session.isLoggedIn) { if (to.meta.requiresIncompleteProfile && session.isLoggedIn) {
try { try {
const me = await api.getMe(); const me = await useCachedReads().me();
if (me.verificationStep === 'VERIFIED' || me.verificationStep === 'STEP_TWO_SUBMITTED') { if (me.verificationStep === 'VERIFIED' || me.verificationStep === 'STEP_TWO_SUBMITTED') {
return { name: 'home' }; return { name: 'home' };
} }
+4 -3
View File
@@ -3,7 +3,8 @@
// localStorage key), just via Pinia's own state instead of @vueuse's // localStorage key), just via Pinia's own state instead of @vueuse's
// useStorage wrapping a hand-rolled class. // useStorage wrapping a hand-rolled class.
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { api, type ProductSummary } from '../api/client' import type { ProductSummary } from '../api/client'
import { useCatalogStore } from './catalogStore'
export interface CartItem { export interface CartItem {
pid: string pid: string
@@ -36,9 +37,9 @@ export const useCartStore = defineStore('cart', {
// Re-syncs cart entries against fresh product data (e.g. price/weight // Re-syncs cart entries against fresh product data (e.g. price/weight
// changes since the item was added), dropping entries whose // changes since the item was added), dropping entries whose
// product/variant no longer exists — same intent as legacy's init(). // product/variant no longer exists — same intent as legacy's init().
async init() { async init(force = false) {
try { try {
const products = await api.listProducts() const products = await useCatalogStore().load(force)
const refreshed: CartItem[] = [] const refreshed: CartItem[] = []
for (const item of this.items) { for (const item of this.items) {
const product = products.find((p) => p.id === item.pid) 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. // Not ported (no backend): promotionList, ignoreList, priceList, isLoaded.
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { api } from '../api/client' import { useCatalogStore } from './catalogStore'
import shippingArt from '../assets/img/shipping-and-delivery.png' import shippingArt from '../assets/img/shipping-and-delivery.png'
import competitionArt from '../assets/img/sports-and-competition.png' import competitionArt from '../assets/img/sports-and-competition.png'
import brandArt from '../assets/img/montzar-logo.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. // Legacy's store.init() fanned out to /config, /promotion, /ignore, /price.
// Here the only live source is the product catalogue, from which the real // Here the only live source is the product catalogue, from which the real
// category list is derived. // 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 { try {
const products = await api.listProducts() const catalog = useCatalogStore()
const seen = new Map<string, HomeCategory>() await catalog.load(force)
for (const p of products) { this.cats = catalog.categories.map((c) => ({ ...c, icon: { image: brandArt } }))
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()]
this.loaded = true this.loaded = true
} catch (e) { } catch (e) {
console.log(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 { useSessionStore } from '../stores/session'
import { useReveal } from '../composables/useReveal' import { useReveal } from '../composables/useReveal'
import { api } from '../api/client' import { api } from '../api/client'
import { useCatalogStore } from '../stores/catalogStore'
import { KEYS, useQueryCache } from '../stores/queryCache'
const router = useRouter() const router = useRouter()
const cart = useCartStore() const cart = useCartStore()
@@ -222,6 +224,9 @@ const submitOrder = async () => {
cartData.orderID = order.orderNumber cartData.orderID = order.orderNumber
cartData.step = 2 cartData.step = 2
cart.clear() 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) { } catch (e) {
console.log(e) console.log(e)
} finally { } finally {
@@ -230,7 +235,7 @@ const submitOrder = async () => {
} }
async function onRefresh() { async function onRefresh() {
await cart.init() await cart.init(true)
refreshing.value = false refreshing.value = false
} }
+24 -26
View File
@@ -133,22 +133,26 @@ import type { HomeCategory } from '../stores/configStore'
import { useCartStore } from '../stores/cartStore' import { useCartStore } from '../stores/cartStore'
import { useFiltersStore } from '../stores/filtersStore' import { useFiltersStore } from '../stores/filtersStore'
import { useReveal } from '../composables/useReveal' import { useReveal } from '../composables/useReveal'
import { api } from '../api/client'
import type { AdminSlide, GoldPriceQuote, ProductSummary } 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 router = useRouter()
const memory = useConfigStore() const memory = useConfigStore()
const cart = useCartStore() const cart = useCartStore()
const filters = useFiltersStore() const filters = useFiltersStore()
const catalog = useCatalogStore()
const reads = useCachedReads()
const loading = ref(true) const loading = ref(!catalog.loaded)
const refreshing = ref(false) const refreshing = ref(false)
const products = ref<ProductSummary[]>([]) const products = computed(() => catalog.products)
const goldPrice = ref<GoldPriceQuote | null>(null) const goldPrice = ref<GoldPriceQuote | null>(reads.peek<GoldPriceQuote>(KEYS.goldPrice) ?? null)
// Admin-managed banner slides (manage/Const.vue "اسلایدر موبایل"). null while // Admin-managed banner slides (manage/Const.vue "اسلایدر موبایل"). null while
// unfetched/unavailable — distinct from "fetched, empty" — so `slides` below // unfetched/unavailable — distinct from "fetched, empty" — so `slides` below
// knows whether to wait or fall back to the category-derived hero. // 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 // First-paint entrance: played once, after the first data load, so the
@@ -240,31 +244,25 @@ function goSearch() {
function goNewest() { function goNewest() {
router.push({ name: 'newestProduct' }) router.push({ name: 'newestProduct' })
} }
async function loadData() { // The three reads are independent, so they run side by side. Each one is
try { // cached until pull-to-refresh asks the server again.
const [list] = await Promise.all([api.listProducts(), memory.init()]) async function loadData(force = false) {
products.value = list const [catalogResult, priceResult, slidesResult] = await Promise.allSettled([
} catch (e) { // memory.init() reads the same cached catalogue, so this is one request.
console.log(e) memory.init(force),
} reads.goldPrice({ force }),
reads.homeSlider({ force }),
])
try { if (catalogResult.status === 'rejected') console.log(catalogResult.reason)
goldPrice.value = await api.getLatestGoldPrice() goldPrice.value = priceResult.status === 'fulfilled' ? priceResult.value : null
} catch { // No admin slides configured, or the endpoint failed — the category-derived
goldPrice.value = null // hero below covers this silently, same as before this existed.
} adminSlides.value = slidesResult.status === 'fulfilled' ? slidesResult.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
}
} }
async function onRefresh() { async function onRefresh() {
await loadData() await loadData(true)
refreshing.value = false refreshing.value = false
} }
+4 -2
View File
@@ -62,12 +62,14 @@ onBeforeMount(async () => {
const pull = async () => { const pull = async () => {
// Reveal more of what is already loaded first; only go back to the server // 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) { if (visibleCount.value < productsData.productList.length) {
visibleCount.value += PAGE_SIZE visibleCount.value += PAGE_SIZE
return return
} }
await getProducts(loadingPull, true) if (loadingPull.value) return
await getProducts(loadingPull, false, true)
} }
</script> </script>
@@ -40,12 +40,13 @@ import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue' import Spinner from '../../components/Spinner.vue'
import SectionHead from '../../components/home/SectionHead.vue' import SectionHead from '../../components/home/SectionHead.vue'
import ProductCard from '../../components/product/ProductCard.vue' import ProductCard from '../../components/product/ProductCard.vue'
import { api, type ProductSummary } from '../../api/client' import { useCatalogStore } from '../../stores/catalogStore'
const route = useRoute() const route = useRoute()
const catalog = useCatalogStore()
const loadingPage = ref(false) 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 // 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. // neutral fallback so the bar is never blank on a direct link.
@@ -56,9 +57,11 @@ const collectionProducts = computed(() =>
) )
onMounted(async () => { onMounted(async () => {
loadingPage.value = true loadingPage.value = !catalog.loaded
try { try {
allProducts.value = await api.listProducts() await catalog.load()
} catch (e) {
console.log(e)
} finally { } finally {
loadingPage.value = false 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 SectionHead from '../../components/home/SectionHead.vue'
import ProductCard from '../../components/product/ProductCard.vue' import ProductCard from '../../components/product/ProductCard.vue'
import { useFiltersStore } from '../../stores/filtersStore' import { useFiltersStore } from '../../stores/filtersStore'
import { api, type ProductSummary } from '../../api/client' import { useCatalogStore } from '../../stores/catalogStore'
const filters = useFiltersStore() const filters = useFiltersStore()
const catalog = useCatalogStore()
// Starts true: the first render happens before onMounted kicks off the fetch, // Starts true on a cold start: the first render happens before onMounted kicks
// and with false the "nothing found" box flashed up for a frame before the // off the fetch, and with false the "nothing found" box flashed up for a frame
// loading state replaced it. // before the loading state replaced it. With the catalogue cached it renders at once.
const loadingPage = ref(true) const loadingPage = ref(!catalog.loaded)
// Two rows of the two-column grid — enough to fill the screen under the bar. // Two rows of the two-column grid — enough to fill the screen under the bar.
const SKELETON_CARDS = 6 const SKELETON_CARDS = 6
const refreshing = ref(false) const refreshing = ref(false)
const allProducts = ref<ProductSummary[]>([]) const allProducts = computed(() => catalog.products)
const showFilter = ref(false) const showFilter = ref(false)
const filterKeys = ref<string[]>([]) const filterKeys = ref<string[]>([])
@@ -232,17 +233,19 @@ watch(sentinel, mountObserver)
onBeforeUnmount(() => observer?.disconnect()) onBeforeUnmount(() => observer?.disconnect())
async function loadProducts() { async function loadProducts(force = false) {
loadingPage.value = true loadingPage.value = !catalog.loaded
try { try {
allProducts.value = await api.listProducts() await catalog.load(force)
} catch (e) {
console.log(e)
} finally { } finally {
loadingPage.value = false loadingPage.value = false
} }
} }
async function onRefresh() { async function onRefresh() {
await loadProducts() await loadProducts(true)
refreshing.value = false refreshing.value = false
} }
@@ -256,7 +259,7 @@ function showFilters() {
showFilter.value = true showFilter.value = true
} }
onMounted(loadProducts) onMounted(() => loadProducts())
</script> </script>
<style scoped lang="less"> <style scoped lang="less">
@@ -87,6 +87,7 @@ import Spinner from '../../components/Spinner.vue'
import DialogVantOneButton from '../../components/DialogVantOneButton.vue' import DialogVantOneButton from '../../components/DialogVantOneButton.vue'
import SingleImageUploader from '../../components/upload/SingleImageUploader.vue' import SingleImageUploader from '../../components/upload/SingleImageUploader.vue'
import { api } from '../../api/client' import { api } from '../../api/client'
import { KEYS, useQueryCache } from '../../stores/queryCache'
const router = useRouter() const router = useRouter()
@@ -117,6 +118,7 @@ async function submit() {
shop_image_id: form.shop_image_id, shop_image_id: form.shop_image_id,
ncode_image_id: form.ncode_image_id, ncode_image_id: form.ncode_image_id,
}) })
useQueryCache().invalidate(KEYS.profile)
done.value = true done.value = true
} catch { } catch {
error.value = 'ثبت اطلاعات انجام نشد. دوباره تلاش کنید.' error.value = 'ثبت اطلاعات انجام نشد. دوباره تلاش کنید.'
+28 -21
View File
@@ -154,7 +154,7 @@
</template> </template>
<script setup lang="ts"> <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 { useRouter } from 'vue-router'
import { Loading, PullRefresh } from 'vant' import { Loading, PullRefresh } from 'vant'
import Icon from '../../components/icon/Icon.vue' import Icon from '../../components/icon/Icon.vue'
@@ -167,12 +167,15 @@ import { price } from '../../utils/filters'
import { useSessionStore } from '../../stores/session' import { useSessionStore } from '../../stores/session'
import { useReveal } from '../../composables/useReveal' import { useReveal } from '../../composables/useReveal'
import { api, type CustomerDetail, type GoldPriceQuote } from '../../api/client' import { api, type CustomerDetail, type GoldPriceQuote } from '../../api/client'
import { useCachedReads } from '../../api/cachedReads'
import { KEYS } from '../../stores/queryCache'
const router = useRouter() const router = useRouter()
const session = useSessionStore() const session = useSessionStore()
const reads = useCachedReads()
const customer = ref<CustomerDetail | null>(null) const customer = ref<CustomerDetail | null>(reads.peek<CustomerDetail>(KEYS.profile) ?? null)
const goldPrice = ref<GoldPriceQuote | null>(null) const goldPrice = ref<GoldPriceQuote | null>(reads.peek<GoldPriceQuote>(KEYS.goldPrice) ?? null)
const refreshing = ref(false) const refreshing = ref(false)
// The ledger stores the gram position as a signed figure — positive means the // The ledger stores the gram position as a signed figure — positive means the
@@ -254,30 +257,34 @@ async function logOut() {
router.push({ name: 'home' }) router.push({ name: 'home' })
} }
async function loadDashboard() { async function loadDashboard(force = false) {
if (session.isLoggedIn) { // Not wrapped before: a failure here (expired token, network blip) left
// Not wrapped before: a failure here (expired token, network blip) left // the exception uncaught, so onRefresh's refreshing.value = false never
// the exception uncaught, so onRefresh's refreshing.value = false never // ran and PullRefresh stayed stuck in its loading state — a working pull
// ran and PullRefresh stayed stuck in its loading state — a working pull // gesture never dismissed it again until a full page reload.
// gesture never dismissed it again until a full page reload. const [me, price] = await Promise.allSettled([
try { // The balance on this tab is the one figure read fresh on every visit;
customer.value = await api.getMe() // the cached copy only fills the screen until it arrives.
} catch (e) { session.isLoggedIn ? reads.me({ force: true }) : Promise.resolve(null),
console.log(e) reads.goldPrice({ force }),
} ])
} if (me.status === 'fulfilled') customer.value = me.value
try { else console.log(me.reason)
goldPrice.value = await api.getLatestGoldPrice() goldPrice.value = price.status === 'fulfilled' ? price.value : null
} catch {
goldPrice.value = null
}
} }
async function onRefresh() { async function onRefresh() {
await loadDashboard() await loadDashboard(true)
refreshing.value = false 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, // First-paint entrance, the same choreography HomeView uses: played once,
// after the first load; pull-to-refresh and tab returns don't replay it. // after the first load; pull-to-refresh and tab returns don't replay it.
const { revealed, revealDelay, reveal } = useReveal('dashboard') const { revealed, revealDelay, reveal } = useReveal('dashboard')
@@ -43,19 +43,22 @@ import Spinner from '../../components/Spinner.vue'
import SectionHead from '../../components/home/SectionHead.vue' import SectionHead from '../../components/home/SectionHead.vue'
import ProductCard from '../../components/product/ProductCard.vue' import ProductCard from '../../components/product/ProductCard.vue'
import { useFavoritesStore } from '../../stores/favoritesStore' import { useFavoritesStore } from '../../stores/favoritesStore'
import { api, type ProductSummary } from '../../api/client' import { useCatalogStore } from '../../stores/catalogStore'
const router = useRouter() const router = useRouter()
const favorites = useFavoritesStore() const favorites = useFavoritesStore()
const catalog = useCatalogStore()
const loading = ref(false) const loading = ref(false)
const allProducts = ref<ProductSummary[]>([]) const allProducts = computed(() => catalog.products)
const favoriteProducts = computed(() => allProducts.value.filter((p) => favorites.isFavorite(p.id))) const favoriteProducts = computed(() => allProducts.value.filter((p) => favorites.isFavorite(p.id)))
onMounted(async () => { onMounted(async () => {
loading.value = true loading.value = !catalog.loaded
try { try {
allProducts.value = await api.listProducts() await catalog.load()
} catch (e) {
console.log(e)
} finally { } finally {
loading.value = false loading.value = false
} }
+16 -8
View File
@@ -164,9 +164,12 @@ import {
} from '../../utils/orderStatus' } from '../../utils/orderStatus'
import { jdate } from '../../utils/filters' import { jdate } from '../../utils/filters'
import { useSessionStore } from '../../stores/session' 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 session = useSessionStore()
const reads = useCachedReads()
const tabs = [ const tabs = [
{ title: 'همه', key: 'ALL' }, { title: 'همه', key: 'ALL' },
@@ -183,7 +186,7 @@ const activeTab = ref('ALL')
const loading = ref(false) const loading = ref(false)
const refreshing = 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 searchTerm = ref<string | undefined>(undefined)
const filteredOrders = computed(() => { const filteredOrders = computed(() => {
@@ -197,11 +200,16 @@ function readSearch(value: string | undefined) {
searchTerm.value = value searchTerm.value = value
} }
async function loadOrders() { async function loadOrders(force = false) {
if (!session.isLoggedIn) return if (!session.isLoggedIn) {
loading.value = true allOrders.value = []
return
}
loading.value = reads.peek(KEYS.orders) === undefined
try { try {
allOrders.value = await api.getMyOrders() allOrders.value = await reads.myOrders({ force })
} catch (e) {
console.log(e)
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -220,11 +228,11 @@ function openDetail(item: OrderDetail) {
} }
async function onRefresh() { async function onRefresh() {
await loadOrders() await loadOrders(true)
refreshing.value = false refreshing.value = false
} }
onMounted(loadOrders) onMounted(() => loadOrders())
</script> </script>
<style scoped lang="less"> <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 PageBar from '../../components/ui/PageBar.vue'
import SectionHead from '../../components/home/SectionHead.vue' import SectionHead from '../../components/home/SectionHead.vue'
import { useSessionStore } from '../../stores/session' 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> = { const VERIFICATION_LABELS: Record<string, string> = {
UNVERIFIED: 'احراز نشده', UNVERIFIED: 'احراز نشده',
@@ -115,7 +117,8 @@ const VERIFICATION_LABELS: Record<string, string> = {
} }
const session = useSessionStore() 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 loading = ref(false)
const isVerified = computed(() => customer.value?.verificationStep === 'VERIFIED') const isVerified = computed(() => customer.value?.verificationStep === 'VERIFIED')
@@ -133,8 +136,9 @@ const canCompleteProfile = computed(() => {
const router = useRouter() const router = useRouter()
// D27 §12 — fetched on its own, every time the page opens, so a slow // D27 §12 — fetched on its own, every time the page opens, so a slow
// accounting server never holds up the profile itself. // accounting server never holds up the profile itself. The last copy fills the
const balance = ref<AccountingBalance | null>(null) // screen until the fresh one arrives.
const balance = ref<AccountingBalance | null>(reads.peek<AccountingBalance>(KEYS.balance) ?? null)
const balanceLoading = ref(false) const balanceLoading = ref(false)
const num = (v: string | null) => Number(v ?? 0) const num = (v: string | null) => Number(v ?? 0)
@@ -151,9 +155,9 @@ const balanceSubtitle = computed(() => {
}) })
async function loadBalance() { async function loadBalance() {
balanceLoading.value = true balanceLoading.value = !balance.value
try { try {
balance.value = await api.getAccountingBalance() balance.value = await reads.accountingBalance({ force: true })
} catch { } catch {
balance.value = null balance.value = null
} finally { } finally {
@@ -164,9 +168,9 @@ async function loadBalance() {
onMounted(async () => { onMounted(async () => {
if (!session.isLoggedIn) return if (!session.isLoggedIn) return
void loadBalance() void loadBalance()
loading.value = true loading.value = !customer.value
try { try {
customer.value = await api.getMe() customer.value = await reads.me({ force: true })
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -107,7 +107,9 @@ import Spinner from '../../components/Spinner.vue'
import SectionHead from '../../components/home/SectionHead.vue' import SectionHead from '../../components/home/SectionHead.vue'
import { jdate, price } from '../../utils/filters' import { jdate, price } from '../../utils/filters'
import { useSessionStore } from '../../stores/session' 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> = { const SOURCE_LABELS: Record<string, string> = {
ORDER_STOCK: 'سفارش', ORDER_STOCK: 'سفارش',
@@ -121,9 +123,10 @@ const SOURCE_LABELS: Record<string, string> = {
} }
const session = useSessionStore() const session = useSessionStore()
const reads = useCachedReads()
const loading = ref(false) const loading = ref(false)
const entries = ref<LedgerEntrySummary[]>([]) const entries = ref<LedgerEntrySummary[]>(reads.peek<LedgerEntrySummary[]>(KEYS.ledger) ?? [])
const showPopUp = ref(false) const showPopUp = ref(false)
const activeEntry = ref<LedgerEntrySummary | null>(null) const activeEntry = ref<LedgerEntrySummary | null>(null)
@@ -141,9 +144,9 @@ const openEntry = (item: LedgerEntrySummary) => {
onMounted(async () => { onMounted(async () => {
if (!session.isLoggedIn) return if (!session.isLoggedIn) return
loading.value = true loading.value = reads.peek(KEYS.ledger) === undefined
try { try {
entries.value = await api.getMyLedgerEntries() entries.value = await reads.myLedgerEntries()
} finally { } finally {
loading.value = false loading.value = false
} }