feat: paginate customer orders and polish the dashboard hero

Add paged orders API and infinite scroll, short order numbers, and align the guest invite card with the logged-in hero shell.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
s.esmaeili
2026-10-17 13:11:42 +03:30
co-authored by Cursor
parent f550172567
commit afb068c643
89 changed files with 4518 additions and 1325 deletions
+1 -1
View File
@@ -166,7 +166,7 @@ ORDER_ADMIN_MOBILES=
# ---------------------------------------------------------------------------
# Brand
# ---------------------------------------------------------------------------
# The name at the top of every SMS («زرتاج\n…») and the site link in the
# The name at the top of every SMS («تهران تاج\n…») and the site link in the
# welcome SMS. Blank = the defaults in apps/api/src/common/brand.ts. The
# frontends take their brand from the repo-root brand.ts instead; the full
# rebrand checklist is docs/BRANDING.md.
@@ -0,0 +1,14 @@
-- App (B2B) orders were stored as RETAIL because createOrder defaulted
-- channel to RETAIL and the apps never sent one. Legacy's mobile setOrder
-- wrote to the wholesale `orders` table.
--
-- Flip mis-filed rows: any RETAIL order whose customer is NOT a تک
-- (CustomerKind.RETAIL) belongs on the omde list. Genuine retail-list
-- orders for CustomerKind.RETAIL stay RETAIL.
UPDATE "Order" AS o
SET channel = 'WHOLESALE'
FROM "Customer" AS c
WHERE o."customerId" = c.id
AND o.channel = 'RETAIL'
AND c.kind <> 'RETAIL';
@@ -0,0 +1,31 @@
-- Short global customer-facing order numbers (SMS / app), replacing
-- ORD-{timestamp}-{random}. Pure digits, starting at 100.
-- Existing ORD-* / MOCK-ORD-* rows stay as-is; the sequence only advances
-- past any already-numeric orderNumber so new allocations never collide.
DROP SEQUENCE IF EXISTS order_number_seq;
CREATE SEQUENCE order_number_seq
AS BIGINT
INCREMENT BY 1
MINVALUE 1
START WITH 100
NO CYCLE;
DO $$
DECLARE
max_numeric bigint;
BEGIN
SELECT MAX("orderNumber"::bigint)
INTO max_numeric
FROM "Order"
WHERE "orderNumber" ~ '^[0-9]+$';
IF max_numeric IS NULL THEN
-- next nextval() returns 100
PERFORM setval('order_number_seq', 100, false);
ELSE
-- next nextval() returns max_numeric + 1
PERFORM setval('order_number_seq', max_numeric, true);
END IF;
END $$;
+2 -2
View File
@@ -389,8 +389,8 @@ async function main() {
await prisma.brand.updateMany({ where: { id: 'brand-henza' }, data: { id: 'brand-montzar' } });
const brandMain = await prisma.brand.upsert({
where: { id: 'brand-montzar' },
update: { title: 'زرتاج طلا' },
create: { id: 'brand-montzar', title: 'زرتاج طلا' },
update: { title: 'تهران تاج طلا' },
create: { id: 'brand-montzar', title: 'تهران تاج طلا' },
});
const brandArtisan = await prisma.brand.upsert({
where: { id: 'brand-artisan' },
@@ -0,0 +1,16 @@
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
async function main() {
const row = await prisma.setting.findUnique({ where: { key: 'accounting.config' } })
console.log('accounting.config:', row ? JSON.stringify(row.value, null, 2) : 'MISSING')
const links = await prisma.accountingLink.count({ where: { entityType: 'PRODUCT' } })
console.log('PRODUCT accounting links:', links)
await prisma.$disconnect()
}
main().catch((e) => {
console.error(e)
process.exit(1)
})
+15
View File
@@ -0,0 +1,15 @@
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
async function main() {
const a = await prisma.$queryRaw<Array<{ n: bigint }>>`SELECT nextval('order_number_seq') AS n`
const b = await prisma.$queryRaw<Array<{ n: bigint }>>`SELECT nextval('order_number_seq') AS n`
console.log(JSON.stringify({ first: String(a[0]!.n), second: String(b[0]!.n) }))
await prisma.$disconnect()
}
main().catch((e) => {
console.error(e)
process.exit(1)
})
+27
View File
@@ -0,0 +1,27 @@
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
async function main() {
const counts = await prisma.order.groupBy({
by: ['status'],
_count: { _all: true },
where: { orderNumber: { startsWith: 'MOCK-ORD' } },
})
const total = counts.reduce((s, c) => s + c._count._all, 0)
const sample = await prisma.order.findFirst({
where: { orderNumber: { startsWith: 'MOCK-ORD' } },
select: {
orderNumber: true,
status: true,
customer: { select: { mobile: true, fullName: true } },
},
})
console.log(JSON.stringify({ total, byStatus: Object.fromEntries(counts.map((c) => [c.status, c._count._all])), sample }, null, 2))
await prisma.$disconnect()
}
main().catch((e) => {
console.error(e)
process.exit(1)
})
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* Legacy system (MySQL `oapi`) → zartaj (PostgreSQL) data import.
* Legacy system (MySQL `oapi`) → tehran-taj (PostgreSQL) data import.
*
* The rules live in docs/DATA-MIGRATION-MAP.md; every step cites its section.
*
@@ -56,7 +56,7 @@ export class Report {
}
render(header: string[]): string {
const out: string[] = ['# legacy (oapi) → zartaj import report', '', ...header, ''];
const out: string[] = ['# legacy (oapi) → tehran-taj import report', '', ...header, ''];
for (const [step, s] of this.sections) {
out.push(`## ${step}`, '');
if (s.counts.size) {
+53
View File
@@ -0,0 +1,53 @@
/**
* Move all MOCK-ORD-* orders to a target customer mobile.
* Run: npx tsx scripts/reassign-mock-orders.ts
*/
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
const TARGET_MOBILE = process.env.MOCK_ORDERS_MOBILE?.trim() || '09382507041'
const TAG = 'MOCK-ORD'
async function main() {
const customer = await prisma.customer.findUnique({
where: { mobile: TARGET_MOBILE },
select: { id: true, mobile: true, fullName: true },
})
if (!customer) {
throw new Error(`Customer not found for mobile=${TARGET_MOBILE}`)
}
const before = await prisma.order.count({ where: { orderNumber: { startsWith: TAG } } })
const result = await prisma.order.updateMany({
where: { orderNumber: { startsWith: TAG } },
data: { customerId: customer.id },
})
const byStatus = await prisma.order.groupBy({
by: ['status'],
_count: { _all: true },
where: { orderNumber: { startsWith: TAG }, customerId: customer.id },
})
console.log(
JSON.stringify(
{
target: { mobile: customer.mobile, fullName: customer.fullName },
matched: before,
updated: result.count,
byStatus: Object.fromEntries(byStatus.map((c) => [c.status, c._count._all])),
},
null,
2,
),
)
}
main()
.catch((e) => {
console.error(e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})
+199
View File
@@ -0,0 +1,199 @@
/**
* Dev helper: seed >15 mock orders per OrderStatus so the Orders UI can be inspected.
* Run: npx tsx scripts/seed-mock-orders.ts
*/
import {
OrderChannel,
OrderStatus,
Prisma,
PrismaClient,
} from '@prisma/client'
const prisma = new PrismaClient()
const STATUSES: OrderStatus[] = [
OrderStatus.SUSPENDED,
OrderStatus.PLACED,
OrderStatus.ADMIN_APPROVED,
OrderStatus.SOURCING,
OrderStatus.PREPARING,
OrderStatus.DELIVERED_TO_AGENT,
OrderStatus.DELIVERED_TO_CUSTOMER,
OrderStatus.CANCELLED,
]
const PER_STATUS = 18
const TAG = 'MOCK-ORD'
function d(n: number | string) {
return new Prisma.Decimal(n)
}
function orderNumber(status: OrderStatus, i: number) {
// Unique, easy to spot/delete later
return `${TAG}-${status.slice(0, 4)}-${String(i).padStart(3, '0')}-${Date.now().toString(36)}`
}
async function main() {
const mobileOverride = process.env.MOCK_ORDERS_MOBILE?.trim()
const customer = mobileOverride
? await prisma.customer.findUnique({
where: { mobile: mobileOverride },
select: { id: true, mobile: true, fullName: true, referredByAgentId: true },
})
: await prisma.customer.findFirst({
orderBy: { updatedAt: 'desc' },
select: { id: true, mobile: true, fullName: true, referredByAgentId: true },
})
if (!customer) {
throw new Error(
mobileOverride
? `No customer with mobile=${mobileOverride}`
: 'No customers in DB — create/login a customer first',
)
}
const variants = await prisma.productVariant.findMany({
take: 30,
where: { isActive: true },
include: { product: { select: { title: true } } },
})
if (variants.length === 0) {
throw new Error('No product variants in DB — seed catalog first')
}
const branch = await prisma.branch.findFirst({ select: { id: true, name: true } })
console.log(
`Seeding ${PER_STATUS} orders × ${STATUSES.length} statuses for customer ${customer.mobile} (${customer.fullName}), ${variants.length} variants` +
(branch ? `, branch=${branch.name}` : ''),
)
let created = 0
for (const status of STATUSES) {
for (let i = 1; i <= PER_STATUS; i++) {
const variant = variants[i % variants.length]!
const qty = 1 + (i % 3)
const rash = Number(variant.rashWeightGrams ?? 1.25)
const wagePct = Number(variant.defaultWagePercent ?? 7)
const discountPct = 0
const computed = rash * (1 + (wagePct - discountPct) / 100)
const totalGold = computed * qty
const wageWeight = totalGold - rash * qty
const flatRial = i % 4 === 0 ? 150_000 * i : 0
const daysAgo = (STATUSES.indexOf(status) * PER_STATUS + i) % 40
const createdAt = new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000 - i * 60_000)
const deliveredAt =
status === OrderStatus.DELIVERED_TO_CUSTOMER
? new Date(createdAt.getTime() + 2 * 24 * 60 * 60 * 1000)
: null
// 1–2 line items so list/detail both look realistic
const second = variants[(i + 3) % variants.length]!
const useSecond = i % 3 === 0
const order = await prisma.order.create({
data: {
orderNumber: orderNumber(status, i),
customerId: customer.id,
branchId: branch?.id ?? null,
channel: i % 5 === 0 ? OrderChannel.WHOLESALE : OrderChannel.RETAIL,
status,
totalGoldWeightGrams: d(
(useSecond
? totalGold + Number(second.rashWeightGrams) * (1 + Number(second.defaultWagePercent) / 100)
: totalGold
).toFixed(4),
),
wageWeightGrams: d(Math.max(0, wageWeight).toFixed(4)),
flatRialCharge: d(flatRial),
isSuspendedOverCreditLimit: status === OrderStatus.SUSPENDED,
deliveredAt,
salesAgentId: customer.referredByAgentId ?? null,
createdAt,
updatedAt: createdAt,
items: {
create: [
{
productVariantId: variant.id,
quantity: qty,
rashWeightGramsSnapshot: d(rash.toFixed(4)),
wagePercentSnapshot: d(wagePct.toFixed(3)),
discountPercentSnapshot: d(discountPct.toFixed(3)),
computedWeightGrams: d(computed.toFixed(4)),
flatRialCharge: d(flatRial),
},
...(useSecond
? [
{
productVariantId: second.id,
quantity: 1,
rashWeightGramsSnapshot: d(Number(second.rashWeightGrams).toFixed(4)),
wagePercentSnapshot: d(Number(second.defaultWagePercent).toFixed(3)),
discountPercentSnapshot: d(0),
computedWeightGrams: d(
(
Number(second.rashWeightGrams) *
(1 + Number(second.defaultWagePercent) / 100)
).toFixed(4),
),
flatRialCharge: d(0),
},
]
: []),
],
},
statusEvents: {
create: [
{
fromStatus: null,
toStatus: OrderStatus.PLACED,
note: 'mock seed: placed',
createdAt,
},
...(status !== OrderStatus.PLACED
? [
{
fromStatus: OrderStatus.PLACED,
toStatus: status,
note: `mock seed: set to ${status}`,
createdAt: new Date(createdAt.getTime() + 60_000),
},
]
: []),
],
},
},
})
created++
if (i === 1) {
console.log(` ${status}: first=${order.orderNumber}`)
}
}
console.log(` ${status}: +${PER_STATUS}`)
}
const counts = await prisma.order.groupBy({
by: ['status'],
_count: { _all: true },
where: { orderNumber: { startsWith: TAG } },
})
console.log(`Done. Created ${created} mock orders (tag ${TAG}).`)
console.log(
'Mock counts:',
Object.fromEntries(counts.map((c) => [c.status, c._count._all])),
)
console.log(`Login as mobile ${customer.mobile} to see them in the app.`)
}
main()
.catch((e) => {
console.error(e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})
+1 -1
View File
@@ -8,7 +8,7 @@ import { envOr } from './env';
* docs/BRANDING.md for the full list.
*/
export function brandName(): string {
return envOr('BRAND_NAME', 'زرتاج');
return envOr('BRAND_NAME', 'تهران تاج');
}
export function brandSiteUrl(): string {
@@ -367,21 +367,26 @@ export class AccountingSyncService {
select: { accountingGoldBalance: true, accountingRialBalance: true, accountingBalanceAt: true },
});
const stale = !c?.accountingBalanceAt || Date.now() - c.accountingBalanceAt.getTime() > BALANCE_FRESH_MS;
if (stale) {
try {
const [b] = await a.provider.getBalances(
{ ...a.settings, timeoutMs: Math.min(a.settings.timeoutMs, BALANCE_ON_DEMAND_TIMEOUT_MS) },
[link.externalCode],
// Already have a stored figure: hand it back immediately and refresh in the
// background. Waiting on Tahesab/Zarrin here blocked GET /customer/me for up
// to BALANCE_ON_DEMAND_TIMEOUT_MS on almost every dashboard open (freshness
// is only a few seconds), so the app painted the guest invite while the
// token was still valid.
if (c?.accountingBalanceAt && stale) {
void this.refreshStoredBalance(a, customerId, link.externalCode).catch((err) => {
this.logger.warn(
`Background balance refresh for ${customerId} failed: ${(err as Error).message}`,
);
if (b) {
c = await this.prisma.customer.update({
where: { id: customerId },
data: { accountingGoldBalance: b.goldGrams, accountingRialBalance: b.rial, accountingBalanceAt: new Date() },
select: { accountingGoldBalance: true, accountingRialBalance: true, accountingBalanceAt: true },
});
}
});
} else if (stale) {
// First read — nothing to show yet, so wait (with the short timeout).
try {
c = (await this.refreshStoredBalance(a, customerId, link.externalCode)) ?? c;
} catch (err) {
this.logger.warn(`On-demand balance for ${customerId} failed, serving the stored one: ${(err as Error).message}`);
this.logger.warn(
`On-demand balance for ${customerId} failed, serving the stored one: ${(err as Error).message}`,
);
}
}
if (!c?.accountingBalanceAt) return null;
@@ -393,6 +398,32 @@ export class AccountingSyncService {
};
}
/** Pull one linked customer's balance from the provider and persist it. */
private async refreshStoredBalance(
a: ActiveProvider,
customerId: string,
externalCode: string,
) {
const [b] = await a.provider.getBalances(
{ ...a.settings, timeoutMs: Math.min(a.settings.timeoutMs, BALANCE_ON_DEMAND_TIMEOUT_MS) },
[externalCode],
);
if (!b) return null;
return this.prisma.customer.update({
where: { id: customerId },
data: {
accountingGoldBalance: b.goldGrams,
accountingRialBalance: b.rial,
accountingBalanceAt: new Date(),
},
select: {
accountingGoldBalance: true,
accountingRialBalance: true,
accountingBalanceAt: true,
},
});
}
// ════════════════════════════════════════════════════════════════════
// Real-time: change poll + live reads
@@ -133,6 +133,12 @@ describe('CatalogService — paging and filtering', () => {
});
});
it('accepts several brands as a comma-separated brandId', async () => {
const { service, findMany } = setup();
await service.findProducts({ page: 1, brandId: 'b1,b2' });
expect(lastWhere(findMany).brandId).toEqual({ in: ['b1', 'b2'] });
});
it('matches a gram range against the variants, as the app did', async () => {
// A product qualifies when ANY of its variants is in range
// (SearchView.vue:189-195), not when all of them are.
@@ -143,7 +143,17 @@ export class CatalogService {
where.title = { contains: query.search.trim(), mode: 'insensitive' };
}
if (query.categoryId) where.categoryId = query.categoryId;
if (query.brandId) where.brandId = query.brandId;
// Apps' filter sheet allows several brands at once. Accept a single id or
// a comma-separated list (`?brandId=a,b`) so Search does not have to fake
// multi-brand by loading the whole catalogue.
if (query.brandId) {
const brandIds = query.brandId
.split(',')
.map((s) => s.trim())
.filter(Boolean);
if (brandIds.length === 1) where.brandId = brandIds[0];
else if (brandIds.length > 1) where.brandId = { in: brandIds };
}
if (query.collectionId) where.collections = { some: { collectionId: query.collectionId } };
// A product is in range when at least ONE of its variants is, which is what
@@ -97,8 +97,19 @@ export class CustomerAuthController {
@CustomerAuth()
@Get('me/orders')
myOrders(@CurrentCustomer() me: AuthenticatedCustomer) {
return this.customers.getOrders(me.id);
myOrders(
@CurrentCustomer() me: AuthenticatedCustomer,
@Query('page') page?: string,
@Query('limit') limit?: string,
@Query('status') status?: string,
@Query('q') q?: string,
) {
return this.customers.getOrders(me.id, {
page: Number(page) || 1,
limit: limit !== undefined && limit !== '' ? Number(limit) : undefined,
status,
q,
});
}
@CustomerAuth()
@@ -1,8 +1,23 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { OrderStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { AccountingSyncService } from '../accounting-sync/accounting-sync.service';
/** Default page size for GET /customer/me/orders (mobile infinite scroll). */
const ORDERS_PAGE_LIMIT = 20;
const ORDERS_PAGE_LIMIT_MAX = 50;
const ORDER_STATUSES = new Set<string>(Object.values(OrderStatus));
export interface GetOrdersQuery {
page?: number;
limit?: number;
/** One OrderStatus value, or omit / ALL for every status. */
status?: string;
/** Substring match on `orderNumber`. */
q?: string;
}
// passwordHash must never leave the API — every customer read goes through this.
const PUBLIC_CUSTOMER_SELECT = {
id: true,
@@ -36,6 +51,8 @@ const PUBLIC_CUSTOMER_SELECT = {
*/
@Injectable()
export class CustomersService {
private readonly logger = new Logger(CustomersService.name);
constructor(
private readonly prisma: PrismaService,
private readonly accounting: AccountingSyncService,
@@ -51,52 +68,105 @@ export class CustomersService {
// D27 — with the accounting integration on, the customer sees the balance
// the accounting software holds. The apps only display these two fields;
// the credit-limit check runs server-side on the internal ledger, untouched.
const accounting = await this.accounting.customerBalance(customerId);
if (accounting) {
return {
...customer,
goldDebtGrams: accounting.gold,
fiatDebtRial: accounting.rial,
balanceSource: 'accounting' as const,
balanceAt: accounting.at,
};
//
// Accounting must never take down /customer/me: the dashboard treats a
// failed me() as "no customer" and shows the guest invite while the token
// (and logout) still work — orders keep loading because they skip this path.
try {
const accounting = await this.accounting.customerBalance(customerId);
if (accounting) {
return {
...customer,
goldDebtGrams: accounting.gold,
fiatDebtRial: accounting.rial,
balanceSource: 'accounting' as const,
balanceAt: accounting.at,
};
}
} catch (err) {
this.logger.warn(
`Balance overlay for ${customerId} failed; serving app ledger: ${(err as Error).message}`,
);
}
return { ...customer, balanceSource: 'app' as const, balanceAt: null };
}
getOrders(customerId: string) {
return this.prisma.order.findMany({
where: { customerId },
include: {
items: {
// Each item carried only `productVariantId`, so the apps looked the
// title up by loading the ENTIRE catalogue and joining in the browser
// (composables/useOrderItemProduct.ts). With ~16k products that is
// the wrong trade for a few row labels, and it also failed for an
// order whose product has since left the storefront — the catalogue
// hides it, so the row rendered blank.
//
// A nested read here is one join and always answers, including for
// products that are no longer on sale.
include: {
productVariant: {
select: {
id: true,
title: true,
product: {
select: {
id: true,
title: true,
images: { orderBy: { sortOrder: 'asc' }, take: 1, select: { url: true } },
},
/**
* Paginated order history for the signed-in customer.
*
* Shape matches the report endpoints (`result` / `count` / `page` / `limit`)
* so the apps can infinite-scroll the same way. Status and search are applied
* server-side — client-side filtering after a page slice would miss matches
* that sit on later pages.
*/
async getOrders(customerId: string, query: GetOrdersQuery = {}) {
const page = Math.max(1, Math.floor(query.page ?? 1) || 1);
const limit = Math.min(
ORDERS_PAGE_LIMIT_MAX,
Math.max(1, Math.floor(query.limit ?? ORDERS_PAGE_LIMIT) || ORDERS_PAGE_LIMIT),
);
const where: Prisma.OrderWhereInput = { customerId };
if (query.status && query.status !== 'ALL' && ORDER_STATUSES.has(query.status)) {
where.status = query.status as OrderStatus;
}
const q = query.q?.trim();
if (q) {
where.orderNumber = { contains: q, mode: 'insensitive' };
}
const include = {
items: {
// Each item carried only `productVariantId`, so the apps looked the
// title up by loading the ENTIRE catalogue and joining in the browser
// (composables/useOrderItemProduct.ts). With ~16k products that is
// the wrong trade for a few row labels, and it also failed for an
// order whose product has since left the storefront — the catalogue
// hides it, so the row rendered blank.
//
// A nested read here is one join and always answers, including for
// products that are no longer on sale.
include: {
productVariant: {
select: {
id: true,
title: true,
product: {
select: {
id: true,
title: true,
images: { orderBy: { sortOrder: 'asc' as const }, take: 1, select: { url: true } },
},
},
},
},
},
},
orderBy: { createdAt: 'desc' },
});
// Chronological status changes for the order-detail timeline in the app.
statusEvents: {
orderBy: { createdAt: 'asc' as const },
select: {
id: true,
fromStatus: true,
toStatus: true,
note: true,
createdAt: true,
},
},
};
const [result, count] = await Promise.all([
this.prisma.order.findMany({
where,
include,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
this.prisma.order.count({ where }),
]);
return { result, count, page, limit };
}
getLedgerEntries(customerId: string) {
@@ -0,0 +1,71 @@
import { NotFoundException } from '@nestjs/common';
import { CustomersService } from './customers.service';
describe('CustomersService.getOrThrow', () => {
const base = {
id: 'c1',
fullName: 'Honar',
mobile: '09382507041',
nationalCode: null,
cityId: null,
referredByAgentId: null,
goldCreditLimitGrams: '100',
goldDebtGrams: '1.5',
fiatDebtRial: '2000',
verificationStep: 'VERIFIED',
nationalIdImageUrl: null,
businessLicenseUrl: null,
shopImageUrl: null,
isActive: true,
isArchived: false,
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
};
it('falls back to the app ledger when accounting throws', async () => {
const prisma = {
customer: { findUnique: jest.fn().mockResolvedValue(base) },
};
const accounting = {
customerBalance: jest.fn().mockRejectedValue(new Error('tahesab down')),
};
const service = new CustomersService(prisma as never, accounting as never);
await expect(service.getOrThrow('c1')).resolves.toMatchObject({
id: 'c1',
goldDebtGrams: '1.5',
fiatDebtRial: '2000',
balanceSource: 'app',
balanceAt: null,
});
});
it('overlays the accounting balance when available', async () => {
const prisma = {
customer: { findUnique: jest.fn().mockResolvedValue(base) },
};
const accounting = {
customerBalance: jest.fn().mockResolvedValue({
gold: '3.25',
rial: '9000',
at: new Date('2026-10-17'),
provider: 'ته‌حساب',
}),
};
const service = new CustomersService(prisma as never, accounting as never);
await expect(service.getOrThrow('c1')).resolves.toMatchObject({
goldDebtGrams: '3.25',
fiatDebtRial: '9000',
balanceSource: 'accounting',
});
});
it('404s when the customer is missing', async () => {
const prisma = {
customer: { findUnique: jest.fn().mockResolvedValue(null) },
};
const service = new CustomersService(prisma as never, { customerBalance: jest.fn() } as never);
await expect(service.getOrThrow('missing')).rejects.toBeInstanceOf(NotFoundException);
});
});
@@ -16,10 +16,11 @@ import { CustomersService } from './customers.service';
describe('CustomersService.getOrders — items carry their product', () => {
function setup() {
const findMany = jest.fn().mockResolvedValue([]);
const prisma = { order: { findMany } };
const count = jest.fn().mockResolvedValue(0);
const prisma = { order: { findMany, count } };
// getOrders touches neither the accounting sync nor anything on it.
const service = new CustomersService(prisma as never, {} as never);
return { service, findMany };
return { service, findMany, count };
}
it('includes the variant, its product and the first image', async () => {
@@ -39,6 +40,22 @@ describe('CustomersService.getOrders — items carry their product', () => {
});
});
it('includes status events oldest-first for the detail timeline', async () => {
const { service, findMany } = setup();
await service.getOrders('c1');
expect(findMany.mock.calls[0][0].include.statusEvents).toMatchObject({
orderBy: { createdAt: 'asc' },
select: {
id: true,
fromStatus: true,
toStatus: true,
note: true,
createdAt: true,
},
});
});
it('is scoped to the caller and newest first', async () => {
// Guards the two properties that were already here: one customer must never
// receive another's orders, and the list is reverse-chronological.
@@ -59,4 +76,34 @@ describe('CustomersService.getOrders — items carry their product', () => {
const product = findMany.mock.calls[0][0].include.items.include.productVariant.select.product;
expect(product.where).toBeUndefined();
});
it('pages with skip/take and returns count', async () => {
const { service, findMany, count } = setup();
findMany.mockResolvedValue([{ id: 'o1' }]);
count.mockResolvedValue(41);
await expect(service.getOrders('c1', { page: 2, limit: 20 })).resolves.toEqual({
result: [{ id: 'o1' }],
count: 41,
page: 2,
limit: 20,
});
expect(findMany.mock.calls[0][0]).toMatchObject({ skip: 20, take: 20 });
});
it('filters by status and orderNumber on the server', async () => {
const { service, findMany } = setup();
await service.getOrders('c1', { status: 'PLACED', q: '12' });
expect(findMany.mock.calls[0][0].where).toEqual({
customerId: 'c1',
status: 'PLACED',
orderNumber: { contains: '12', mode: 'insensitive' },
});
});
it('ignores an unknown status instead of throwing', async () => {
const { service, findMany } = setup();
await service.getOrders('c1', { status: 'NOT_A_STATUS' });
expect(findMany.mock.calls[0][0].where).toEqual({ customerId: 'c1' });
});
});
+2 -2
View File
@@ -24,7 +24,7 @@ describe('IamService', () => {
IAM_ISSUER: 'https://auth.test.local',
IAM_CLIENT_ID: 'goldbank_api',
IAM_CLIENT_SECRET: 'server-secret',
IAM_TENANT_SLUG: 'zartaj',
IAM_TENANT_SLUG: 'tehrantaj',
IAM_LOGIN_DELEGATION: 'fallback',
...overrides,
};
@@ -110,7 +110,7 @@ describe('IamService', () => {
expect(body.identifier).toBe('09120000000');
expect(body.client_id).toBe('goldbank_api');
expect(body.client_secret).toBe('server-secret');
expect(body.tenant_slug).toBe('zartaj');
expect(body.tenant_slug).toBe('tehrantaj');
// Without this the IAM would rate-limit on this server's IP and lock
// out every admin after a handful of attempts.
expect(body.client_ip).toBe('203.0.113.7');
@@ -47,7 +47,11 @@ export class CreateOrderDto {
/**
* Which sales channel placed the order. Legacy had no such field because the
* two channels lived in separate tables (orders vs orders_orders); the one
* Order model carries the distinction here. Defaults to RETAIL.
* Order model carries the distinction here.
*
* On the customer (app) path the server forces WHOLESALE and ignores this
* field. Operators may set it; admin MakeOrder always sends WHOLESALE.
* Default when an operator omits it remains RETAIL (retail list builder).
*/
@IsOptional()
@IsEnum(OrderChannel)
@@ -5,6 +5,9 @@ import { OrdersService } from './orders.service';
/** Order SMS is fire-and-forget (D20); these suites only assert order data. */
const smsStub = { send: jest.fn(async () => ({ ok: true })) };
/** Stands in for Postgres `nextval('order_number_seq')` inside the create tx. */
const nextOrderNumberRaw = () => jest.fn(async () => [{ n: 100n }]);
/**
* Owner ruling 2026-10-08: an order is credited to the customer's expert AT
* THE MOMENT IT IS PLACED (legacy `orders.reagent_id`), and a later change of
@@ -20,6 +23,7 @@ describe('OrdersService.createOrder — sales expert credit', () => {
return { id: 'o1', ...data };
}),
},
$queryRaw: nextOrderNumberRaw(),
};
const prisma = {
customer: {
@@ -48,6 +52,12 @@ describe('OrdersService.createOrder — sales expert credit', () => {
expect(created[0].salesAgentId).toBe('agent-A');
});
it('assigns a short numeric orderNumber from the sequence', async () => {
const { service, created } = build(null);
await service.createOrder(dto, 'c1');
expect(created[0].orderNumber).toBe('100');
});
it('records no expert for a customer without one', async () => {
const { service, created } = build(null);
await service.createOrder(dto, 'c1');
@@ -63,7 +73,10 @@ const wageStub = {
/** MON-05 (owner ruling 2026-10-08): «عدم سفارش» stops app orders only. */
describe('OrdersService.createOrder — order ban', () => {
function build(canPlaceOrders: boolean) {
const tx = { order: { create: jest.fn(async ({ data }: { data: object }) => ({ id: 'o1', ...data })) } };
const tx = {
order: { create: jest.fn(async ({ data }: { data: object }) => ({ id: 'o1', ...data })) },
$queryRaw: nextOrderNumberRaw(),
};
const prisma = {
customer: {
findUnique: jest.fn(async () => ({
@@ -98,7 +111,15 @@ describe('OrdersService.createOrder — order ban', () => {
describe('OrdersService.createOrder — server-side making charge', () => {
function build() {
const created: Record<string, any>[] = [];
const tx = { order: { create: jest.fn(async ({ data }: { data: Record<string, any> }) => { created.push(data); return { id: 'o1', ...data }; }) } };
const tx = {
order: {
create: jest.fn(async ({ data }: { data: Record<string, any> }) => {
created.push(data);
return { id: 'o1', ...data };
}),
},
$queryRaw: nextOrderNumberRaw(),
};
const prisma = {
customer: { findUnique: jest.fn(async () => ({ id: 'c1', canPlaceOrders: true, referredByAgentId: null, goldDebtGrams: new Prisma.Decimal(0), goldCreditLimitGrams: new Prisma.Decimal(1000) })) },
$transaction: jest.fn(async (fn: (t: typeof tx) => unknown) => fn(tx)),
@@ -200,6 +221,75 @@ describe('OrdersService.createOrder — server-side making charge', () => {
});
});
/** App orders are B2B wholesale; RETAIL is only for the admin retail list. */
describe('OrdersService.createOrder — channel', () => {
function build() {
const created: Record<string, any>[] = [];
const tx = {
order: {
create: jest.fn(async ({ data }: { data: Record<string, any> }) => {
created.push(data);
return { id: 'o1', ...data };
}),
},
$queryRaw: nextOrderNumberRaw(),
};
const prisma = {
customer: {
findUnique: jest.fn(async () => ({
id: 'c1',
canPlaceOrders: true,
referredByAgentId: null,
goldDebtGrams: new Prisma.Decimal(0),
goldCreditLimitGrams: new Prisma.Decimal(1000),
})),
},
$transaction: jest.fn(async (fn: (t: typeof tx) => unknown) => fn(tx)),
};
const catalog = { getVariantOrThrow: jest.fn(async () => ({ id: 'v1', rashWeightGrams: '10' })) };
const service = new OrdersService(
prisma as never,
catalog as never,
{} as never,
{ orderStatusReached: jest.fn() } as never,
wageStub as never,
smsStub as never,
);
return { service, created };
}
const items = [{ productVariantId: 'v1', quantity: 1, wagePercent: 10 }];
it('stores an app order as WHOLESALE even when the body omits channel', async () => {
const { service, created } = build();
await service.createOrder({ items } as never, 'c1', { placedBy: 'customer' });
expect(created[0].channel).toBe('WHOLESALE');
});
it('ignores a RETAIL channel sent by the app', async () => {
const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
try {
const { service, created } = build();
await service.createOrder({ items, channel: 'RETAIL' } as never, 'c1', { placedBy: 'customer' });
expect(created[0].channel).toBe('WHOLESALE');
expect(warn.mock.calls.map((c) => String(c[0])).join('\n')).toContain('forcing WHOLESALE');
} finally {
warn.mockRestore();
}
});
it('keeps an operator-supplied RETAIL channel', async () => {
const { service, created } = build();
await service.createOrder({ items, channel: 'RETAIL' } as never, 'c1', { placedBy: 'operator' });
expect(created[0].channel).toBe('RETAIL');
});
it('defaults an operator order without channel to RETAIL', async () => {
const { service, created } = build();
await service.createOrder({ items } as never, 'c1', { placedBy: 'operator' });
expect(created[0].channel).toBe('RETAIL');
});
});
/**
* Legacy ApiMobileController::setOrder:733-766 texted three audiences on every
* placed order. None of it was wired in the target until now.
@@ -207,7 +297,10 @@ describe('OrdersService.createOrder — server-side making charge', () => {
describe('OrdersService.createOrder — order SMS', () => {
function build(opts: { overLimit?: boolean; agentId?: string | null; agentMobile?: string | null } = {}) {
const sent: { to: string; body: string }[] = [];
const tx = { order: { create: jest.fn(async ({ data }: { data: object }) => ({ id: 'o1', ...data })) } };
const tx = {
order: { create: jest.fn(async ({ data }: { data: object }) => ({ id: 'o1', ...data })) },
$queryRaw: nextOrderNumberRaw(),
};
const prisma = {
customer: {
findUnique: jest.fn(async () => ({
@@ -241,7 +334,7 @@ describe('OrdersService.createOrder — order SMS', () => {
await service.createOrder(dto, 'c1', { placedBy: 'customer' });
await settle();
const toCustomer = sent.find((s) => s.to === '09120000001');
expect(toCustomer?.body).toContain('به ثبت رسید. زرتاج');
expect(toCustomer?.body).toContain('به ثبت رسید. تهران تاج');
expect(toCustomer?.body).not.toContain('معلق');
});
+34 -5
View File
@@ -135,13 +135,29 @@ export class OrdersService {
.gt(customer.goldCreditLimitGrams);
const initialStatus = isSuspendedOverCreditLimit ? OrderStatus.SUSPENDED : OrderStatus.PLACED;
// App (B2B) orders are always WHOLESALE. Legacy wrote them to `orders`,
// not `orders_orders` (RETAIL). The DTO default was RETAIL and the apps
// never sent `channel`, so every app order landed on the retail list.
// Operators still pick the channel (admin MakeOrder forces WHOLESALE;
// the retail list builder can pass RETAIL). A client-supplied channel on
// the customer path is ignored — same posture as MON-08 pricing.
const channel =
opts.placedBy === 'customer'
? OrderChannel.WHOLESALE
: (dto.channel ?? OrderChannel.RETAIL);
if (opts.placedBy === 'customer' && dto.channel && dto.channel !== OrderChannel.WHOLESALE) {
this.logger.warn(
`Order body carried channel=${dto.channel} from the app; forcing WHOLESALE.`,
);
}
const placed = await this.prisma.$transaction(async (tx) => {
const order = await tx.order.create({
data: {
orderNumber: this.generateOrderNumber(),
orderNumber: await this.nextOrderNumber(tx),
customerId,
branchId: dto.branchId,
channel: dto.channel ?? OrderChannel.RETAIL,
channel,
status: initialStatus,
totalGoldWeightGrams,
wageWeightGrams,
@@ -360,8 +376,21 @@ export class OrdersService {
return order;
}
private generateOrderNumber(): string {
const random = Math.random().toString(36).slice(2, 8).toUpperCase();
return `ORD-${Date.now()}-${random}`;
/**
* Short global sequential number for the customer, SMS and search
* (`100`, `101`, …). Allocated from Postgres `order_number_seq` inside the
* placement transaction so concurrent creates cannot collide. Legacy used
* the auto-increment order id the same way; the old `ORD-{ts}-{rand}` form
* was too long for SMS and the list UI.
*/
private async nextOrderNumber(tx: Prisma.TransactionClient): Promise<string> {
const rows = await tx.$queryRaw<Array<{ n: bigint | number | string }>>`
SELECT nextval('order_number_seq') AS n
`;
const n = rows[0]?.n;
if (n === undefined || n === null) {
throw new Error('order_number_seq returned no value');
}
return String(n);
}
}
+3 -3
View File
@@ -310,18 +310,18 @@ describe('SmsService.send', () => {
/**
* The OTP text keeps the reference's shape (ApiMobileController.php:47) with
* the brand زرتاج — the first line and the trailing token are the Android SMS
* the brand تهران تاج — the first line and the trailing token are the Android SMS
* Retriever hash, and autofill breaks if either changes.
*/
describe('OTP message template', () => {
const build = (code: string) =>
`<#> code is ${code}\n` +
`رمز عبور یکبار مصرف شما جهت ورود به اپلیکیشن زرتاج ${code} می باشد\n` +
`رمز عبور یکبار مصرف شما جهت ورود به اپلیکیشن تهران تاج ${code} می باشد\n` +
`aMo6iFyUQaY`;
it('matches the expected text byte for byte', () => {
expect(build('1234')).toBe(
'<#> code is 1234\nرمز عبور یکبار مصرف شما جهت ورود به اپلیکیشن زرتاج 1234 می باشد\naMo6iFyUQaY',
'<#> code is 1234\nرمز عبور یکبار مصرف شما جهت ورود به اپلیکیشن تهران تاج 1234 می باشد\naMo6iFyUQaY',
);
});
+4 -1
View File
@@ -7,7 +7,10 @@ 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),
// Orders are paged (infinite scroll). The list screen calls api.getMyOrders
// directly; this helper only covers a rare full-first-page peek/invalidate.
myOrders: (o?: FetchOptions) =>
cache.fetch(KEYS.orders, () => api.getMyOrders({ page: 1 }).then((p) => p.result), 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),
+36 -1
View File
@@ -226,6 +226,14 @@ export interface OrderItemDetail {
} | null;
}
export interface OrderStatusEvent {
id: string;
fromStatus: string | null;
toStatus: string;
note: string | null;
createdAt: string;
}
export interface OrderDetail {
id: string;
orderNumber: string;
@@ -237,6 +245,25 @@ export interface OrderDetail {
deliveredAt: string | null;
createdAt: string;
items: OrderItemDetail[];
/** Chronological status changes (oldest first). May be empty on older rows. */
statusEvents?: OrderStatusEvent[];
}
/** One page of GET /customer/me/orders — same envelope as the report endpoints. */
export interface OrdersPage {
result: OrderDetail[];
count: number;
page: number;
limit: number;
}
export interface OrdersQuery {
page?: number;
limit?: number;
/** OrderStatus, or omit / ALL for every status. */
status?: string;
/** Substring of orderNumber. */
q?: string;
}
export interface CustomerDetail {
@@ -509,7 +536,15 @@ export const api = {
getMe: () => request<CustomerDetail>('/customer/me'),
/** D27 §12 — this customer's balance, read from the accounting software on every call. */
getAccountingBalance: () => request<AccountingBalance>('/customer/me/accounting-balance'),
getMyOrders: () => request<OrderDetail[]>('/customer/me/orders'),
getMyOrders: (query: OrdersQuery = {}) => {
const params = new URLSearchParams();
if (query.page) params.set('page', String(query.page));
if (query.limit) params.set('limit', String(query.limit));
if (query.status && query.status !== 'ALL') params.set('status', query.status);
if (query.q?.trim()) params.set('q', query.q.trim());
const qs = params.toString();
return request<OrdersPage>(`/customer/me/orders${qs ? `?${qs}` : ''}`);
},
getMyLedgerEntries: () => request<LedgerEntrySummary[]>('/customer/me/ledger-entries'),
/** The making charge the server will charge for these variants (MON-08). */
getWageQuotes: (variantIds: string[]) =>
@@ -35,7 +35,7 @@
<button v-else type="button" class="reel__all" @click="onAll(i)">
<span class="reel__allMark" aria-hidden="true">
<Icon icon="vuesax-linear:arrow-left-2" size="26" />
<Icon icon="vuesax-linear:arrow-left-1" size="26" />
</span>
<span class="reel__allText">مشاهده همه محصولات جدید</span>
</button>
@@ -108,7 +108,7 @@ const perGram = computed(() => (props.quote ? props.quote.pricePerGramRial.split
align-items: center;
gap: 7px;
font-size: 12px;
color: rgba(255, 255, 255, 0.68);
color: #fff;
white-space: nowrap;
}
@@ -141,12 +141,13 @@ const perGram = computed(() => (props.quote ? props.quote.pricePerGramRial.split
.rate-strip__unit {
font-size: 10.5px;
color: rgba(255, 255, 255, 0.55);
/* Tailwind gray-100 */
color: #f3f4f6;
}
.rate-strip__time {
font-size: 10.5px;
color: rgba(255, 255, 255, 0.42);
color: #fff;
padding-inline-start: 10px;
border-inline-start: 1px solid rgba(255, 255, 255, 0.14);
white-space: nowrap;
@@ -7,7 +7,7 @@
</div>
<button v-if="actionLabel" type="button" class="sec__action" @click="emit('action')">
{{ actionLabel }}
<Icon icon="vuesax-linear:arrow-left-2" size="15" />
<Icon icon="vuesax-linear:arrow-left-1" size="15" />
</button>
</header>
</template>
@@ -30,8 +30,8 @@ const emit = defineEmits<{ (e: 'action'): void }>()
.sec__title {
margin: 0;
font-size: 15px;
font-weight: 800;
font-size: 13.5px;
font-weight: 600;
color: @ink;
letter-spacing: -0.1px;
}
@@ -25,7 +25,7 @@
</template>
<script setup lang="ts">
import { onBeforeMount, ref, watch, type Ref } from 'vue'
import { onMounted, ref, watch, type Ref } from 'vue'
const props = withDefaults(
defineProps<{
@@ -101,7 +101,9 @@ watch(
},
)
onBeforeMount(() => {
// Must be onMounted — onBeforeMount runs before the <input> is in the DOM,
// so autofocus was a silent no-op and the OTP boxes never grabbed focus.
onMounted(() => {
if (input.value && props.focus && props.shouldAutoFocus) {
input.value.focus()
input.value.select()
@@ -14,14 +14,14 @@
-->
<template>
<article class="oc" @click="emit('view', value)">
<span class="oc__mark" :style="{ background: color?.background, color: color?.font }" aria-hidden="true">
<span class="oc__mark" :style="{ background: color.background, color: color.font }" aria-hidden="true">
<Icon icon="vuesax-linear:receipt-text" size="16" />
</span>
<div class="oc__main">
<div class="oc__head">
<h3 class="oc__id">{{ value.orderNumber }}</h3>
<span class="oc__status" :style="{ background: color?.background, color: color?.font }">
<p class="oc__id">{{ value.orderNumber }}</p>
<span class="oc__status" :style="{ background: color.background, color: color.font }">
{{ statusLabel }}
</span>
</div>
@@ -41,7 +41,7 @@
<span class="oc__date">{{ jdate(value.createdAt) }}</span>
<span class="oc__more">
جزئیات
<Icon icon="vuesax-linear:arrow-left-2" size="14" />
<Icon icon="vuesax-linear:arrow-left-1" size="14" />
</span>
</div>
</div>
@@ -51,15 +51,15 @@
<script setup lang="ts">
import { computed } from 'vue'
import Icon from '../icon/Icon.vue'
import { ORDER_STATUS_COLORS, ORDER_STATUS_LABELS } from '../../utils/orderStatus'
import { orderStatusColor, orderStatusLabel } from '../../utils/orderStatus'
import { jdate } from '../../utils/filters'
import type { OrderDetail } from '../../api/client'
const props = defineProps<{ value: OrderDetail }>()
const emit = defineEmits<{ (e: 'view', value: OrderDetail): void }>()
const statusLabel = computed(() => ORDER_STATUS_LABELS[props.value.status] ?? props.value.status)
const color = computed(() => ORDER_STATUS_COLORS[props.value.status])
const statusLabel = computed(() => orderStatusLabel(props.value.status))
const color = computed(() => orderStatusColor(props.value.status))
</script>
<style scoped lang="less">
@@ -104,7 +104,8 @@ const color = computed(() => ORDER_STATUS_COLORS[props.value.status])
.oc__id {
margin: 0;
font-size: 13.5px;
font-weight: 800;
/* yekan_bakh has no 600 face — 600 silently maps to bold (700). */
font-weight: 500;
color: @ink;
white-space: nowrap;
overflow: hidden;
@@ -116,7 +117,7 @@ const color = computed(() => ORDER_STATUS_COLORS[props.value.status])
padding: 4px 11px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 700;
font-weight: 500;
white-space: nowrap;
}
@@ -128,13 +129,13 @@ const color = computed(() => ORDER_STATUS_COLORS[props.value.status])
.oc__figure {
font-size: 12px;
font-weight: 700;
font-weight: 500;
color: @inkSoft;
}
.oc__figure i {
font-style: normal;
font-size: 10px;
font-weight: 600;
font-weight: 400;
color: @inkFaint;
padding-inline-start: 2px;
}
@@ -157,7 +158,7 @@ const color = computed(() => ORDER_STATUS_COLORS[props.value.status])
align-items: center;
gap: 4px;
font-size: 11.5px;
font-weight: 700;
font-weight: 500;
color: @wine;
}
</style>
@@ -15,11 +15,17 @@
-->
<template>
<article class="oi">
<div class="oi__art">
<img v-if="lookup?.imageUrl" v-lazy="lookup.imageUrl" :alt="lookup?.productTitle ?? ''" />
<div v-else class="oi__artEmpty" aria-hidden="true">
<Icon icon="vuesax-linear:diamonds" size="18" />
</div>
<button
v-if="lookup?.imageUrl"
type="button"
class="oi__art"
aria-label="بزرگ‌نمایی تصویر"
@click="previewOpen = true"
>
<img v-lazy="lookup.imageUrl" :alt="lookup?.productTitle ?? ''" />
</button>
<div v-else class="oi__art oi__art--empty" aria-hidden="true">
<Icon icon="vuesax-linear:diamonds" size="18" />
</div>
<div class="oi__main">
@@ -43,11 +49,29 @@
</span>
</div>
</div>
<Teleport to="body">
<Transition name="oi-preview">
<div
v-if="previewOpen && lookup?.imageUrl"
class="oi__preview"
role="dialog"
aria-modal="true"
aria-label="پیش‌نمایش تصویر"
@click.self="previewOpen = false"
>
<button type="button" class="oi__previewClose" aria-label="بستن" @click="previewOpen = false">
<Icon icon="vuesax-linear:close-circle" size="22" />
</button>
<img class="oi__previewImg" :src="lookup.imageUrl" :alt="lookup.productTitle ?? ''" />
</div>
</Transition>
</Teleport>
</article>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref, watch, onBeforeUnmount } from 'vue'
import Icon from '../icon/Icon.vue'
import { orderItemLookup } from '../../composables/useOrderItemProduct'
import type { OrderItemDetail } from '../../api/client'
@@ -58,6 +82,15 @@ const props = defineProps<{ value: OrderItemDetail }>()
// nothing to wait for — the row renders complete on its first frame instead of
// filling in after a catalogue download.
const lookup = computed(() => orderItemLookup(props.value))
const previewOpen = ref(false)
watch(previewOpen, (open) => {
document.body.style.overflow = open ? 'hidden' : ''
})
onBeforeUnmount(() => {
if (previewOpen.value) document.body.style.overflow = ''
})
</script>
<style scoped lang="less">
@@ -74,9 +107,20 @@ const lookup = computed(() => orderItemLookup(props.value))
flex: 0 0 62px;
width: 62px;
height: 62px;
padding: 0;
border: 0;
border-radius: 12px;
overflow: hidden;
background: @hairlineSoft;
cursor: zoom-in;
appearance: none;
}
.oi__art--empty {
display: grid;
place-items: center;
color: @inkFaint;
cursor: default;
}
.oi__art img {
@@ -84,6 +128,7 @@ const lookup = computed(() => orderItemLookup(props.value))
height: 100%;
object-fit: cover;
display: block;
pointer-events: none;
}
// Dark mode: product photos are shot on white, so they sit on a light mat
@@ -94,14 +139,6 @@ html.theme-dark .oi__art img {
border-radius: 12px;
}
.oi__artEmpty {
width: 100%;
height: 100%;
display: grid;
place-items: center;
color: @inkFaint;
}
.oi__main {
flex: 1;
min-width: 0;
@@ -117,7 +154,8 @@ html.theme-dark .oi__art img {
.oi__title {
margin: 0;
font-size: 12.5px;
font-weight: 700;
/* yekan_bakh has no 600 face — 600 silently maps to bold (700). */
font-weight: 500;
color: @ink;
white-space: nowrap;
overflow: hidden;
@@ -139,14 +177,61 @@ html.theme-dark .oi__art img {
.oi__figure {
font-size: 12px;
font-weight: 700;
font-weight: 500;
color: @inkSoft;
}
.oi__figure i {
font-style: normal;
font-size: 10px;
font-weight: 600;
font-weight: 400;
color: @inkFaint;
padding-inline-start: 2px;
}
</style>
<style lang="less">
/* Teleported to body — unscoped so the overlay styles apply. */
.oi__preview {
position: fixed;
inset: 0;
z-index: 2100;
display: grid;
place-items: center;
padding: 28px 18px;
background: rgba(8, 8, 10, 0.88);
backdrop-filter: blur(6px);
}
.oi__previewClose {
position: absolute;
top: 14px;
inset-inline-end: 14px;
width: 40px;
height: 40px;
border: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.12);
color: #fff;
display: grid;
place-items: center;
cursor: pointer;
}
.oi__previewImg {
max-width: min(920px, 100%);
max-height: min(80vh, 100%);
object-fit: contain;
border-radius: 14px;
background: #fff;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.35);
}
.oi-preview-enter-active,
.oi-preview-leave-active {
transition: opacity 0.18s ease;
}
.oi-preview-enter-from,
.oi-preview-leave-to {
opacity: 0;
}
</style>
@@ -151,24 +151,39 @@ import { Slider } from 'ant-design-vue'
import Icon from '../icon/Icon.vue'
import DropDown from '../ui/DropDown.vue'
import type { FiltersQuery } from '../../stores/filtersStore'
import type { ProductSummary } from '../../api/client'
import type { CatalogFacets, ProductSummary } from '../../api/client'
const props = defineProps<{ show: boolean; modelValue: FiltersQuery; filterKeys: string[]; products: ProductSummary[] }>()
const props = defineProps<{
show: boolean
modelValue: FiltersQuery
filterKeys: string[]
/** @deprecated Prefer `facets`. */
products?: ProductSummary[]
facets?: CatalogFacets | null
}>()
const emit = defineEmits<{
(e: 'update:show', value: boolean): void
(e: 'update:filterKeys', value: string[]): void
}>()
const availableBrands = computed(() => {
if (props.facets?.brands?.length) return props.facets.brands
const seen = new Map<string, { id: string; title: string }>()
for (const p of props.products) {
for (const p of props.products ?? []) {
if (p.brand) seen.set(p.brand.id, p.brand)
}
return [...seen.values()]
})
const gramBounds = computed((): [number, number] => {
const weights = props.products.flatMap((p) => p.variants.map((v) => parseFloat(v.rashWeightGrams)))
if (props.facets?.gramRange) {
const min = parseFloat(props.facets.gramRange.min)
const max = parseFloat(props.facets.gramRange.max)
if (Number.isFinite(min) && Number.isFinite(max) && max >= min) {
return [Math.floor(min), Math.ceil(max)]
}
}
const weights = (props.products ?? []).flatMap((p) => p.variants.map((v) => parseFloat(v.rashWeightGrams)))
if (weights.length === 0) return [0, 100]
return [Math.floor(Math.min(...weights)), Math.ceil(Math.max(...weights))]
})
@@ -0,0 +1,82 @@
<!--
Vant PullRefresh with content locked in place — the luxury RefreshOverlay
carries all visual feedback (no page slide-down).
-->
<template>
<PullRefresh
v-model="model"
class="apr"
:head-height="0"
:pull-distance="PULL_DISTANCE"
v-bind="$attrs"
@change="onChange"
@refresh="emit('refresh')"
>
<template #pulling><span /></template>
<template #loosing><span /></template>
<template #loading><span /></template>
<template #success><span /></template>
<slot />
</PullRefresh>
<RefreshOverlay :show="overlayShow" :intensity="overlayIntensity" />
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { PullRefresh } from 'vant'
import RefreshOverlay from './RefreshOverlay.vue'
/** Must be set explicitly — Vant defaults pullDistance to headHeight, which is 0. */
const PULL_DISTANCE = 72
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'refresh'): void
}>()
const model = computed({
get: () => props.modelValue,
set: (v: boolean) => emit('update:modelValue', v),
})
/** 0…1 pull progress while the finger is down; 1 while loading. */
const pullProgress = ref(0)
const overlayShow = computed(() => model.value || pullProgress.value > 0.08)
const overlayIntensity = computed(() =>
model.value ? 1 : Math.min(1, pullProgress.value),
)
function onChange(payload: { status: string; distance: number }) {
if (payload.status === 'loading' || model.value) {
pullProgress.value = 1
return
}
if (payload.status === 'normal') {
pullProgress.value = 0
return
}
pullProgress.value = Math.min(1, payload.distance / PULL_DISTANCE)
}
watch(model, (v) => {
if (!v) pullProgress.value = 0
})
defineOptions({ inheritAttrs: false })
</script>
<style scoped lang="less">
/* Keep page content still — Vant still tracks the gesture, just don't translate. */
.apr {
:deep(.van-pull-refresh__track) {
transform: none !important;
transition: none !important;
}
:deep(.van-pull-refresh__head) {
display: none;
}
}
</style>
@@ -2,6 +2,8 @@
Bottom nav — a compact rounded bar of square, icon-only tiles (Home,
Search, Cart, Orders, Profile). A single square indicator slides behind the
active tile; labels live in aria-label/title instead of under the icon.
On tab switch the newly active icon gets a short spring pop (CSS only).
-->
<template>
<nav class="tabbar" v-show="nav.showNav">
@@ -16,7 +18,10 @@
:key="item.index"
:to="{ name: item.name }"
class="tabbar__item"
:class="{ 'is-active': nav.activeLink === item.index }"
:class="{
'is-active': nav.activeLink === item.index,
'is-popping': popping === item.index,
}"
:aria-label="item.label"
:title="item.label"
>
@@ -27,11 +32,14 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref, watch } from 'vue'
import Icon from '../icon/Icon.vue'
import { useNavStore } from '../../stores/navStore'
const nav = useNavStore()
const popping = ref<number | null>(null)
let popTimer: ReturnType<typeof setTimeout> | null = null
let ready = false
// Index is 1-based; the indicator sits on tile (activeLink - 1). Tiles are a
// fixed size, so one step is one tile plus the gap between tiles.
@@ -41,6 +49,29 @@ const nav = useNavStore()
const indicatorOffset = computed(
() => `calc(${-(nav.activeLink - 1)} * (var(--tile) + var(--gap)))`,
)
watch(
() => nav.activeLink,
(next) => {
// Skip the first paint so opening the app doesn't fire a pop on Home.
if (!ready) {
ready = true
return
}
if (document.documentElement.classList.contains('motion-reduced')) return
// Remount the class so a fast second tap still replays the pop.
popping.value = null
requestAnimationFrame(() => {
popping.value = next
})
if (popTimer) clearTimeout(popTimer)
popTimer = setTimeout(() => {
popping.value = null
popTimer = null
}, 480)
},
)
</script>
<style scoped lang="less">
@@ -57,7 +88,7 @@ const indicatorOffset = computed(
pointer-events: none;
}
// Compact bar that hugs its square tiles instead of spanning the screen.
// Compact solid bar that hugs its square tiles instead of spanning the screen.
.tabbar__inner {
--tile: 52px;
--gap: 6px;
@@ -69,68 +100,13 @@ const indicatorOffset = computed(
padding: var(--pad);
border-radius: 22px;
pointer-events: auto;
// Frosted glass: a thin, mostly clear pane that heavily blurs and
// brightens whatever scrolls beneath it. A light spot sits top-left, a
// faint wine tint pools bottom-right, and the drop shadow lifts the pane.
background:
radial-gradient(120% 140% at 0% 0%, alpha(@canvas, 55%) 0%, alpha(@canvas, 0%) 55%),
radial-gradient(90% 120% at 100% 100%, alpha(@wine, 7%) 0%, alpha(@wine, 0%) 60%),
linear-gradient(160deg, alpha(@canvas, 42%) 0%, alpha(@canvas, 18%) 100%);
-webkit-backdrop-filter: blur(26px) saturate(210%) brightness(1.06);
backdrop-filter: blur(26px) saturate(210%) brightness(1.06);
border: 0;
box-shadow:
inset 0 1px 1px alpha(@canvas, 90%),
inset 0 -8px 16px -10px alpha(@canvas, 60%),
inset 0 0 0 0.5px alpha(@canvas, 35%),
0 18px 40px -14px rgba(20, 22, 26, 0.32),
0 6px 14px -8px alpha(@wine, 22%),
0 1px 3px rgba(20, 22, 26, 0.06);
// Curved specular gloss across the top, like light on a bent pane.
&::before {
content: '';
position: absolute;
inset: 1px 8% auto 8%;
height: 55%;
border-radius: 20px 20px 50% 50% / 20px 20px 100% 100%;
background: linear-gradient(180deg, alpha(@canvas, 60%) 0%, alpha(@canvas, 0%) 100%);
pointer-events: none;
}
// Edge light: a 1px gradient rim, bright where the light hits (top-left)
// and fading out on the far edges. Masked so only the border ring shows.
&::after {
content: '';
position: absolute;
inset: 0;
padding: 1px;
border-radius: inherit;
background: linear-gradient(
145deg,
alpha(@canvas, 100%) 0%,
alpha(@canvas, 35%) 35%,
alpha(@canvas, 10%) 65%,
alpha(@canvas, 70%) 100%
);
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
}
// Browsers without backdrop-filter get a mostly opaque fill so it stays legible.
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
.tabbar__inner {
background: alpha(@canvas, 94%);
}
background: @canvas;
border: 1px solid @hairline;
box-shadow: 0 10px 28px -12px rgba(20, 22, 26, 0.18);
}
// Paints above the tiles' own fill (z-index 1) but below their icons (z 2).
// Soft wash + a quiet drop shadow marks the active tile.
.tabbar__indicator {
position: absolute;
top: var(--pad);
@@ -140,8 +116,10 @@ const indicatorOffset = computed(
height: var(--tile);
border-radius: 16px;
background: @wineWash;
box-shadow: 0 4px 12px -6px rgba(20, 22, 26, 0.25);
transition: transform 0.35s cubic-bezier(0.65, 0, 0.35, 1);
box-shadow:
0 6px 16px -6px rgba(20, 22, 26, 0.28),
0 2px 6px -2px rgba(20, 22, 26, 0.14);
transition: transform 0.28s cubic-bezier(0.65, 0, 0.35, 1);
will-change: transform;
}
@@ -157,13 +135,16 @@ const indicatorOffset = computed(
background: @hairlineSoft;
color: @inkMuted;
text-decoration: none;
transition: color 0.25s ease;
transition: color 0.2s ease;
}
.tabbar__item :deep(svg) {
position: relative;
z-index: 2;
transition: transform 0.3s cubic-bezier(0.65, 0, 0.35, 1.4);
display: block;
// Transform-only: stays on the compositor, no lag from layout/paint.
will-change: transform;
transition: transform 0.2s cubic-bezier(0.65, 0, 0.35, 1);
}
.tabbar__item.is-active {
@@ -174,6 +155,23 @@ const indicatorOffset = computed(
transform: scale(1.08);
}
// Spring pop — transform only (compositor-friendly); no rotate.
.tabbar__item.is-popping :deep(svg) {
animation: tabbar-icon-pop 0.45s cubic-bezier(0.22, 1.35, 0.36, 1) both;
}
@keyframes tabbar-icon-pop {
0% {
transform: scale(0.78);
}
60% {
transform: scale(1.16);
}
100% {
transform: scale(1.08);
}
}
// Scale the icon, not the tile: a transform on the tile would make it a
// stacking context and drop the icon beneath the indicator.
.tabbar__item:active :deep(svg) {
+12 -3
View File
@@ -107,11 +107,17 @@ function onBack() {
}
.bar__title {
// Take the space between the leading/trailing controls. Without flex:1 the
// title was free to shrink (default flex-shrink:1) and overflow:hidden then
// clipped the RTL start of short titles like «میز کار».
flex: 1 1 auto;
min-width: 0;
margin: 0;
font-size: 14.5px;
font-weight: 800;
font-size: 13.5px;
font-weight: 600;
color: @ink;
letter-spacing: -0.1px;
letter-spacing: 0;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -140,10 +146,13 @@ function onBack() {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
min-width: 38px;
}
.bar__spacer {
flex: 0 0 auto;
display: block;
width: 38px;
height: 38px;
@@ -0,0 +1,134 @@
<!--
Full-viewport luxury veil while pull-to-refresh is in flight.
Dark translucent glass + brand mark so the refresh is unmistakable.
Intensity (0…1) fades the veil with the gesture; content itself never moves.
-->
<template>
<Teleport to="body">
<div
v-show="show"
class="rfx"
:class="{ 'rfx--blocking': blocking }"
:style="{ opacity: intensity }"
role="status"
aria-live="polite"
:aria-busy="blocking"
aria-label="در حال تازه‌سازی"
>
<div class="rfx__veil" aria-hidden="true" />
<div
class="rfx__stage"
:style="{ transform: `scale(${0.92 + 0.08 * intensity})` }"
>
<img
class="rfx__logo"
src="/img/brand-logo.png"
:alt="brand.name"
width="140"
height="140"
/>
<div class="rfx__spin" aria-hidden="true">
<Loading type="spinner" size="28px" color="#fff" />
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Loading } from 'vant'
import { brand } from '../../brand'
const props = withDefaults(
defineProps<{
show: boolean
/** 0…1 — fades in with the pull; 1 while the request is in flight. */
intensity?: number
}>(),
{ intensity: 1 },
)
/** Only block touches once refresh has actually started. */
const blocking = computed(() => props.intensity >= 0.99)
</script>
<style scoped lang="less">
.rfx {
position: fixed;
inset: 0;
z-index: 4000;
display: grid;
place-items: center;
pointer-events: none;
transition: opacity 0.12s ease;
}
.rfx--blocking {
pointer-events: all;
}
.rfx__veil {
position: absolute;
inset: 0;
background: rgba(12, 6, 10, 0.58);
backdrop-filter: blur(22px) saturate(140%);
-webkit-backdrop-filter: blur(22px) saturate(140%);
}
.rfx__stage {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 22px;
padding: 28px 32px;
will-change: transform;
}
.rfx__logo {
width: 140px;
height: 140px;
object-fit: contain;
filter: drop-shadow(0 22px 44px rgba(0, 0, 0, 0.5))
drop-shadow(0 0 36px rgba(124, 0, 53, 0.4));
animation: rfx-breathe 1.8s ease-in-out infinite;
}
.rfx__spin {
display: grid;
place-items: center;
width: 48px;
height: 48px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.14);
box-shadow: 0 10px 28px -12px rgba(0, 0, 0, 0.55);
}
@keyframes rfx-breathe {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.045);
}
}
@media (prefers-reduced-motion: reduce) {
.rfx {
transition: none;
}
.rfx__veil {
backdrop-filter: none;
-webkit-backdrop-filter: none;
background: rgba(12, 6, 10, 0.78);
}
.rfx__logo {
animation: none;
filter: drop-shadow(0 12px 24px rgba(0, 0, 0, 0.4));
}
}
</style>
@@ -0,0 +1,65 @@
// Server-side catalogue paging (STUB-03 step 4 / docs/CATALOG-PAGINATION-PLAN.md).
// Replaces "download everything, slice in the browser" for list screens.
import { computed, ref } from 'vue'
import { api, type ProductQuery, type ProductSummary } from '../api/client'
export const CATALOG_PAGE_SIZE = 20
export function usePagedProducts() {
const rows = ref<ProductSummary[]>([])
const total = ref(0)
const page = ref(0)
const loading = ref(false)
const loadingMore = ref(false)
const hasMore = computed(() => rows.value.length < total.value)
/**
* @param opts.preserveRows — pull-to-refresh: keep the current list on screen
* until the new page arrives (avoids an empty flash / stuck full-page spinner).
*/
async function resetAndLoad(query: ProductQuery, opts: { preserveRows?: boolean } = {}) {
const showFullSpinner = !opts.preserveRows || rows.value.length === 0
if (showFullSpinner) loading.value = true
page.value = 1
try {
const res = await api.listProductsPaged({
...query,
page: 1,
limit: query.limit ?? CATALOG_PAGE_SIZE,
})
rows.value = res.rows
total.value = res.total
} finally {
loading.value = false
}
}
async function loadMore(query: ProductQuery) {
if (loading.value || loadingMore.value || !hasMore.value) return
loadingMore.value = true
const next = page.value + 1
try {
const res = await api.listProductsPaged({
...query,
page: next,
limit: query.limit ?? CATALOG_PAGE_SIZE,
})
page.value = next
// Append; drop accidental duplicates if the catalogue shifted between pages.
const seen = new Set(rows.value.map((p) => p.id))
rows.value = [...rows.value, ...res.rows.filter((p) => !seen.has(p.id))]
total.value = res.total
} finally {
loadingMore.value = false
}
}
function clear() {
rows.value = []
total.value = 0
page.value = 0
}
return { rows, total, page, loading, loadingMore, hasMore, resetAndLoad, loadMore, clear }
}
+30 -24
View File
@@ -1,13 +1,9 @@
// Adapted from legacy henza_pwa/src/Mixins/getProducts.ts. Legacy paginated
// against POST /get-products (page/limit/count). The new API's GET
// /catalog/products returns the full active catalog in one flat response with
// no pagination — so `isPull` (legacy's infinite-scroll "next page" trigger)
// is a no-op here, and `totalPages` is always 0. A caller that wants new
// products at the end of the list passes `force` to refresh the cached
// catalogue instead of fabricating fake pages.
import { reactive, watch } from 'vue'
// Newest / list screens: true server paging (STUB-03 step 4).
// Legacy paginated against POST /get-products; the early Nest port returned the
// whole catalogue and treated `@fetch` as a no-op. This restores real pages.
import { reactive } from 'vue'
import type { ProductSummary } from '../api/client'
import { useCatalogStore } from '../stores/catalogStore'
import { usePagedProducts, CATALOG_PAGE_SIZE } from './usePagedProducts'
export interface ProductsData {
totalPages: number
@@ -16,28 +12,38 @@ export interface ProductsData {
}
export function useProducts() {
const catalog = useCatalogStore()
const paged = usePagedProducts()
const productsData: ProductsData = reactive({
totalPages: 0,
productList: catalog.products,
productList: [] as ProductSummary[],
flag: false,
})
// A refresh from another screen (pull-to-refresh) lands here too.
watch(
() => catalog.products,
(list) => {
productsData.productList = list
productsData.flag = list.length < 1
},
)
function sync() {
productsData.productList = paged.rows.value
productsData.totalPages = Math.ceil(paged.total.value / CATALOG_PAGE_SIZE) || 0
productsData.flag = paged.total.value < 1 && !paged.loading.value
}
const getProducts = async (loading: { value: boolean }, isPull = false, force = false) => {
if (isPull) return // no further pages exist server-side yet
loading.value = force || !catalog.loaded
void force
if (isPull) {
if (!paged.hasMore.value || paged.loadingMore.value) return
loading.value = true
try {
await paged.loadMore({ sort: 'newest' })
sync()
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
return
}
loading.value = true
try {
productsData.productList = await catalog.load(force)
productsData.flag = productsData.productList.length < 1
await paged.resetAndLoad({ sort: 'newest' })
sync()
} catch (e) {
console.log(e)
} finally {
@@ -45,5 +51,5 @@ export function useProducts() {
}
}
return { productsData, getProducts }
return { productsData, getProducts, hasMore: paged.hasMore }
}
+16 -17
View File
@@ -1,9 +1,5 @@
// 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.
// The storefront catalogue slice shared by home rails and leftover callers.
// List/search screens page via api.listProductsPaged (STUB-03 step 4).
import { defineStore } from 'pinia'
import { computed, ref, shallowRef } from 'vue'
import { api, type ProductSummary } from '../api/client'
@@ -11,10 +7,8 @@ import { KEYS, useQueryCache } from './queryCache'
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(() => {
@@ -25,27 +19,32 @@ export const useCatalogStore = defineStore('catalog', () => {
return [...seen.values()]
})
// `variantLookup` lived here: a variantId -> {productTitle, variantTitle,
// imageUrl} map built over the whole catalogue, used only to label order
// rows. The server now includes those fields with the order itself
// (customers.service.ts getOrders), so the map is gone rather than left as a
// reason to keep loading the full catalogue.
async function load(force = false): Promise<ProductSummary[]> {
const list = await cache.fetch(KEYS.catalog, api.listProducts, { force })
const list = await cache.fetch(
KEYS.catalog,
async () => {
const page = await api.listProductsPaged({ page: 1, limit: 100, sort: 'newest' })
return page.rows
},
{ force },
)
products.value = list
loaded.value = true
return list
}
function replace(list: ProductSummary[]) {
products.value = list
loaded.value = true
}
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, load, byId, invalidate }
return { products, loaded, categories, load, replace, byId, invalidate }
})
+16
View File
@@ -20,3 +20,19 @@ export function jdate(val: string | undefined | null): string {
}
return val as any
}
/** Jalali date + time for status timelines and similar detail surfaces. */
export function jdatetime(val: string | undefined | null): string {
if (val) {
return moment(val).format('jYYYY/jMM/jDD HH:mm')
}
return val as any
}
/** Compact timeline stamp — e.g. «25 مهر، 11:19» (reference card style). */
export function jtimelineWhen(val: string | undefined | null): string {
if (val) {
return moment(val).format('jD jMMMM، HH:mm')
}
return val as any
}
+80 -43
View File
@@ -1,59 +1,96 @@
// Persian labels ported from the legacy Laravel Order::$STS enum (henza_server
// app/Models/Order.php) — the authoritative source, not henza_pwa's own
// Orders.vue filter-tab labels, which used a slightly different, inconsistent
// set (its key=0 was labeled "لغو شده"/cancelled, while the actual backend
// model's sts=0 means "معلق"/suspended — legacy itself was inconsistent here).
// Our OrderStatus enum cleanly separates SUSPENDED and CANCELLED, so mapped
// directly, one label per real status.
// Persian labels for the real OrderStatus enum (apps/api prisma schema).
// One label per status — shared by filter chips, list cards, and detail.
export const ORDER_STATUS_LABELS: Record<string, string> = {
SUSPENDED: 'معلق',
PLACED: 'ثبت سفارش',
ADMIN_APPROVED: 'تایید مدیر سیستم',
SOURCING: 'درحال تامین',
PREPARING: 'درحال آماده سازی',
DELIVERED_TO_AGENT: 'تحویل مسئول فروش',
ADMIN_APPROVED: 'تایید مدیر',
SOURCING: 'در حال تامین',
PREPARING: 'آماده‌سازی',
DELIVERED_TO_AGENT: 'تحویل نماینده',
DELIVERED_TO_CUSTOMER: 'تحویل مشتری',
CANCELLED: 'لغو شده',
};
}
/** Short blurbs under timeline titles (reference card style). */
export const ORDER_STATUS_BLURBS: Record<string, string> = {
SUSPENDED: 'سفارش به‌دلیل سقف اعتبار معلق شد',
PLACED: 'سفارش ثبت و دریافت شد',
ADMIN_APPROVED: 'سفارش توسط مدیر تایید شد',
SOURCING: 'کالا در حال تامین است',
PREPARING: 'سفارش در حال آماده‌سازی است',
DELIVERED_TO_AGENT: 'تحویل به نماینده فروش انجام شد',
DELIVERED_TO_CUSTOMER: 'سفارش به مشتری تحویل شد',
CANCELLED: 'سفارش لغو شده است',
}
/** Line icons for the timeline nodes (vuesax-linear set). */
export const ORDER_STATUS_ICONS: Record<string, string> = {
SUSPENDED: 'vuesax-linear:bag-timer',
PLACED: 'vuesax-linear:bag-tick',
ADMIN_APPROVED: 'vuesax-linear:verify',
SOURCING: 'vuesax-linear:box-search',
PREPARING: 'vuesax-linear:box-1',
DELIVERED_TO_AGENT: 'vuesax-linear:truck-time',
DELIVERED_TO_CUSTOMER: 'vuesax-linear:location-tick',
CANCELLED: 'vuesax-linear:bag-cross',
}
/** Happy-path pipeline shown in the detail stepper (excludes terminal SUSPENDED / CANCELLED). */
export const ORDER_PIPELINE = [
'PLACED',
'ADMIN_APPROVED',
'SOURCING',
'PREPARING',
'DELIVERED_TO_AGENT',
'DELIVERED_TO_CUSTOMER',
] as const
export type OrderPipelineStatus = (typeof ORDER_PIPELINE)[number]
/** Step titles = system status labels, in pipeline order. */
export const ORDER_STEPS = ORDER_PIPELINE.map((status) => ({
status,
title: ORDER_STATUS_LABELS[status]!,
}))
// Ported from legacy order.vue/Orders.vue's setColor() — legacy only ever
// colored sts 1,3,4,5,6 (never sts=0 or sts=2), an existing gap preserved here
// for the directly-analogous statuses (ADMIN_APPROVED, SUSPENDED, CANCELLED
// stay uncolored, same as legacy's untouched sts=0/2).
//
// Backgrounds are the font colour at 12% alpha (the trailing 1F), not the
// opaque pale tints they used to be: a translucent wash reads the same on a
// white card and stays a soft tint on a dark one, where #EFF6FF & co. were
// glaring light patches.
export const ORDER_STATUS_COLORS: Record<string, { font: string; background: string }> = {
SUSPENDED: { font: '#A16207', background: '#A162071F' },
PLACED: { font: '#3B82F6', background: '#3B82F61F' },
ADMIN_APPROVED: { font: '#6366F1', background: '#6366F11F' },
SOURCING: { font: '#EAB308', background: '#EAB3081F' },
PREPARING: { font: '#F97316', background: '#F973161F' },
DELIVERED_TO_AGENT: { font: '#06B6D4', background: '#06B6D41F' },
DELIVERED_TO_CUSTOMER: { font: '#22C55E', background: '#22C55E1F' },
};
CANCELLED: { font: '#DC2626', background: '#DC26261F' },
}
// Ported from legacy Orders.vue's 4-step tracker (`steps`/`setCurrentStep`).
// Legacy's ambiguous sts=0 (suspended vs cancelled) is resolved here since our
// enum keeps them distinct; SUSPENDED is grouped with the first step (not yet
// progressing), matching the spirit of legacy's grouping of early statuses.
export const ORDER_STEPS = ['ثبت سفارش', 'آماده سازی', 'تحویل مشتری', 'لغو شده'];
const FALLBACK_COLOR = { font: '#64748B', background: '#64748B1F' }
const FALLBACK_ICON = 'vuesax-linear:receipt-text'
export function orderStatusColor(status: string) {
return ORDER_STATUS_COLORS[status] ?? FALLBACK_COLOR
}
export function orderStatusLabel(status: string) {
return ORDER_STATUS_LABELS[status] ?? status
}
export function orderStatusBlurb(status: string) {
return ORDER_STATUS_BLURBS[status] ?? ''
}
export function orderStatusIcon(status: string) {
return ORDER_STATUS_ICONS[status] ?? FALLBACK_ICON
}
/**
* Index into ORDER_PIPELINE for the active step.
* CANCELLED → -1 (no pipeline progress).
* SUSPENDED → 0 (stuck at placement / first step).
*/
export function orderStepIndex(status: string): number {
switch (status) {
case 'PLACED':
case 'ADMIN_APPROVED':
case 'SUSPENDED':
return 0;
case 'SOURCING':
case 'PREPARING':
return 1;
case 'DELIVERED_TO_AGENT':
case 'DELIVERED_TO_CUSTOMER':
return 2;
case 'CANCELLED':
return 3;
default:
return 0;
}
if (status === 'CANCELLED') return -1
if (status === 'SUSPENDED') return 0
const i = (ORDER_PIPELINE as readonly string[]).indexOf(status)
return i >= 0 ? i : 0
}
+9 -10
View File
@@ -27,16 +27,12 @@
-->
<template>
<div class="cart">
<PageBar title="سبد خرید" back-to="home" />
<PageBar title="سبد خرید" :back="false" />
<Spinner :spinning="cartData.loadingPage" tip="درحال دریافت اطلاعات">
<container class="cart__body">
<!-- Step 1: the basket -->
<PullRefresh v-if="cartData.step === 1" v-model="refreshing" @refresh="onRefresh">
<template #pulling><Loading size="18" /></template>
<template #loosing><Loading size="18" /></template>
<template #loading><Loading size="18" /></template>
<template #success><span /></template>
<AppPullRefresh v-if="cartData.step === 1" v-model="refreshing" @refresh="onRefresh">
<template v-if="cart.items.length">
<div class="cart__items" :class="{ 'is-revealed': revealed }">
<CartItem
@@ -62,7 +58,7 @@
دیدن محصولات
</button>
</section>
</PullRefresh>
</AppPullRefresh>
<!-- Step 2: submitted -->
<section v-else class="cart__done">
@@ -141,7 +137,7 @@
import { nextTick, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Drawer, Form, FormItem, Textarea } from 'ant-design-vue'
import { Loading, PullRefresh } from 'vant'
import AppPullRefresh from '../components/ui/AppPullRefresh.vue'
import Icon from '../components/icon/Icon.vue'
import PageBar from '../components/ui/PageBar.vue'
import Spinner from '../components/Spinner.vue'
@@ -244,8 +240,11 @@ const submitOrder = async () => {
}
async function onRefresh() {
await cart.init(true)
refreshing.value = false
try {
await cart.init(true)
} finally {
refreshing.value = false
}
}
onMounted(async () => {
+33 -33
View File
@@ -27,12 +27,7 @@
</div>
</header>
<PullRefresh v-model="refreshing" @refresh="onRefresh">
<template #pulling><Loading size="18" /></template>
<template #loosing><Loading size="18" /></template>
<template #loading><Loading size="18" /></template>
<template #success><span /></template>
<AppPullRefresh v-model="refreshing" @refresh="onRefresh">
<container class="home__body" :class="{ 'is-revealed': revealed }">
<HeroCarousel
v-if="slides.length"
@@ -74,7 +69,7 @@
<p>هنوز محصولی در مجموعه ثبت نشده است.</p>
</section>
</container>
</PullRefresh>
</AppPullRefresh>
</div>
</template>
@@ -82,7 +77,7 @@
import { brand } from '../brand'
import { computed, nextTick, onActivated, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Loading, PullRefresh } from 'vant'
import AppPullRefresh from '../components/ui/AppPullRefresh.vue'
import Icon from '../components/icon/Icon.vue'
import { openProduct as openProductMorph } from '../composables/useProductMorph'
import RateStrip from '../components/home/RateStrip.vue'
@@ -94,7 +89,7 @@ import ProductCarousel from '../components/home/ProductCarousel.vue'
import { useConfigStore } from '../stores/configStore'
import type { HomeCategory } from '../stores/configStore'
import { useFiltersStore } from '../stores/filtersStore'
import type { AdminSlide, GoldPriceQuote, ProductSummary } from '../api/client'
import { api, type AdminSlide, type GoldPriceQuote, type ProductSummary } from '../api/client'
import { useCachedReads } from '../api/cachedReads'
import { useCatalogStore } from '../stores/catalogStore'
import { KEYS } from '../stores/queryCache'
@@ -178,15 +173,18 @@ function goNewest() {
// cached until pull-to-refresh asks the server again.
async function loadData(force = false) {
const [catalogResult, catsResult, priceResult, slidesResult] = await Promise.allSettled([
// The home rails read `catalog.products`, so the catalogue is loaded
// explicitly. It used to come in as a side effect of `memory.init()`, which
// now reads the small facets endpoint instead of the whole catalogue — so
// without this call the home screen would render empty rails.
//
// Still the unpaged list: the rails slice it locally («جدیدترین», per
// category). Moving them onto paged reads is step 4 of
// docs/CATALOG-PAGINATION-PLAN.md.
catalog.load(force),
// STUB-03: rails need a small newest page. Pull-to-refresh must drop the
// cached catalogue first (same rule as before paging) or the screen keeps
// showing the previous slice while gold/slides refresh.
(async () => {
if (force) catalog.invalidate()
const page = await api.listProductsPaged({
page: 1,
limit: Math.max(REEL_LIMIT, LIST_LIMIT),
sort: 'newest',
})
catalog.replace(page.rows)
})(),
memory.init(force),
reads.goldPrice({ force }),
reads.homeSlider({ force }),
@@ -201,8 +199,13 @@ async function loadData(force = false) {
}
async function onRefresh() {
await loadData(true)
refreshing.value = false
try {
await loadData(true)
} finally {
// Always release Vant's PullRefresh, even when a request fails — otherwise
// the spinner sticks until a full reload (same bug Dashboard already fixed).
refreshing.value = false
}
}
onMounted(async () => {
@@ -252,25 +255,22 @@ onActivated(async () => {
min-height: 100vh;
}
// Same chrome as components/ui/PageBar.vue — one look for every top bar.
.home__bar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 20;
background: alpha(@canvas, 82%);
backdrop-filter: blur(18px) saturate(140%);
// A hairline rule cut a hard line across the page. A very soft fade under
// the bar separates it from the content that scrolls beneath without
// drawing an edge.
border-bottom: 1px solid alpha(@wine, 5%);
box-shadow: 0 8px 20px -18px alpha(@wine, 55%);
background: alpha(@canvas, 88%);
backdrop-filter: blur(14px);
border-bottom: 1px solid @hairline;
}
.home__barInner {
max-width: 768px;
margin: 0 auto;
padding: 10px 16px;
padding: 11px 16px;
display: flex;
align-items: center;
justify-content: space-between;
@@ -278,15 +278,15 @@ onActivated(async () => {
}
.home__logo {
// Sized to match the pill's height so the two sides of the bar carry equal
// optical weight instead of the logo looking stranded next to it.
height: 25px;
// Tall enough to read at a glance; still fits the bar next to RateStrip.
height: 36px;
width: auto;
object-fit: contain;
}
.home__body {
// Clears the fixed bar (~50px) plus a little breathing room.
padding-top: 58px;
// Clears the fixed bar (same offset as PageBar / CartView).
padding-top: 76px;
padding-bottom: 90px;
}
+7 -32
View File
@@ -1,12 +1,8 @@
<!--
Ported from legacy henza_pwa/src/views/newest.vue, restyled into the
redesigned design language (see components/home/* and HomeView).
See useProducts.ts for the pagination-simplification note.
redesigned design language.
Data wiring is untouched: the same useProducts() composable, the same
pull-to-fetch pagination, and the same ScrollFetchSearch infinite scroll.
Only the chrome and the card change — AppHeader -> PageBar, and the
Tailwind-utility product rows -> the shared ProductCard grid.
STUB-03 step 4: real server paging via useProducts / listProductsPaged.
-->
<template>
<div class="newest">
@@ -24,12 +20,9 @@
<SectionHead title="تازه‌ها" subtitle="آخرین افزوده‌های مجموعه" />
<div v-if="productsData.productList.length" class="newest__grid">
<ProductCard v-for="item in visibleProducts" :key="item.id" :value="item" />
<ProductCard v-for="item in productsData.productList" :key="item.id" :value="item" />
</div>
<!-- Previously this view rendered its heading over blank space when
the catalogue came back empty. Same empty state the other
catalogue screens use, chained to the results above it. -->
<section v-else-if="!loadingPage" class="newest__empty">
<Icon icon="vuesax-linear:box" size="26" />
<p>هنوز محصولی در مجموعه ثبت نشده است.</p>
@@ -41,7 +34,7 @@
</template>
<script setup lang="ts">
import { computed, onBeforeMount, ref } from 'vue'
import { onBeforeMount, ref } from 'vue'
import Icon from '../components/icon/Icon.vue'
import PageBar from '../components/ui/PageBar.vue'
import Spinner from '../components/Spinner.vue'
@@ -50,35 +43,18 @@ import ProductCard from '../components/product/ProductCard.vue'
import ScrollFetchSearch from '../components/ui/ScrollFetchSearch.vue'
import { useProducts } from '../composables/useProducts'
const { productsData, getProducts } = useProducts()
const { productsData, getProducts, hasMore } = useProducts()
const loadingPage = ref(false)
const loadingPull = ref(false)
// ── Incremental rendering ────────────────────────────────────────────────
// useProducts() accumulates the fetched list, and this port fetches the whole
// (small) catalogue rather than truly paging the server. Mounting every card
// at once puts hundreds of images on screen, so render a window and grow it
// each time ScrollFetchSearch reports the user reached the end — which is the
// same @fetch signal that used to drive pagination.
const PAGE_SIZE = 20
const visibleCount = ref(PAGE_SIZE)
const visibleProducts = computed(() => productsData.productList.slice(0, visibleCount.value))
onBeforeMount(async () => {
await getProducts(loadingPage, false)
})
const pull = async () => {
// Reveal more of what is already loaded first; only go back to the server
// once the whole fetched list is on screen. Reaching the end is how this
// page asks for new products: the cached catalogue is refreshed.
if (visibleCount.value < productsData.productList.length) {
visibleCount.value += PAGE_SIZE
return
}
if (loadingPull.value) return
await getProducts(loadingPull, false, true)
if (loadingPull.value || !hasMore.value) return
await getProducts(loadingPull, true)
}
</script>
@@ -103,7 +79,6 @@ const pull = async () => {
gap: 14px;
}
// Same empty state the other catalogue screens draw.
.newest__empty {
margin-top: 8px;
padding: 34px 20px;
@@ -36,11 +36,7 @@
</PageBar>
<Spinner :spinning="loading && !rows.length" tip="درحال دریافت اطلاعات">
<PullRefresh v-model="refreshing" @refresh="onRefresh">
<template #pulling><Loading size="18" /></template>
<template #loosing><Loading size="18" /></template>
<template #loading><Loading size="18" /></template>
<template #success><span /></template>
<AppPullRefresh v-model="refreshing" @refresh="onRefresh">
<container class="cho__body">
<template v-if="rows.length">
<div class="cho__list">
@@ -99,7 +95,7 @@
<!-- Sentinel for the infinite scroll, as the reference used (:216). -->
<div ref="sentinel" class="cho__sentinel" />
</container>
</PullRefresh>
</AppPullRefresh>
</Spinner>
<Drawer
@@ -146,7 +142,7 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { Drawer } from 'ant-design-vue'
import { Loading, PullRefresh } from 'vant'
import AppPullRefresh from '../../components/ui/AppPullRefresh.vue'
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue'
@@ -209,8 +205,11 @@ async function readSearch(value: string | undefined) {
}
async function onRefresh() {
await reload()
refreshing.value = false
try {
await reload()
} finally {
refreshing.value = false
}
}
function openDetail(item: ExpertChildOrder) {
@@ -40,11 +40,7 @@
</PageBar>
<Spinner :spinning="loading && !rows.length" tip="درحال دریافت اطلاعات">
<PullRefresh v-model="refreshing" @refresh="onRefresh">
<template #pulling><Loading size="18" /></template>
<template #loosing><Loading size="18" /></template>
<template #loading><Loading size="18" /></template>
<template #success><span /></template>
<AppPullRefresh v-model="refreshing" @refresh="onRefresh">
<container class="cch__body">
<div v-if="rows.length" class="cch__list">
<article
@@ -88,7 +84,7 @@
<button type="button" class="cch__retry" @click="load">تلاش دوباره</button>
</section>
</container>
</PullRefresh>
</AppPullRefresh>
</Spinner>
<Drawer
@@ -143,7 +139,7 @@
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Drawer } from 'ant-design-vue'
import { Loading, PullRefresh } from 'vant'
import AppPullRefresh from '../../components/ui/AppPullRefresh.vue'
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue'
@@ -196,8 +192,11 @@ async function readSearch(value: string | undefined) {
}
async function onRefresh() {
await load()
refreshing.value = false
try {
await load()
} finally {
refreshing.value = false
}
}
function openDetail(item: ExpertCustomer) {
@@ -52,11 +52,7 @@
</PageBar>
<Spinner :spinning="loading && !rows.length && !details.length" tip="درحال دریافت اطلاعات">
<PullRefresh v-model="refreshing" @refresh="onRefresh">
<template #pulling><Loading size="18" /></template>
<template #loosing><Loading size="18" /></template>
<template #loading><Loading size="18" /></template>
<template #success><span /></template>
<AppPullRefresh v-model="refreshing" @refresh="onRefresh">
<container class="rep__body">
<!-- ── کلیات حساب ─────────────────────────────────────────── -->
<template v-if="activeKey === '1'">
@@ -124,7 +120,7 @@
<div v-if="loading && (rows.length || details.length)" class="rep__more">در حال دریافت…</div>
<div ref="sentinel" class="rep__sentinel" />
</container>
</PullRefresh>
</AppPullRefresh>
</Spinner>
<Drawer
@@ -151,7 +147,7 @@
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { Drawer } from 'ant-design-vue'
import { Loading, PullRefresh } from 'vant'
import AppPullRefresh from '../../components/ui/AppPullRefresh.vue'
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue'
@@ -243,8 +239,11 @@ async function changeTab(key: '1' | '2') {
}
async function onRefresh() {
await reload()
refreshing.value = false
try {
await reload()
} finally {
refreshing.value = false
}
}
/** The «جزئیات» link — the reference opened the same card in a modal. */
@@ -1,15 +1,9 @@
<!--
Ported from legacy henza_pwa/src/views/search/collection/index.vue, restyled
into the redesigned design language (see components/home/* and HomeView).
into the redesigned design language.
Filters the one flat product list client-side by collection id (route
param) — no server-side ciny_id query param exists; consistent with the
rest of this port's "small catalog, client-side filtering" approach. That
logic is carried over unchanged.
The view no longer owns a Drawer: ProductCard opens the product detail
itself, the same way it does on every other catalogue screen, so the
detail behaviour is identical everywhere instead of re-implemented here.
STUB-03 step 4: products for this collection come from
GET /catalog/products?collectionId=&page=&limit= — not a full-catalogue scan.
-->
<template>
<div class="coll">
@@ -19,11 +13,15 @@
<container class="coll__body">
<SectionHead title="محصولات این مجموعه" subtitle="برای دیدن جزئیات، روی محصول ضربه بزنید" />
<div v-if="collectionProducts.length" class="coll__grid">
<ProductCard v-for="item in collectionProducts" :key="item.id" :value="item" />
<div v-if="rows.length" class="coll__grid">
<ProductCard v-for="item in rows" :key="item.id" :value="item" />
</div>
<section v-else-if="!loadingPage" class="coll__empty">
<div v-if="hasMore || loadingMore" ref="sentinel" class="coll__more">
<Spinner :spinning="true" :indicator="true" :fixed="false" tip="" cls="my-[10px]" />
</div>
<section v-else-if="!loadingPage && !rows.length" class="coll__empty">
<Icon icon="vuesax-linear:box" size="26" />
<p>در این مجموعه محصولی ثبت نشده است.</p>
</section>
@@ -33,37 +31,51 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue'
import SectionHead from '../../components/home/SectionHead.vue'
import ProductCard from '../../components/product/ProductCard.vue'
import { useCatalogStore } from '../../stores/catalogStore'
import { usePagedProducts } from '../../composables/usePagedProducts'
const route = useRoute()
const catalog = useCatalogStore()
const { rows, loading, loadingMore, hasMore, resetAndLoad, loadMore } = usePagedProducts()
const loadingPage = ref(false)
const allProducts = computed(() => catalog.products)
// legacy passed the collection's title through as ?page=<title>; kept, with a
// neutral fallback so the bar is never blank on a direct link.
const loadingPage = computed(() => loading.value && rows.value.length === 0)
const collectionTitle = computed(() => (route.query.page as string) || 'مجموعه')
const collectionId = computed(() => String(route.params.id ?? ''))
const collectionProducts = computed(() =>
allProducts.value.filter((p) => p.collections.some((c) => c.collectionId === route.params.id)),
)
const sentinel = ref<HTMLElement | null>(null)
let observer: IntersectionObserver | undefined
function query() {
return { collectionId: collectionId.value, sort: 'newest' as const }
}
function mountObserver() {
observer?.disconnect()
if (!sentinel.value) return
observer = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting) && hasMore.value) {
void loadMore(query())
}
},
{ rootMargin: '400px' },
)
observer.observe(sentinel.value)
}
watch(sentinel, mountObserver)
onBeforeUnmount(() => observer?.disconnect())
onMounted(async () => {
loadingPage.value = !catalog.loaded
try {
await catalog.load()
await resetAndLoad(query())
} catch (e) {
console.log(e)
} finally {
loadingPage.value = false
}
})
</script>
@@ -85,6 +97,12 @@ onMounted(async () => {
gap: 14px;
}
.coll__more {
display: flex;
justify-content: center;
padding: 18px 0 4px;
}
.coll__empty {
margin-top: 8px;
padding: 34px 20px;
+84 -110
View File
@@ -14,7 +14,7 @@
-->
<template>
<div class="search">
<PageBar title="جستجو">
<PageBar title="جستجو" :back="false">
<template #below>
<div class="search__barExtra">
<SearchInput @search="search" place-holder="جستجوی دسته بندی، محصولات و ..." />
@@ -54,155 +54,92 @@
</PageBar>
<Spinner :spinning="loadingPage" tip="درحال دریافت اطلاعات">
<PullRefresh v-model="refreshing" @refresh="onRefresh">
<template #pulling><Loading size="18" /></template>
<template #loosing><Loading size="18" /></template>
<template #loading><Loading size="18" /></template>
<template #success><span /></template>
<container class="search__body">
<div v-if="filteredProducts.length" class="search__grid">
<ProductCard v-for="item in visibleProducts" :key="item.id" :value="item" />
</div>
<AppPullRefresh v-model="refreshing" @refresh="onRefresh">
<container class="search__body">
<div v-if="rows.length" class="search__grid">
<ProductCard v-for="item in rows" :key="item.id" :value="item" />
</div>
<!-- The catalogue is fetched in one request (no server-side paging
exists), but rendering all of it at once mounts hundreds of cards
and images on a phone. Render a window and grow it as the user
reaches the end — the same incremental behaviour the previous
ScrollFetchSearch-based list had. -->
<div v-if="hasMore" ref="sentinel" class="search__more">
<Spinner :spinning="true" :indicator="true" :fixed="false" tip="" cls="my-[10px]" />
</div>
<div v-if="hasMore || loadingMore" ref="sentinel" class="search__more">
<Spinner :spinning="true" :indicator="true" :fixed="false" tip="" cls="my-[10px]" />
</div>
<!-- Keyed off the result count, NOT chained to the sentinel above it.
As a `v-else-if` on `hasMore` this rendered whenever the window had
caught up with the list — i.e. at the end of every fully-rendered
result set — so "nothing found" sat under a screen full of
products. -->
<section v-if="!filteredProducts.length && !loadingPage" class="search__empty">
<Icon icon="vuesax-linear:search-normal-1" size="26" />
<p>محصولی با این مشخصات پیدا نشد.</p>
</section>
</container>
</PullRefresh>
<section v-if="!rows.length && !loadingPage" class="search__empty">
<Icon icon="vuesax-linear:search-normal-1" size="26" />
<p>محصولی با این مشخصات پیدا نشد.</p>
</section>
</container>
</AppPullRefresh>
</Spinner>
<!-- A bottom sheet over a see-through backdrop (teleported to <body>);
see Filters.vue. The result list stays in place underneath. -->
<Filters
v-model:show="showFilter"
v-model:filterKeys="filterKeys"
v-model="filters.query"
:products="allProducts"
:facets="facets"
/>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { PullRefresh } from 'vant'
import AppPullRefresh from '../../components/ui/AppPullRefresh.vue'
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue'
import SearchInput from '../../components/search/SearchInput.vue'
import Filters from '../../components/search/Filters.vue'
import SectionHead from '../../components/home/SectionHead.vue'
import ProductCard from '../../components/product/ProductCard.vue'
import { api, type CatalogFacets, type ProductQuery } from '../../api/client'
import { useFiltersStore } from '../../stores/filtersStore'
import { useCatalogStore } from '../../stores/catalogStore'
import { usePagedProducts } from '../../composables/usePagedProducts'
const filters = useFiltersStore()
const catalog = useCatalogStore()
const { rows, loading, loadingMore, hasMore, resetAndLoad, loadMore } = usePagedProducts()
const loadingPage = ref(false)
const loadingPage = computed(() => loading.value && rows.value.length === 0)
const refreshing = ref(false)
const allProducts = computed(() => catalog.products)
const showFilter = ref(false)
const filterKeys = ref<string[]>([])
const facets = ref<CatalogFacets | null>(null)
const availableCategories = computed(() => {
const seen = new Map<string, { id: string; title: string }>()
for (const p of allProducts.value) {
if (p.category) seen.set(p.category.id, p.category)
}
return [...seen.values()]
})
const availableCategories = computed(() => facets.value?.categories ?? [])
const availableCollections = computed(() => {
const seen = new Map<string, { id: string; title: string }>()
for (const p of allProducts.value) {
for (const c of p.collections) seen.set(c.collection.id, { id: c.collection.id, title: c.collection.title })
}
return [...seen.values()]
})
const filteredProducts = computed(() => {
let list = allProducts.value
function toServerQuery(): ProductQuery {
const q = filters.query
if (q.text) list = list.filter((p) => p.title.includes(q.text!))
if (q.cats && q.cats.length > 0) list = list.filter((p) => p.category && q.cats!.includes(p.category.id))
if (q.brands.length > 0) list = list.filter((p) => p.brand && q.brands.includes(p.brand.id))
const out: ProductQuery = { sort: 'newest' }
if (q.text?.trim()) out.search = q.text.trim()
if (q.cats?.[0]) out.categoryId = q.cats[0]
if (q.brands.length) out.brandId = q.brands.join(',')
if (q.limit_gram.length === 2) {
list = list.filter((p) =>
p.variants.some(
(v) => parseFloat(v.rashWeightGrams) >= q.limit_gram[0] && parseFloat(v.rashWeightGrams) <= q.limit_gram[1],
),
)
out.minGram = q.limit_gram[0]
out.maxGram = q.limit_gram[1]
}
return list
})
// ── Incremental rendering ────────────────────────────────────────────────
// The whole catalogue arrives in one request (no server-side paging exists),
// so the filtering above is instant — but mounting every matching card at once
// means hundreds of cards and images on a phone. Render a window instead and
// grow it when the sentinel at the end scrolls into view.
const PAGE_SIZE = 20
const visibleCount = ref(PAGE_SIZE)
const visibleProducts = computed(() => filteredProducts.value.slice(0, visibleCount.value))
const hasMore = computed(() => visibleCount.value < filteredProducts.value.length)
const sentinel = ref<HTMLElement | null>(null)
let observer: IntersectionObserver | undefined
function mountObserver() {
observer?.disconnect()
if (!sentinel.value) return
observer = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting) && hasMore.value) {
visibleCount.value += PAGE_SIZE
}
},
{ rootMargin: '400px' },
)
observer.observe(sentinel.value)
return out
}
// Any change to the filters restarts the window from the top.
watch(filteredProducts, () => {
visibleCount.value = PAGE_SIZE
})
// The sentinel is v-if'd, so re-attach whenever it appears or is replaced.
watch(sentinel, mountObserver)
onBeforeUnmount(() => observer?.disconnect())
async function loadProducts(force = false) {
// Cached data renders at once; the spinner is only for a cold start.
loadingPage.value = !catalog.loaded
async function loadFacets() {
try {
await catalog.load(force)
facets.value = await api.getCatalogFacets()
} catch (e) {
console.log(e)
}
}
async function reload(opts: { preserveRows?: boolean } = {}) {
try {
await resetAndLoad(toServerQuery(), opts)
} catch (e) {
console.log(e)
} finally {
loadingPage.value = false
}
}
async function onRefresh() {
await loadProducts(true)
refreshing.value = false
try {
await Promise.all([loadFacets(), reload({ preserveRows: true })])
} finally {
refreshing.value = false
}
}
function search(value: string | undefined) {
@@ -215,7 +152,44 @@ function showFilters() {
showFilter.value = true
}
onMounted(() => loadProducts())
const sentinel = ref<HTMLElement | null>(null)
let observer: IntersectionObserver | undefined
function mountObserver() {
observer?.disconnect()
if (!sentinel.value) return
observer = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting) && hasMore.value) {
void loadMore(toServerQuery())
}
},
{ rootMargin: '400px' },
)
observer.observe(sentinel.value)
}
watch(sentinel, mountObserver)
onBeforeUnmount(() => observer?.disconnect())
let textTimer: ReturnType<typeof setTimeout> | undefined
watch(
() => filters.query.text,
() => {
clearTimeout(textTimer)
textTimer = setTimeout(() => void reload(), 300)
},
)
watch(
() => [filters.query.cats, filters.query.brands, filters.query.limit_gram] as const,
() => void reload(),
{ deep: true },
)
onMounted(async () => {
await loadFacets()
await reload()
})
</script>
<style scoped lang="less">
+273 -175
View File
@@ -46,9 +46,8 @@
-->
<template>
<div class="dash">
<header class="dash__bar">
<div class="dash__barInner">
<span class="dash__brand">میز کار</span>
<PageBar title="میز کار" :back="false">
<template #trailing>
<button
v-if="session.isLoggedIn"
type="button"
@@ -58,20 +57,16 @@
>
<Icon icon="vuesax-linear:logout" size="18" />
</button>
</div>
</header>
<span v-else class="dash__exitSpacer" aria-hidden="true" />
</template>
</PageBar>
<PullRefresh v-model="refreshing" @refresh="onRefresh">
<template #pulling><Loading size="18" /></template>
<template #loosing><Loading size="18" /></template>
<template #loading><Loading size="18" /></template>
<template #success><span /></template>
<container class="dash__body" :class="{ 'is-revealed': revealed }">
<!-- Signed in: real position from GET /customers/:id, quick actions
built into the same dark surface as the balance itself. -->
<section v-if="customer" class="dash__hero dash__reveal" :style="revealDelay(0)">
<div class="dash__heroTop">
<AppPullRefresh v-model="refreshing" @refresh="onRefresh">
<container class="dash__body" :class="{ 'is-revealed': revealed }">
<!-- Signed in: real position from GET /customer/me. Auth is the token —
never treat a slow/failed profile fetch as a guest session. -->
<section v-if="session.isLoggedIn && customer" class="dash__hero dash__reveal" :style="revealDelay(0)">
<header class="dash__heroHead">
<div class="dash__heroIdentity">
<span class="dash__heroName">{{ customer.fullName }}</span>
<span class="dash__heroMobile">{{ session.mobile }}</span>
@@ -79,27 +74,49 @@
<span class="dash__heroSeal" :class="isDebtor ? 'is-debtor' : 'is-creditor'">
{{ isDebtor ? 'بدهکار' : 'بستانکار' }}
</span>
</div>
</header>
<div class="dash__heroFigure">
<span class="dash__heroNum">{{ grams }}</span>
<span class="dash__heroUnit">گرم {{ remainText }}</span>
<div class="dash__heroBalance">
<div class="dash__heroFigure">
<span class="dash__heroNum">{{ gramsLabel }}</span>
<span class="dash__heroUnit">گرم</span>
</div>
<span class="dash__heroEyebrow">موجودی طلا</span>
</div>
<div class="dash__heroDebt">
<span>بدهی ریالی</span>
<span class="dash__heroDebtLabel">بدهی ریالی</span>
<b>{{ price(customer.fiatDebtRial) }} <i>ریال</i></b>
</div>
</section>
<!-- Signed out: a quiet invitation, no fabricated numbers -->
<section v-else class="dash__invite dash__reveal" :style="revealDelay(0)">
<div class="dash__inviteIcon" aria-hidden="true">
<Icon icon="vuesax-linear:security-user" size="22" color="#fff" />
</div>
<h3>حساب گرمی شما</h3>
<p>برای دیدن موجودی، سفارش‌ها و اسناد خود وارد شوید.</p>
<section
v-else-if="session.isLoggedIn"
class="dash__hero dash__hero--pending dash__reveal"
:style="revealDelay(0)"
>
<header class="dash__heroHead">
<div class="dash__heroIdentity">
<span class="dash__heroName">حساب شما</span>
<span class="dash__heroMobile">{{ session.mobile }}</span>
</div>
</header>
<p class="dash__heroPending">
{{ profileError ? 'موجودی الان در دسترس نیست. دوباره تلاش کنید.' : 'در حال دریافت موجودی…' }}
</p>
<button
v-if="profileError"
type="button"
class="dash__heroRetry"
@click="loadDashboard"
>
تلاش دوباره
</button>
</section>
<!-- Signed out: same card shell as the logged-in hero; logo + CTA only. -->
<section v-else class="dash__hero dash__invite dash__reveal" :style="revealDelay(0)">
<img class="dash__inviteLogo" src="/img/brand-logo.png" :alt="brand.name" />
<button type="button" class="dash__inviteCta" @click="router.push({ name: 'login' })">
ورود / ثبت‌نام
</button>
@@ -114,9 +131,10 @@
</span>
<span class="dash__rowText">
<b>سفارشات</b>
<small>پیگیری سفارش‌های شما</small>
</span>
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="var(--c-inkFaint)" />
<span class="dash__rowChevron" aria-hidden="true">
<Icon icon="vuesax-linear:arrow-left-1" size="16" />
</span>
</button>
<button type="button" class="dash__row" @click="goToDetails(1)">
@@ -125,9 +143,10 @@
</span>
<span class="dash__rowText">
<b>اسناد</b>
<small>تراکنش‌ها و رسیدهای حساب</small>
</span>
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="var(--c-inkFaint)" />
<span class="dash__rowChevron" aria-hidden="true">
<Icon icon="vuesax-linear:arrow-left-1" size="16" />
</span>
</button>
<button type="button" class="dash__row" @click="goToDetails(2)">
@@ -136,9 +155,10 @@
</span>
<span class="dash__rowText">
<b>پروفایل</b>
<small>اطلاعات و تنظیمات حساب</small>
</span>
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="var(--c-inkFaint)" />
<span class="dash__rowChevron" aria-hidden="true">
<Icon icon="vuesax-linear:arrow-left-1" size="16" />
</span>
</button>
<button type="button" class="dash__row" @click="goToDetails(3)">
@@ -147,9 +167,10 @@
</span>
<span class="dash__rowText">
<b>علاقمندی</b>
<small>محصولات ذخیره‌شده شما</small>
</span>
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="var(--c-inkFaint)" />
<span class="dash__rowChevron" aria-hidden="true">
<Icon icon="vuesax-linear:arrow-left-1" size="16" />
</span>
</button>
<!--
D24 — the account statement. The reference put it on the dashboard
@@ -163,9 +184,10 @@
</span>
<span class="dash__rowText">
<b>گزارش</b>
<small>گردش حساب طلایی و ریالی شما</small>
</span>
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="var(--c-inkFaint)" />
<span class="dash__rowChevron" aria-hidden="true">
<Icon icon="vuesax-linear:arrow-left-1" size="16" />
</span>
</button>
<!-- Appearance (theme + reduced motion). Device-level preferences, not
@@ -176,9 +198,10 @@
</span>
<span class="dash__rowText">
<b>تنظیمات ظاهری</b>
<small>پوسته روشن و تیره، انیمیشن‌ها</small>
</span>
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="var(--c-inkFaint)" />
<span class="dash__rowChevron" aria-hidden="true">
<Icon icon="vuesax-linear:arrow-left-1" size="16" />
</span>
</button>
<!--
@@ -195,9 +218,10 @@
</span>
<span class="dash__rowText">
<b>سفارش مشتریان</b>
<small>سفارش‌های مشتریان معرفی‌شده شما</small>
</span>
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="var(--c-inkFaint)" />
<span class="dash__rowChevron" aria-hidden="true">
<Icon icon="vuesax-linear:arrow-left-1" size="16" />
</span>
</button>
<button type="button" class="dash__row" @click="goToDetails(5)">
@@ -206,9 +230,10 @@
</span>
<span class="dash__rowText">
<b>مشتریان من</b>
<small>فهرست مشتریان معرفی‌شده شما</small>
</span>
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="var(--c-inkFaint)" />
<span class="dash__rowChevron" aria-hidden="true">
<Icon icon="vuesax-linear:arrow-left-1" size="16" />
</span>
</button>
</template>
</section>
@@ -216,7 +241,7 @@
<!-- Live 18k rate — a single inline row, not a titled section. -->
<RateStrip :quote="goldPrice" class="dash__rate dash__reveal" :style="revealDelay(2)" />
</container>
</PullRefresh>
</AppPullRefresh>
<IslandSheet
v-model:show="data.notify"
@@ -244,8 +269,9 @@
import { brand } from '../../brand'
import { computed, nextTick, onActivated, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Loading, PullRefresh } from 'vant'
import AppPullRefresh from '../../components/ui/AppPullRefresh.vue'
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
import RateStrip from '../../components/home/RateStrip.vue'
import IslandSheet from '../../components/ui/IslandSheet.vue'
import { price } from '../../utils/filters'
@@ -261,15 +287,20 @@ const reads = useCachedReads()
const customer = ref<CustomerDetail | null>(reads.peek<CustomerDetail>(KEYS.profile) ?? null)
const goldPrice = ref<GoldPriceQuote | null>(reads.peek<GoldPriceQuote>(KEYS.goldPrice) ?? null)
const refreshing = ref(false)
const profileError = ref(false)
// The ledger stores the gram position as a signed figure — positive means the
// customer owes gold. Labelled the same way AccountStrip does on the home
// screen so the two never disagree.
const isDebtor = computed(() => (customer.value ? parseFloat(customer.value.goldDebtGrams) > 0 : false))
const remainText = computed(() => (isDebtor.value ? 'بدهکار' : 'بستانکار'))
const grams = computed(() =>
customer.value ? Math.abs(parseFloat(customer.value.goldDebtGrams)) : 0,
)
const gramsLabel = computed(() => {
const n = grams.value
if (!Number.isFinite(n)) return '۰'
return n.toLocaleString('fa-IR', { maximumFractionDigits: 3 })
})
const data = reactive({ notify: false, loginNotify: false })
@@ -314,6 +345,7 @@ async function loadDashboard() {
// the exception uncaught, so onRefresh's refreshing.value = false never
// ran and PullRefresh stayed stuck in its loading state — a working pull
// gesture never dismissed it again until a full page reload.
profileError.value = false
const [me, price] = await Promise.allSettled([
// The balance on this tab is the one figure read fresh on every visit;
// the cached copy only fills the screen until it arrives.
@@ -325,14 +357,22 @@ async function loadDashboard() {
// to control, so it is gone.
reads.goldPrice({ force: true }),
])
if (me.status === 'fulfilled') customer.value = me.value
else console.log(me.reason)
if (me.status === 'fulfilled') {
customer.value = me.value
profileError.value = false
} else {
profileError.value = session.isLoggedIn && !customer.value
console.log(me.reason)
}
goldPrice.value = price.status === 'fulfilled' ? price.value : null
}
async function onRefresh() {
await loadDashboard()
refreshing.value = false
try {
await loadDashboard()
} finally {
refreshing.value = false
}
}
let activatedOnce = false
@@ -369,43 +409,15 @@ onMounted(async () => {
min-height: 100vh;
}
// A bare bar, sized like Home's, but with no border/title-bar chrome — the
// hero card below carries the page's identity instead of a header label.
.dash__bar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 20;
background: alpha(@canvas, 82%);
backdrop-filter: blur(18px) saturate(140%);
border-bottom: 1px solid alpha(@wine, 5%);
box-shadow: 0 8px 20px -18px alpha(@wine, 55%);
}
.dash__barInner {
max-width: 768px;
margin: 0 auto;
padding: 10px 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.dash__brand {
font-size: 14.5px;
font-weight: 800;
color: @ink;
}
// Same footprint as PageBar's `.bar__btn` (cart / search / …) so the trailing
// logout doesn't shrink the bar relative to the rest of the app.
.dash__exit {
flex: 0 0 auto;
width: 34px;
height: 34px;
width: 38px;
height: 38px;
border: 0;
cursor: pointer;
border-radius: 11px;
border-radius: 12px;
display: grid;
place-items: center;
color: @wine;
@@ -413,11 +425,18 @@ onMounted(async () => {
transition: transform 0.18s ease;
}
.dash__exit:active {
transform: scale(0.92);
transform: scale(0.94);
}
.dash__exitSpacer {
display: block;
width: 38px;
height: 38px;
}
.dash__body {
padding-top: 58px;
// Clears the shared PageBar (same offset as CartView).
padding-top: 76px;
padding-bottom: 90px;
}
@@ -446,22 +465,55 @@ onMounted(async () => {
}
// ── Hero ────────────────────────────────────────────────────────────────
// One full surface: identity, balance and quick actions all on the same
// ink → wine gradient, instead of a dark card handing off to a separate
// white grid underneath it.
// Same cool neutrals as the rest of the workbench (canvasSoft / gray-*), so
// labels and nested panels don't sit on a warm cream that fights the page.
.dash__hero {
margin-top: 16px;
margin-bottom: 28px;
padding: 20px 18px 16px;
border-radius: 26px;
background: linear-gradient(155deg, @heroSurface 0%, @heroSurface 55%, #5A0027 130%);
color: #fff;
box-shadow: 0 22px 40px -26px rgba(124, 0, 53, 0.32);
position: relative;
isolation: isolate;
display: flex;
flex-direction: column;
margin-top: 12px;
margin-bottom: 14px;
padding: 16px 16px 14px;
/* Fixed footprint so guest / pending / signed-in cards stay the same size. */
height: 194px;
box-sizing: border-box;
border-radius: 22px;
color: @ink;
/* Tailwind gray-50 */
background: #f9fafb;
border: 1px solid @hairline;
box-shadow: none;
overflow: hidden;
}
.dash__heroTop {
.dash__hero::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
z-index: 0;
opacity: 1;
/* Soft lines on gray-50 — barely-there gray-200 */
background-image: repeating-linear-gradient(
-28deg,
transparent 0,
transparent 10px,
rgba(229, 231, 235, 0.22) 10px,
rgba(229, 231, 235, 0.22) 11px
);
}
.dash__hero > * {
position: relative;
z-index: 1;
}
.dash__heroHead {
display: flex;
align-items: flex-start;
flex: 0 0 auto;
align-items: center;
justify-content: space-between;
gap: 12px;
}
@@ -469,154 +521,198 @@ onMounted(async () => {
.dash__heroIdentity {
display: flex;
flex-direction: column;
gap: 3px;
gap: 2px;
min-width: 0;
}
.dash__heroName {
font-size: 13.5px;
font-weight: 800;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dash__heroMobile {
font-size: 11px;
color: rgba(255, 255, 255, 0.55);
color: @inkFaint;
direction: ltr;
text-align: right;
}
.dash__heroSeal {
flex: 0 0 auto;
padding: 5px 12px;
padding: 6px 11px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 700;
font-weight: 500;
white-space: nowrap;
}
.dash__heroSeal.is-debtor {
color: #fff;
background: @wineSoft;
color: @wine;
background: @wineWash;
}
.dash__heroSeal.is-creditor {
color: #14161A;
background: rgba(255, 255, 255, 0.9);
color: @inkSoft;
background: @canvas;
border: 1px solid @hairline;
}
.dash__heroBalance {
flex: 1 1 auto;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin-top: 0;
text-align: center;
}
.dash__heroEyebrow {
display: block;
margin-top: 6px;
font-size: 11px;
font-weight: 500;
color: @inkFaint;
}
.dash__heroFigure {
margin-top: 20px;
display: flex;
align-items: baseline;
justify-content: center;
gap: 8px;
}
.dash__heroNum {
font-size: 34px;
font-weight: 800;
letter-spacing: -0.5px;
font-size: 38px;
font-weight: 500;
letter-spacing: -0.6px;
line-height: 1.05;
color: @ink;
font-variant-numeric: tabular-nums;
}
.dash__heroUnit {
font-size: 12px;
font-weight: 600;
color: rgba(255, 255, 255, 0.62);
font-size: 12.5px;
font-weight: 500;
color: @inkFaint;
}
.dash__heroDebt {
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 14px;
padding-top: 13px;
border-top: 1px solid rgba(255, 255, 255, 0.14);
margin-top: 0;
padding: 11px 13px;
border-radius: 14px;
background: @canvas;
border: 1px solid @hairline;
font-size: 11.5px;
color: rgba(255, 255, 255, 0.62);
}
.dash__heroDebtLabel {
color: @inkFaint;
font-weight: 500;
}
.dash__heroDebt b {
font-size: 13px;
font-weight: 700;
color: #fff;
font-weight: 500;
color: @ink;
font-variant-numeric: tabular-nums;
}
.dash__heroDebt i {
font-style: normal;
font-size: 10.5px;
font-weight: 600;
color: rgba(255, 255, 255, 0.62);
font-weight: 400;
color: @inkFaint;
padding-inline-start: 3px;
}
// ── Signed-out invite ──────────────────────────────────────────────────
.dash__invite {
margin-top: 16px;
padding: 26px 20px;
border-radius: 26px;
background: @heroSurface;
color: #fff;
.dash__heroPending {
flex: 1 1 auto;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin: 0;
font-size: 12.5px;
font-weight: 500;
color: @inkFaint;
text-align: center;
gap: 10px;
}
.dash__inviteIcon {
width: 46px;
height: 46px;
border-radius: 50%;
display: grid;
place-items: center;
background: rgba(255, 255, 255, 0.1);
margin-bottom: 2px;
}
.dash__invite h3 {
margin: 0;
font-size: 14px;
font-weight: 800;
}
.dash__invite p {
margin: 0;
font-size: 11.5px;
color: rgba(255, 255, 255, 0.6);
max-width: 240px;
}
.dash__inviteCta {
margin-top: 6px;
padding: 10px 22px;
.dash__heroRetry {
flex: 0 0 auto;
align-self: center;
margin-top: 0;
padding: 9px 16px;
border: 0;
cursor: pointer;
border-radius: 999px;
font-family: inherit;
font-size: 12px;
font-weight: 700;
color: #14161A;
background: #fff;
font-weight: 500;
color: #fff;
background: @wine;
}
// ── Signed-out invite (same footprint as logged-in .dash__hero) ────────
.dash__inviteLogo {
display: block;
flex: 1 1 auto;
height: 88px;
max-height: 88px;
width: auto;
object-fit: contain;
margin: auto;
}
.dash__inviteCta {
display: block;
flex: 0 0 auto;
width: 100%;
/* Mirror .dash__heroDebt so the bottom band matches after login. */
margin-top: 0;
padding: 11px 13px;
border: 0;
cursor: pointer;
border-radius: 14px;
font-family: inherit;
font-size: 12.5px;
font-weight: 500;
color: #fff;
background: @wine;
transition: transform 0.18s ease;
}
.dash__inviteCta:active {
transform: scale(0.96);
transform: scale(0.98);
}
// ── Settings-style rows ────────────────────────────────────────────────
// Same 16px gutter as <container>, equal inline padding so the leading icon
// tile and the trailing chevron sit the same distance from the card edges.
.dash__rows {
margin-top: 0;
display: flex;
flex-direction: column;
gap: 9px;
gap: 8px;
}
.dash__row {
display: flex;
align-items: center;
gap: 12px;
padding: 13px 14px;
width: 100%;
padding: 12px 14px;
border: 1px solid @hairline;
border-radius: 17px;
border-radius: 16px;
cursor: pointer;
background: @canvas;
font-family: inherit;
text-align: right;
text-align: start;
-webkit-tap-highlight-color: transparent;
transition: transform 0.18s ease, border-color 0.18s ease;
}
.dash__row:active {
@@ -626,9 +722,9 @@ onMounted(async () => {
.dash__rowIcon {
flex: 0 0 auto;
width: 38px;
height: 38px;
border-radius: 12px;
width: 36px;
height: 36px;
border-radius: 11px;
display: grid;
place-items: center;
color: @wine;
@@ -638,22 +734,24 @@ onMounted(async () => {
.dash__rowText {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.dash__rowText b {
font-size: 12.5px;
font-weight: 700;
font-weight: 600;
color: @ink;
}
.dash__rowText small {
font-size: 10.5px;
.dash__rowChevron {
flex: 0 0 auto;
width: 28px;
height: 28px;
display: grid;
place-items: center;
color: @inkFaint;
}
// ── Rate row ───────────────────────────────────────────────────────────
.dash__rate {
margin-top: 14px;
margin-top: 12px;
}
</style>
+31 -17
View File
@@ -23,9 +23,7 @@
<container class="auth__body">
<section class="auth__intro">
<div class="auth__mark" aria-hidden="true">
<Icon icon="vuesax-linear:security-user" size="28" />
</div>
<img class="auth__logo" src="/img/brand-logo.png" :alt="brand.name" />
<h2 class="auth__title">ورود به {{ brand.name }}</h2>
<p class="auth__sub">
{{ body.step === 1 ? 'شماره موبایل خود را وارد کنید' : 'رمز یکبارمصرف را وارد کنید' }}
@@ -58,7 +56,15 @@
label="تلفن همراه"
:rules="[required('تلفن همراه'), mobileRule('تلفن همراه')]"
>
<Input type="number" v-model:value="formModel.username" />
<Input
:value="formModel.username"
type="tel"
inputmode="numeric"
:maxlength="11"
placeholder="09123456789"
autocomplete="tel"
@update:value="(v: string) => (formModel.username = digits(v))"
/>
</FormItem>
<button
@@ -85,7 +91,6 @@ import { brand } from '../../brand'
import { computed, nextTick, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Form, FormItem, Input } from 'ant-design-vue'
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
import { toast } from '../../composables/useToast'
import OtpInput from '../../components/input/OtpInput.vue'
@@ -105,6 +110,15 @@ const body = reactive({
defaultFocus: false,
})
/** Digits only, Persian/Arabic numerals normalised, capped at 11 (09xxxxxxxxx). */
function digits(v: string): string {
return (v ?? '')
.replace(/[۰-۹]/g, (d) => String(d.charCodeAt(0) - 0x06f0))
.replace(/[٠-٩]/g, (d) => String(d.charCodeAt(0) - 0x0660))
.replace(/\D/g, '')
.slice(0, 11)
}
const timer = ref(0)
const countDownTimer = () => {
if (timer.value > 0) {
@@ -137,9 +151,12 @@ const sendPhone = async () => {
body.step = 2
timer.value = 120
countDownTimer()
await nextTick(() => {
body.defaultFocus = true
})
body.defaultFocus = true
// After the OTP boxes mount (and after the submit button releases focus),
// put the caret in the first digit field.
await nextTick()
await nextTick()
document.querySelector<HTMLInputElement>('.auth__otp .otp-input')?.focus()
} catch (e: unknown) {
// Both of these carry a Persian, user-facing `message` from the server, so
// show it instead of a generic one:
@@ -236,18 +253,15 @@ const btnText = computed(() => (body.step === 1 ? 'دریافت رمز عبور'
padding-bottom: 24px;
}
.auth__mark {
width: 64px;
height: 64px;
border-radius: 50%;
display: grid;
place-items: center;
color: @wine;
background: @wineWash;
.auth__logo {
display: block;
height: 72px;
width: auto;
object-fit: contain;
}
.auth__title {
margin: 14px 0 0;
margin: 16px 0 0;
font-size: 16px;
font-weight: 800;
color: @ink;
+505 -126
View File
@@ -8,10 +8,9 @@
utils/orderStatus.ts) rather than legacy's own inconsistent tab labels.
They are chips now instead of ant Tabs, which lets them scroll
horizontally like every other filter rail in the redesign.
- Fetches real order history via the already-existing GET
/customers/:id/orders endpoint, filtered client-side by chip (that
endpoint has no server-side status filter — small enough dataset that
client-side filtering is reasonable, and apps/api stays untouched).
- Fetches paginated order history via GET /customer/me/orders (page /
status / q). Infinite-scroll like ReportView; filters run server-side
so a status chip never misses rows on later pages.
- Search matches against `orderNumber` (our real user-facing identifier)
instead of legacy's numeric `id`.
@@ -26,7 +25,7 @@
-->
<template>
<div class="ord">
<PageBar title="سفارشات" back-to="home">
<PageBar title="سفارشات" :back="false">
<template #below>
<div class="ord__barExtra">
<SearchInput @search="readSearch" place-holder="جستجوی شماره سفارش" />
@@ -47,25 +46,30 @@
</template>
</PageBar>
<Spinner :spinning="loading" tip="درحال دریافت اطلاعات">
<PullRefresh v-model="refreshing" @refresh="onRefresh">
<template #pulling><Loading size="18" /></template>
<template #loosing><Loading size="18" /></template>
<template #loading><Loading size="18" /></template>
<template #success><span /></template>
<container class="ord__body">
<template v-if="filteredOrders.length">
<Spinner :spinning="loading && !orders.length" tip="درحال دریافت اطلاعات">
<AppPullRefresh v-model="refreshing" @refresh="onRefresh">
<container class="ord__body">
<template v-if="orders.length">
<div class="ord__list">
<OrderCard v-for="item in filteredOrders" :key="item.id" :value="item" @view="openDetail" />
<OrderCard v-for="item in orders" :key="item.id" :value="item" @view="openDetail" />
</div>
</template>
<section v-else-if="!loading" class="ord__empty">
<section v-else-if="!loading && !failed" class="ord__empty">
<Icon icon="vuesax-linear:box" size="26" />
<p>سفارشی با این مشخصات پیدا نشد.</p>
</section>
<section v-if="failed" class="ord__empty">
<Icon icon="vuesax-linear:danger" size="26" />
<p>دریافت سفارش‌ها با خطا روبه‌رو شد.</p>
<button type="button" class="ord__retry" @click="reload">تلاش دوباره</button>
</section>
<div v-if="loading && orders.length" class="ord__more">در حال دریافت…</div>
<div ref="sentinel" class="ord__sentinel" />
</container>
</PullRefresh>
</AppPullRefresh>
</Spinner>
<Drawer
@@ -79,39 +83,31 @@
>
<div class="detail">
<header class="detail__bar">
<button type="button" class="detail__close" aria-label="بستن" @click="viewData.model = false">
<button type="button" class="detail__close" aria-label="بستن" @click="closeDetail">
<Icon icon="vuesax-linear:arrow-right-1" size="20" />
</button>
<h2 class="detail__title">جزئیات سفارش</h2>
<span class="detail__spacer" aria-hidden="true" />
<button
type="button"
class="detail__trail"
aria-label="تاریخچه وضعیت"
@click="timelineOpen = true"
>
<Icon icon="vuesax-linear:clock-1" size="18" />
</button>
</header>
<div class="detail__scroll">
<!-- Progress -->
<section class="detail__block">
<div class="detail__steps">
<div
v-for="(step, index) in ORDER_STEPS"
:key="step"
class="detail__step"
:class="{ 'is-done': index <= currentStep, 'is-active': index === currentStep }"
>
<span class="detail__stepDot">{{ index + 1 }}</span>
<span class="detail__stepTitle">{{ step }}</span>
</div>
</div>
</section>
<!-- Headline facts -->
<!-- Headline facts — order path lives in the timeline sheet (header clock). -->
<section class="detail__block">
<div class="detail__headline">
<div>
<div class="detail__idBlock">
<div class="detail__eyebrow">شماره سفارش</div>
<div class="detail__number">{{ viewData.item.orderNumber }}</div>
</div>
<span
class="detail__status"
:style="{ background: modalColor?.background, color: modalColor?.font }"
:style="{ background: modalColor.background, color: modalColor.font }"
>
{{ modalStatusLabel }}
</span>
@@ -143,13 +139,82 @@
</div>
</div>
</Drawer>
<!-- Status history as an island action sheet (same pattern as cart note). -->
<Drawer
v-model:visible="timelineOpen"
placement="bottom"
:closable="false"
:zIndex="1100"
height="70%"
class="rtl island-sheet"
:bodyStyle="{ padding: 0 }"
>
<div class="tlSheet">
<header class="tlSheet__banner">
<span class="tlSheet__chip">وضعیت</span>
<span class="tlSheet__dash" aria-hidden="true" />
<span
v-if="viewData.item"
class="tlSheet__badge"
:style="{
background: modalColor.background,
color: modalColor.font,
}"
>
<Icon icon="vuesax-linear:tick-circle" size="14" />
{{ modalStatusLabel }}
</span>
</header>
<ol v-if="timelinePath.length" class="tlSheet__rail">
<li
v-for="(node, index) in timelinePath"
:key="node.status"
class="tlSheet__node"
:class="[`is-${node.state}`]"
>
<div class="tlSheet__axis" aria-hidden="true">
<span
v-if="node.state === 'current'"
class="tlSheet__halo"
:style="{ color: orderStatusColor(node.status).font }"
/>
<span class="tlSheet__marker" :style="markerStyle(node)">
<Icon :icon="orderStatusIcon(node.status)" size="16" />
</span>
<span
v-if="index < timelinePath.length - 1"
class="tlSheet__stem"
:style="node.state === 'pending' ? undefined : stemStyle(node.status)"
/>
</div>
<div class="tlSheet__main">
<div class="tlSheet__row">
<div class="tlSheet__copy">
<div class="tlSheet__status">{{ orderStatusLabel(node.status) }}</div>
<p class="tlSheet__blurb">{{ orderStatusBlurb(node.status) }}</p>
</div>
<time v-if="node.at" class="tlSheet__when" :datetime="node.at">
{{ jtimelineWhen(node.at) }}
</time>
<span v-else class="tlSheet__when is-wait">در انتظار</span>
</div>
</div>
</li>
</ol>
<p v-else class="tlSheet__empty">مسیری برای این سفارش ثبت نشده است.</p>
</div>
</Drawer>
</div>
</template>
<script setup lang="ts">
import { computed, onActivated, onMounted, reactive, ref } from 'vue'
import { computed, nextTick, onActivated, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { Drawer } from 'ant-design-vue'
import { Loading, PullRefresh } from 'vant'
import AppPullRefresh from '../../components/ui/AppPullRefresh.vue'
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue'
@@ -157,19 +222,26 @@ import SearchInput from '../../components/search/SearchInput.vue'
import OrderCard from '../../components/product/OrderCard.vue'
import OrderItemCard from '../../components/product/OrderItemCard.vue'
import {
ORDER_STATUS_COLORS,
ORDER_PIPELINE,
ORDER_STATUS_LABELS,
ORDER_STEPS,
orderStepIndex,
orderStatusBlurb,
orderStatusColor,
orderStatusIcon,
orderStatusLabel,
} from '../../utils/orderStatus'
import { jdate } from '../../utils/filters'
import { jdate, jtimelineWhen } from '../../utils/filters'
import { useSessionStore } from '../../stores/session'
import type { OrderDetail } from '../../api/client'
import { useCachedReads } from '../../api/cachedReads'
import { KEYS } from '../../stores/queryCache'
import { api, type OrderDetail } from '../../api/client'
import { useQueryCache, KEYS } from '../../stores/queryCache'
type TimelineNode = {
status: string
at: string | null
state: 'done' | 'current' | 'pending'
}
const session = useSessionStore()
const reads = useCachedReads()
const cache = useQueryCache()
const tabs = [
{ title: 'همه', key: 'ALL' },
@@ -184,81 +256,179 @@ const tabs = [
]
const activeTab = ref('ALL')
const orders = ref<OrderDetail[]>([])
const page = ref(1)
const hasMore = ref(true)
const loading = ref(false)
const refreshing = ref(false)
const allOrders = ref<OrderDetail[]>(reads.peek<OrderDetail[]>(KEYS.orders) ?? [])
const failed = ref(false)
const searchTerm = ref<string | undefined>(undefined)
const filteredOrders = computed(() => {
let list = allOrders.value
if (activeTab.value !== 'ALL') list = list.filter((o) => o.status === activeTab.value)
if (searchTerm.value) list = list.filter((o) => o.orderNumber.includes(searchTerm.value!))
return list
})
const sentinel = ref<HTMLElement | null>(null)
let observer: IntersectionObserver | null = null
/** Bumped on every filter/reload so an in-flight page response is dropped. */
let fetchSeq = 0
function readSearch(value: string | undefined) {
searchTerm.value = value
}
async function loadOrders(force = false) {
async function fetchPage() {
if (!session.isLoggedIn) {
allOrders.value = []
orders.value = []
hasMore.value = false
return
}
loading.value = reads.peek(KEYS.orders) === undefined
if (loading.value || !hasMore.value) return
const my = fetchSeq
loading.value = true
failed.value = false
try {
allOrders.value = await reads.myOrders({ force })
const data = await api.getMyOrders({
page: page.value,
status: activeTab.value,
q: searchTerm.value,
})
if (my !== fetchSeq) return
const batch = data.result ?? []
orders.value = page.value === 1 ? batch : [...orders.value, ...batch]
if (batch.length < (data.limit ?? 20)) hasMore.value = false
else page.value += 1
} catch (e) {
if (my !== fetchSeq) return
failed.value = true
hasMore.value = false
console.log(e)
} finally {
loading.value = false
if (my === fetchSeq) loading.value = false
}
}
function resetList() {
orders.value = []
page.value = 1
hasMore.value = true
failed.value = false
}
async function reload() {
fetchSeq += 1
loading.value = false
resetList()
await fetchPage()
}
const viewData = reactive<{ model: boolean; item: OrderDetail | null }>({ model: false, item: null })
const timelineOpen = ref(false)
const modalStatusLabel = computed(() =>
viewData.item ? ORDER_STATUS_LABELS[viewData.item.status] ?? viewData.item.status : '',
viewData.item ? orderStatusLabel(viewData.item.status) : '',
)
const modalColor = computed(() => (viewData.item ? ORDER_STATUS_COLORS[viewData.item.status] : undefined))
const currentStep = computed(() => (viewData.item ? orderStepIndex(viewData.item.status) : 0))
const modalColor = computed(() =>
viewData.item ? orderStatusColor(viewData.item.status) : orderStatusColor('PLACED'),
)
/** Pending stays grey. Reached steps colour the icon/border only — fill stays opaque so the halo never shows through. */
function markerStyle(node: TimelineNode) {
if (node.state === 'pending') return undefined
const c = orderStatusColor(node.status)
return { color: c.font, borderColor: c.font }
}
function stemStyle(status: string) {
return { background: orderStatusColor(status).font }
}
/**
* Full order path for the timeline sheet (replaces the old detail stepper).
* Past steps are dated from statusEvents; future ones stay «در انتظار».
*/
const timelinePath = computed((): TimelineNode[] => {
const item = viewData.item
if (!item) return []
const times = new Map<string, string>()
for (const ev of item.statusEvents ?? []) {
if (!times.has(ev.toStatus)) times.set(ev.toStatus, ev.createdAt)
}
if (!times.has(item.status)) times.set(item.status, item.createdAt)
if (item.status === 'SUSPENDED') {
return [
{ status: 'SUSPENDED', at: times.get('SUSPENDED') ?? item.createdAt, state: 'current' },
...ORDER_PIPELINE.map((status) => ({ status, at: null, state: 'pending' as const })),
]
}
if (item.status === 'CANCELLED') {
const prior = ORDER_PIPELINE.filter((status) => times.has(status)).map((status) => ({
status,
at: times.get(status) ?? null,
state: 'done' as const,
}))
return [
...prior,
{ status: 'CANCELLED', at: times.get('CANCELLED') ?? item.createdAt, state: 'current' },
]
}
const idx = (ORDER_PIPELINE as readonly string[]).indexOf(item.status)
const currentIdx = idx >= 0 ? idx : 0
return ORDER_PIPELINE.map((status, i) => ({
status,
at: times.get(status) ?? (i === currentIdx ? item.createdAt : null),
state: i < currentIdx ? 'done' : i === currentIdx ? 'current' : 'pending',
}))
})
function openDetail(item: OrderDetail) {
viewData.item = item
viewData.model = true
timelineOpen.value = false
}
function closeDetail() {
timelineOpen.value = false
viewData.model = false
}
async function onRefresh() {
await loadOrders(true)
refreshing.value = false
try {
cache.invalidate(KEYS.orders)
await reload()
} finally {
refreshing.value = false
}
}
watch([activeTab, searchTerm], () => {
void reload()
})
/**
* Owner ruling 2026-10-12: fetch what the screen needs each time it is opened,
* rather than caching it with a lifetime — «هربار با رفتن به اون صفحه اون
* مقادیر مورد نیاز رو درخواست بزنه، هم سربار برای سرور نداره، هم درخواست الکی
* نمیخوره، هم دیتاها به‌روز میمونه».
*
* `force` matters because an order's status changes on the ADMIN side — nothing
* in this app invalidates the cache when that happens, so without it a list
* fetched once was shown unchanged for the rest of the session.
* Owner ruling 2026-10-12: fetch what the screen needs each time it is opened.
* Status changes on the ADMIN side, so each visit reloads from page 1.
*
* `onActivated` as well as `onMounted`: this view is in MasterLayout's
* keep-alive list, so it is mounted once and every later visit only activates
* it. With `onMounted` alone the second visit made no request at all.
*
* The cached copy still renders instantly (`peek` above) and `loading` only
* shows a spinner when there is nothing to show, so the refresh is invisible.
*
* `activatedOnce` guards the first visit, where Vue runs both hooks — the same
* shape DashboardView uses.
* keep-alive list. `activatedOnce` guards the first visit where Vue runs both.
*/
onMounted(() => loadOrders(true))
onMounted(async () => {
await reload()
await nextTick()
if (typeof IntersectionObserver !== 'undefined') {
observer = new IntersectionObserver((entries) => {
if (entries.some((e) => e.isIntersecting)) void fetchPage()
})
if (sentinel.value) observer.observe(sentinel.value)
}
})
let activatedOnce = false
onActivated(() => {
if (activatedOnce) void loadOrders(true)
if (activatedOnce) void reload()
activatedOnce = true
})
onBeforeUnmount(() => observer?.disconnect())
</script>
<style scoped lang="less">
@@ -289,7 +459,7 @@ onActivated(() => {
cursor: pointer;
font-family: inherit;
font-size: 12px;
font-weight: 600;
font-weight: 500;
white-space: nowrap;
color: @inkSoft;
background: @canvas;
@@ -335,6 +505,25 @@ onActivated(() => {
font-size: 12.5px;
}
.ord__retry {
padding: 8px 18px;
border-radius: 999px;
border: 1px solid @hairline;
background: @canvas;
font-family: inherit;
font-size: 12px;
font-weight: 500;
cursor: pointer;
}
.ord__more,
.ord__sentinel {
padding: 12px 0;
text-align: center;
font-size: 12px;
color: @inkMuted;
}
/* ── Detail drawer ─────────────────────────────────────────────────── */
.detail {
height: 100vh;
@@ -357,8 +546,8 @@ onActivated(() => {
.detail__title {
margin: 0;
font-size: 14px;
font-weight: 800;
font-size: 13px;
font-weight: 500;
color: @ink;
}
@@ -374,10 +563,20 @@ onActivated(() => {
background: @hairlineSoft;
}
.detail__spacer {
display: block;
.detail__trail {
width: 38px;
height: 38px;
border: 0;
cursor: pointer;
border-radius: 12px;
display: grid;
place-items: center;
color: @ink;
background: @hairlineSoft;
}
.detail__trail:active {
transform: scale(0.96);
}
.detail__scroll {
@@ -393,63 +592,235 @@ onActivated(() => {
.detail__blockTitle {
margin: 0 0 11px;
font-size: 13px;
font-weight: 800;
font-size: 12.5px;
font-weight: 500;
color: @ink;
}
.detail__steps {
display: flex;
gap: 4px;
overflow-x: auto;
padding-bottom: 4px;
.tlSheet {
padding: 4px 18px 22px;
max-height: 100%;
overflow-y: auto;
}
.detail__step {
flex: 1 0 74px;
.tlSheet__banner {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 18px;
}
.tlSheet__chip {
flex: 0 0 auto;
padding: 6px 12px;
border-radius: 999px;
border: 1px solid @hairline;
background: @canvas;
box-shadow: 0 1px 2px rgba(20, 22, 26, 0.04);
font-size: 11px;
font-weight: 500;
color: @inkSoft;
}
.tlSheet__dash {
flex: 1 1 auto;
height: 0;
border-top: 1.5px dashed @hairline;
min-width: 16px;
}
.tlSheet__badge {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 5px;
padding: 6px 11px;
border-radius: 999px;
font-size: 11px;
font-weight: 500;
white-space: nowrap;
}
.tlSheet__rail {
list-style: none;
margin: 0;
padding: 0 2px;
}
.tlSheet__node {
display: flex;
gap: 12px;
align-items: stretch;
min-height: 64px;
}
.tlSheet__axis {
flex: 0 0 40px;
width: 40px;
display: flex;
flex-direction: column;
align-items: center;
gap: 7px;
text-align: center;
position: relative;
}
.detail__stepDot {
width: 28px;
height: 28px;
/* Soft shadow-like halo — sits behind the opaque marker disc. */
.tlSheet__halo {
position: absolute;
top: 0;
left: 50%;
width: 40px;
height: 40px;
margin-left: -20px;
border-radius: 50%;
pointer-events: none;
z-index: 0;
}
.tlSheet__halo::before {
content: '';
position: absolute;
inset: -14px;
border-radius: 50%;
background: radial-gradient(
circle,
color-mix(in srgb, currentColor 32%, transparent) 0%,
color-mix(in srgb, currentColor 12%, transparent) 38%,
transparent 70%
);
filter: blur(1.5px);
opacity: 0;
animation: tlSheetHalo 4.8s ease-in-out infinite;
}
@keyframes tlSheetHalo {
0%,
100% {
opacity: 0;
}
50% {
opacity: 0.7;
}
}
@media (prefers-reduced-motion: reduce) {
.tlSheet__halo::before {
animation: none;
opacity: 0.35;
}
}
.tlSheet__marker {
flex: 0 0 40px;
width: 40px;
height: 40px;
border-radius: 50%;
border: 1.5px solid currentColor;
background: @canvas;
display: grid;
place-items: center;
font-size: 11.5px;
font-weight: 700;
color: @inkFaint;
background: @hairlineSoft;
border: 1px solid @hairline;
position: relative;
z-index: 1;
}
.detail__step.is-done .detail__stepDot {
color: #fff;
background: @wine;
border-color: @wine;
.tlSheet__node.is-pending .tlSheet__marker {
/* Tailwind gray-400 — clearer than hairline for inactive dashed rings */
color: #9ca3af;
background: @canvas;
border-color: #9ca3af;
border-style: dashed;
}
.detail__step.is-active .detail__stepDot {
color: #fff;
background: @wineDeep;
border-color: @wineDeep;
.tlSheet__node.is-done .tlSheet__marker,
.tlSheet__node.is-current .tlSheet__marker {
background: @canvas;
}
.detail__stepTitle {
font-size: 10px;
line-height: 1.5;
.tlSheet__node.is-pending .tlSheet__status,
.tlSheet__node.is-pending .tlSheet__blurb,
.tlSheet__node.is-pending .tlSheet__when {
color: @inkFaint;
}
.detail__step.is-done .detail__stepTitle {
color: @inkSoft;
font-weight: 600;
.tlSheet__stem {
flex: 1 1 auto;
width: 1.5px;
margin: 4px 0;
background: @hairline;
min-height: 16px;
}
.tlSheet__node.is-pending .tlSheet__stem {
background: @hairlineSoft;
border-inline-start: 0;
background-image: repeating-linear-gradient(
180deg,
@hairline 0 4px,
transparent 4px 8px
);
width: 2px;
}
.tlSheet__main {
flex: 1;
min-width: 0;
padding-top: 4px;
padding-bottom: 18px;
}
.tlSheet__node:last-child .tlSheet__main {
padding-bottom: 4px;
}
.tlSheet__row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.tlSheet__copy {
min-width: 0;
flex: 1;
}
.tlSheet__status {
font-size: 13px;
font-weight: 500;
color: @ink;
line-height: 1.35;
}
.tlSheet__blurb {
margin: 3px 0 0;
font-size: 11.5px;
font-weight: 500;
line-height: 1.45;
color: @inkFaint;
}
.tlSheet__when {
flex: 0 0 auto;
padding-top: 2px;
font-size: 11px;
font-weight: 500;
color: @inkFaint;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.tlSheet__when.is-wait {
font-weight: 500;
opacity: 0.85;
}
.tlSheet__empty {
margin: 4px 0 0;
padding: 20px 12px;
font-size: 12px;
color: @inkFaint;
text-align: center;
border: 1px dashed @hairline;
border-radius: 14px;
}
.detail__headline {
@@ -460,24 +831,32 @@ onActivated(() => {
padding-bottom: 14px;
}
.detail__idBlock {
min-width: 0;
flex: 1;
}
.detail__eyebrow {
font-size: 11px;
color: @inkFaint;
}
.detail__number {
padding-top: 4px;
font-size: 18px;
font-weight: 800;
color: @ink;
padding-top: 3px;
font-size: 13px;
font-weight: 500;
line-height: 1.4;
color: @inkSoft;
word-break: break-all;
}
.detail__status {
flex: 0 0 auto;
align-self: flex-start;
padding: 5px 12px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 700;
font-weight: 500;
white-space: nowrap;
}
@@ -510,7 +889,7 @@ onActivated(() => {
.detail__spec dd {
margin: 0;
font-size: 12.5px;
font-weight: 700;
font-weight: 500;
color: @ink;
}
@@ -43,6 +43,7 @@ export const THEME_TOKENS = [
'wine',
'wineDeep',
'wineSoft',
'wine200',
'wineWash',
'primary',
'primaryLighten',
@@ -13,6 +13,7 @@
"wine": "#D04A80",
"wineDeep": "#A8366A",
"wineSoft": "#E486AB",
"wine200": "#6B3A4E",
"wineWash": "#3A1A27",
"primary": "#A3B6EA",
"primaryLighten": "#B8C8F0",
+1
View File
@@ -30,6 +30,7 @@
"wine": "#7C0035",
"wineDeep": "#5A0027",
"wineSoft": "#A0386A",
"wine200": "#E8B4C8",
"wineWash": "#FBEFF3",
"canvas": "#FFFFFF",
"canvasSoft": "#FAFAFA",
+1 -1
View File
@@ -14,7 +14,7 @@ const curve = {
px: 'px',
}
const vars = {
prefix: 'zartaj',
prefix: 'tehrantaj',
// headers
'font-size': curve.font + curve.px,
'h1-font-size': curve.font * 2 + curve.px,
+4 -1
View File
@@ -7,7 +7,10 @@ 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),
// Orders are paged (infinite scroll). The list screen calls api.getMyOrders
// directly; this helper only covers a rare full-first-page peek/invalidate.
myOrders: (o?: FetchOptions) =>
cache.fetch(KEYS.orders, () => api.getMyOrders({ page: 1 }).then((p) => p.result), 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),
+36 -1
View File
@@ -241,6 +241,14 @@ export interface OrderItemDetail {
} | null;
}
export interface OrderStatusEvent {
id: string;
fromStatus: string | null;
toStatus: string;
note: string | null;
createdAt: string;
}
export interface OrderDetail {
id: string;
orderNumber: string;
@@ -252,6 +260,25 @@ export interface OrderDetail {
deliveredAt: string | null;
createdAt: string;
items: OrderItemDetail[];
/** Chronological status changes (oldest first). May be empty on older rows. */
statusEvents?: OrderStatusEvent[];
}
/** One page of GET /customer/me/orders — same envelope as the report endpoints. */
export interface OrdersPage {
result: OrderDetail[];
count: number;
page: number;
limit: number;
}
export interface OrdersQuery {
page?: number;
limit?: number;
/** OrderStatus, or omit / ALL for every status. */
status?: string;
/** Substring of orderNumber. */
q?: string;
}
export interface CustomerDetail {
@@ -525,7 +552,15 @@ export const api = {
getMe: () => request<CustomerDetail>('/customer/me'),
/** D27 §12 — this customer's balance, read from the accounting software on every call. */
getAccountingBalance: () => request<AccountingBalance>('/customer/me/accounting-balance'),
getMyOrders: () => request<OrderDetail[]>('/customer/me/orders'),
getMyOrders: (query: OrdersQuery = {}) => {
const params = new URLSearchParams();
if (query.page) params.set('page', String(query.page));
if (query.limit) params.set('limit', String(query.limit));
if (query.status && query.status !== 'ALL') params.set('status', query.status);
if (query.q?.trim()) params.set('q', query.q.trim());
const qs = params.toString();
return request<OrdersPage>(`/customer/me/orders${qs ? `?${qs}` : ''}`);
},
getMyLedgerEntries: () => request<LedgerEntrySummary[]>('/customer/me/ledger-entries'),
/** The making charge the server will charge for these variants (MON-08). */
getWageQuotes: (variantIds: string[]) =>
@@ -49,7 +49,7 @@ const perGram = computed(() => (props.quote ? props.quote.pricePerGramRial.split
align-items: center;
gap: 7px;
font-size: 12px;
color: rgba(255, 255, 255, 0.68);
color: #fff;
white-space: nowrap;
}
@@ -82,12 +82,13 @@ const perGram = computed(() => (props.quote ? props.quote.pricePerGramRial.split
.rate-strip__unit {
font-size: 10.5px;
color: rgba(255, 255, 255, 0.55);
/* Tailwind gray-100 */
color: #f3f4f6;
}
.rate-strip__time {
font-size: 10.5px;
color: rgba(255, 255, 255, 0.42);
color: #fff;
padding-inline-start: 10px;
border-inline-start: 1px solid rgba(255, 255, 255, 0.14);
white-space: nowrap;
@@ -7,7 +7,7 @@
</div>
<button v-if="actionLabel" type="button" class="sec__action" @click="emit('action')">
{{ actionLabel }}
<Icon icon="vuesax-linear:arrow-left-2" size="15" />
<Icon icon="vuesax-linear:arrow-left-1" size="15" />
</button>
</header>
</template>
@@ -30,8 +30,8 @@ const emit = defineEmits<{ (e: 'action'): void }>()
.sec__title {
margin: 0;
font-size: 15px;
font-weight: 800;
font-size: 13.5px;
font-weight: 600;
color: @ink;
letter-spacing: -0.1px;
}
@@ -14,14 +14,14 @@
-->
<template>
<article class="oc" @click="emit('view', value)">
<span class="oc__mark" :style="{ background: color?.background, color: color?.font }" aria-hidden="true">
<span class="oc__mark" :style="{ background: color.background, color: color.font }" aria-hidden="true">
<Icon icon="vuesax-linear:receipt-text" size="16" />
</span>
<div class="oc__main">
<div class="oc__head">
<h3 class="oc__id">{{ value.orderNumber }}</h3>
<span class="oc__status" :style="{ background: color?.background, color: color?.font }">
<p class="oc__id">{{ value.orderNumber }}</p>
<span class="oc__status" :style="{ background: color.background, color: color.font }">
{{ statusLabel }}
</span>
</div>
@@ -41,7 +41,7 @@
<span class="oc__date">{{ jdate(value.createdAt) }}</span>
<span class="oc__more">
جزئیات
<Icon icon="vuesax-linear:arrow-left-2" size="14" />
<Icon icon="vuesax-linear:arrow-left-1" size="14" />
</span>
</div>
</div>
@@ -51,15 +51,15 @@
<script setup lang="ts">
import { computed } from 'vue'
import Icon from '../icon/Icon.vue'
import { ORDER_STATUS_COLORS, ORDER_STATUS_LABELS } from '../../utils/orderStatus'
import { orderStatusColor, orderStatusLabel } from '../../utils/orderStatus'
import { jdate } from '../../utils/filters'
import type { OrderDetail } from '../../api/client'
const props = defineProps<{ value: OrderDetail }>()
const emit = defineEmits<{ (e: 'view', value: OrderDetail): void }>()
const statusLabel = computed(() => ORDER_STATUS_LABELS[props.value.status] ?? props.value.status)
const color = computed(() => ORDER_STATUS_COLORS[props.value.status])
const statusLabel = computed(() => orderStatusLabel(props.value.status))
const color = computed(() => orderStatusColor(props.value.status))
</script>
<style scoped lang="less">
@@ -104,7 +104,8 @@ const color = computed(() => ORDER_STATUS_COLORS[props.value.status])
.oc__id {
margin: 0;
font-size: 13.5px;
font-weight: 800;
/* yekan_bakh has no 600 face — 600 silently maps to bold (700). */
font-weight: 500;
color: @ink;
white-space: nowrap;
overflow: hidden;
@@ -116,7 +117,7 @@ const color = computed(() => ORDER_STATUS_COLORS[props.value.status])
padding: 4px 11px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 700;
font-weight: 500;
white-space: nowrap;
}
@@ -128,13 +129,13 @@ const color = computed(() => ORDER_STATUS_COLORS[props.value.status])
.oc__figure {
font-size: 12px;
font-weight: 700;
font-weight: 500;
color: @inkSoft;
}
.oc__figure i {
font-style: normal;
font-size: 10px;
font-weight: 600;
font-weight: 400;
color: @inkFaint;
padding-inline-start: 2px;
}
@@ -157,7 +158,7 @@ const color = computed(() => ORDER_STATUS_COLORS[props.value.status])
align-items: center;
gap: 4px;
font-size: 11.5px;
font-weight: 700;
font-weight: 500;
color: @wine;
}
</style>
@@ -15,11 +15,17 @@
-->
<template>
<article class="oi">
<div class="oi__art">
<img v-if="lookup?.imageUrl" v-lazy="lookup.imageUrl" :alt="lookup?.productTitle ?? ''" />
<div v-else class="oi__artEmpty" aria-hidden="true">
<Icon icon="vuesax-linear:diamonds" size="18" />
</div>
<button
v-if="lookup?.imageUrl"
type="button"
class="oi__art"
aria-label="بزرگ‌نمایی تصویر"
@click="previewOpen = true"
>
<img v-lazy="lookup.imageUrl" :alt="lookup?.productTitle ?? ''" />
</button>
<div v-else class="oi__art oi__art--empty" aria-hidden="true">
<Icon icon="vuesax-linear:diamonds" size="18" />
</div>
<div class="oi__main">
@@ -43,11 +49,29 @@
</span>
</div>
</div>
<Teleport to="body">
<Transition name="oi-preview">
<div
v-if="previewOpen && lookup?.imageUrl"
class="oi__preview"
role="dialog"
aria-modal="true"
aria-label="پیش‌نمایش تصویر"
@click.self="previewOpen = false"
>
<button type="button" class="oi__previewClose" aria-label="بستن" @click="previewOpen = false">
<Icon icon="vuesax-linear:close-circle" size="22" />
</button>
<img class="oi__previewImg" :src="lookup.imageUrl" :alt="lookup.productTitle ?? ''" />
</div>
</Transition>
</Teleport>
</article>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref, watch, onBeforeUnmount } from 'vue'
import Icon from '../icon/Icon.vue'
import { orderItemLookup } from '../../composables/useOrderItemProduct'
import type { OrderItemDetail } from '../../api/client'
@@ -58,6 +82,15 @@ const props = defineProps<{ value: OrderItemDetail }>()
// nothing to wait for — the row renders complete on its first frame instead of
// filling in after a catalogue download.
const lookup = computed(() => orderItemLookup(props.value))
const previewOpen = ref(false)
watch(previewOpen, (open) => {
document.body.style.overflow = open ? 'hidden' : ''
})
onBeforeUnmount(() => {
if (previewOpen.value) document.body.style.overflow = ''
})
</script>
<style scoped lang="less">
@@ -74,9 +107,20 @@ const lookup = computed(() => orderItemLookup(props.value))
flex: 0 0 62px;
width: 62px;
height: 62px;
padding: 0;
border: 0;
border-radius: 12px;
overflow: hidden;
background: @hairlineSoft;
cursor: zoom-in;
appearance: none;
}
.oi__art--empty {
display: grid;
place-items: center;
color: @inkFaint;
cursor: default;
}
.oi__art img {
@@ -84,6 +128,7 @@ const lookup = computed(() => orderItemLookup(props.value))
height: 100%;
object-fit: cover;
display: block;
pointer-events: none;
}
// Dark mode: product photos are shot on white, so they sit on a light mat
@@ -94,14 +139,6 @@ html.theme-dark .oi__art img {
border-radius: 12px;
}
.oi__artEmpty {
width: 100%;
height: 100%;
display: grid;
place-items: center;
color: @inkFaint;
}
.oi__main {
flex: 1;
min-width: 0;
@@ -117,7 +154,8 @@ html.theme-dark .oi__art img {
.oi__title {
margin: 0;
font-size: 12.5px;
font-weight: 700;
/* yekan_bakh has no 600 face — 600 silently maps to bold (700). */
font-weight: 500;
color: @ink;
white-space: nowrap;
overflow: hidden;
@@ -139,14 +177,61 @@ html.theme-dark .oi__art img {
.oi__figure {
font-size: 12px;
font-weight: 700;
font-weight: 500;
color: @inkSoft;
}
.oi__figure i {
font-style: normal;
font-size: 10px;
font-weight: 600;
font-weight: 400;
color: @inkFaint;
padding-inline-start: 2px;
}
</style>
<style lang="less">
/* Teleported to body — unscoped so the overlay styles apply. */
.oi__preview {
position: fixed;
inset: 0;
z-index: 2100;
display: grid;
place-items: center;
padding: 28px 18px;
background: rgba(8, 8, 10, 0.88);
backdrop-filter: blur(6px);
}
.oi__previewClose {
position: absolute;
top: 14px;
inset-inline-end: 14px;
width: 40px;
height: 40px;
border: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.12);
color: #fff;
display: grid;
place-items: center;
cursor: pointer;
}
.oi__previewImg {
max-width: min(920px, 100%);
max-height: min(80vh, 100%);
object-fit: contain;
border-radius: 14px;
background: #fff;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.35);
}
.oi-preview-enter-active,
.oi-preview-leave-active {
transition: opacity 0.18s ease;
}
.oi-preview-enter-from,
.oi-preview-leave-to {
opacity: 0;
}
</style>
@@ -227,11 +227,11 @@
@click="isUserLoggedIn() ? addToCart() : (loginNotify = true)"
>
{{ is_exist ? 'افزودن به سفارش' : 'ناموجود' }}
<Icon v-if="is_exist" icon="vuesax-linear:arrow-left-2" size="17" />
<Icon v-if="is_exist" icon="vuesax-linear:arrow-left-1" size="17" />
</button>
<router-link v-else class="pd__cta pd__cta--ghost" :to="{ name: 'cart' }">
مشاهده سبد خرید
<Icon icon="vuesax-linear:arrow-left-2" size="17" />
<Icon icon="vuesax-linear:arrow-left-1" size="17" />
</router-link>
</div>
</article>
@@ -151,24 +151,40 @@ import { Slider } from 'ant-design-vue'
import Icon from '../icon/Icon.vue'
import DropDown from '../ui/DropDown.vue'
import type { FiltersQuery } from '../../stores/filtersStore'
import type { ProductSummary } from '../../api/client'
import type { CatalogFacets, ProductSummary } from '../../api/client'
const props = defineProps<{ show: boolean; modelValue: FiltersQuery; filterKeys: string[]; products: ProductSummary[] }>()
const props = defineProps<{
show: boolean
modelValue: FiltersQuery
filterKeys: string[]
/** @deprecated Prefer `facets` — scanning products only works while the whole catalogue is in memory. */
products?: ProductSummary[]
/** Brands + gram range from GET /catalog/products/facets (STUB-03). */
facets?: CatalogFacets | null
}>()
const emit = defineEmits<{
(e: 'update:show', value: boolean): void
(e: 'update:filterKeys', value: string[]): void
}>()
const availableBrands = computed(() => {
if (props.facets?.brands?.length) return props.facets.brands
const seen = new Map<string, { id: string; title: string }>()
for (const p of props.products) {
for (const p of props.products ?? []) {
if (p.brand) seen.set(p.brand.id, p.brand)
}
return [...seen.values()]
})
const gramBounds = computed((): [number, number] => {
const weights = props.products.flatMap((p) => p.variants.map((v) => parseFloat(v.rashWeightGrams)))
if (props.facets?.gramRange) {
const min = parseFloat(props.facets.gramRange.min)
const max = parseFloat(props.facets.gramRange.max)
if (Number.isFinite(min) && Number.isFinite(max) && max >= min) {
return [Math.floor(min), Math.ceil(max)]
}
}
const weights = (props.products ?? []).flatMap((p) => p.variants.map((v) => parseFloat(v.rashWeightGrams)))
if (weights.length === 0) return [0, 100]
return [Math.floor(Math.min(...weights)), Math.ceil(Math.max(...weights))]
})
@@ -0,0 +1,82 @@
<!--
Vant PullRefresh with content locked in place — the luxury RefreshOverlay
carries all visual feedback (no page slide-down).
-->
<template>
<PullRefresh
v-model="model"
class="apr"
:head-height="0"
:pull-distance="PULL_DISTANCE"
v-bind="$attrs"
@change="onChange"
@refresh="emit('refresh')"
>
<template #pulling><span /></template>
<template #loosing><span /></template>
<template #loading><span /></template>
<template #success><span /></template>
<slot />
</PullRefresh>
<RefreshOverlay :show="overlayShow" :intensity="overlayIntensity" />
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { PullRefresh } from 'vant'
import RefreshOverlay from './RefreshOverlay.vue'
/** Must be set explicitly — Vant defaults pullDistance to headHeight, which is 0. */
const PULL_DISTANCE = 72
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'refresh'): void
}>()
const model = computed({
get: () => props.modelValue,
set: (v: boolean) => emit('update:modelValue', v),
})
/** 0…1 pull progress while the finger is down; 1 while loading. */
const pullProgress = ref(0)
const overlayShow = computed(() => model.value || pullProgress.value > 0.08)
const overlayIntensity = computed(() =>
model.value ? 1 : Math.min(1, pullProgress.value),
)
function onChange(payload: { status: string; distance: number }) {
if (payload.status === 'loading' || model.value) {
pullProgress.value = 1
return
}
if (payload.status === 'normal') {
pullProgress.value = 0
return
}
pullProgress.value = Math.min(1, payload.distance / PULL_DISTANCE)
}
watch(model, (v) => {
if (!v) pullProgress.value = 0
})
defineOptions({ inheritAttrs: false })
</script>
<style scoped lang="less">
/* Keep page content still — Vant still tracks the gesture, just don't translate. */
.apr {
:deep(.van-pull-refresh__track) {
transform: none !important;
transition: none !important;
}
:deep(.van-pull-refresh__head) {
display: none;
}
}
</style>
+3 -3
View File
@@ -100,10 +100,10 @@ function onBack() {
.bar__title {
margin: 0;
font-size: 14.5px;
font-weight: 800;
font-size: 13.5px;
font-weight: 600;
color: @ink;
letter-spacing: -0.1px;
letter-spacing: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -0,0 +1,134 @@
<!--
Full-viewport luxury veil while pull-to-refresh is in flight.
Dark translucent glass + brand mark so the refresh is unmistakable.
Intensity (0…1) fades the veil with the gesture; content itself never moves.
-->
<template>
<Teleport to="body">
<div
v-show="show"
class="rfx"
:class="{ 'rfx--blocking': blocking }"
:style="{ opacity: intensity }"
role="status"
aria-live="polite"
:aria-busy="blocking"
aria-label="در حال تازه‌سازی"
>
<div class="rfx__veil" aria-hidden="true" />
<div
class="rfx__stage"
:style="{ transform: `scale(${0.92 + 0.08 * intensity})` }"
>
<img
class="rfx__logo"
src="/img/brand-logo.png"
:alt="brand.name"
width="140"
height="140"
/>
<div class="rfx__spin" aria-hidden="true">
<Loading type="spinner" size="28px" color="#fff" />
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Loading } from 'vant'
import { brand } from '../../brand'
const props = withDefaults(
defineProps<{
show: boolean
/** 0…1 — fades in with the pull; 1 while the request is in flight. */
intensity?: number
}>(),
{ intensity: 1 },
)
/** Only block touches once refresh has actually started. */
const blocking = computed(() => props.intensity >= 0.99)
</script>
<style scoped lang="less">
.rfx {
position: fixed;
inset: 0;
z-index: 4000;
display: grid;
place-items: center;
pointer-events: none;
transition: opacity 0.12s ease;
}
.rfx--blocking {
pointer-events: all;
}
.rfx__veil {
position: absolute;
inset: 0;
background: rgba(14, 16, 20, 0.56);
backdrop-filter: blur(22px) saturate(130%);
-webkit-backdrop-filter: blur(22px) saturate(130%);
}
.rfx__stage {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 22px;
padding: 28px 32px;
will-change: transform;
}
.rfx__logo {
width: 140px;
height: 140px;
object-fit: contain;
filter: drop-shadow(0 22px 44px rgba(0, 0, 0, 0.45))
drop-shadow(0 0 32px rgba(20, 22, 26, 0.3));
animation: rfx-breathe 1.8s ease-in-out infinite;
}
.rfx__spin {
display: grid;
place-items: center;
width: 48px;
height: 48px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.14);
box-shadow: 0 10px 28px -12px rgba(0, 0, 0, 0.5);
}
@keyframes rfx-breathe {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.045);
}
}
@media (prefers-reduced-motion: reduce) {
.rfx {
transition: none;
}
.rfx__veil {
backdrop-filter: none;
-webkit-backdrop-filter: none;
background: rgba(14, 16, 20, 0.78);
}
.rfx__logo {
animation: none;
filter: drop-shadow(0 12px 24px rgba(0, 0, 0, 0.4));
}
}
</style>
@@ -0,0 +1,65 @@
// Server-side catalogue paging (STUB-03 step 4 / docs/CATALOG-PAGINATION-PLAN.md).
// Replaces "download everything, slice in the browser" for list screens.
import { computed, ref } from 'vue'
import { api, type ProductQuery, type ProductSummary } from '../api/client'
export const CATALOG_PAGE_SIZE = 20
export function usePagedProducts() {
const rows = ref<ProductSummary[]>([])
const total = ref(0)
const page = ref(0)
const loading = ref(false)
const loadingMore = ref(false)
const hasMore = computed(() => rows.value.length < total.value)
/**
* @param opts.preserveRows — pull-to-refresh: keep the current list on screen
* until the new page arrives (avoids an empty flash / stuck full-page spinner).
*/
async function resetAndLoad(query: ProductQuery, opts: { preserveRows?: boolean } = {}) {
const showFullSpinner = !opts.preserveRows || rows.value.length === 0
if (showFullSpinner) loading.value = true
page.value = 1
try {
const res = await api.listProductsPaged({
...query,
page: 1,
limit: query.limit ?? CATALOG_PAGE_SIZE,
})
rows.value = res.rows
total.value = res.total
} finally {
loading.value = false
}
}
async function loadMore(query: ProductQuery) {
if (loading.value || loadingMore.value || !hasMore.value) return
loadingMore.value = true
const next = page.value + 1
try {
const res = await api.listProductsPaged({
...query,
page: next,
limit: query.limit ?? CATALOG_PAGE_SIZE,
})
page.value = next
// Append; drop accidental duplicates if the catalogue shifted between pages.
const seen = new Set(rows.value.map((p) => p.id))
rows.value = [...rows.value, ...res.rows.filter((p) => !seen.has(p.id))]
total.value = res.total
} finally {
loadingMore.value = false
}
}
function clear() {
rows.value = []
total.value = 0
page.value = 0
}
return { rows, total, page, loading, loadingMore, hasMore, resetAndLoad, loadMore, clear }
}
+30 -24
View File
@@ -1,13 +1,9 @@
// Adapted from legacy henza_pwa/src/Mixins/getProducts.ts. Legacy paginated
// against POST /get-products (page/limit/count). The new API's GET
// /catalog/products returns the full active catalog in one flat response with
// no pagination — so `isPull` (legacy's infinite-scroll "next page" trigger)
// is a no-op here, and `totalPages` is always 0. A caller that wants new
// products at the end of the list passes `force` to refresh the cached
// catalogue instead of fabricating fake pages.
import { reactive, watch } from 'vue'
// Newest / list screens: true server paging (STUB-03 step 4).
// Legacy paginated against POST /get-products; the early Nest port returned the
// whole catalogue and treated `@fetch` as a no-op. This restores real pages.
import { reactive } from 'vue'
import type { ProductSummary } from '../api/client'
import { useCatalogStore } from '../stores/catalogStore'
import { usePagedProducts, CATALOG_PAGE_SIZE } from './usePagedProducts'
export interface ProductsData {
totalPages: number
@@ -16,28 +12,38 @@ export interface ProductsData {
}
export function useProducts() {
const catalog = useCatalogStore()
const paged = usePagedProducts()
const productsData: ProductsData = reactive({
totalPages: 0,
productList: catalog.products,
productList: [] as ProductSummary[],
flag: false,
})
// A refresh from another screen (pull-to-refresh) lands here too.
watch(
() => catalog.products,
(list) => {
productsData.productList = list
productsData.flag = list.length < 1
},
)
function sync() {
productsData.productList = paged.rows.value
productsData.totalPages = Math.ceil(paged.total.value / CATALOG_PAGE_SIZE) || 0
productsData.flag = paged.total.value < 1 && !paged.loading.value
}
const getProducts = async (loading: { value: boolean }, isPull = false, force = false) => {
if (isPull) return // no further pages exist server-side yet
loading.value = force || !catalog.loaded
void force
if (isPull) {
if (!paged.hasMore.value || paged.loadingMore.value) return
loading.value = true
try {
await paged.loadMore({ sort: 'newest' })
sync()
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
return
}
loading.value = true
try {
productsData.productList = await catalog.load(force)
productsData.flag = productsData.productList.length < 1
await paged.resetAndLoad({ sort: 'newest' })
sync()
} catch (e) {
console.log(e)
} finally {
@@ -45,5 +51,5 @@ export function useProducts() {
}
}
return { productsData, getProducts }
return { productsData, getProducts, hasMore: paged.hasMore }
}
+18 -2
View File
@@ -32,12 +32,28 @@ export const useCatalogStore = defineStore('catalog', () => {
// reason to keep loading the full catalogue.
async function load(force = false): Promise<ProductSummary[]> {
const list = await cache.fetch(KEYS.catalog, api.listProducts, { force })
// Prefer a bounded page when a caller still uses the unpaged helper — the
// real catalogue reaches ~16k rows (STUB-03). Screens that need filters or
// infinite scroll should call api.listProductsPaged directly.
const list = await cache.fetch(
KEYS.catalog,
async () => {
const page = await api.listProductsPaged({ page: 1, limit: 100, sort: 'newest' })
return page.rows
},
{ force },
)
products.value = list
loaded.value = true
return list
}
/** Replace the in-memory slice (home rails, after a paged fetch). */
function replace(list: ProductSummary[]) {
products.value = list
loaded.value = true
}
function byId(id: string): ProductSummary | null {
return products.value.find((p) => p.id === id) ?? null
}
@@ -47,5 +63,5 @@ export const useCatalogStore = defineStore('catalog', () => {
cache.invalidate(KEYS.catalog)
}
return { products, loaded, categories, load, byId, invalidate }
return { products, loaded, categories, load, replace, byId, invalidate }
})
+16
View File
@@ -20,3 +20,19 @@ export function jdate(val: string | undefined | null): string {
}
return val as any
}
/** Jalali date + time for status timelines and similar detail surfaces. */
export function jdatetime(val: string | undefined | null): string {
if (val) {
return moment(val).format('jYYYY/jMM/jDD HH:mm')
}
return val as any
}
/** Compact timeline stamp — e.g. «25 مهر، 11:19» (reference card style). */
export function jtimelineWhen(val: string | undefined | null): string {
if (val) {
return moment(val).format('jD jMMMM، HH:mm')
}
return val as any
}
+80 -43
View File
@@ -1,59 +1,96 @@
// Persian labels ported from the legacy Laravel Order::$STS enum (henza_server
// app/Models/Order.php) — the authoritative source, not henza_pwa's own
// Orders.vue filter-tab labels, which used a slightly different, inconsistent
// set (its key=0 was labeled "لغو شده"/cancelled, while the actual backend
// model's sts=0 means "معلق"/suspended — legacy itself was inconsistent here).
// Our OrderStatus enum cleanly separates SUSPENDED and CANCELLED, so mapped
// directly, one label per real status.
// Persian labels for the real OrderStatus enum (apps/api prisma schema).
// One label per status — shared by filter chips, list cards, and detail.
export const ORDER_STATUS_LABELS: Record<string, string> = {
SUSPENDED: 'معلق',
PLACED: 'ثبت سفارش',
ADMIN_APPROVED: 'تایید مدیر سیستم',
SOURCING: 'درحال تامین',
PREPARING: 'درحال آماده سازی',
DELIVERED_TO_AGENT: 'تحویل مسئول فروش',
ADMIN_APPROVED: 'تایید مدیر',
SOURCING: 'در حال تامین',
PREPARING: 'آماده‌سازی',
DELIVERED_TO_AGENT: 'تحویل نماینده',
DELIVERED_TO_CUSTOMER: 'تحویل مشتری',
CANCELLED: 'لغو شده',
};
}
/** Short blurbs under timeline titles (reference card style). */
export const ORDER_STATUS_BLURBS: Record<string, string> = {
SUSPENDED: 'سفارش به‌دلیل سقف اعتبار معلق شد',
PLACED: 'سفارش ثبت و دریافت شد',
ADMIN_APPROVED: 'سفارش توسط مدیر تایید شد',
SOURCING: 'کالا در حال تامین است',
PREPARING: 'سفارش در حال آماده‌سازی است',
DELIVERED_TO_AGENT: 'تحویل به نماینده فروش انجام شد',
DELIVERED_TO_CUSTOMER: 'سفارش به مشتری تحویل شد',
CANCELLED: 'سفارش لغو شده است',
}
/** Line icons for the timeline nodes (vuesax-linear set). */
export const ORDER_STATUS_ICONS: Record<string, string> = {
SUSPENDED: 'vuesax-linear:bag-timer',
PLACED: 'vuesax-linear:bag-tick',
ADMIN_APPROVED: 'vuesax-linear:verify',
SOURCING: 'vuesax-linear:box-search',
PREPARING: 'vuesax-linear:box-1',
DELIVERED_TO_AGENT: 'vuesax-linear:truck-time',
DELIVERED_TO_CUSTOMER: 'vuesax-linear:location-tick',
CANCELLED: 'vuesax-linear:bag-cross',
}
/** Happy-path pipeline shown in the detail stepper (excludes terminal SUSPENDED / CANCELLED). */
export const ORDER_PIPELINE = [
'PLACED',
'ADMIN_APPROVED',
'SOURCING',
'PREPARING',
'DELIVERED_TO_AGENT',
'DELIVERED_TO_CUSTOMER',
] as const
export type OrderPipelineStatus = (typeof ORDER_PIPELINE)[number]
/** Step titles = system status labels, in pipeline order. */
export const ORDER_STEPS = ORDER_PIPELINE.map((status) => ({
status,
title: ORDER_STATUS_LABELS[status]!,
}))
// Ported from legacy order.vue/Orders.vue's setColor() — legacy only ever
// colored sts 1,3,4,5,6 (never sts=0 or sts=2), an existing gap preserved here
// for the directly-analogous statuses (ADMIN_APPROVED, SUSPENDED, CANCELLED
// stay uncolored, same as legacy's untouched sts=0/2).
//
// Backgrounds are the font colour at 12% alpha (the trailing 1F), not the
// opaque pale tints they used to be: a translucent wash reads the same on a
// white card and stays a soft tint on a dark one, where #EFF6FF & co. were
// glaring light patches.
export const ORDER_STATUS_COLORS: Record<string, { font: string; background: string }> = {
SUSPENDED: { font: '#A16207', background: '#A162071F' },
PLACED: { font: '#3B82F6', background: '#3B82F61F' },
ADMIN_APPROVED: { font: '#6366F1', background: '#6366F11F' },
SOURCING: { font: '#EAB308', background: '#EAB3081F' },
PREPARING: { font: '#F97316', background: '#F973161F' },
DELIVERED_TO_AGENT: { font: '#06B6D4', background: '#06B6D41F' },
DELIVERED_TO_CUSTOMER: { font: '#22C55E', background: '#22C55E1F' },
};
CANCELLED: { font: '#DC2626', background: '#DC26261F' },
}
// Ported from legacy Orders.vue's 4-step tracker (`steps`/`setCurrentStep`).
// Legacy's ambiguous sts=0 (suspended vs cancelled) is resolved here since our
// enum keeps them distinct; SUSPENDED is grouped with the first step (not yet
// progressing), matching the spirit of legacy's grouping of early statuses.
export const ORDER_STEPS = ['ثبت سفارش', 'آماده سازی', 'تحویل مشتری', 'لغو شده'];
const FALLBACK_COLOR = { font: '#64748B', background: '#64748B1F' }
const FALLBACK_ICON = 'vuesax-linear:receipt-text'
export function orderStatusColor(status: string) {
return ORDER_STATUS_COLORS[status] ?? FALLBACK_COLOR
}
export function orderStatusLabel(status: string) {
return ORDER_STATUS_LABELS[status] ?? status
}
export function orderStatusBlurb(status: string) {
return ORDER_STATUS_BLURBS[status] ?? ''
}
export function orderStatusIcon(status: string) {
return ORDER_STATUS_ICONS[status] ?? FALLBACK_ICON
}
/**
* Index into ORDER_PIPELINE for the active step.
* CANCELLED → -1 (no pipeline progress).
* SUSPENDED → 0 (stuck at placement / first step).
*/
export function orderStepIndex(status: string): number {
switch (status) {
case 'PLACED':
case 'ADMIN_APPROVED':
case 'SUSPENDED':
return 0;
case 'SOURCING':
case 'PREPARING':
return 1;
case 'DELIVERED_TO_AGENT':
case 'DELIVERED_TO_CUSTOMER':
return 2;
case 'CANCELLED':
return 3;
default:
return 0;
}
if (status === 'CANCELLED') return -1
if (status === 'SUSPENDED') return 0
const i = (ORDER_PIPELINE as readonly string[]).indexOf(status)
return i >= 0 ? i : 0
}
+8 -9
View File
@@ -32,11 +32,7 @@
<Spinner :spinning="cartData.loadingPage" tip="درحال دریافت اطلاعات">
<container class="cart__body">
<!-- Step 1: the basket -->
<PullRefresh v-if="cartData.step === 1" v-model="refreshing" @refresh="onRefresh">
<template #pulling><Loading size="18" /></template>
<template #loosing><Loading size="18" /></template>
<template #loading><Loading size="18" /></template>
<template #success><span /></template>
<AppPullRefresh v-if="cartData.step === 1" v-model="refreshing" @refresh="onRefresh">
<template v-if="cart.items.length">
<div class="cart__items" :class="{ 'is-revealed': revealed }">
<CartItem
@@ -62,7 +58,7 @@
دیدن محصولات
</button>
</section>
</PullRefresh>
</AppPullRefresh>
<!-- Step 2: submitted -->
<section v-else class="cart__done">
@@ -145,7 +141,7 @@
import { onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Drawer, Form, FormItem, Textarea } from 'ant-design-vue'
import { Loading, PullRefresh } from 'vant'
import AppPullRefresh from '../components/ui/AppPullRefresh.vue'
import dialogEmpty4 from '../assets/img/empty/dialogEmpty4.svg'
import loginDialogImage from '../assets/img/empty/dialogEmpty1.svg'
import Icon from '../components/icon/Icon.vue'
@@ -235,8 +231,11 @@ const submitOrder = async () => {
}
async function onRefresh() {
await cart.init(true)
refreshing.value = false
try {
await cart.init(true)
} finally {
refreshing.value = false
}
}
onMounted(async () => {
+20 -20
View File
@@ -35,12 +35,8 @@
</div>
</header>
<PullRefresh v-model="refreshing" @refresh="onRefresh">
<template #pulling><Loading size="18" /></template>
<template #loosing><Loading size="18" /></template>
<template #loading><Loading size="18" /></template>
<template #success><span /></template>
<container class="home__body" :class="{ 'is-revealed': revealed }">
<AppPullRefresh v-model="refreshing" @refresh="onRefresh">
<container class="home__body" :class="{ 'is-revealed': revealed }">
<RateStrip :quote="goldPrice" class="home__rate reveal" :style="revealDelay(0)" />
<ShowcaseSlider
@@ -111,7 +107,7 @@
</div>
</div>
</container>
</PullRefresh>
</AppPullRefresh>
</div>
</template>
@@ -119,7 +115,7 @@
import { brand } from '../brand'
import { computed, onActivated, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Loading, PullRefresh } from 'vant'
import AppPullRefresh from '../components/ui/AppPullRefresh.vue'
import Icon from '../components/icon/Icon.vue'
import { openProduct as openProductMorph } from '../composables/useProductMorph'
import RateStrip from '../components/home/RateStrip.vue'
@@ -133,7 +129,7 @@ import type { HomeCategory } from '../stores/configStore'
import { useCartStore } from '../stores/cartStore'
import { useFiltersStore } from '../stores/filtersStore'
import { useReveal } from '../composables/useReveal'
import type { AdminSlide, GoldPriceQuote, ProductSummary } from '../api/client'
import { api, type AdminSlide, type GoldPriceQuote, type ProductSummary } from '../api/client'
import { useCachedReads } from '../api/cachedReads'
import { useCatalogStore } from '../stores/catalogStore'
import { KEYS } from '../stores/queryCache'
@@ -195,15 +191,14 @@ function goNewest() {
// cached until pull-to-refresh asks the server again.
async function loadData(force = false) {
const [catalogResult, catsResult, priceResult, slidesResult] = await Promise.allSettled([
// The home rails read `catalog.products`, so the catalogue is loaded
// explicitly. It used to come in as a side effect of `memory.init()`, which
// now reads the small facets endpoint instead of the whole catalogue — so
// without this call the home screen would render empty rails.
//
// Still the unpaged list: the rails slice it locally («جدیدترین», per
// category). Moving them onto paged reads is step 4 of
// docs/CATALOG-PAGINATION-PLAN.md.
catalog.load(force),
// STUB-03: rails need a small newest page. Pull-to-refresh must drop the
// cached catalogue first (same rule as before paging) or the screen keeps
// showing the previous slice while gold/slides refresh.
(async () => {
if (force) catalog.invalidate()
const page = await api.listProductsPaged({ page: 1, limit: 13, sort: 'newest' })
catalog.replace(page.rows)
})(),
memory.init(force),
reads.goldPrice({ force }),
reads.homeSlider({ force }),
@@ -218,8 +213,13 @@ async function loadData(force = false) {
}
async function onRefresh() {
await loadData(true)
refreshing.value = false
try {
await loadData(true)
} finally {
// Always release Vant's PullRefresh, even when a request fails — otherwise
// the spinner sticks until a full reload (same bug Dashboard already fixed).
refreshing.value = false
}
}
onMounted(async () => {
+7 -27
View File
@@ -1,12 +1,9 @@
<!--
Ported from legacy henza_pwa/src/views/newest.vue, restyled into the
redesigned design language (see components/home/* and HomeView).
See useProducts.ts for the pagination-simplification note.
Data wiring is untouched: the same useProducts() composable, the same
pull-to-fetch pagination, and the same ScrollFetchSearch infinite scroll.
Only the chrome and the card change — AppHeader -> PageBar, and the
Tailwind-utility product rows -> the shared ProductCard grid.
STUB-03 step 4: ScrollFetchSearch @fetch asks the server for the next page
(sort=newest). UI chrome unchanged.
-->
<template>
<div class="newest">
@@ -24,7 +21,7 @@
<SectionHead title="تازه‌ها" subtitle="آخرین افزوده‌های مجموعه" />
<div v-if="productsData.productList.length" class="newest__grid">
<ProductCard v-for="item in visibleProducts" :key="item.id" :value="item" />
<ProductCard v-for="item in productsData.productList" :key="item.id" :value="item" />
</div>
</container>
</ScrollFetchSearch>
@@ -33,7 +30,7 @@
</template>
<script setup lang="ts">
import { computed, onBeforeMount, ref } from 'vue'
import { onBeforeMount, ref } from 'vue'
import PageBar from '../components/ui/PageBar.vue'
import Spinner from '../components/Spinner.vue'
import SectionHead from '../components/home/SectionHead.vue'
@@ -41,35 +38,18 @@ import ProductCard from '../components/product/ProductCard.vue'
import ScrollFetchSearch from '../components/ui/ScrollFetchSearch.vue'
import { useProducts } from '../composables/useProducts'
const { productsData, getProducts } = useProducts()
const { productsData, getProducts, hasMore } = useProducts()
const loadingPage = ref(false)
const loadingPull = ref(false)
// ── Incremental rendering ────────────────────────────────────────────────
// useProducts() accumulates the fetched list, and this port fetches the whole
// (small) catalogue rather than truly paging the server. Mounting every card
// at once puts hundreds of images on screen, so render a window and grow it
// each time ScrollFetchSearch reports the user reached the end — which is the
// same @fetch signal that used to drive pagination.
const PAGE_SIZE = 20
const visibleCount = ref(PAGE_SIZE)
const visibleProducts = computed(() => productsData.productList.slice(0, visibleCount.value))
onBeforeMount(async () => {
await getProducts(loadingPage, false)
})
const pull = async () => {
// Reveal more of what is already loaded first; only go back to the server
// once the whole fetched list is on screen. Reaching the end is how this
// page asks for new products: the cached catalogue is refreshed.
if (visibleCount.value < productsData.productList.length) {
visibleCount.value += PAGE_SIZE
return
}
if (loadingPull.value) return
await getProducts(loadingPull, false, true)
if (loadingPull.value || !hasMore.value) return
await getProducts(loadingPull, true)
}
</script>
@@ -36,11 +36,7 @@
</PageBar>
<Spinner :spinning="loading && !rows.length" tip="درحال دریافت اطلاعات">
<PullRefresh v-model="refreshing" @refresh="onRefresh">
<template #pulling><Loading size="18" /></template>
<template #loosing><Loading size="18" /></template>
<template #loading><Loading size="18" /></template>
<template #success><span /></template>
<AppPullRefresh v-model="refreshing" @refresh="onRefresh">
<container class="cho__body">
<template v-if="rows.length">
<div class="cho__list">
@@ -99,7 +95,7 @@
<!-- Sentinel for the infinite scroll, as the reference used (:216). -->
<div ref="sentinel" class="cho__sentinel" />
</container>
</PullRefresh>
</AppPullRefresh>
</Spinner>
<Drawer
@@ -146,7 +142,7 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { Drawer } from 'ant-design-vue'
import { Loading, PullRefresh } from 'vant'
import AppPullRefresh from '../../components/ui/AppPullRefresh.vue'
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue'
@@ -209,8 +205,11 @@ async function readSearch(value: string | undefined) {
}
async function onRefresh() {
await reload()
refreshing.value = false
try {
await reload()
} finally {
refreshing.value = false
}
}
function openDetail(item: ExpertChildOrder) {
@@ -40,11 +40,7 @@
</PageBar>
<Spinner :spinning="loading && !rows.length" tip="درحال دریافت اطلاعات">
<PullRefresh v-model="refreshing" @refresh="onRefresh">
<template #pulling><Loading size="18" /></template>
<template #loosing><Loading size="18" /></template>
<template #loading><Loading size="18" /></template>
<template #success><span /></template>
<AppPullRefresh v-model="refreshing" @refresh="onRefresh">
<container class="cch__body">
<div v-if="rows.length" class="cch__list">
<article
@@ -88,7 +84,7 @@
<button type="button" class="cch__retry" @click="load">تلاش دوباره</button>
</section>
</container>
</PullRefresh>
</AppPullRefresh>
</Spinner>
<Drawer
@@ -143,7 +139,7 @@
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Drawer } from 'ant-design-vue'
import { Loading, PullRefresh } from 'vant'
import AppPullRefresh from '../../components/ui/AppPullRefresh.vue'
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue'
@@ -196,8 +192,11 @@ async function readSearch(value: string | undefined) {
}
async function onRefresh() {
await load()
refreshing.value = false
try {
await load()
} finally {
refreshing.value = false
}
}
function openDetail(item: ExpertCustomer) {
@@ -52,11 +52,7 @@
</PageBar>
<Spinner :spinning="loading && !rows.length && !details.length" tip="درحال دریافت اطلاعات">
<PullRefresh v-model="refreshing" @refresh="onRefresh">
<template #pulling><Loading size="18" /></template>
<template #loosing><Loading size="18" /></template>
<template #loading><Loading size="18" /></template>
<template #success><span /></template>
<AppPullRefresh v-model="refreshing" @refresh="onRefresh">
<container class="rep__body">
<!-- ── کلیات حساب ─────────────────────────────────────────── -->
<template v-if="activeKey === '1'">
@@ -124,7 +120,7 @@
<div v-if="loading && (rows.length || details.length)" class="rep__more">در حال دریافت…</div>
<div ref="sentinel" class="rep__sentinel" />
</container>
</PullRefresh>
</AppPullRefresh>
</Spinner>
<Drawer
@@ -151,7 +147,7 @@
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { Drawer } from 'ant-design-vue'
import { Loading, PullRefresh } from 'vant'
import AppPullRefresh from '../../components/ui/AppPullRefresh.vue'
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue'
@@ -243,8 +239,11 @@ async function changeTab(key: '1' | '2') {
}
async function onRefresh() {
await reload()
refreshing.value = false
try {
await reload()
} finally {
refreshing.value = false
}
}
/** The «جزئیات» link — the reference opened the same card in a modal. */
@@ -1,15 +1,9 @@
<!--
Ported from legacy henza_pwa/src/views/search/collection/index.vue, restyled
into the redesigned design language (see components/home/* and HomeView).
into the redesigned design language.
Filters the one flat product list client-side by collection id (route
param) — no server-side ciny_id query param exists; consistent with the
rest of this port's "small catalog, client-side filtering" approach. That
logic is carried over unchanged.
The view no longer owns a Drawer: ProductCard opens the product detail
itself, the same way it does on every other catalogue screen, so the
detail behaviour is identical everywhere instead of re-implemented here.
STUB-03 step 4: products for this collection come from
GET /catalog/products?collectionId=&page=&limit= — not a full-catalogue scan.
-->
<template>
<div class="coll">
@@ -19,11 +13,15 @@
<container class="coll__body">
<SectionHead title="محصولات این مجموعه" subtitle="برای دیدن جزئیات، روی محصول ضربه بزنید" />
<div v-if="collectionProducts.length" class="coll__grid">
<ProductCard v-for="item in collectionProducts" :key="item.id" :value="item" />
<div v-if="rows.length" class="coll__grid">
<ProductCard v-for="item in rows" :key="item.id" :value="item" />
</div>
<section v-else-if="!loadingPage" class="coll__empty">
<div v-if="hasMore || loadingMore" ref="sentinel" class="coll__more">
<Spinner :spinning="true" :indicator="true" :fixed="false" tip="" cls="my-[10px]" />
</div>
<section v-else-if="!loadingPage && !rows.length" class="coll__empty">
<Icon icon="vuesax-linear:box" size="26" />
<p>در این مجموعه محصولی ثبت نشده است.</p>
</section>
@@ -33,37 +31,51 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue'
import SectionHead from '../../components/home/SectionHead.vue'
import ProductCard from '../../components/product/ProductCard.vue'
import { useCatalogStore } from '../../stores/catalogStore'
import { usePagedProducts } from '../../composables/usePagedProducts'
const route = useRoute()
const catalog = useCatalogStore()
const { rows, loading, loadingMore, hasMore, resetAndLoad, loadMore } = usePagedProducts()
const loadingPage = ref(false)
const allProducts = computed(() => catalog.products)
// legacy passed the collection's title through as ?page=<title>; kept, with a
// neutral fallback so the bar is never blank on a direct link.
const loadingPage = computed(() => loading.value && rows.value.length === 0)
const collectionTitle = computed(() => (route.query.page as string) || 'مجموعه')
const collectionId = computed(() => String(route.params.id ?? ''))
const collectionProducts = computed(() =>
allProducts.value.filter((p) => p.collections.some((c) => c.collectionId === route.params.id)),
)
const sentinel = ref<HTMLElement | null>(null)
let observer: IntersectionObserver | undefined
function query() {
return { collectionId: collectionId.value, sort: 'newest' as const }
}
function mountObserver() {
observer?.disconnect()
if (!sentinel.value) return
observer = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting) && hasMore.value) {
void loadMore(query())
}
},
{ rootMargin: '400px' },
)
observer.observe(sentinel.value)
}
watch(sentinel, mountObserver)
onBeforeUnmount(() => observer?.disconnect())
onMounted(async () => {
loadingPage.value = !catalog.loaded
try {
await catalog.load()
await resetAndLoad(query())
} catch (e) {
console.log(e)
} finally {
loadingPage.value = false
}
})
</script>
@@ -85,6 +97,12 @@ onMounted(async () => {
gap: 11px;
}
.coll__more {
display: flex;
justify-content: center;
padding: 18px 0 4px;
}
.coll__empty {
margin-top: 8px;
padding: 34px 20px;
+84 -114
View File
@@ -2,15 +2,9 @@
Ported from legacy henza_pwa/src/views/search/searchPage.vue, restyled into
the redesigned design language (see components/home/* and HomeView).
All filtering still happens client-side against the one flat product list
(no server-side query params exist for any of this — consistent with
NewestView's own "single fetch" adaptation from an earlier batch). The
filter logic, the Filters sheet, the category chips and the collection
tiles are all carried over unchanged; only the chrome and the cards change.
The collection tiles keep their original destination (/collection/:id with
the title in ?page=), and are shown above the results exactly as the legacy
ProductList did on this route.
STUB-03 step 4: filtering and paging are server-side (GET /catalog/products
with page/limit/search/…). Facets drive the chip row and the Filters sheet.
UI chrome is unchanged — only the data path moved.
-->
<template>
<div class="search">
@@ -61,13 +55,9 @@
indicator, over the results already on screen.
-->
<div>
<PullRefresh v-model="refreshing" @refresh="onRefresh">
<template #pulling><Loading size="18" /></template>
<template #loosing><Loading size="18" /></template>
<template #loading><Loading size="18" /></template>
<template #success><span /></template>
<container class="search__body">
<div v-if="loadingPage && !allProducts.length" class="search__skeleton" aria-hidden="true">
<AppPullRefresh v-model="refreshing" @refresh="onRefresh">
<container class="search__body">
<div v-if="loadingPage" class="search__skeleton" aria-hidden="true">
<div class="search__skHead">
<span class="skeleton search__skLine search__skLine--title" />
<span class="skeleton search__skLine search__skLine--sub" />
@@ -97,49 +87,39 @@
</div>
<SectionHead
v-if="filteredProducts.length"
v-if="rows.length"
title="نتایج"
:subtitle="`${filteredProducts.length} محصول`"
:subtitle="`${total} محصول`"
/>
<div v-if="filteredProducts.length" class="search__grid">
<ProductCard v-for="item in visibleProducts" :key="item.id" :value="item" />
<div v-if="rows.length" class="search__grid">
<ProductCard v-for="item in rows" :key="item.id" :value="item" />
</div>
<!-- The catalogue is fetched in one request (no server-side paging
exists), but rendering all of it at once mounts hundreds of cards
and images on a phone. Render a window and grow it as the user
reaches the end — the same incremental behaviour the previous
ScrollFetchSearch-based list had. -->
<div v-if="hasMore" ref="sentinel" class="search__more">
<div v-if="hasMore || loadingMore" ref="sentinel" class="search__more">
<Spinner :spinning="true" :indicator="true" :fixed="false" tip="" cls="my-[10px]" />
</div>
<!-- Its own condition, not a v-else of the sentinel above: chained to
it, the box also showed under the results whenever they all fit
in the first window. Same fix as user-app-v2. -->
<section v-if="!filteredProducts.length && !loadingPage" class="search__empty">
<section v-if="!rows.length && !loadingPage" class="search__empty">
<Icon icon="vuesax-linear:search-normal-1" size="26" />
<p>محصولی با این مشخصات پیدا نشد.</p>
</section>
</container>
</PullRefresh>
</AppPullRefresh>
</div>
<!-- A bottom sheet over a see-through backdrop (teleported to <body>);
see Filters.vue. The result list stays in place underneath. -->
<Filters
v-model:show="showFilter"
v-model:filterKeys="filterKeys"
v-model="filters.query"
:products="allProducts"
:facets="facets"
/>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { Loading, PullRefresh } from 'vant'
import AppPullRefresh from '../../components/ui/AppPullRefresh.vue'
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue'
@@ -147,106 +127,58 @@ import SearchInput from '../../components/search/SearchInput.vue'
import Filters from '../../components/search/Filters.vue'
import SectionHead from '../../components/home/SectionHead.vue'
import ProductCard from '../../components/product/ProductCard.vue'
import { api, type CatalogFacets, type ProductQuery } from '../../api/client'
import { useFiltersStore } from '../../stores/filtersStore'
import { useCatalogStore } from '../../stores/catalogStore'
import { usePagedProducts } from '../../composables/usePagedProducts'
const filters = useFiltersStore()
const catalog = useCatalogStore()
const { rows, total, loading, loadingMore, hasMore, resetAndLoad, loadMore } = usePagedProducts()
// Starts true on a cold start: the first render happens before onMounted kicks
// off the fetch, and with false the "nothing found" box flashed up for a frame
// before the loading state replaced it. With the catalogue cached it renders at once.
const loadingPage = ref(!catalog.loaded)
// Two rows of the two-column grid — enough to fill the screen under the bar.
const loadingPage = computed(() => loading.value && rows.value.length === 0)
const SKELETON_CARDS = 6
const refreshing = ref(false)
const allProducts = computed(() => catalog.products)
const showFilter = ref(false)
const filterKeys = ref<string[]>([])
const facets = ref<CatalogFacets | null>(null)
const availableCategories = computed(() => {
const seen = new Map<string, { id: string; title: string }>()
for (const p of allProducts.value) {
if (p.category) seen.set(p.category.id, p.category)
}
return [...seen.values()]
})
const availableCategories = computed(() => facets.value?.categories ?? [])
const availableCollections = computed(() => facets.value?.collections ?? [])
const availableCollections = computed(() => {
const seen = new Map<string, { id: string; title: string }>()
for (const p of allProducts.value) {
for (const c of p.collections) seen.set(c.collection.id, { id: c.collection.id, title: c.collection.title })
}
return [...seen.values()]
})
const filteredProducts = computed(() => {
let list = allProducts.value
function toServerQuery(): ProductQuery {
const q = filters.query
if (q.text) list = list.filter((p) => p.title.includes(q.text!))
if (q.cats && q.cats.length > 0) list = list.filter((p) => p.category && q.cats!.includes(p.category.id))
if (q.brands.length > 0) list = list.filter((p) => p.brand && q.brands.includes(p.brand.id))
const out: ProductQuery = { sort: 'newest' }
if (q.text?.trim()) out.search = q.text.trim()
if (q.cats?.[0]) out.categoryId = q.cats[0]
if (q.brands.length) out.brandId = q.brands.join(',')
if (q.limit_gram.length === 2) {
list = list.filter((p) =>
p.variants.some(
(v) => parseFloat(v.rashWeightGrams) >= q.limit_gram[0] && parseFloat(v.rashWeightGrams) <= q.limit_gram[1],
),
)
out.minGram = q.limit_gram[0]
out.maxGram = q.limit_gram[1]
}
return list
})
// ── Incremental rendering ────────────────────────────────────────────────
// The whole catalogue arrives in one request (no server-side paging exists),
// so the filtering above is instant — but mounting every matching card at once
// means hundreds of cards and images on a phone. Render a window instead and
// grow it when the sentinel at the end scrolls into view.
const PAGE_SIZE = 20
const visibleCount = ref(PAGE_SIZE)
const visibleProducts = computed(() => filteredProducts.value.slice(0, visibleCount.value))
const hasMore = computed(() => visibleCount.value < filteredProducts.value.length)
const sentinel = ref<HTMLElement | null>(null)
let observer: IntersectionObserver | undefined
function mountObserver() {
observer?.disconnect()
if (!sentinel.value) return
observer = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting) && hasMore.value) {
visibleCount.value += PAGE_SIZE
}
},
{ rootMargin: '400px' },
)
observer.observe(sentinel.value)
return out
}
// Any change to the filters restarts the window from the top.
watch(filteredProducts, () => {
visibleCount.value = PAGE_SIZE
})
// The sentinel is v-if'd, so re-attach whenever it appears or is replaced.
watch(sentinel, mountObserver)
onBeforeUnmount(() => observer?.disconnect())
async function loadProducts(force = false) {
loadingPage.value = !catalog.loaded
async function loadFacets() {
try {
await catalog.load(force)
facets.value = await api.getCatalogFacets()
} catch (e) {
console.log(e)
}
}
async function reload(opts: { preserveRows?: boolean } = {}) {
try {
await resetAndLoad(toServerQuery(), opts)
} catch (e) {
console.log(e)
} finally {
loadingPage.value = false
}
}
async function onRefresh() {
await loadProducts(true)
refreshing.value = false
try {
await Promise.all([loadFacets(), reload({ preserveRows: true })])
} finally {
refreshing.value = false
}
}
function search(value: string | undefined) {
@@ -259,7 +191,45 @@ function showFilters() {
showFilter.value = true
}
onMounted(() => loadProducts())
const sentinel = ref<HTMLElement | null>(null)
let observer: IntersectionObserver | undefined
function mountObserver() {
observer?.disconnect()
if (!sentinel.value) return
observer = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting) && hasMore.value) {
void loadMore(toServerQuery())
}
},
{ rootMargin: '400px' },
)
observer.observe(sentinel.value)
}
watch(sentinel, mountObserver)
onBeforeUnmount(() => observer?.disconnect())
// Debounce text; category / brand / gram apply immediately.
let textTimer: ReturnType<typeof setTimeout> | undefined
watch(
() => filters.query.text,
() => {
clearTimeout(textTimer)
textTimer = setTimeout(() => void reload(), 300)
},
)
watch(
() => [filters.query.cats, filters.query.brands, filters.query.limit_gram] as const,
() => void reload(),
{ deep: true },
)
onMounted(async () => {
await loadFacets()
await reload()
})
</script>
<style scoped lang="less">
+45 -14
View File
@@ -48,14 +48,11 @@
</div>
</header>
<PullRefresh v-model="refreshing" @refresh="onRefresh">
<template #pulling><Loading size="18" /></template>
<template #loosing><Loading size="18" /></template>
<template #loading><Loading size="18" /></template>
<template #success><span /></template>
<container class="dash__body" :class="{ 'is-revealed': revealed }">
<!-- Signed in: real position from GET /customers/:id -->
<template v-if="customer">
<AppPullRefresh v-model="refreshing" @refresh="onRefresh">
<container class="dash__body" :class="{ 'is-revealed': revealed }">
<!-- Signed in: real position from GET /customer/me. Auth is the token —
never treat a slow/failed profile fetch as a guest session. -->
<template v-if="session.isLoggedIn && customer">
<section class="dash__balance reveal" :style="revealDelay(0)">
<div class="dash__balanceHead">
<div>
@@ -82,6 +79,30 @@
</section>
</template>
<section
v-else-if="session.isLoggedIn"
class="dash__invite reveal"
:style="revealDelay(0)"
>
<div class="dash__inviteIcon" aria-hidden="true">
<Icon icon="vuesax-linear:security-user" size="20" />
</div>
<div class="dash__inviteCopy">
<h3>حساب شما</h3>
<p>
{{ profileError ? 'موجودی الان در دسترس نیست. دوباره تلاش کنید.' : 'در حال دریافت موجودی…' }}
</p>
</div>
<button
v-if="profileError"
type="button"
class="dash__inviteCta"
@click="loadDashboard"
>
تلاش دوباره
</button>
</section>
<!-- Signed out: a quiet invitation, no fabricated numbers -->
<section v-else class="dash__invite reveal" :style="revealDelay(0)">
<div class="dash__inviteIcon" aria-hidden="true">
@@ -122,7 +143,7 @@
<RateStrip :quote="goldPrice" />
</section>
</container>
</PullRefresh>
</AppPullRefresh>
<DialogVant :timeOut="-1" v-model:show="data.notify" :closeOnClickOverlay="true">
<template v-slot:img>
@@ -157,7 +178,7 @@
import { brand } from '../../brand'
import { computed, onActivated, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Loading, PullRefresh } from 'vant'
import AppPullRefresh from '../../components/ui/AppPullRefresh.vue'
import Icon from '../../components/icon/Icon.vue'
import SectionHead from '../../components/home/SectionHead.vue'
import RateStrip from '../../components/home/RateStrip.vue'
@@ -176,6 +197,7 @@ const session = useSessionStore()
const reads = useCachedReads()
const customer = ref<CustomerDetail | null>(reads.peek<CustomerDetail>(KEYS.profile) ?? null)
const profileError = ref(false)
const goldPrice = ref<GoldPriceQuote | null>(reads.peek<GoldPriceQuote>(KEYS.goldPrice) ?? null)
const refreshing = ref(false)
@@ -268,6 +290,7 @@ async function loadDashboard() {
// the exception uncaught, so onRefresh's refreshing.value = false never
// ran and PullRefresh stayed stuck in its loading state — a working pull
// gesture never dismissed it again until a full page reload.
profileError.value = false
const [me, price] = await Promise.allSettled([
// The balance on this tab is the one figure read fresh on every visit;
// the cached copy only fills the screen until it arrives.
@@ -279,14 +302,22 @@ async function loadDashboard() {
// to control, so it is gone.
reads.goldPrice({ force: true }),
])
if (me.status === 'fulfilled') customer.value = me.value
else console.log(me.reason)
if (me.status === 'fulfilled') {
customer.value = me.value
profileError.value = false
} else {
profileError.value = session.isLoggedIn && !customer.value
console.log(me.reason)
}
goldPrice.value = price.status === 'fulfilled' ? price.value : null
}
async function onRefresh() {
await loadDashboard()
refreshing.value = false
try {
await loadDashboard()
} finally {
refreshing.value = false
}
}
let activatedOnce = false
+513 -127
View File
@@ -8,10 +8,9 @@
utils/orderStatus.ts) rather than legacy's own inconsistent tab labels.
They are chips now instead of ant Tabs, which lets them scroll
horizontally like every other filter rail in the redesign.
- Fetches real order history via the already-existing GET
/customers/:id/orders endpoint, filtered client-side by chip (that
endpoint has no server-side status filter — small enough dataset that
client-side filtering is reasonable, and apps/api stays untouched).
- Fetches paginated order history via GET /customer/me/orders (page /
status / q). Infinite-scroll like ReportView; filters run server-side
so a status chip never misses rows on later pages.
- Search matches against `orderNumber` (our real user-facing identifier)
instead of legacy's numeric `id`.
@@ -47,25 +46,30 @@
</template>
</PageBar>
<Spinner :spinning="loading" tip="درحال دریافت اطلاعات">
<PullRefresh v-model="refreshing" @refresh="onRefresh">
<template #pulling><Loading size="18" /></template>
<template #loosing><Loading size="18" /></template>
<template #loading><Loading size="18" /></template>
<template #success><span /></template>
<container class="ord__body">
<template v-if="filteredOrders.length">
<Spinner :spinning="loading && !orders.length" tip="درحال دریافت اطلاعات">
<AppPullRefresh v-model="refreshing" @refresh="onRefresh">
<container class="ord__body">
<template v-if="orders.length">
<div class="ord__list">
<OrderCard v-for="item in filteredOrders" :key="item.id" :value="item" @view="openDetail" />
<OrderCard v-for="item in orders" :key="item.id" :value="item" @view="openDetail" />
</div>
</template>
<section v-else-if="!loading" class="ord__empty">
<section v-else-if="!loading && !failed" class="ord__empty">
<Icon icon="vuesax-linear:box" size="26" />
<p>سفارشی با این مشخصات پیدا نشد.</p>
</section>
<section v-if="failed" class="ord__empty">
<Icon icon="vuesax-linear:danger" size="26" />
<p>دریافت سفارش‌ها با خطا روبه‌رو شد.</p>
<button type="button" class="ord__retry" @click="reload">تلاش دوباره</button>
</section>
<div v-if="loading && orders.length" class="ord__more">در حال دریافت…</div>
<div ref="sentinel" class="ord__sentinel" />
</container>
</PullRefresh>
</AppPullRefresh>
</Spinner>
<Drawer
@@ -79,39 +83,31 @@
>
<div class="detail">
<header class="detail__bar">
<button type="button" class="detail__close" aria-label="بستن" @click="viewData.model = false">
<button type="button" class="detail__close" aria-label="بستن" @click="closeDetail">
<Icon icon="vuesax-linear:arrow-right-1" size="20" />
</button>
<h2 class="detail__title">جزئیات سفارش</h2>
<span class="detail__spacer" aria-hidden="true" />
<button
type="button"
class="detail__trail"
aria-label="تاریخچه وضعیت"
@click="timelineOpen = true"
>
<Icon icon="vuesax-linear:clock-1" size="18" />
</button>
</header>
<div class="detail__scroll">
<!-- Progress -->
<section class="detail__block">
<div class="detail__steps">
<div
v-for="(step, index) in ORDER_STEPS"
:key="step"
class="detail__step"
:class="{ 'is-done': index <= currentStep, 'is-active': index === currentStep }"
>
<span class="detail__stepDot">{{ index + 1 }}</span>
<span class="detail__stepTitle">{{ step }}</span>
</div>
</div>
</section>
<!-- Headline facts -->
<!-- Headline facts — order path lives in the timeline sheet (header clock). -->
<section class="detail__block">
<div class="detail__headline">
<div>
<div class="detail__idBlock">
<div class="detail__eyebrow">شماره سفارش</div>
<div class="detail__number">{{ viewData.item.orderNumber }}</div>
</div>
<span
class="detail__status"
:style="{ background: modalColor?.background, color: modalColor?.font }"
:style="{ background: modalColor.background, color: modalColor.font }"
>
{{ modalStatusLabel }}
</span>
@@ -143,13 +139,83 @@
</div>
</div>
</Drawer>
<!-- Status history as an island action sheet (same pattern as cart note). -->
<Drawer
v-model:visible="timelineOpen"
placement="bottom"
:closable="false"
:zIndex="1100"
height="70%"
class="rtl"
:bodyStyle="{ padding: 0 }"
>
<div class="tlSheet">
<span class="tlSheet__grip" aria-hidden="true" />
<header class="tlSheet__banner">
<span class="tlSheet__chip">وضعیت</span>
<span class="tlSheet__dash" aria-hidden="true" />
<span
v-if="viewData.item"
class="tlSheet__badge"
:style="{
background: modalColor.background,
color: modalColor.font,
}"
>
<Icon icon="vuesax-linear:tick-circle" size="14" />
{{ modalStatusLabel }}
</span>
</header>
<ol v-if="timelinePath.length" class="tlSheet__rail">
<li
v-for="(node, index) in timelinePath"
:key="node.status"
class="tlSheet__node"
:class="[`is-${node.state}`]"
>
<div class="tlSheet__axis" aria-hidden="true">
<span
v-if="node.state === 'current'"
class="tlSheet__halo"
:style="{ color: orderStatusColor(node.status).font }"
/>
<span class="tlSheet__marker" :style="markerStyle(node)">
<Icon :icon="orderStatusIcon(node.status)" size="16" />
</span>
<span
v-if="index < timelinePath.length - 1"
class="tlSheet__stem"
:style="node.state === 'pending' ? undefined : stemStyle(node.status)"
/>
</div>
<div class="tlSheet__main">
<div class="tlSheet__row">
<div class="tlSheet__copy">
<div class="tlSheet__status">{{ orderStatusLabel(node.status) }}</div>
<p class="tlSheet__blurb">{{ orderStatusBlurb(node.status) }}</p>
</div>
<time v-if="node.at" class="tlSheet__when" :datetime="node.at">
{{ jtimelineWhen(node.at) }}
</time>
<span v-else class="tlSheet__when is-wait">در انتظار</span>
</div>
</div>
</li>
</ol>
<p v-else class="tlSheet__empty">مسیری برای این سفارش ثبت نشده است.</p>
</div>
</Drawer>
</div>
</template>
<script setup lang="ts">
import { computed, onActivated, onMounted, reactive, ref } from 'vue'
import { computed, nextTick, onActivated, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { Drawer } from 'ant-design-vue'
import { Loading, PullRefresh } from 'vant'
import AppPullRefresh from '../../components/ui/AppPullRefresh.vue'
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue'
@@ -157,19 +223,26 @@ import SearchInput from '../../components/search/SearchInput.vue'
import OrderCard from '../../components/product/OrderCard.vue'
import OrderItemCard from '../../components/product/OrderItemCard.vue'
import {
ORDER_STATUS_COLORS,
ORDER_PIPELINE,
ORDER_STATUS_LABELS,
ORDER_STEPS,
orderStepIndex,
orderStatusBlurb,
orderStatusColor,
orderStatusIcon,
orderStatusLabel,
} from '../../utils/orderStatus'
import { jdate } from '../../utils/filters'
import { jdate, jtimelineWhen } from '../../utils/filters'
import { useSessionStore } from '../../stores/session'
import type { OrderDetail } from '../../api/client'
import { useCachedReads } from '../../api/cachedReads'
import { KEYS } from '../../stores/queryCache'
import { api, type OrderDetail } from '../../api/client'
import { useQueryCache, KEYS } from '../../stores/queryCache'
type TimelineNode = {
status: string
at: string | null
state: 'done' | 'current' | 'pending'
}
const session = useSessionStore()
const reads = useCachedReads()
const cache = useQueryCache()
const tabs = [
{ title: 'همه', key: 'ALL' },
@@ -184,83 +257,179 @@ const tabs = [
]
const activeTab = ref('ALL')
const orders = ref<OrderDetail[]>([])
const page = ref(1)
const hasMore = ref(true)
const loading = ref(false)
const refreshing = ref(false)
const allOrders = ref<OrderDetail[]>(reads.peek<OrderDetail[]>(KEYS.orders) ?? [])
const failed = ref(false)
const searchTerm = ref<string | undefined>(undefined)
const filteredOrders = computed(() => {
let list = allOrders.value
if (activeTab.value !== 'ALL') list = list.filter((o) => o.status === activeTab.value)
if (searchTerm.value) list = list.filter((o) => o.orderNumber.includes(searchTerm.value!))
return list
})
const sentinel = ref<HTMLElement | null>(null)
let observer: IntersectionObserver | null = null
/** Bumped on every filter/reload so an in-flight page response is dropped. */
let fetchSeq = 0
function readSearch(value: string | undefined) {
searchTerm.value = value
}
async function loadOrders(force = false) {
async function fetchPage() {
if (!session.isLoggedIn) {
allOrders.value = []
orders.value = []
hasMore.value = false
return
}
loading.value = reads.peek(KEYS.orders) === undefined
if (loading.value || !hasMore.value) return
const my = fetchSeq
loading.value = true
failed.value = false
try {
allOrders.value = await reads.myOrders({ force })
const data = await api.getMyOrders({
page: page.value,
status: activeTab.value,
q: searchTerm.value,
})
if (my !== fetchSeq) return
const batch = data.result ?? []
orders.value = page.value === 1 ? batch : [...orders.value, ...batch]
if (batch.length < (data.limit ?? 20)) hasMore.value = false
else page.value += 1
} catch (e) {
if (my !== fetchSeq) return
failed.value = true
hasMore.value = false
console.log(e)
} finally {
loading.value = false
if (my === fetchSeq) loading.value = false
}
}
function resetList() {
orders.value = []
page.value = 1
hasMore.value = true
failed.value = false
}
async function reload() {
fetchSeq += 1
loading.value = false
resetList()
await fetchPage()
}
const viewData = reactive<{ model: boolean; item: OrderDetail | null }>({ model: false, item: null })
const timelineOpen = ref(false)
const modalStatusLabel = computed(() =>
viewData.item ? ORDER_STATUS_LABELS[viewData.item.status] ?? viewData.item.status : '',
viewData.item ? orderStatusLabel(viewData.item.status) : '',
)
const modalColor = computed(() => (viewData.item ? ORDER_STATUS_COLORS[viewData.item.status] : undefined))
const currentStep = computed(() => (viewData.item ? orderStepIndex(viewData.item.status) : 0))
const modalColor = computed(() =>
viewData.item ? orderStatusColor(viewData.item.status) : orderStatusColor('PLACED'),
)
/** Pending stays grey. Reached steps colour the icon/border only — fill stays opaque so the halo never shows through. */
function markerStyle(node: TimelineNode) {
if (node.state === 'pending') return undefined
const c = orderStatusColor(node.status)
return { color: c.font, borderColor: c.font }
}
function stemStyle(status: string) {
return { background: orderStatusColor(status).font }
}
/**
* Full order path for the timeline sheet (replaces the old detail stepper).
* Past steps are dated from statusEvents; future ones stay «در انتظار».
*/
const timelinePath = computed((): TimelineNode[] => {
const item = viewData.item
if (!item) return []
const times = new Map<string, string>()
for (const ev of item.statusEvents ?? []) {
if (!times.has(ev.toStatus)) times.set(ev.toStatus, ev.createdAt)
}
if (!times.has(item.status)) times.set(item.status, item.createdAt)
if (item.status === 'SUSPENDED') {
return [
{ status: 'SUSPENDED', at: times.get('SUSPENDED') ?? item.createdAt, state: 'current' },
...ORDER_PIPELINE.map((status) => ({ status, at: null, state: 'pending' as const })),
]
}
if (item.status === 'CANCELLED') {
const prior = ORDER_PIPELINE.filter((status) => times.has(status)).map((status) => ({
status,
at: times.get(status) ?? null,
state: 'done' as const,
}))
return [
...prior,
{ status: 'CANCELLED', at: times.get('CANCELLED') ?? item.createdAt, state: 'current' },
]
}
const idx = (ORDER_PIPELINE as readonly string[]).indexOf(item.status)
const currentIdx = idx >= 0 ? idx : 0
return ORDER_PIPELINE.map((status, i) => ({
status,
at: times.get(status) ?? (i === currentIdx ? item.createdAt : null),
state: i < currentIdx ? 'done' : i === currentIdx ? 'current' : 'pending',
}))
})
function openDetail(item: OrderDetail) {
viewData.item = item
viewData.model = true
timelineOpen.value = false
}
function closeDetail() {
timelineOpen.value = false
viewData.model = false
}
async function onRefresh() {
await loadOrders(true)
refreshing.value = false
try {
cache.invalidate(KEYS.orders)
await reload()
} finally {
refreshing.value = false
}
}
watch([activeTab, searchTerm], () => {
void reload()
})
/**
* Owner ruling 2026-10-12: fetch what the screen needs each time it is opened,
* rather than caching it with a lifetime — «هربار با رفتن به اون صفحه اون
* مقادیر مورد نیاز رو درخواست بزنه، هم سربار برای سرور نداره، هم درخواست الکی
* نمیخوره، هم دیتاها به‌روز میمونه».
*
* `force` matters because an order's status changes on the ADMIN side — nothing
* in this app invalidates the cache when that happens, so without it a list
* fetched once was shown unchanged for the rest of the session.
* Owner ruling 2026-10-12: fetch what the screen needs each time it is opened.
* Status changes on the ADMIN side, so each visit reloads from page 1.
*
* `onActivated` as well as `onMounted`: this view is in MasterLayout's
* keep-alive list, so it is mounted once and every later visit only activates
* it. With `onMounted` alone the second visit made no request at all.
*
* The cached copy still renders instantly (`peek` above) and `loading` only
* shows a spinner when there is nothing to show, so the refresh is invisible.
*
* `activatedOnce` guards the first visit, where Vue runs both hooks — the same
* shape DashboardView uses. Without it the two calls would overlap; the cache
* would collapse them into one request anyway, but not firing twice is clearer
* than relying on that.
* keep-alive list. `activatedOnce` guards the first visit where Vue runs both.
*/
onMounted(() => loadOrders(true))
onMounted(async () => {
await reload()
await nextTick()
if (typeof IntersectionObserver !== 'undefined') {
observer = new IntersectionObserver((entries) => {
if (entries.some((e) => e.isIntersecting)) void fetchPage()
})
if (sentinel.value) observer.observe(sentinel.value)
}
})
let activatedOnce = false
onActivated(() => {
if (activatedOnce) void loadOrders(true)
if (activatedOnce) void reload()
activatedOnce = true
})
onBeforeUnmount(() => observer?.disconnect())
</script>
<style scoped lang="less">
@@ -291,7 +460,7 @@ onActivated(() => {
cursor: pointer;
font-family: inherit;
font-size: 12px;
font-weight: 600;
font-weight: 500;
white-space: nowrap;
color: @inkSoft;
background: @canvas;
@@ -337,6 +506,25 @@ onActivated(() => {
font-size: 12.5px;
}
.ord__retry {
padding: 8px 18px;
border-radius: 999px;
border: 1px solid @hairline;
background: @canvas;
font-family: inherit;
font-size: 12px;
font-weight: 500;
cursor: pointer;
}
.ord__more,
.ord__sentinel {
padding: 12px 0;
text-align: center;
font-size: 12px;
color: @inkMuted;
}
/* ── Detail drawer ─────────────────────────────────────────────────── */
.detail {
height: 100vh;
@@ -359,8 +547,8 @@ onActivated(() => {
.detail__title {
margin: 0;
font-size: 14px;
font-weight: 800;
font-size: 13px;
font-weight: 500;
color: @ink;
}
@@ -376,10 +564,20 @@ onActivated(() => {
background: @hairlineSoft;
}
.detail__spacer {
display: block;
.detail__trail {
width: 38px;
height: 38px;
border: 0;
cursor: pointer;
border-radius: 12px;
display: grid;
place-items: center;
color: @ink;
background: @hairlineSoft;
}
.detail__trail:active {
transform: scale(0.96);
}
.detail__scroll {
@@ -395,63 +593,243 @@ onActivated(() => {
.detail__blockTitle {
margin: 0 0 11px;
font-size: 13px;
font-weight: 800;
font-size: 12.5px;
font-weight: 500;
color: @ink;
}
.detail__steps {
display: flex;
gap: 4px;
overflow-x: auto;
padding-bottom: 4px;
.tlSheet {
padding: 4px 18px 22px;
max-height: 100%;
overflow-y: auto;
}
.detail__step {
flex: 1 0 74px;
.tlSheet__grip {
display: block;
width: 38px;
height: 4px;
margin: 2px auto 10px;
border-radius: 4px;
background: @hairline;
}
.tlSheet__banner {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 18px;
}
.tlSheet__chip {
flex: 0 0 auto;
padding: 6px 12px;
border-radius: 999px;
border: 1px solid @hairline;
background: @canvas;
box-shadow: 0 1px 2px rgba(20, 22, 26, 0.04);
font-size: 11px;
font-weight: 500;
color: @inkSoft;
}
.tlSheet__dash {
flex: 1 1 auto;
height: 0;
border-top: 1.5px dashed @hairline;
min-width: 16px;
}
.tlSheet__badge {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 5px;
padding: 6px 11px;
border-radius: 999px;
font-size: 11px;
font-weight: 500;
white-space: nowrap;
}
.tlSheet__rail {
list-style: none;
margin: 0;
padding: 0 2px;
}
.tlSheet__node {
display: flex;
gap: 12px;
align-items: stretch;
min-height: 64px;
}
.tlSheet__axis {
flex: 0 0 40px;
width: 40px;
display: flex;
flex-direction: column;
align-items: center;
gap: 7px;
text-align: center;
position: relative;
}
.detail__stepDot {
width: 28px;
height: 28px;
/* Soft shadow-like halo — sits behind the opaque marker disc. */
.tlSheet__halo {
position: absolute;
top: 0;
left: 50%;
width: 40px;
height: 40px;
margin-left: -20px;
border-radius: 50%;
pointer-events: none;
z-index: 0;
}
.tlSheet__halo::before {
content: '';
position: absolute;
inset: -14px;
border-radius: 50%;
background: radial-gradient(
circle,
color-mix(in srgb, currentColor 32%, transparent) 0%,
color-mix(in srgb, currentColor 12%, transparent) 38%,
transparent 70%
);
filter: blur(1.5px);
opacity: 0;
animation: tlSheetHalo 4.8s ease-in-out infinite;
}
@keyframes tlSheetHalo {
0%,
100% {
opacity: 0;
}
50% {
opacity: 0.7;
}
}
@media (prefers-reduced-motion: reduce) {
.tlSheet__halo::before {
animation: none;
opacity: 0.35;
}
}
.tlSheet__marker {
flex: 0 0 40px;
width: 40px;
height: 40px;
border-radius: 50%;
border: 1.5px solid currentColor;
background: @canvas;
display: grid;
place-items: center;
font-size: 11.5px;
font-weight: 700;
color: @inkFaint;
background: @hairlineSoft;
border: 1px solid @hairline;
position: relative;
z-index: 1;
}
.detail__step.is-done .detail__stepDot {
color: #fff;
background: @primary;
border-color: @primary;
.tlSheet__node.is-pending .tlSheet__marker {
/* Tailwind gray-400 — clearer than hairline for inactive dashed rings */
color: #9ca3af;
background: @canvas;
border-color: #9ca3af;
border-style: dashed;
}
.detail__step.is-active .detail__stepDot {
color: #fff;
background: @wine;
border-color: @wine;
.tlSheet__node.is-done .tlSheet__marker,
.tlSheet__node.is-current .tlSheet__marker {
background: @canvas;
}
.detail__stepTitle {
font-size: 10px;
line-height: 1.5;
.tlSheet__node.is-pending .tlSheet__status,
.tlSheet__node.is-pending .tlSheet__blurb,
.tlSheet__node.is-pending .tlSheet__when {
color: @inkFaint;
}
.detail__step.is-done .detail__stepTitle {
color: @inkSoft;
font-weight: 600;
.tlSheet__stem {
flex: 1 1 auto;
width: 1.5px;
margin: 4px 0;
background: @hairline;
min-height: 16px;
}
.tlSheet__node.is-pending .tlSheet__stem {
background: @hairlineSoft;
background-image: repeating-linear-gradient(
180deg,
@hairline 0 4px,
transparent 4px 8px
);
width: 2px;
}
.tlSheet__main {
flex: 1;
min-width: 0;
padding-top: 4px;
padding-bottom: 18px;
}
.tlSheet__node:last-child .tlSheet__main {
padding-bottom: 4px;
}
.tlSheet__row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.tlSheet__copy {
min-width: 0;
flex: 1;
}
.tlSheet__status {
font-size: 13px;
font-weight: 500;
color: @ink;
line-height: 1.35;
}
.tlSheet__blurb {
margin: 3px 0 0;
font-size: 11.5px;
font-weight: 500;
line-height: 1.45;
color: @inkFaint;
}
.tlSheet__when {
flex: 0 0 auto;
padding-top: 2px;
font-size: 11px;
font-weight: 500;
color: @inkFaint;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.tlSheet__when.is-wait {
font-weight: 500;
opacity: 0.85;
}
.tlSheet__empty {
margin: 4px 0 0;
padding: 20px 12px;
font-size: 12px;
color: @inkFaint;
text-align: center;
border: 1px dashed @hairline;
border-radius: 14px;
}
.detail__headline {
@@ -462,24 +840,32 @@ onActivated(() => {
padding-bottom: 14px;
}
.detail__idBlock {
min-width: 0;
flex: 1;
}
.detail__eyebrow {
font-size: 11px;
color: @inkFaint;
}
.detail__number {
padding-top: 4px;
font-size: 18px;
font-weight: 800;
color: @ink;
padding-top: 3px;
font-size: 13px;
font-weight: 500;
line-height: 1.4;
color: @inkSoft;
word-break: break-all;
}
.detail__status {
flex: 0 0 auto;
align-self: flex-start;
padding: 5px 12px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 700;
font-weight: 500;
white-space: nowrap;
}
@@ -512,7 +898,7 @@ onActivated(() => {
.detail__spec dd {
margin: 0;
font-size: 12.5px;
font-weight: 700;
font-weight: 500;
color: @ink;
}
+1 -1
View File
@@ -14,7 +14,7 @@ const curve = {
px: 'px',
}
const vars = {
prefix: 'zartaj',
prefix: 'tehrantaj',
// headers
'font-size': curve.font + curve.px,
'h1-font-size': curve.font * 2 + curve.px,
+5 -5
View File
@@ -12,14 +12,14 @@
* in docs/BRANDING.md.
*/
export const brand = {
/** Persian name, used inside sentences: «ورود به زرتاج». */
name: 'زرتاج',
/** Persian name, used inside sentences: «ورود به تهران تاج». */
name: 'تهران تاج',
/** Latin name: PWA install name, alt texts, footer. */
nameEn: 'Zartaj',
nameEn: 'Tehran Taj',
/** Browser tab / home-screen title of the customer apps. */
appTitle: 'طلای زرتاج',
appTitle: 'طلای تهران تاج',
/** Browser tab title of the admin panel. */
adminTitle: 'زرتاج — پنل مدیریت',
adminTitle: 'تهران تاج — پنل مدیریت',
/** Public customer site; product links in the admin panel point here. */
siteUrl: 'https://bnk.montzar.ir',
} as const;
+5 -5
View File
@@ -1,13 +1,13 @@
# برند پروژه (زرتاج) — چک‌لیست تغییر نام
# برند پروژه (تهران تاج) — چک‌لیست تغییر نام
نام برند **زرتاج / Zartaj** است. برای عوض کردن نام دوباره **لازم نیست کل کد را بگردید**؛ فقط موارد همین فهرست.
نام برند **تهران تاج / Tehran Taj** است. برای عوض کردن نام دوباره **لازم نیست کل کد را بگردید**؛ فقط موارد همین فهرست.
## ۱. جاهایی که باید عوض شوند (فقط همین‌ها)
| چه چیزی | کجا | توضیح |
|---|---|---|
| نام فارسی، نام لاتین، عنوان تب مرورگر، آدرس سایت | [`brand.ts`](../brand.ts) در ریشهٔ پروژه | پنل ادمین، اپ مشتری و اپ مشتری v2 همه از همین یک فایل می‌خوانند: عنوان صفحه، نام نصب PWA، عنوان صفحهٔ ورود، پیام خوش‌آمد، متن‌های راهنما، لینک محصول. |
| نام برند در **همهٔ پیامک‌ها** | متغیر محیطی `BRAND_NAME` روی سرور API | خالی باشد = «زرتاج» (پیش‌فرض در [`apps/api/src/common/brand.ts`](../apps/api/src/common/brand.ts)). بعد از تغییر فقط API را ری‌استارت کنید. |
| نام برند در **همهٔ پیامک‌ها** | متغیر محیطی `BRAND_NAME` روی سرور API | خالی باشد = «تهران تاج» (پیش‌فرض در [`apps/api/src/common/brand.ts`](../apps/api/src/common/brand.ts)). بعد از تغییر فقط API را ری‌استارت کنید. |
| لینک سایت در پیامک خوش‌آمد | متغیر محیطی `BRAND_SITE_URL` روی سرور API | پیش‌فرض فعلاً `https://bnk.montzar.ir` است (دامنهٔ زنده). |
| لوگو | فایل‌های تصویر با نام ثابت `brand-*` (پایین) | نام فایل‌ها عمومی است؛ فقط خود تصویر را با همان نام جایگزین کنید. |
@@ -35,7 +35,7 @@
| کلیدهای مرورگر مثل `gold-bank:token` | تغییر = خروج همهٔ مشتری‌ها از اپ |
| جدول `_goldbank_migrations`، نام دیتابیس `goldbank`، سرویس داکر `goldbank-api` | تغییر = خراب شدن مایگریشن و استقرار روی سرور |
| نام پکیج‌ها `@gold-bank/*` و پوشهٔ پروژه | فقط نام فنی |
| شناسهٔ برند محصول `brand-montzar` در دیتابیس | کلید اصلی است و محصولات به آن وصل‌اند؛ عنوانش به «زرتاج طلا» تغییر کرد |
| شناسهٔ برند محصول `brand-montzar` در دیتابیس | کلید اصلی است و محصولات به آن وصل‌اند؛ عنوانش به «تهران تاج طلا» تغییر کرد |
| پوشه‌های `henza_admin`، `henza_pwa`، `henza_server` و ارجاع‌های کد به آن‌ها | کد سیستم قدیمی، فقط برای مرجع |
| آدرس‌های `henzagold.com` در توضیحات کد | سابقهٔ سیستم قدیمی، اجرا نمی‌شوند |
| پوشهٔ `docs/` (سوابق) | سابقهٔ کار است، نه متن برنامه |
@@ -45,7 +45,7 @@
برای اطمینان از اینکه نام قدیمی جایی در متن‌های برنامه نمانده:
```bash
grep -rn "منتزر\|Montzar\|هنزا" apps/*/src apps/*/index.html apps/api/prisma/seed.ts
grep -rn "زرتاج\|Zartaj\|منتزر\|Montzar\|هنزا" apps/*/src apps/*/index.html apps/api/prisma/seed.ts brand.ts
```
خروجی خالی یعنی تمیز است.
+2 -2
View File
@@ -118,8 +118,8 @@ ProductImage: @@index([productId])
| ۱ | مهاجرت ایندکس‌ها | ✅ **انجام شد** (`cbb93de`) — `Product` هیچ ایندکسی نداشت |
| ۲ | مسیرهای سرور + تست | ✅ **انجام شد** (`cbb93de`) — صفحه‌بندی opt-in، فیلتر سمت سرور، ۱۷ تست |
| ۳ | صفحه محصول، سبد، علاقه‌مندی، دسته‌ها، برچسب آیتم سفارش | ✅ **انجام شد** — پنج مصرف‌کننده منتقل شد |
| ۴ | `SearchView` / `NewestView` / rails خانه → صفحه‌بندی واقعی | ⏳ **باقی** — پرریسک‌ترین (فیلتر + اسکرول) |
| ۵ | حذف خوانش کل کاتالوگ و هشدار لاگ | ⏳ فقط وقتی هیچ مصرف‌کننده‌ای نمانده |
| ۴ | `SearchView` / `NewestView` / rails خانه → صفحه‌بندی واقعی | ✅ **انجام شد 2026-10-17** — هر دو اپ؛ facets + `listProductsPaged`؛ multi-brand با `brandId=a,b` |
| ۵ | حذف خوانش کل کاتالوگ و هشدار لاگ | ⏳ `catalog.load` هنوز یک صفحهٔ ۱۰۰تایی می‌گیرد برای callerهای باقی‌مانده؛ هشدار لاگ بعد از پاک‌سازی |
### یادداشت مرحله ۳ (انجام‌شده)
+384
View File
@@ -0,0 +1,384 @@
# راهنمای ارائه — گلدبانک
> تاریخ: ۲۰۲۶-۱۰-۱۴ (۱۴۰۵/۰۷/۲۲) · بر اساس کد فعلی مخزن. اعداد آزمون‌ها از تاریخ خودشان نقل شده‌اند.
> این سند برای ارائه است: اول منطق کار را توضیح می‌دهد، بعد جزئیات فنی. نام فایل‌ها فقط در پیوست آمده است.
---
## ۱. نگاه کلی
گلدبانک از سه برنامه و یک سرور تشکیل شده:
| بخش | کاربر | کار |
|---|---|---|
| **سرور (API)** | — | تمام قانون‌های کسب‌وکار: مشتری، محصول، سفارش، بدهی، اتصال به حسابداری |
| **پنل ادمین** | مدیر، کارشناس فروش، حسابدار، سرپرست کارگاه | مدیریت مشتریان، محصولات، سفارش‌ها، تنظیمات |
| **اپ مشتری** (دو نسخه) | مشتری و مغازه‌دار | ثبت‌نام، ورود با پیامک، کاتالوگ، سفارش، مانده و گزارش حساب |
سه موضوع اصلی که باید در ارائه روشن شود:
1. **اشخاص** — چه کسی مشتری است، چطور تأیید می‌شود و چه سقفی دارد.
2. **محصولات** — کالا، واریانت، عکس، موجودی و اجرت.
3. **اتصال به حسابداری** — مهم‌ترین بخش؛ اپ با ته‌حساب یا زرین همگام می‌شود.
**اصل طراحی که همه‌ی بخش‌ها روی آن ساخته شده:** دفتر داخلی (`LedgerEntry`) فقط افزودنی است. هیچ ردیفی ویرایش یا حذف نمی‌شود؛ بدهی هر مشتری از جمع این ردیف‌ها به دست می‌آید و ستون‌های مانده در مشتری فقط کش آن‌ها هستند.
---
## ۲. اشخاص
### ۲.۱ انواع «شخص» در سامانه
- **مشتری (Customer):** کلید شناسایی او **شماره موبایل** است و یکتاست. کد ملی اختیاری است (از ۲۰۲۶-۱۰-۰۷ به بعد). نوع مشتری یکی از این‌هاست:
- مغازه‌دار اپ، مغازه‌دار حضوری، بنکدار، سرپرست کارگاه، تک‌فروشی.
- **کاربر ادمین (AdminUser):** نقش‌ها: مدیر کل، تأییدکننده‌ی سیستم، **کارشناس فروش**، سرپرست کارگاه، حسابدار.
- کارشناس فروش هم یک حساب معاملاتی مشتری دارد و با همان پیامک وارد اپ می‌شود.
### ۲.۲ چرخه‌ی زندگی یک مشتری
1. **ثبت‌نام در اپ:** مشتری فرم را پر می‌کند (نام، فروشگاه، تلفن، آدرس، شهر). حساب **غیرفعال** ساخته می‌شود و وضعیت آن «مرحله‌ی اول ارسال شد» است. با شماره‌ای که قبلاً ثبت شده، ثبت‌نام تازه پذیرفته نمی‌شود. تعداد ثبت‌نام از هر IP محدود است.
2. **بررسی ادمین در دو مرحله:**
- تأیید مرحله‌ی اول: حساب فعال می‌شود و وضعیت «مرحله‌ی دوم» می‌گیرد.
- تأیید مرحله‌ی دوم: وضعیت **تأییدشده (VERIFIED)**. همین لحظه حساب در حسابداری ساخته یا لینک می‌شود (بخش ۵).
- رد: وضعیت **ردشده** با دلیلی که ادمین می‌نویسد. دلیل برای مشتری‌های قدیمی که دلیل نداشتند، متن ثابت «فاقد شرح علت» است.
3. **ورود با کد پیامکی (OTP):** کد هش می‌شود، انقضا دارد، تعداد تلاش‌های غلط محدود است و ارسال برای هر شماره و هر IP محدود است. فقط حساب فعال و غیرآرشیو وارد می‌شود.
4. **سه کلید جدا برای کنترل:**
- `isActive`: حساب روشن یا خاموش.
- `canPlaceOrders`: «معلق کردن سفارش» — مشتری از اپ سفارش نمی‌دهد. ادمین می‌تواند از پنل برایش سفارش ثبت کند.
- `isArchived`: آرشیو.
### ۲.۳ سقف اعتبار و بدهی
- هر مشتری یک **سقف گرمی** دارد (`goldCreditLimitGrams`) و یک **بدهی طلای داخلی** (`goldDebtGrams`).
- وقتی سفارش ثبت می‌شود: اگر `وزن سفارش + بدهی فعلی` از سقف بیشتر شود، سفارش در وضعیت **معلق** ثبت می‌شود و مشتری پیامک می‌گیرد.
- **این چک فقط با دفتر داخلی انجام می‌شود، نه با حسابداری.** مانده‌ی حسابداری به مشتری نشان داده می‌شود، اما سقف اعتبار دست نمی‌خورد (تصمیم مالک، D27).
### ۲.۴ کارشناس فروش (معرف)
- هر مشتری می‌تواند یک کارشناس فروش معرف داشته باشد.
- **سفارش، کارشناسِ همان لحظه‌ی ثبت را می‌گیرد.** اگر بعداً معرف مشتری عوض شود، فروش‌های گذشته جابه‌جا نمی‌شوند (حکم مالک).
- کارشناس می‌تواند با «ورود به حساب مشتری» به جای او سفارش ثبت کند. سفارش کاملاً مال مشتری است، ولی در لاگ ثبت می‌شود که چه کسی واقعاً آن را ثبت کرده است.
### ۲.۵ صفحات ادمین برای مشتریان
- **لیست‌ها:** عمده (همه)، بنکدار، مغازه‌دار، تأییدنشده، آرشیو، تک‌فروشی، کارشناسان فروش.
- **عملیات:** تأیید یا رد مرحله‌ها، فعال و غیرفعال، معلق سفارش، آرشیو، شارژ کیف پول، ثبت بازپرداخت، داشبورد مشتری، سفارش‌ها و اسناد او.
- **بازپرداخت** (طلای آب‌شده، حواله، چک، نقد): ثبت آن **در انتظار تأیید** است. تا ادمین تأیید نکند روی بدهی اثر نمی‌گذارد. این تفاوت عمدی با سیستم قدیم است.
---
## ۳. محصولات
### ۳.۱ ساختار
- **محصول (Product):** عنوان، کد محصول (یکتا)، دسته‌بندی، برند، نوع نمایش (سایت، موبایل یا هر دو)، فعال یا غیرفعال، مجموعه‌ها.
- **واریانت (ProductVariant):** گزینه‌ای که مشتری می‌خرد؛ مثلاً یک وزن مشخص. دارای وزن راش، درصد اجرت پیش‌فرض، موجودی، پیش‌سفارش و تصویر نمایشی.
- **عکس (ProductImage):** چهار نقش دارد: شاخص سایت، شاخص موبایل، گالری سایت، گالری موبایل.
### ۳.۲ قانون‌های مهم
- **بدون عکس، نه نمایش داده می‌شود و نه قابل سفارش است.** این برای همه‌ی محصولات است، نه فقط محصولات حسابداری.
- **موجودی صفر** یعنی قابل سفارش نیست، مگر واریانت پیش‌سفارش باشد.
- **فهرست کاتالوگ صفحه‌بندی شده است** چون تعداد محصولات به حدود ۱۶ هزار می‌رسد.
- **محصول متصل به حسابداری:** عنوان و کد آن در پنل ادمین قفل است و فقط در حسابداری تغییر می‌کند. عکس، توضیح، واریانت، دسته‌بندی و درصد اجرت آزاد است و در اپ تعیین می‌شود.
- **موجودی حسابداری** (اگر محصول متصل باشد): صفر یا کمتر یعنی ناموجود در اپ. موجودی هنگام سفارش **همان لحظه** از حسابداری خوانده می‌شود. اگر حسابداری در ۲٫۵ ثانیه جواب نداد، آخرین موجودی ذخیره‌شده ملاک است.
### ۳.۳ محاسبه‌ی وزن سفارش و اجرت
برای هر قلم سفارش:
> **وزن کل = وزن راش × (۱ + (اجرت − تخفیف) ÷ ۱۰۰) × تعداد**
- **اجرت** = درصد اجرت واریانت + درصد اختصاصی مشتری − **بزرگ‌ترین** تخفیف فعال (روی محصول، دسته یا برند).
- این فرمول همان فرمول قدیمی است. مدیر می‌تواند ضرایب، کف و سقف و گرد کردن آن را در پنل تغییر دهد.
- **سرور خودش اجرت را حساب می‌کند.** اعدادی که اپ مشتری می‌فرستد نادیده گرفته می‌شود، پس دستکاری درخواست در قیمت اثری ندارد. اگر اختلاف باشد، در لاگ ثبت می‌شود.
- بخش ریالی ثابت (مثلاً سنگ) از اپ مشتری صفر است و فقط ادمین می‌تواند آن را وارد کند.
---
## ۴. سفارش و بدهی
### ۴.۱ وضعیت‌های سفارش
| وضعیت | معنی |
|---|---|
| معلق | از سقف گرمی گذشته؛ منتظر تصمیم |
| ثبت‌شده | سفارش آمده |
| تأیید مدیر | مدیر تأیید کرده |
| تأمین | در حال تأمین کالا |
| آماده‌سازی | در حال آماده‌سازی |
| تحویل به مسئول فروش | **نقطه‌ی شناسایی بدهی** (پیش‌فرض) |
| تحویل به مشتری | تحویل نهایی؛ بدهی هم شناخته می‌شود |
| لغو | سفارش کنسل شده |
### ۴.۲ منطق بدهی
- بدهی طلا و ریال سفارش **فقط در دو وضعیت تحویل** (به مسئول فروش یا به مشتری) ثبت می‌شود.
- هر بار که وضعیت عوض می‌شود، سیستم بدهی را با وضعیت سفارش **هم‌تراز** می‌کند: فقط اختلاف را به دفتر اضافه می‌کند. پس:
- تغییر وضعیت دوباره به «تحویل» بدهی را دوبار ثبت نمی‌کند (این مشکل قبلاً بود و برطرف شده است؛ ACC-03).
- برگشت به زیر «تحویل» یا لغو، بدهی را برمی‌دارد.
- ثبت بدهی در همان تراکنش تغییر وضعیت انجام می‌شود، پس یا هر دو ثبت می‌شوند یا هیچ‌کدام.
### ۴.۳ سفارش‌های ساختنی (طراحی سفارشی)
**تعریف:** درخواست طراحی و ساخت یک قطعه‌ی سفارشی است (رنگ، سایز، رنگ مینا، رنگ سنگ، عکس طرح). **این کار با «پیش‌سفارش» فرق دارد:** پیش‌سفارش یک گزینه‌ی واریانت است که با موجودی صفر هم قابل خرید است (بخش ۳). سفارش ساختنی یک رکورد جدا (`PreOrder`) است.
**چیزی که هر رکورد دارد:**
- مشتری، شهر، کارگاه، عکس طرح، رنگ‌ها و سایز، وزن و قیمت تخمینی، توضیح.
- وضعیت‌ها: ثبت شده ← تأیید مدیر ← تأیید کارگاه ← آماده ارسال ← تحویل شده. یا لغو.
- تاریخچه‌ی تغییرات (`PreOrderStatusEvent`).
**منطق پنل ادمین** (صفحه «سفارش‌های ساختنی»، مجوز `order.makeOrder`):
- فهرست با جستجو بر اساس نام و موبایل مشتری.
- مشاهده‌ی جزئیات و ویرایش **رنگ محصول، رنگ مینا و رنگ سنگ** (فقط همین سه فیلد ذخیره می‌شوند).
- حذف فقط وقتی ممکن است که سفارش هنوز به سفارش قطعی تبدیل نشده باشد.
**وضعیت واقعی آن در سامانه (برای ارائه‌ی صادقانه):**
- **ثبت جدید وجود ندارد.** نه سرور راهی برای ساختن سفارش ساختنی دارد، نه پنل ادمین و نه اپ‌های مشتری. رکوردها فقط از داده‌ی سیستم قدیم منتقل شده‌اند.
- **تبدیل به سفارش قطعی وجود ندارد.** فیلد `convertedOrderId` در مدل هست، ولی راهی برای پر کردن آن نوشته نشده است. مدل می‌گوید «مدیر تبدیل می‌کند»، اما این کار در کد فعلی امکان‌پذیر نیست.
- **تخصیص کارگاه ذخیره نمی‌شود:** پنل گزینه‌ی انتخاب کارگاه نشان می‌دهد، ولی هنگام ذخیره فقط سه فیلد رنگ فرستاده می‌شود.
- **تعداد ثابت است:** هر سفارش ساختنی تعداد ۱ حساب می‌شود؛ فیلد تعداد در پنل ذخیره نمی‌شود.
- **تغییر وضعیت:** سرور این کار را می‌پذیرد، ولی پنل آن را نشان نمی‌دهد.
### ۴.۴ لیست تامین
**تعریف:** فهرست سفارش‌هایی که به مرحله‌ی **«در حال تأمین»** رسیده‌اند؛ یعنی مرحله‌ای که کالا یا مواد لازم برای ساخت تهیه می‌شود. مجوز `order.tamin` (صفحه‌ی «لیست تامین»).
**منطق نمایش:**
- فهرست شامل **سفارش‌هایی است که حداقل یک‌بار** به وضعیت «در حال تأمین» رسیده‌اند، حتی اگر حالا جلوتر رفته باشند (تأمین شده یا آماده‌سازی). این یعنی فهرست «تاریخچه‌ی تأمین» است، نه فقط «الان در تأمین».
- ستون‌ها: شماره، مشتری، موبایل، وزن ورودی، اجرت، تعداد آیتم، تاریخ ثبت و تاریخ ویرایش.
- با کلیک روی یک سفارش، جزئیات آیتم‌ها باز می‌شود: نام محصول، عنوان واریانت، وزن نهایی، تعداد.
**تغییر وضعیت از این فهرست:**
- با مجوز `order.tamin.state` می‌توان وضعیت را ثبت کرد. هر ثبت یک یادداشت دارد و در تاریخچه‌ی سفارش می‌نشیند.
- وضعیت آخر را می‌توان حذف کرد (با مجوز `order.tamin.state.remove`)؛ سفارش به وضعیت قبلی برمی‌گردد.
- هر تغییر وضعیت **همان منطق بدهی و سند حسابداری** سفارش (بخش ۴.۲ و ۵.۸) را اجرا می‌کند. یعنی ثبت یا حذف وضعیت می‌تواند بدهی و سند حسابداری را جابه‌جا کند.
**نقص‌های شناخته‌شده (باید گفته شود):**
- **ستون «وضعیت تأیید مشتری» خالی است.** پنل این ستون و فیلتر آن را دارد، ولی سرور این فیلد را برنمی‌گرداند. پس این فیلتر عملاً کاری نمی‌کند.
- **نام صفحه با عنوان آن یکی نیست:** عنوان کارت «لیست سفارش‌های عمده» است، همان عنوان فهرست عمده. این عمداً در نسخه‌ی قدیم هم همین بود و تغییر داده نشده است.
---
## ۵. اتصال به حسابداری (بخش مهم)
### ۵.۱ خلاصه
اپ به **یکی** از دو نرم‌افزار حسابداری که روی سرور شرکت نصب است وصل می‌شود:
- **ته‌حساب** (Tahesab)
- **زرین** (Zarrin)
انتخاب با یک سوییچ در پنل ادمین انجام می‌شود: `بدون اتصال / ته‌حساب / زرین`. هر بخش هم جداگانه روشن یا خاموش می‌شود. تا وقتی ادمین روشن نکند، سیستم هیچ ارتباطی برقرار نمی‌کند.
### ۵.۲ آنچه همگام می‌شود
| بخش | جهت | کار |
|---|---|---|
| **مشتریان** | حسابداری ← اپ | حساب‌های گروه‌های انتخاب‌شده وارد اپ یا به مشتری موجود لینک می‌شوند |
| **مشتریان** | اپ ← حسابداری | مشتری تأییدشده در حسابداری ساخته می‌شود؛ ویرایش مشخصات هم فرستاده می‌شود |
| **مانده** | حسابداری ← اپ | مانده‌ی طلا و ریال مشتری، نمایش در پروفایل |
| **گزارش حساب** | حسابداری ← اپ | صورت‌حساب مشتری از حسابداری خوانده می‌شود تا با مانده یکی باشد |
| **کارساخته / محصول** | دو طرفه | عنوان، کد و موجودی همگام؛ محصول تازه‌ی حسابداری در اپ ساخته می‌شود |
| **سند سفارش** | اپ ← حسابداری | سند موقت (پیش‌فاکتور) در وضعیتی که مدیر تعیین کرده ثبت می‌شود |
**عمداً همگام نمی‌شود:** اسناد دریافت و بازپرداخت (طلای آب‌شده، نقد، چک)، کیف پول. این‌ها در اپ می‌مانند.
### ۵.۳ لحظه‌ای بودن — چطور کار می‌کند
سه لایه، چون هیچ‌کدام از دو نرم‌افزار وب‌هوک ندارند:
1. **صف خروجی (Outbox):** هر رویداد لازم برای حسابداری (مثلاً ثبت سفارش) **در همان تراکنش** دیتابیس یک ردیف در صف می‌نویسد. پس اگر حسابداری خاموش باشد، هیچ رویدادی گم نمی‌شود و ثبت سفارش در اپ متوقف نمی‌شود.
2. **کارگر (worker) داخل سرور:**
- هر **۲ ثانیه** صف را می‌فرستد.
- هر **۵ ثانیه** فید تغییرات حسابداری را می‌خواند؛ فقط حساب‌ها و کارساخته‌های تغییرکرده دوباره خوانده می‌شوند.
3. **شبکه‌ی اطمینان:** هر **۳۰ ثانیه** کارهای کامل اجرا می‌شود تا چیزی که فید نمی‌بیند (حذف، مشتری جدید بدون سند) گم نشود.
قوانین تکمیلی:
- **قفل:** اگر چند نمونه‌ی سرور در حال اجرا باشند، فقط یکی کارها را اجرا می‌کند.
- **شکست:** تلاش دوباره با فاصله‌ی نمایی، حداکثر ۸ بار؛ بعد وضعیت «ناموفق» و نمایش در پنل.
- **تکرار خطرناک:** اگر نتیجه‌ی یک درخواست نامشخص باشد و تکرار آن سند را دو بار ثبت کند، کار به وضعیت **«نیاز به بررسی»** می‌رود و خودکار دوباره فرستاده نمی‌شود.
- ته‌حساب: قبل از تلاش دوباره، سیستم استعلام می‌کند که سند قبلاً ثبت نشده باشد.
- زرین: کلید ضد تکرار ندارد، پس در صورت timeout فقط بررسی دستی.
- **مانده در پروفایل مشتری:** اگر قدیمی‌تر از ۵ ثانیه باشد، همان لحظه از حسابداری خوانده می‌شود (مهلت ۳ ثانیه). اگر جواب نداد، آخرین مقدار نشان داده می‌شود.
### ۵.۴ مشتری — ورود، لینک و ساخت
**منبع:** فقط گروه‌هایی که در تنظیمات انتخاب شده‌اند. اگر هیچ گروهی انتخاب نشده باشد، ورود متوقف می‌شود (چون «همه» یعنی صندوق‌ها و کارمندان هم مشتری اپ شوند).
**کلید تطبیق:** شماره موبایل نرمال‌شده (ارقام فارسی به انگلیسی، `912…` به `0912…`).
- موبایل در اپ هست ← **لینک** می‌شود و کد حسابداری ذخیره می‌شود.
- موبایل در اپ نیست ← **مشتری تازه در اپ** ساخته می‌شود:
- وضعیت «تأییدنشده»، سقف اعتبار صفر، ورود فقط با پیامک.
- کد ملی از حسابداری گرفته می‌شود اگر معتبر و آزاد باشد؛ وگرنه **خود شماره موبایل** به‌عنوان کد ملی ذخیره می‌شود. این یک راه‌حل موقت است و باید بعداً پاک شود (بخش ۸).
- موبایل خالی، یا دو حساب با یک موبایل ← به **فهرست مغایرت** می‌رود و خودکار اقدامی نمی‌شود.
**ارسال به حسابداری (push):**
- وقتی مشتری **تأیید نهایی** شد، ابتدا با موبایل جستجو می‌شود. اگر پیدا شد لینک می‌شود؛ اگر نه، حساب تازه در گروه مشخص‌شده ساخته می‌شود.
- این کار فقط یک‌بار برای مشتری بدون لینک انجام می‌شود.
- تغییر نام، تلفن یا آدرس مشتری لینک‌شده هم فرستاده می‌شود.
### ۵.۵ مانده
- **علامت:** در اپ، **مثبت یعنی بدهکار**. ته‌حساب مانده‌ی منفی را بدهکار نشان می‌دهد و در درایور برعکس می‌شود. در زرین پیش‌فرض «منفی = بدهکار» است و قابل تنظیم است.
- **نمایش:** در پروفایل مشتری دو مانده کنار هم: طلا (گرم) و ریال، به همراه زمان آخرین به‌روزرسانی.
- **اعتبار:** سقف اعتبار با مانده‌ی حسابداری حساب نمی‌شود؛ همان دفتر داخلی است.
### ۵.۶ گزارش حساب
- وقتی اتصال روشن باشد، گزارش از حسابداری خوانده می‌شود تا با مانده یکی باشد.
- اگر حسابداری جواب ندهد، گزارش **از دفتر داخلی** نشان داده می‌شود و در صفحه برچسب منبع می‌خورد. صفحه باز می‌ماند.
- **امنیت:** جزئیات هر سند فقط وقتی نشان داده می‌شود که کد حساب سند با کد لینک‌شده‌ی همان مشتری یکی باشد.
- **تفاوت دو حسابداری:** در ته‌حساب، سند موقت روی مانده اثر ندارد تا حسابدار آن را نهایی کند. در زرین، سند موقت همان لحظه روی مانده اثر دارد.
### ۵.۷ کارساخته و محصول
- **از حسابداری به اپ:** کارساخته‌ی جدید ← محصول جدید در اپ، **بدون عکس و بدون واریانت**. تا ادمین عکس و واریانت نگذارد، در اپ دیده نمی‌شود.
- **از اپ به حسابداری:**
- **زرین:** محصول جدید در پنل ← کارساخته‌ی جدید در زرین ساخته می‌شود. (ساخت کارساخته روی نصب واقعی هنوز آزمایش نشده است.)
- **ته‌حساب:** API ساخت کارساخته ندارد. نام کار با **اولین سند** آن در ته‌حساب ساخته می‌شود و از آن به بعد لینک می‌شود. این محدودیت از خود API است.
- **موجودی:** در پنل ادمین نشان داده می‌شود. صفر یا کمتر ← ناموجود در اپ.
### ۵.۸ سند سفارش
- **زمان ثبت:** هر وضعیتی که مدیر در تنظیمات مشخص کرده باشد (پیش‌فرض: تحویل به مسئول فروش) یا بعد از آن. سند **موقت** (پیش‌فاکتور) ثبت می‌شود و **یک‌بار** فرستاده می‌شود.
- **برگشت:** اگر سفارش به وضعیت زیر آن برگردد یا لغو شود، سند پس گرفته می‌شود. اگر سند هنوز موقت باشد، حذف می‌شود؛ اگر در حسابداری نهایی شده باشد، کار به «بررسی» می‌رود.
- **ته‌حساب:** هر قلم یک ردیف سند است. کد سند از شناسه‌ی سفارش ساخته می‌شود تا دوبار ثبت نشود.
- **زرین:** سند چندردیفی است و پیشرفت هر ردیف ذخیره می‌شود. اگر وسط کار قطع شود، از همان‌جا ادامه می‌دهد.
- **پذیرش:** بعد از ثبت، تغییر مانده‌ی وزنی حسابداری باید دقیقاً برابر وزن سفارش باشد. این معیار روی سرور تست ته‌حساب آزمایش شده است.
- **اجرت در ته‌حساب:** اجرت به صورت **افزایش عیار** فرستاده می‌شود (مثلاً ۷۵۰ با ۱۰٪ اجرت ← ۸۲۵). وزن فیزیکی کالا ثابت می‌ماند.
### ۵.۹ پنل مدیریت اتصال
صفحه‌ی **«اتصال حسابداری»** با مجوز `config.accounting`:
1. **اتصال:** سوییچ درایور، آدرس، توکن (فقط نوشتنی؛ هرگز نمایش داده نمی‌شود)، گواهی CA برای نصب‌های self-signed، دکمه‌ی «تست اتصال».
2. **نگاشت:** گروه‌های مشتری برای ورود، گروه مشتری تازه، گروه کارساخته به دسته‌بندی، نوع کارساخته، عیار پیش‌فرض، بازه‌ی گزارش.
3. **قابلیت‌ها و زمان‌بندی:** روشن یا خاموش کردن هر بخش، قانون ثبت سند، دکمه‌های «سینک الان».
4. **لاگ:** صف کارها با فیلتر وضعیت، تلاش دوباره، «حل‌شده»، و درخواست و پاسخ هر کار (بدون توکن).
5. **مغایرت‌ها:** موبایل تکراری یا خالی، کارساخته بی‌دسته، سندهای «نیاز به بررسی».
دکمه‌های **«سینک با …»** در لیست مشتریان عمده و لیست محصولات هم هست. این دکمه‌ها مستقل از سوییچ قابلیت‌ها کار می‌کنند و فقط اتصال برقرار بودن لازم است.
**پیش‌فرض:** اتصال خاموش است و همه‌ی قابلیت‌ها غیرفعال. تا ادمین تنظیم نکند، اپ هیچ کاری با حسابداری نمی‌کند.
### ۵.۱۰ امنیت اتصال
- **توکن:** با AES-256-GCM رمز می‌شود. کلید آن از متغیر `ACCOUNTING_SECRET_KEY` می‌آید.
- اگر این متغیر تنظیم نشده باشد، از `JWT_SECRET` استفاده می‌شود و هشدار داده می‌شود.
- **تغییر `JWT_SECRET` توکن ذخیره‌شده را غیرقابل خواندن می‌کند** و ادمین باید دوباره وارد کند.
- **هیچ پاسخی توکن را برنمی‌گرداند.** مسیرهای عمومی تنظیمات (`const`) کلیدهای `accounting.*` را نمی‌دهند و نمی‌پذیرند.
- **TLS هرگز خاموش نمی‌شود.** برای نصب‌های با گواهی شخصی، می‌توان گواهی CA را به‌صورت PEM داد.
- **هشدار برای ارائه:** نصب زرین مالک روی **http بدون رمزنگاری** و در دسترس اینترنت است. توصیه: https یا VPN و توکن قوی. این موضوع باید صریح گفته شود.
### ۵.۱۱ وضعیت آزمون (با تاریخ)
| مورد | نتیجه | تاریخ و منبع |
|---|---|---|
| ته‌حساب روی سرور تست عمومی | ۲۹ از ۲۹ بررسی؛ شامل لینک دوباره‌ی همان حساب و سند با وزن دقیق | ۲۰۲۶-۱۰-۰۵ (طرح، بخش ۱۰) |
| زرین روی نصب مالک (v228) — خواندن | همه‌ی بررسی‌ها؛ مانده، صفحه‌بندی کارساخته، گردش | ۲۰۲۶-۱۰-۱۲ (ACC-01) |
| زرین روی نصب مالک — نوشتن | ۲۰ از ۲۰ روی حساب آزمایشی ۹۵۳ | ۲۰۲۶-۱۰-۱۲ (ACC-01) |
| تست‌های واحد درایورها | ۴۳ تست؛ کل سرور ۱۸۰ تست | ۲۰۲۶-۱۰-۰۵ (طرح، بخش ۱۰) |
**آزمون‌نشده:** ساخت کارساخته در زرین روی نصب واقعی؛ سنگ در سند زرین؛ فید تغییرات زرین روی نصب واقعی (زرین با بازه‌ی کارساخته و خواندن لحظه‌ی سفارش جایگزین را پوشش می‌دهد).
### ۵.۱۲ آنچه باید صادقانه گفته شود
- **سند موقت ته‌حساب روی مانده اثر ندارد** تا حسابدار نهایی کند (ACC-02). پس مانده‌ای که مشتری می‌بیند تا نهایی شدن سند، سفارش‌های تازه را شامل نمی‌شود.
- **لحظه‌ای بودن یعنی «چند ثانیه بعد از ثبت در حسابداری»** و نه «در همان لحظه‌ی ثبت». هیچ‌کدام از دو نرم‌افزار وب‌هوک ندارند.
- **قابلیت‌های ناتمام:** ساخت کارساخته در زرین، سنگ در زرین.
- **حساب آزمایشی روی نصب مالک:** برای آزمون نوشتن در زرین، حساب آزمایشی ۹۵۳ («تست اپ گلدبانک») با تأیید مالک ساخته شد. زرین API حذف حساب ندارد، پس این حساب در نصب مالک باقی می‌ماند.
---
## ۶. ترتیب پیشنهادی ارائه (حدود ۲۰ دقیقه)
1. **نگاه کلی** (۲ دقیقه): سه برنامه، اصل دفتر افزودنی.
2. **اشخاص** (۴ دقیقه): ثبت‌نام ← تأیید دو مرحله‌ای ← ورود پیامکی ← سقف اعتبار ← معرف.
3. **محصولات** (۳ دقیقه): واریانت و عکس، قانون بدون عکس، موجودی، فرمول اجرت.
4. **سفارش و بدهی** (۳ دقیقه): وضعیت‌ها، نقطه‌ی شناسایی بدهی، سفارش ساختنی و لیست تامین (بخش ۴.۳ و ۴.۴). بخش ساختنی را با صراحت درباره‌ی نقص‌هایش ارائه کنید.
5. **حسابداری** (۷ دقیقه): سوییچ ← مشتری تأییدشده ← ساخت در حسابداری؛ سفارش ← سند موقت؛ مانده در پروفایل؛ لاگ.
6. **باز و تصمیم‌ها** (۲ دقیقه): بخش ۸.
**هشدار برای نمایش زنده:** برای نشان دادن نوشتن در حسابداری فقط از **سرور تست** استفاده کنید. نوشتن روی نصب مالک بدون تأیید جداگانه‌ی او انجام نشود.
---
## ۷. واژه‌نامه
| واژه | معنی |
|---|---|
| **کارساخته** | کار/محصول ساخته‌شده‌ی طلا؛ در حسابداری یک «کالا» است و در اپ یک «محصول» |
| **ته‌حساب / زرین** | دو نرم‌افزار حسابداری که اپ با آن‌ها همگام می‌شود |
| **مانده** | بدهی یا بستانکاری مشتری؛ در اپ مثبت = بدهکار |
| **سند موقت (پیش‌فاکتور)** | سندی که برای حسابدار ثبت می‌شود و هنوز نهایی نیست |
| **راش (وزن راش)** | وزن خام کالا قبل از اجرت |
| **اجرت** | درصدی که به وزن راش اضافه می‌شود تا وزن کل سفارش به دست آید |
| **عیار** | خلوص طلا بر حسب هزارم؛ ۷۵۰ = ۱۸ عیار |
| **صف خروجی (Outbox)** | فهرست کارهایی که باید به حسابداری فرستاده شود |
| **لینک** | وصل شدن یک مشتری یا کارساخته‌ی اپ به رکورد متناظر در حسابداری |
| **مغایرت** | موردی که سیستم خودکار تصمیم نمی‌گیرد و باید ادمین بررسی کند |
| **معلق** | سفارشی که از سقف گرمی گذشته و منتظر تصمیم است |
| **پیش‌سفارش** | گزینه‌ی واریانت که با موجودی صفر هم قابل خرید است |
| **سفارش ساختنی** | درخواست طراحی و ساخت سفارشی (رنگ، سایز، طرح)؛ رکورد جدا از سفارش خرید |
| **تأمین** | مرحله‌ای که کالا یا مواد لازم برای ساخت سفارش تهیه می‌شود (وضعیت «در حال تأمین») |
| **لیست تامین** | فهرست سفارش‌هایی که حداقل یک‌بار به مرحله‌ی تأمین رسیده‌اند |
---
## ۸. باز و تصمیم‌های لازم قبل از راه‌اندازی
| # | موضوع | وضعیت |
|---|---|---|
| ۱ | **رمز مدیریتی OTP** (`OTP_MASTER_CODE`) در نسخه‌ی تست است و باید قبل از نسخه‌ی واقعی حذف شود. | باز |
| ۲ | **کد ملی = موبایل** برای مشتری‌هایی که از حسابداری ساخته شده‌اند. بدهی داده است و باید پاک شود. | باز (DATA-MIGRATION-MAP §10.4) |
| ۳ | **نصب زرین روی http بدون رمز** — رمزنگاری یا VPN. | باز، تصمیم مالک |
| ۴ | **سند موقت ته‌حساب** روی مانده اثر ندارد تا نهایی شود. | باز، به‌عنوان قانون کار قبول شده (ACC-02) |
| ۵ | **مهاجرت دیتابیس خالی** در ACC-04 گزارش شده. یادداشت‌ها می‌گویند برطرف شده؛ قبل از نصب تازه بررسی شود. | بررسی لازم |
| ۶ | **ساخت کارساخته در زرین** و **فید تغییرات زرین** روی نصب واقعی تأیید نشده‌اند. | آزمون‌نشده |
| ۷ | **ACC-03** (بدهی تکراری) برطرف شده است؛ طرح ۱۰/۰۵ هنوز آن را باز نشان می‌دهد، ولی ملاک فعلی `ISSUES.md` است. | برطرف (۲۰۲۶-۱۰-۰۸) |
| ۸ | **سفارش ساختنی:** ثبت جدید، تبدیل به سفارش قطعی، ذخیره‌ی کارگاه و تعداد در پنل. تصمیم لازم: آیا این بخش در نسخه‌ی اول لازم است؟ | باز، نیاز به تصمیم مالک |
| ۹ | **لیست تامین:** ستون و فیلتر «تأیید مشتری» داده ندارد. | باز (کم‌اهمیت) |
---
## پیوست فنی — نقشه‌ی فایل‌ها
برای پاسخ به سؤال‌های فنی:
**داده و قوانین کلی**
- [apps/api/prisma/schema.prisma](../apps/api/prisma/schema.prisma) — مدل‌ها: `Customer`, `AdminUser`, `Product`, `ProductVariant`, `Order`, `LedgerEntry`, `AccountingLink`, `AccountingOutbox`
- [apps/api/src/modules/accounting/ledger.service.ts](../apps/api/src/modules/accounting/ledger.service.ts) — دفتر داخلی و `syncOrderDebt`
**اشخاص**
- [customer-auth.service.ts](../apps/api/src/modules/customer-auth/customer-auth.service.ts) — OTP، ثبت‌نام، سقف ارسال
- [admin-customers.service.ts](../apps/api/src/modules/admin-customers/admin-customers.service.ts) — تأیید دو مرحله‌ای، سقف اعتبار، فهرست‌ها
**محصولات و سفارش**
- [catalog.service.ts](../apps/api/src/modules/catalog/catalog.service.ts) — قانون عکس و موجودی
- [admin-products.service.ts](../apps/api/src/modules/admin-products/admin-products.service.ts) — قفل عنوان و کد محصول متصل
- [wage-formula.ts](../apps/api/src/modules/pricing/wage-formula.ts) — فرمول اجرت
- [orders.service.ts](../apps/api/src/modules/orders/orders.service.ts) — ثبت سفارش و تغییر وضعیت
- [admin-orders.service.ts](../apps/api/src/modules/admin-orders/admin-orders.service.ts) — `listTamin` (لیست تامین) و `removeStatusEvent`
- [admin-preorders.service.ts](../apps/api/src/modules/admin-preorders/admin-preorders.service.ts) — سفارش‌های ساختنی (فهرست، ویرایش، حذف)
- [MakeOrder.vue](../apps/admin-panel/src/views/order/MakeOrder.vue) و [TaminList.vue](../apps/admin-panel/src/views/order/TaminList.vue) — صفحه‌های پنل این دو فهرست
- [docs/PRODUCT-MODULE.md](PRODUCT-MODULE.md) — جزئیات فرم محصول در پنل
**اتصال به حسابداری**
- [accounting-sync.service.ts](../apps/api/src/modules/accounting-sync/accounting-sync.service.ts) — منطق همگام‌سازی (مشتری، مانده، کارساخته، سند)
- [accounting-hooks.service.ts](../apps/api/src/modules/accounting-sync/accounting-hooks.service.ts) — نقاط ورود از بقیه‌ی برنامه (سفارش، مشتری، محصول)
- [accounting-worker.service.ts](../apps/api/src/modules/accounting-sync/accounting-worker.service.ts) — صف، زمان‌بند، فید تغییرات، قفل، تلاش دوباره
- [accounting-config.ts](../apps/api/src/modules/accounting-sync/accounting-config.ts) — ساختار تنظیمات و پیش‌فرض‌ها
- [providers/tahesab.provider.ts](../apps/api/src/modules/accounting-sync/providers/tahesab.provider.ts) و [providers/zarrin.provider.ts](../apps/api/src/modules/accounting-sync/providers/zarrin.provider.ts) — درایورها
- [accounting-http.ts](../apps/api/src/modules/accounting-sync/accounting-http.ts) — کلاینت HTTP و TLS
- [secret-box.ts](../apps/api/src/modules/accounting-sync/secret-box.ts) — رمزنگاری توکن
- [accounting-admin.controller.ts](../apps/api/src/modules/accounting-sync/accounting-admin.controller.ts) — مسیرهای پنل ادمین
- [customer-report.service.ts](../apps/api/src/modules/customer-report/customer-report.service.ts) — گزارش و مانده برای مشتری
**مستندات مرتبط**
- [ACCOUNTING-INTEGRATION-PLAN.md](ACCOUNTING-INTEGRATION-PLAN.md) — طرح کامل، از جمله بخش‌های ۱۰ تا ۱۳ (وضعیت اجرا، فید لحظه‌ای، زرین)
- [parity/DECISIONS.md](parity/DECISIONS.md) — تصمیم‌های مالک؛ **D27** اتصال به حسابداری
- [parity/ISSUES.md](parity/ISSUES.md) — **ACC-01 تا ACC-09**؛ ملاک وضعیت فعلی
+259
View File
@@ -0,0 +1,259 @@
# چک‌لیست تکرار ایرادها (کاری — قدم‌به‌قدم)
منبع جزئیات: [`parity/CHANGELOG.md`](parity/CHANGELOG.md)، [`parity/ISSUES.md`](parity/ISSUES.md)، [`parity/DECISIONS.md`](parity/DECISIONS.md).
این سند برای **همین ریپو (gold-bank / تهران تاج)** است تا ایرادها را verify کنیم یا باقی‌مانده‌ها را ببندیم — و در عین حال قالب تکرار برای پروژهٔ مشابه.
> قبل از هر تغییر **UI سمت اپ**: از مالک (Honar) بپرس. منطق/رفتار بدون سوال؛ ظاهر نه.
> اپ مبنا برای منطق: هر دو `apps/user-app` و `apps/user-app-v2` (مگر خلافش گفته شود).
---
## دامنهٔ تکرار (قوانین سخت)
| موضوع | تکرار؟ | قانون |
|---|---|---|
| ایرادهای امنیتی / پولی / دامنه / UX کارکردی | بله | منطق یکسان |
| فونت IRANSans (اپ + ادمین) | بله | همان فونت و wiring |
| لوگو / فاویکون / آیکن برند / wordmark | **خیر** | برند تهران تاج دست‌نخورده — [`BRANDING.md`](BRANDING.md) |
| کاروسل / hero / ShowcaseSlider | **خیر** | **اصلاً دست نزن** (حتی در verify) |
| فیچر اپ مثل PWA install، pull-refresh، incomplete-profile banner | بله | **فقط منطق/رفتار**؛ UI با زبان طراحی خودِ اپ |
| دیزاین / layout ادمین | بله | ادمین قابل‌رفرنس از همین ریپو (بدون تعویض لوگو) |
---
## نحوهٔ کار
| نماد وضعیت | معنا |
|---|---|
| `انجام‌شده — verify` | در parity به‌عنوان FIXED ثبت شده؛ فقط smoke/خواندن کد، بازپیاده‌سازی نکن |
| `باقی‌مانده` | هنوز باید پیاده یا اصلاح شود |
| `منتظر تصمیم` | منتظر ruling مالک |
| `عمداً خارج` | یخ‌زده یا deferred — دست نزن |
چک‌باکس‌ها:
- `[x]` = بسته / verify پاس شده در همین دور کار
- `[ ]` = هنوز باز یا verify نشده
**ترتیب پیشنهادی:** ۱ امنیت → ۲ پول/سفارش → ۳ مشتری → ۴ UX اپ (منطق) → ۵ کاتالوگ/SMS → ۶ فیچر اپ → ۷ ادمین → ۸ حسابداری.
---
## یخ‌زده (عمداً خارج — دست نزن)
| موضوع | قانون |
|---|---|
| کاروسل / hero اسپرینگ / ShowcaseSlider | هیچ پورت دیزاین Hedena؛ هیچ بازنویسی layout |
| لوگو / فاویکون / brand-* / wordmark | فقط طبق [`BRANDING.md`](BRANDING.md)؛ از Hedena کپی نکن |
- [ ] تأیید کلامی: کاروسل و برند در این دور کار دست نخورده‌اند
---
## ۱) امنیت و احراز هویت (اولویت بحرانی)
| ID | ایراد | مسیر تقریبی | فیکس | وضعیت | انجام |
|---|---|---|---|---|---|
| SEC-09 | روت‌های ledger با `@Public()` قابل نوشتن | API `accounting.controller.ts` | حذف `@Public` سطح کلاس؛ rid روی هر روت: `wallet.list`, `wallet.charge`, `document.create`, `document.list.confirm` | انجام‌شده — verify | [ ] |
| SEC-10 | `PATCH orders/:id/status` عمومی + `actorAdminUserId` جعلی | API `orders.controller.ts` | فقط ادمین؛ rid `order.list.sts.submit` | انجام‌شده — verify | [ ] |
| SEC-08 | `GET /customers` کل دفتر مشتری را لو می‌داد | API customers (حذف)؛ اپ Login / `api/client` | حذف روت‌های با `customerId`؛ `/customer/me*` از JWT | انجام‌شده — verify | [ ] |
| SEC-03 / D6 | OTP تأیید نمی‌شد | API `customer-auth/` + `CustomerOtp`؛ اپ Login | bcrypt، انقضا ۲ دقیقه، ۵ تلاش، یک‌بارمصرف، throttle | انجام‌شده — verify | [ ] |
| T1 | سشن مشتری جدا نبود | `customer-auth/`، `JWT_CUSTOMER_SECRET`، `typ: 'customer'` | JWT جدا از ادمین؛ خواندن مجدد DB در هر درخواست | انجام‌شده — verify | [ ] |
| SEC-02 | `POST /orders` عمومی با `customerId` از body | `orders.controller/service`، DTO، Cart | `@CustomerAuth()`؛ customerId فقط از توکن | انجام‌شده — verify | [ ] |
| SEC-02b | `GET /orders/:id` IDOR | همان ماژول | `getOwnedById` → **404** نه 403؛ ادمین مسیر جدا | انجام‌شده — verify | [ ] |
| SEC-11 | OTP در `SmsMessage.body` plaintext | `customer-auth.service` `deliver()` | فقط خط audit سانسورشده | انجام‌شده — verify | [ ] |
| SEC-04 | JWT ضعیف پیش‌فرض + CORS باز | boot API / CORS | production بدون `JWT_SECRET` بالا نیاید؛ `CORS_ORIGINS` | انجام‌شده — verify | [ ] |
| SEC-05 | seed دمو (پسورد = موبایل) در production | `prisma/seed.ts`، `bootstrap.ts` | دمو فقط با `SEED_DEMO_DATA=true` | انجام‌شده — verify | [ ] |
| SEC-07 | `setRoles` رشتهٔ دلخواه | admin-users / roles | فقط `ALL_RIDS` (+ والدین درخت) | انجام‌شده — verify | [ ] |
| DEPLOY-02 | env خالی `""` در Docker JWT را می‌شکست | `common/env.ts` (`envOr`) | blank = unset | انجام‌شده — verify | [ ] |
| DEPLOY-01 | compose بدون JWT/SMS/OTP | `docker-compose.yml` | forward همهٔ envهای auth/SMS | انجام‌شده — verify | [ ] |
| REL-01 | master OTP در production | `customer-auth`، boot checks | `OTP_MASTER_CODE`؛ در prod ممنوع مگر فلگ تست | انجام‌شده — verify | [ ] |
| REL-03 | `JWT_CUSTOMER_SECRET` fallback به secret ادمین | customer JWT helper | secret جدا؛ enforce در production | انجام‌شده — verify | [ ] |
Smoke پیشنهادی: `apps/api/scripts/auth-smoke.sh` (در صورت در دسترس بودن سرور محلی).
---
## ۲) پول، قیمت، سفارش
| ID | ایراد | مسیر تقریبی | فیکس | وضعیت | انجام |
|---|---|---|---|---|---|
| MON-08 | کلاینت می‌توانست wage/discount/rial بگذارد | `orders.service`، `pricing/wage-formula`، wage-quote | قیمت فقط سرور؛ نادیده کلاینت؛ لاگ mismatch | انجام‌شده — verify | [ ] |
| ACC-03 / MON-04 | بدهی دوبار با ورود مجدد به `DELIVERED_TO_AGENT` | `LedgerService.syncOrderDebt` | sync به هدف؛ فقط delta؛ rollback/cancel درست | انجام‌شده — verify | [ ] |
| MON-05 | `canPlaceOrders` اعمال نمی‌شد | مسیر create سفارش | اپ → 403؛ ادمین می‌تواند برای مشتری ثبت کند | انجام‌شده — verify | [ ] |
| MON-01 | سقف موجودی در سبد نبود (v2) | `cartStore`، CartItem/SingleProduct | `capQty`/`maxQty` + disable + toast | انجام‌شده — verify | [ ] |
| ORD-01 | ویرایش وضعیت سفارش retail خراب | admin `order/List.vue` | body از `tempRow.id` مثل Omde/Tamin | انجام‌شده — verify | [ ] |
| کانال سفارش اپ | سفارش اپ به‌اشتباه RETAIL | `orders.service`، DTO، migration، admin-orders | پیش‌فرض/ذخیره **WHOLESALE** برای مسیر اپ + migrate ردیف‌های قبلی | **انجام شد 2026-10-17** — `placedBy=customer` ⇒ WHOLESALE؛ migration `20261017120000_app_orders_wholesale_channel` | [x] |
---
## ۳) مشتری / اشخاص / نام / درصد
| ID | ایراد | مسیر تقریبی | فیکس | وضعیت | انجام |
|---|---|---|---|---|---|
| CUST-01 | `fname: fullName, lname: ''` نام‌ها را یکی می‌کرد | migration `firstName`/`lastName`؛ فرم‌ها | ستون‌های واقعی؛ حذف `split(' ')` | انجام‌شده — verify (schema دارد) | [ ] |
| CUST-02 | `percent` از کمیسیون نماینده | `makingChargeOffsetPercent`؛ CRUD مشتری | ستون خود مشتری؛ پیش‌فرض ۳؛ required در create | انجام‌شده — verify | [ ] |
| D17 / CUST-03 | فرم کارشناس فروش ناقص | `SalesExpertProfile`؛ `ReagentCustomer.vue` | پروفایل جدا؛ autofill شهر→کمیسیون | انجام‌شده — verify (فرم ncode/city/gram دارد) | [ ] |
| PERS-02 | شهر flat به‌جای درخت استان | `City.parentId`، `CityTreeSelect` | درخت استان؛ ارث‌بری درصد از ancestor | انجام‌شده — verify | [ ] |
| PERS-03 | تاریخ تولد نبود | `Customer.birthDate`، DateInput | فیلد جلالی create/update | انجام‌شده — verify | [ ] |
| D19 | موبایل نرمال نمی‌شد / SMS خوش‌آمد | admin-customers / admin-users | `faToEn` قبل uniqueness؛ welcome SMS fire-and-forget | انجام‌شده — verify | [ ] |
---
## ۴) لاگین اپ / act-as / ناوبری / تازگی داده
> فقط رفتار را port/verify کن؛ ظاهر Login/Dialog/Nav را از زبان طراحی خودِ اپ بگیر — کپی پیکسلی Hedena نکن. قبل از تغییر UI از Honar بپرس.
| ID | ایراد | مسیر تقریبی | فیکس | وضعیت | انجام |
|---|---|---|---|---|---|
| AUTH-01 | شماره ثبت‌نشده → موفقیت جعلی → OTP | `requestOtp`، Login | **404** + پیام فارسی | انجام‌شده — verify | [ ] |
| AUTH-02 | شکست SMS → موفقیت جعلی | `deliver` | **503** + «SMS در دسترس نیست» | انجام‌شده — verify | [ ] |
| AUTH-03 | مشکل حساب کارشناس مثل «ثبت‌نشده» | `resolveExpertTradingAccount` | پیام‌های **403** جدا | انجام‌شده — verify | [ ] |
| APP-03 | صفحات شخصی مهمان silent redirect | `router/index.ts` | toast «ابتدا وارد شوید» + نگه داشتن `?redirect=` | انجام‌شده — verify | [ ] |
| EXP-01…03 | «ورود به حساب مشتری» برای کارشناس نبود | JWT strategy، `X-Act-As-Customer`، session، ActingAsBar | act-as با هدر (نه توکن minted)؛ پاک کردن cache/cart؛ نوار دائمی | انجام‌شده — verify | [ ] |
| NAV-01…04 | back همیشه route ثابت؛ login/401 تاریخچه مرده | `PageBar`، `navStore`، Login، `main.ts` | back تاریخچه‌دار؛ `replace` بعد login/401 | انجام‌شده — verify | [ ] |
| READ-01…03 | keep-alive صفحه را دوباره fetch نمی‌کرد | Orders/Transactions/Home/Dashboard، `queryCache` | refresh روی `onMounted`+`onActivated` | انجام‌شده — verify | [ ] |
---
## ۵) کاتالوگ / علاقه‌مندی / SMS / آپلود
| ID | ایراد | مسیر تقریبی | فیکس | وضعیت | انجام |
|---|---|---|---|---|---|
| STUB-03 | کل کاتالوگ یکجا؛ pagination فقط کلاینت | catalog API + اپ‌ها | page/filter/sort سرور؛ `?ids=`، `/:id`، `/facets` | **مرحله ۴ انجام شد 2026-10-17** — Search/Newest/Home/Collection روی هر دو اپ؛ UI همان زبان طراحی | [x] |
| FAV-01 | علاقه‌مندی فقط memory | API `customer/me/favorites*`، `favoritesStore` | load/toggle سمت سرور | انجام‌شده — verify | [ ] |
| SMS-03 | بدون SMS ثبت سفارش | `orders.service` notify + `ORDER_ADMIN_MOBILES` | SMS بعد commit به ادمین/مشتری/کارشناس | انجام‌شده — verify | [ ] |
| STUB-05 | موبایل ادمین hardcode | env | `PROFILE_ADMIN_MOBILES`، `ORDER_ADMIN_MOBILES` | انجام‌شده — verify | [ ] |
| UPL-01 | اپ نمی‌توانست مدرک آپلود کند | `POST customer/me/documents` | آپلود برای complete-profile | انجام‌شده — verify | [ ] |
| D22–D24 | complete-profile / گزارش بدون IDOR | customer modules | whitelist write؛ گزارش scoped | انجام‌شده — verify | [ ] |
---
## ۶) فیچرهای اپ (منطق بله — دیزاین خیر)
| تم | چه چیزی port شود | چه چیزی port نشود | وضعیت | انجام |
|---|---|---|---|---|
| فونت IRANSans | فایل‌های فونت + `fonts.less` / wiring در اپ و ادمین | — | ادمین wired (`farsi.less`)؛ اپ‌ها را verify کن | [ ] |
| PWA install prompt | `beforeinstallprompt`، prompt قابل dismiss، logic | ظاهر مودال Hedena | **باقی‌مانده** — `usePwaInstall` / InstallPromptModal در ریپو نیست؛ UI بعد از تأیید Honar | [ ] |
| Pull-to-refresh / incomplete-profile / gate login | رفتار و شرط‌ها | layout/illustration/رنگ Hedena | pull-refresh و complete-profile موجود — verify رفتار | [ ] |
| کاروسل / ShowcaseSlider / hero | — | **اصلاً تکرار نکن** | عمداً خارج | — |
| لوگو / فاویکون / brand assets | — | **اصلاً تکرار نکن** | عمداً خارج | — |
رفرنس منطق PWA (اگر از پروژهٔ مرجع بیاید — فقط رفتار، نه UI): composable `usePwaInstall` + مودال dismiss/defer؛ markup را با کامپوننت‌های خود اپ بازنویسی کن.
---
## ۷) ادمین / پرمیشن / UX (دیزاین قابل‌رفرنس)
| ID / تم | ایراد | مسیر تقریبی | فیکس | وضعیت | انجام |
|---|---|---|---|---|---|
| PERM-01 | ridهای یتیم dashboard | migration merge → `dashboard.view` | merge/حذف ridهای قدیمی | انجام‌شده — verify | [ ] |
| PERM-02 | ridهای enforced ولی غایب از درخت | `permissionConst.ts` | افزودن `newsletters`، `persons.list.sellers.view` | **منتظر تصمیم** | [ ] |
| D30 | ویژگی‌های retail/بانک/شعبه فقط super-admin | `super-admin.ts`، منو/گارد | gate با موبایل ثابت؛ hide + 403 | انجام‌شده — verify (در صورت پیاده) | [ ] |
| super-admin rids | سوپرادمین همه rid نداشت | `super-admin.ts`، `permissionConst` | `ALL_RIDS`؛ اسلایدر زیر ثابت‌ها | انجام‌شده — verify | [ ] |
| Order UX | وضعیت سفارش UX ضعیف | Listها / مودال وضعیت | drawer راست + confirm خروج — **دیزاین از ادمین همین ریپو** | باقی‌مانده / verify — فایل‌های `OrderStatusModal.vue` / `OrderViewModal.vue` در این ریپو پیدا نشد؛ List.vue فعلی را بسنج | [ ] |
| Icon migration | وابستگی `@ant-design/icons-vue` | Icon محلی + `icons.json` | آیکون محلی | **باقی‌مانده** — ادمین هنوز از `@ant-design/icons-vue` زیاد استفاده می‌کند | [ ] |
| Dashboard unify / فرم محصول و مشتری | chrome ناهماهنگ | `Dashboard.vue`، AddProduct، AddCustomer | unify بصری (بدون تعویض لوگو) | بخشی انجام‌شده — PROTECTED dashboard chrome؛ verify | [ ] |
| فونت ادمین | خوانایی | `farsi.less` / IRANSans | همان فونت؛ لوگو را عوض نکن | انجام‌شده — verify | [ ] |
| PROMO-TAK-01 | type_customer / ridهای `.tak` | promotions | ثبت و/یا gate API | باقی‌مانده (logged) | [ ] |
| CINY-01 | `ciny_ids` در update نادیده | admin-products | update هم trays را ذخیره کند | باقی‌مانده (logged) | [ ] |
| ANLY-01 | analytics dwell-time | — | — | عمداً خارج — deferred، اولویت پایین | — |
---
## ۸) حسابداری یکپارچه
| ID | ایراد | فیکس | وضعیت | انجام |
|---|---|---|---|---|
| ACC-06 | TLS خودامضا / hostname fail | cert pin؛ هرگز verify را خاموش نکن | انجام‌شده — verify | [ ] |
| ACC-07 | «متصل است ولی sync نمی‌شود» | رد provider ناقص؛ `inactiveReason`؛ گروه‌های انتخاب‌شده الزامی | انجام‌شده — verify | [ ] |
| ACC-08/09 | sync کند / بدون on-demand | poll سریع + دکمه sync + وضعیت زنده | انجام‌شده — verify | [ ] |
جزئیات: [`ACCOUNTING-INTEGRATION-PLAN.md`](ACCOUNTING-INTEGRATION-PLAN.md).
---
## صف باقی‌ماندهٔ واقعی (اولویت اجرا بعد از verify امنیت)
1. ~~کانال WHOLESALE~~ ✅
2. ~~STUB-03 مرحله ۴~~ ✅ (منتظر چک UI تو)
3. **PWA install** — فقط منطق؛ UI بعد از سؤال از Honar
4. **PERM-02** — بعد از ruling
5. **Icon محلی ادمین** / Order drawer UX — در صورت نیاز
6. **PROMO-TAK-01**، **CINY-01**
---
## پرامپت آماده برای Cursor پروژهٔ دوم
کپی کن و در چت پروژهٔ مشابه بگذار:
```text
این پروژه شبیه hedena_gold / gold-banking-monorepo است و همان ایرادها را دارد.
همان فیکس‌ها را با منطق یکسان پیاده کن. اولویت با امنیت و پول است.
قوانین دامنه (سخت):
- فونت IRANSans را برای اپ و ادمین بیاور (فایل + wiring).
- لوگو / فاویکون / آیکن برند / wordmark را عوض نکن و از Hedena کپی نکن.
- کاروسل / hero اسپرینگ / showcase دیزاین‌شدهٔ Hedena را اصلاً تکرار نکن.
- فیچرهای اپ مثل PWA install، pull-refresh، incomplete-profile banner: فقط منطق/رفتار را port کن؛ UI را با زبان طراحی و کامپوننت‌های خودِ این اپ بساز — از مودال/رنگ/illustration هدنا کپی نکن.
- پنل ادمین یکی است: برای دیزاین drawer سفارش، Icon محلی، dashboard unify، فرم‌ها می‌توانی مستقیم از ادمین مرجع رفرنس بگیری (باز هم بدون تعویض لوگو).
- قبل از هر تغییر UI سمت اپ از مالک بپرس.
A) امنیت
- روت‌های ledger و تغییر وضعیت سفارش را از Public دربیاور؛ فقط ادمین با rid مناسب
- GET لیست مشتری برای login را حذف کن؛ سشن مشتری با JWT جدا (JWT_CUSTOMER_SECRET، typ=customer)
- OTP واقعی: bcrypt، انقضا ۲ دقیقه، ۵ تلاش، یک‌بارمصرف، throttle؛ OTP را در SMS audit plaintext ذخیره نکن
- POST /orders پشت CustomerAuth؛ customerId فقط از توکن؛ GET order با مالکیت → 404 نه 403
- production بدون JWT_SECRET بالا نیاید؛ CORS از CORS_ORIGINS؛ seed دمو فقط با SEED_DEMO_DATA
- env خالی "" را unset بگیر (envOr)؛ JWT مشتری fallback به secret ادمین نداشته باشد
- OTP_MASTER_CODE در production ممنوع مگر فلگ تست
B) پول/سفارش
- wage/discount/rial را از کلاینت نپذیر؛ فرمول سرور + لاگ mismatch
- sync بدهی سفارش idempotent با delta (نه پست دوباره)
- canPlaceOrders در اپ 403 کند؛ ادمین بتواند برای مشتری ثبت کند
- سقف موجودی در سبد (capQty/maxQty)
- سفارش‌های اپ را WHOLESALE ذخیره کن نه RETAIL (اگر دامنه B2B است)
C) مشتری
- firstName/lastName جدا؛ percent خود مشتری (makingChargeOffsetPercent) نه کمیسیون نماینده
- پروفایل کارشناس فروش کامل؛ درخت شهر استان؛ تاریخ تولد؛ faToEn روی موبایل
D) اپ (منطق بله — دیزاین از خودِ این پروژه)
- requestOtp: 404 ثبت‌نشده، 503 SMS fail، 403 پیام جدا برای کارشناس
- toast ورود برای صفحات شخصی + redirect=
- act-as کارشناس با هدر X-Act-As-Customer (نه mint توکن)
- back تاریخچه‌دار؛ بعد login/401 از replace استفاده کن
- صفحات keep-alive را روی activate دوباره fetch کن
- PWA install: beforeinstallprompt + dismiss/defer؛ UI با کامپوننت‌های خود اپ
- IRANSans را وصل کن؛ لوگو و کاروسل را دست نزن
E) کاتالوگ/SMS
- pagination/فیلتر سرور برای محصولات؛ favorites سمت سرور
- SMS ثبت سفارش بعد commit؛ شماره‌ها از env نه hardcode
- آپلود مدرک مشتری برای complete-profile
F) ادمین (دیزاین قابل‌رفرنس)
- ridهای یتیم dashboard را merge کن
- ویژگی‌های خطرناک فقط super-admin؛ سوپرادمین ALL_RIDS
- Order status UX به صورت drawer راست + confirm logout — layout از ادمین مرجع
- Icon محلی به‌جای @ant-design/icons-vue اگر همان وابستگی را دارید
- IRANSans در ادمین؛ لوگو را عوض نکن
برای هر آیتم: اول محل معادل در این ریپو را پیدا کن، بعد فیکس را با تست/smoke کوتاه اعمال کن.
جزئیات کامل: در ریپوی مرجع فایل docs/REPLICATION-CHECKLIST.md
```
---
## منبع برای جزئیات بیشتر
- لاگ کامل What/Why/Target: [`parity/CHANGELOG.md`](parity/CHANGELOG.md)
- وضعیت FIXED/باز: [`parity/ISSUES.md`](parity/ISSUES.md)
- تصمیم‌های محصول: [`parity/DECISIONS.md`](parity/DECISIONS.md)
- برند / لوگو: [`BRANDING.md`](BRANDING.md)
- کاتالوگ: [`CATALOG-PAGINATION-PLAN.md`](CATALOG-PAGINATION-PLAN.md)
- حسابداری: [`ACCOUNTING-INTEGRATION-PLAN.md`](ACCOUNTING-INTEGRATION-PLAN.md)
- دیزاین ادمین قابل‌رفرنس: `apps/admin-panel/src/views/order/`، `apps/admin-panel/src/assets/styles/farsi.less`