feat: catalog read cache (api + user apps) and clearer Zarrin accounting errors
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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: 'دستی',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
})
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
})
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = 'ثبت اطلاعات انجام نشد. دوباره تلاش کنید.'
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user