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:
@@ -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 $$;
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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('معلق');
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"wine": "#7C0035",
|
||||
"wineDeep": "#5A0027",
|
||||
"wineSoft": "#A0386A",
|
||||
"wine200": "#E8B4C8",
|
||||
"wineWash": "#FBEFF3",
|
||||
"canvas": "#FFFFFF",
|
||||
"canvasSoft": "#FAFAFA",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user