feat(apps): read products by id instead of loading the whole catalogue
Step 3 of docs/CATALOG-PAGINATION-PLAN.md. Step 2 added the targeted routes; these are the five callers that were fetching all ~16k products to find a handful of rows: product screen whole catalogue, then find(id) -> GET /products/:id cart re-sync whole catalogue for 2-3 lines -> ?ids= favourites whole catalogue, filtered here -> ?ids= home categories categories derived from it -> /products/facets order row labels joined on variantId -> included with the order Three of these were not merely slow, they were going to be wrong. Favourites filtered the loaded catalogue, so once the list pages a favourite outside the loaded slice silently disappears. Home categories were derived the same way, so a category whose products all sit further down would vanish from the home screen. Order rows looked their title up in the catalogue, which ALREADY failed for a product that had since left the storefront — exactly the old orders history exists for — and rendered a blank row. Order labels went to the server rather than to another id lookup: the item carries productVariantId, not a product id, so ?ids= could not answer it. One nested include in getOrders returns the variant, its product and the product's first image. Deliberately NOT filtered by storefront visibility, so a de-listed product still shows what was bought; asserted in the new spec. OrderItemCard is synchronous now — no fetch, no onMounted, the row renders complete on its first frame. variantLookup is deleted from catalogStore rather than left behind: it existed only to label order rows, and keeping it would have kept a reason to load the full catalogue. getProductsByIds chunks at 100 because the route caps ids there and drops the rest. A partial answer reads to callers as "these products are no longer on the storefront" — the cart would silently drop valid lines. Favourites already run to 874 rows in the imported data (FAV-01). A failed cart re-sync now leaves the cart alone: it is the person's own data in localStorage, and dropping lines because the network blinked is worse than a slightly stale price. The product screen tells a real 404 from a failed request, so only the former renders the not-found page. One bug introduced and caught while doing this: moving the home categories to /facets meant memory.init() no longer loaded the catalogue as a side effect, and the home rails read catalog.products — the home screen would have rendered empty rails. HomeView now loads it explicitly. Found by grepping every caller of configStore.init, which is also how I know HomeView is the only one. Still on the unpaged list by design: the home rails and the search/newest lists. Moving those onto paged reads is step 4, kept separate because it is the risky part — debounce, loading state, and telling "no results" from "still loading". api 334/334 across 30 suites; both apps vue-tsc clean. getOrders has a spec now (3 cases) because its include is a contract both apps depend on and the method had no test at all. Nothing click-tested — the cart re-sync and the favourites screen are the two worth a manual pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
cbb93dec5d
commit
9fc0886a57
@@ -67,7 +67,34 @@ export class CustomersService {
|
||||
getOrders(customerId: string) {
|
||||
return this.prisma.order.findMany({
|
||||
where: { customerId },
|
||||
include: { items: true },
|
||||
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 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { CustomersService } from './customers.service';
|
||||
|
||||
/**
|
||||
* `GET customer/me/orders` must carry each item's product and variant.
|
||||
*
|
||||
* The apps used to label order rows by downloading the ENTIRE catalogue and
|
||||
* joining it in the browser (composables/useOrderItemProduct.ts). With the real
|
||||
* catalogue at ~16k products that is the wrong trade for a few row labels, and
|
||||
* it was also broken for the case order history exists for: a product that has
|
||||
* since left the storefront is absent from the catalogue, so the row rendered
|
||||
* blank.
|
||||
*
|
||||
* Both apps now read these fields straight off the item, so the include is part
|
||||
* of the contract — hence a test, which this method did not have.
|
||||
*/
|
||||
describe('CustomersService.getOrders — items carry their product', () => {
|
||||
function setup() {
|
||||
const findMany = jest.fn().mockResolvedValue([]);
|
||||
const prisma = { order: { findMany } };
|
||||
// getOrders touches neither the accounting sync nor anything on it.
|
||||
const service = new CustomersService(prisma as never, {} as never);
|
||||
return { service, findMany };
|
||||
}
|
||||
|
||||
it('includes the variant, its product and the first image', async () => {
|
||||
const { service, findMany } = setup();
|
||||
await service.getOrders('c1');
|
||||
|
||||
const include = findMany.mock.calls[0][0].include;
|
||||
const variant = include.items.include.productVariant;
|
||||
|
||||
expect(variant.select.title).toBe(true);
|
||||
expect(variant.select.product.select.title).toBe(true);
|
||||
// images[0] is the product's main photo, so it must be ordered and limited
|
||||
// to one — the apps read `images[0].url` and nothing else.
|
||||
expect(variant.select.product.select.images).toMatchObject({
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
take: 1,
|
||||
});
|
||||
});
|
||||
|
||||
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.
|
||||
const { service, findMany } = setup();
|
||||
await service.getOrders('c1');
|
||||
expect(findMany.mock.calls[0][0]).toMatchObject({
|
||||
where: { customerId: 'c1' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
});
|
||||
|
||||
it('does not filter the product by storefront visibility', async () => {
|
||||
// Deliberate: an order for a product that has since been deactivated, or
|
||||
// had its photo removed, must still show what was bought. The storefront
|
||||
// rules apply to the catalogue, not to history.
|
||||
const { service, findMany } = setup();
|
||||
await service.getOrders('c1');
|
||||
const product = findMany.mock.calls[0][0].include.items.include.productVariant.select.product;
|
||||
expect(product.where).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,8 @@ const BASE = '/api';
|
||||
const TOKEN_KEY = 'gold-bank:token';
|
||||
/** Must match stores/session.ts — the act-as target, if any. */
|
||||
const ACTING_AS_KEY = 'gold-bank:actingAs';
|
||||
/** Must match MAX_IDS on the server's ListProductsDto. */
|
||||
const ID_CHUNK = 100;
|
||||
|
||||
/**
|
||||
* Called when the API rejects our token. Wired up in main.ts rather than
|
||||
@@ -130,6 +132,54 @@ export interface ProductSummary {
|
||||
collections: Array<{ collectionId: string; collection: CollectionSummary }>;
|
||||
}
|
||||
|
||||
/** Query for the paged storefront list; every field is optional. */
|
||||
export interface ProductQuery {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
categoryId?: string;
|
||||
brandId?: string;
|
||||
collectionId?: string;
|
||||
minGram?: number;
|
||||
maxGram?: number;
|
||||
sort?: 'newest' | 'oldest' | 'title';
|
||||
}
|
||||
|
||||
/**
|
||||
* One page of products. `total` is the size of the FILTERED set, not of the
|
||||
* page, so it both drives the "N محصول" count and tells the list when to stop
|
||||
* asking for more.
|
||||
*/
|
||||
export interface ProductPage {
|
||||
rows: ProductSummary[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
/** Filter options, built from the catalogue server-side. */
|
||||
export interface CatalogFacets {
|
||||
categories: CategorySummary[];
|
||||
brands: BrandSummary[];
|
||||
collections: CollectionSummary[];
|
||||
/** Decimal strings, like every other weight this API returns. */
|
||||
gramRange: { min: string; max: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds `?a=1&b=2`, dropping anything unset so the server sees a bare request
|
||||
* rather than `?search=undefined` — which would be a filter for the literal
|
||||
* text "undefined".
|
||||
*/
|
||||
function toQueryString(query: object): string {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value !== undefined && value !== '') params.set(key, String(value));
|
||||
}
|
||||
const s = params.toString();
|
||||
return s ? `?${s}` : '';
|
||||
}
|
||||
|
||||
export interface LedgerEntrySummary {
|
||||
id: string;
|
||||
currency: string;
|
||||
@@ -158,6 +208,22 @@ export interface OrderItemDetail {
|
||||
discountPercentSnapshot: string;
|
||||
computedWeightGrams: string;
|
||||
flatRialCharge: string;
|
||||
/**
|
||||
* The product and variant this line is for, included by the server.
|
||||
*
|
||||
* Optional because an order fetched by an older client — or a cached response
|
||||
* from before the server included it — has no such field. The apps fall back
|
||||
* to a plain label rather than showing nothing.
|
||||
*
|
||||
* It used to be looked up in the browser against the whole catalogue, which
|
||||
* both downloaded ~16k products for a few row labels and rendered blank for a
|
||||
* product that had since left the storefront.
|
||||
*/
|
||||
productVariant?: {
|
||||
id: string;
|
||||
title: string;
|
||||
product: { id: string; title: string; images: Array<{ url: string }> };
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface OrderDetail {
|
||||
@@ -331,7 +397,53 @@ function iamAppUrl(path: string) {
|
||||
}
|
||||
|
||||
export const api = {
|
||||
/**
|
||||
* The WHOLE active catalogue in one response.
|
||||
*
|
||||
* The real catalogue reaches ~16k products, so prefer the paged/targeted
|
||||
* calls below; this stays for the screens that genuinely work over the full
|
||||
* list and is what the route answers when asked for nothing in particular.
|
||||
*/
|
||||
listProducts: () => request<ProductSummary[]>('/catalog/products'),
|
||||
|
||||
/** One page of the storefront, filtered and counted server-side. */
|
||||
listProductsPaged: (query: ProductQuery = {}) =>
|
||||
request<ProductPage>(`/catalog/products${toQueryString(query)}`),
|
||||
|
||||
/**
|
||||
* One product, instead of downloading the catalogue to `find()` it.
|
||||
* 404s for a product that is not on the storefront (inactive, or no photo).
|
||||
*/
|
||||
getProduct: (id: string) => request<ProductSummary>(`/catalog/products/${encodeURIComponent(id)}`),
|
||||
|
||||
/**
|
||||
* Specific products by id — the cart, favourites, order items.
|
||||
*
|
||||
* Chunked because the route caps `ids` at 100 and drops the rest: a caller
|
||||
* that asked for more would silently get a partial answer, and callers treat
|
||||
* "missing from the answer" as "no longer on the storefront". Favourites
|
||||
* already run to the high hundreds in the imported data (874 rows, FAV-01),
|
||||
* so this is a live case, not a theoretical one.
|
||||
*/
|
||||
getProductsByIds: async (ids: string[]): Promise<ProductSummary[]> => {
|
||||
const unique = [...new Set(ids)].filter(Boolean);
|
||||
if (unique.length === 0) return [];
|
||||
const chunks = Array.from({ length: Math.ceil(unique.length / ID_CHUNK) }, (_, i) =>
|
||||
unique.slice(i * ID_CHUNK, (i + 1) * ID_CHUNK),
|
||||
);
|
||||
const pages = await Promise.all(
|
||||
chunks.map((chunk) =>
|
||||
request<ProductPage>(`/catalog/products?ids=${chunk.map(encodeURIComponent).join(',')}`),
|
||||
),
|
||||
);
|
||||
return pages.flatMap((p) => p.rows);
|
||||
},
|
||||
|
||||
/**
|
||||
* The filter sheet's options (categories, brands, collections, gram range),
|
||||
* which used to be derived by scanning the whole catalogue in the browser.
|
||||
*/
|
||||
getCatalogFacets: () => request<CatalogFacets>('/catalog/products/facets'),
|
||||
/**
|
||||
* Step 1 of login — ports henza_server ApiMobileController::userAuth.
|
||||
* Always resolves `{ sent: true }`, even for an unregistered number, so the
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
language (see components/home/* and HomeView). Ported originally from
|
||||
legacy henza_pwa/src/components/product/orderItem.vue.
|
||||
|
||||
Adapted: product/variant title comes from a client-side lookup (see
|
||||
useOrderItemProduct.ts) since the real order-items response only carries
|
||||
productVariantId.
|
||||
The product/variant title and photo come WITH the order (the server includes
|
||||
each item's variant and product). They used to be looked up in the browser
|
||||
against the whole catalogue, which downloaded ~16k products to label a few
|
||||
rows and left the row blank for a product that had since left the storefront.
|
||||
|
||||
DROPPED the "توضیحات: -" row the previous version rendered: our OrderItem
|
||||
schema has no note field (legacy's per-item note was a real column there;
|
||||
@@ -46,18 +47,17 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import Icon from '../icon/Icon.vue'
|
||||
import { useOrderItemProduct, type VariantLookup } from '../../composables/useOrderItemProduct'
|
||||
import { orderItemLookup } from '../../composables/useOrderItemProduct'
|
||||
import type { OrderItemDetail } from '../../api/client'
|
||||
|
||||
const props = defineProps<{ value: OrderItemDetail }>()
|
||||
const { lookup: lookupVariant } = useOrderItemProduct()
|
||||
const lookup = ref<VariantLookup | undefined>()
|
||||
|
||||
onMounted(async () => {
|
||||
lookup.value = await lookupVariant(props.value.productVariantId)
|
||||
})
|
||||
// The server sends these with the order, so there is nothing to fetch and
|
||||
// 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))
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
|
||||
@@ -1,16 +1,37 @@
|
||||
// The real GET /customers/:id/orders endpoint returns OrderItem rows with
|
||||
// only `productVariantId` (no nested product/variant title) — this joins
|
||||
// against the shared catalogue (stores/catalogStore.ts) client-side rather
|
||||
// than touching apps/api to add an include.
|
||||
import { useCatalogStore, type VariantLookup } from '../stores/catalogStore'
|
||||
// The product and variant titles shown on one line of an order.
|
||||
//
|
||||
// These now arrive WITH the order: GET customer/me/orders includes each item's
|
||||
// variant, its product and the product's first image.
|
||||
//
|
||||
// Before that, this joined against the shared catalogue in the browser. Two
|
||||
// problems, both fixed by the include: it fetched the whole catalogue (~16k
|
||||
// products in the real data) to label a few rows, and a product that had since
|
||||
// left the storefront was absent from the catalogue, so the row rendered blank —
|
||||
// precisely for old orders, which is where order history matters most.
|
||||
import type { OrderItemDetail } from '../api/client'
|
||||
|
||||
export type { VariantLookup }
|
||||
export interface VariantLookup {
|
||||
productTitle: string
|
||||
variantTitle: string
|
||||
imageUrl?: string
|
||||
}
|
||||
|
||||
/** Reads the labels off the item itself; no request, no catalogue. */
|
||||
export function orderItemLookup(item: OrderItemDetail): VariantLookup | undefined {
|
||||
const variant = item.productVariant
|
||||
if (!variant) return undefined
|
||||
return {
|
||||
productTitle: variant.product.title,
|
||||
variantTitle: variant.title,
|
||||
imageUrl: variant.product.images[0]?.url,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kept as a composable so the call sites read unchanged. Synchronous now —
|
||||
* `lookup` is resolved from the item, so it returns a value rather than a
|
||||
* promise, and callers no longer need to await anything on mount.
|
||||
*/
|
||||
export function useOrderItemProduct() {
|
||||
const catalog = useCatalogStore()
|
||||
const lookup = async (productVariantId: string): Promise<VariantLookup | undefined> => {
|
||||
if (!catalog.loaded) await catalog.load()
|
||||
return catalog.variantLookup.get(productVariantId)
|
||||
}
|
||||
return { lookup }
|
||||
return { lookup: orderItemLookup }
|
||||
}
|
||||
|
||||
@@ -1,26 +1,31 @@
|
||||
// Adapted from legacy singleProduct.vue's own fetch (GET /product/:id).
|
||||
// The new API has no single-product-detail endpoint (only GET
|
||||
// /catalog/products, the flat list), so the product is found in the shared
|
||||
// catalogue (stores/catalogStore.ts). When the catalogue is already cached,
|
||||
// opening a product costs no request at all.
|
||||
//
|
||||
// Reads ONE product from GET /catalog/products/:id.
|
||||
//
|
||||
// It used to fetch the shared catalogue and `find()` the row in it, because the
|
||||
// API had no single-product route. With the real catalogue reaching ~16k
|
||||
// products that meant opening any product could download all of them, so the
|
||||
// route now exists (plan step 2) and this is the first caller moved onto it.
|
||||
//
|
||||
// `initial` seeds the page with the ProductSummary the tapped card already
|
||||
// holds — the same shape, from the same endpoint — so the detail view renders
|
||||
// complete on its first frame instead of mounting blank and jumping when the
|
||||
// fetch lands. The lookup still runs, silently, and only replaces the product
|
||||
// if the catalogue's copy actually differs (stock moved since the list loaded).
|
||||
// fetch lands. The read still runs, silently, and only replaces the product if
|
||||
// the server's copy actually differs (stock moved since the list loaded).
|
||||
//
|
||||
// The silent refresh waits until the opening morph (ProductMorph.vue, ≤420ms)
|
||||
// is over: if the catalogue has to come from the server, parsing it — or
|
||||
// swapping the product and re-rendering — mid-animation drops frames.
|
||||
// is over: swapping the product and re-rendering mid-animation drops frames.
|
||||
import { ref } from 'vue'
|
||||
import type { ProductSummary } from '../api/client'
|
||||
import { useCatalogStore } from '../stores/catalogStore'
|
||||
import { api, UnauthorizedError, type ProductSummary } from '../api/client'
|
||||
|
||||
const SEEDED_REFRESH_DELAY_MS = 600
|
||||
|
||||
/** A 404 from the API client, which throws `API <path> failed (404): …`. */
|
||||
function isNotFound(e: unknown): boolean {
|
||||
return e instanceof Error && !(e instanceof UnauthorizedError) && /\(404\)/.test(e.message)
|
||||
}
|
||||
|
||||
export function useProductDetail(id: string, initial?: ProductSummary | null) {
|
||||
const catalog = useCatalogStore()
|
||||
const loading = ref(false)
|
||||
const flag = ref(Boolean(initial))
|
||||
const notFound = ref(false)
|
||||
@@ -31,17 +36,30 @@ export function useProductDetail(id: string, initial?: ProductSummary | null) {
|
||||
if (seeded) await new Promise((resolve) => setTimeout(resolve, SEEDED_REFRESH_DELAY_MS))
|
||||
else loading.value = true
|
||||
try {
|
||||
const products = await catalog.load()
|
||||
const found = products.find((p) => p.id === id) ?? null
|
||||
const found = await api.getProduct(id)
|
||||
if (seeded) {
|
||||
if (found && JSON.stringify(found) !== JSON.stringify(productRef.value)) productRef.value = found
|
||||
if (JSON.stringify(found) !== JSON.stringify(productRef.value)) productRef.value = found
|
||||
} else {
|
||||
productRef.value = found
|
||||
notFound.value = !found
|
||||
}
|
||||
flag.value = true
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
// Only a real 404 means "no such product". Anything else — offline, a
|
||||
// 500 — must not render the "not found" page: before this route existed
|
||||
// a failed catalogue fetch left `notFound` false, and that is still the
|
||||
// right answer, because the product may well exist.
|
||||
if (isNotFound(e)) {
|
||||
if (!seeded) {
|
||||
productRef.value = null
|
||||
notFound.value = true
|
||||
}
|
||||
// Seeded and now 404: the product was pulled from the storefront while
|
||||
// the page was open. The card's copy stays on screen rather than
|
||||
// blanking under the person mid-read; the cart refuses it at checkout.
|
||||
} else {
|
||||
console.log(e)
|
||||
}
|
||||
flag.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
// Ported from legacy henza_pwa/src/store/cartStore.ts, adapted to our string
|
||||
// ids and flat (non-paginated) product list. Persisted the same way (a single
|
||||
// localStorage key), just via Pinia's own state instead of @vueuse's
|
||||
// useStorage wrapping a hand-rolled class.
|
||||
// ids. Persisted the same way (a single localStorage key), just via Pinia's own
|
||||
// state instead of @vueuse's useStorage wrapping a hand-rolled class.
|
||||
import { defineStore } from 'pinia'
|
||||
import type { ProductSummary } from '../api/client'
|
||||
import { useCatalogStore } from './catalogStore'
|
||||
import { api, type ProductSummary } from '../api/client'
|
||||
|
||||
export interface CartItem {
|
||||
pid: string
|
||||
@@ -34,16 +32,33 @@ export const useCartStore = defineStore('cart', {
|
||||
items: loadFromStorage() as CartItem[],
|
||||
}),
|
||||
actions: {
|
||||
// Re-syncs cart entries against fresh product data (e.g. price/weight
|
||||
// changes since the item was added), dropping entries whose
|
||||
// product/variant no longer exists — same intent as legacy's init().
|
||||
/**
|
||||
* Re-syncs cart entries against fresh product data (e.g. price/weight
|
||||
* changes since the item was added), dropping entries whose
|
||||
* product/variant no longer exists — same intent as legacy's init().
|
||||
*
|
||||
* Asks for exactly the products in the cart. It used to load the entire
|
||||
* catalogue to look up two or three of them, which with ~16k products meant
|
||||
* downloading all of them every time the app opened.
|
||||
*
|
||||
* A failed request leaves the cart alone: it is the person's own data, held
|
||||
* in localStorage, and dropping items because the network blinked would be
|
||||
* worse than showing a slightly stale price.
|
||||
*/
|
||||
async init(force = false) {
|
||||
void force // kept for callers; every call now goes to the server anyway
|
||||
if (this.items.length === 0) return
|
||||
try {
|
||||
const products = await useCatalogStore().load(force)
|
||||
const ids = [...new Set(this.items.map((i) => i.pid))]
|
||||
const products = await api.getProductsByIds(ids)
|
||||
const byId = new Map(products.map((p) => [p.id, p]))
|
||||
const refreshed: CartItem[] = []
|
||||
for (const item of this.items) {
|
||||
const product = products.find((p) => p.id === item.pid)
|
||||
const product = byId.get(item.pid)
|
||||
const variant = product?.variants.find((v) => v.id === item.vid)
|
||||
// A product the server no longer lists (deactivated, or its photo was
|
||||
// removed) comes back missing, which is the same signal the catalogue
|
||||
// lookup gave before: drop the line.
|
||||
if (product && variant) {
|
||||
refreshed.push({ ...item, product })
|
||||
}
|
||||
|
||||
@@ -9,12 +9,6 @@ import { computed, ref, shallowRef } from 'vue'
|
||||
import { api, type ProductSummary } from '../api/client'
|
||||
import { KEYS, useQueryCache } from './queryCache'
|
||||
|
||||
export interface VariantLookup {
|
||||
productTitle: string
|
||||
variantTitle: string
|
||||
imageUrl?: string
|
||||
}
|
||||
|
||||
export const useCatalogStore = defineStore('catalog', () => {
|
||||
const cache = useQueryCache()
|
||||
// shallowRef: the list is replaced wholesale, never mutated in place.
|
||||
@@ -31,19 +25,11 @@ export const useCatalogStore = defineStore('catalog', () => {
|
||||
return [...seen.values()]
|
||||
})
|
||||
|
||||
const variantLookup = computed(() => {
|
||||
const map = new Map<string, VariantLookup>()
|
||||
for (const product of products.value) {
|
||||
for (const variant of product.variants) {
|
||||
map.set(variant.id, {
|
||||
productTitle: product.title,
|
||||
variantTitle: variant.title,
|
||||
imageUrl: product.images[0]?.url,
|
||||
})
|
||||
}
|
||||
}
|
||||
return map
|
||||
})
|
||||
// `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 })
|
||||
@@ -61,5 +47,5 @@ export const useCatalogStore = defineStore('catalog', () => {
|
||||
cache.invalidate(KEYS.catalog)
|
||||
}
|
||||
|
||||
return { products, loaded, categories, variantLookup, load, byId, invalidate }
|
||||
return { products, loaded, categories, load, byId, invalidate }
|
||||
})
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
// router guard against a bootstrap request this app does not have; see the same
|
||||
// note in user-app's configStore for the full reasoning (D4).
|
||||
import { defineStore } from 'pinia'
|
||||
import { useCatalogStore } from './catalogStore'
|
||||
import { api } from '../api/client'
|
||||
import shippingArt from '../assets/img/shipping-and-delivery.png'
|
||||
import competitionArt from '../assets/img/sports-and-competition.png'
|
||||
import brandArt from '../assets/img/montzar-logo.png'
|
||||
@@ -72,15 +72,18 @@ export const useConfigStore = defineStore('config', {
|
||||
this.cats = cats
|
||||
},
|
||||
// Legacy's store.init() fanned out to /config, /promotion, /ignore, /price.
|
||||
// Here the only live source is the product catalogue, from which the real
|
||||
// category list is derived.
|
||||
// The catalogue comes from catalogStore, so calling this next to another
|
||||
// catalogue read costs no second request.
|
||||
// Here the live source is the catalogue's own category list.
|
||||
//
|
||||
// Reads GET /catalog/products/facets, a small aggregate. It used to load the
|
||||
// whole catalogue and derive the categories from it in the browser — up to
|
||||
// ~16k products fetched to populate one row of chips, and wrong beyond the
|
||||
// first page once the list is paged, because a category whose products all
|
||||
// sit further down would vanish from the home screen.
|
||||
async init(force = false) {
|
||||
void force // the facets are a small read; there is no cached copy to bypass
|
||||
try {
|
||||
const catalog = useCatalogStore()
|
||||
await catalog.load(force)
|
||||
this.cats = catalog.categories.map((c) => ({ ...c, icon: { image: brandArt } }))
|
||||
const { categories } = await api.getCatalogFacets()
|
||||
this.cats = categories.map((c) => ({ ...c, icon: { image: brandArt } }))
|
||||
this.loaded = true
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
|
||||
@@ -215,14 +215,23 @@ function goNewest() {
|
||||
// The three reads are independent, so they run side by side. Each one is
|
||||
// cached until pull-to-refresh asks the server again.
|
||||
async function loadData(force = false) {
|
||||
const [catalogResult, priceResult, slidesResult] = await Promise.allSettled([
|
||||
// memory.init() reads the same cached catalogue, so this is one request.
|
||||
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),
|
||||
memory.init(force),
|
||||
reads.goldPrice({ force }),
|
||||
reads.homeSlider({ force }),
|
||||
])
|
||||
|
||||
if (catalogResult.status === 'rejected') console.log(catalogResult.reason)
|
||||
if (catsResult.status === 'rejected') console.log(catsResult.reason)
|
||||
goldPrice.value = priceResult.status === 'fulfilled' ? priceResult.value : null
|
||||
// No admin slides configured, or the endpoint failed — the category-derived
|
||||
// hero covers this silently, same as before this existed.
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
restyled into the redesigned design language (see components/home/* and
|
||||
HomeView).
|
||||
|
||||
Real, not stubbed: favorites are the local favoritesStore (per-device, no
|
||||
backend persistence — see favoritesStore.ts from an earlier batch), filtered
|
||||
against the real product catalog client-side. No pagination — the whole
|
||||
catalog is fetched once (same "small catalog" approach as everywhere else).
|
||||
That logic is carried over unchanged; only the chrome and the card change.
|
||||
Favourites are server-side (FAV-01: GET/POST customer/me/favorites), and the
|
||||
products behind them are fetched BY ID — the saved ids go straight to
|
||||
`?ids=…`, one request for the lot.
|
||||
|
||||
It used to fetch the whole catalogue and filter it in the browser, which with
|
||||
~16k products meant downloading all of them to show a handful. It was also
|
||||
wrong beyond the first page once paging existed: a favourite outside the
|
||||
loaded slice would simply not appear.
|
||||
-->
|
||||
<template>
|
||||
<div class="favs">
|
||||
@@ -35,7 +38,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import Icon from '../../components/icon/Icon.vue'
|
||||
import PageBar from '../../components/ui/PageBar.vue'
|
||||
@@ -43,26 +46,41 @@ import Spinner from '../../components/Spinner.vue'
|
||||
import SectionHead from '../../components/home/SectionHead.vue'
|
||||
import ProductCard from '../../components/product/ProductCard.vue'
|
||||
import { useFavoritesStore } from '../../stores/favoritesStore'
|
||||
import { useCatalogStore } from '../../stores/catalogStore'
|
||||
import { api, type ProductSummary } from '../../api/client'
|
||||
|
||||
const router = useRouter()
|
||||
const favorites = useFavoritesStore()
|
||||
const catalog = useCatalogStore()
|
||||
const loading = ref(false)
|
||||
const allProducts = computed(() => catalog.products)
|
||||
const fetched = ref<ProductSummary[]>([])
|
||||
|
||||
const favoriteProducts = computed(() => allProducts.value.filter((p) => favorites.isFavorite(p.id)))
|
||||
/**
|
||||
* Still filtered against the store, so un-hearting a product removes its card
|
||||
* at once without waiting for a refetch — the fetched list is the source of the
|
||||
* product DATA, the store is the source of truth for what is saved.
|
||||
*/
|
||||
const favoriteProducts = computed(() => fetched.value.filter((p) => favorites.isFavorite(p.id)))
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = !catalog.loaded
|
||||
async function load() {
|
||||
const ids = favorites.ids
|
||||
if (ids.length === 0) {
|
||||
fetched.value = []
|
||||
return
|
||||
}
|
||||
loading.value = fetched.value.length === 0
|
||||
try {
|
||||
await catalog.load()
|
||||
fetched.value = await api.getProductsByIds(ids)
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
|
||||
// The ids arrive from the server just after sign-in, and change when the person
|
||||
// hearts something elsewhere in the app — both land here as a new id list.
|
||||
watch(() => favorites.ids.join(','), load)
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
|
||||
@@ -4,6 +4,8 @@ const BASE = '/api';
|
||||
const TOKEN_KEY = 'gold-bank:token';
|
||||
/** Must match stores/session.ts — the act-as target, if any. */
|
||||
const ACTING_AS_KEY = 'gold-bank:actingAs';
|
||||
/** Must match MAX_IDS on the server's ListProductsDto. */
|
||||
const ID_CHUNK = 100;
|
||||
|
||||
/**
|
||||
* Called when the API rejects our token. Wired up in main.ts rather than
|
||||
@@ -145,6 +147,54 @@ export interface ProductSummary {
|
||||
collections: Array<{ collectionId: string; collection: CollectionSummary }>;
|
||||
}
|
||||
|
||||
/** Query for the paged storefront list; every field is optional. */
|
||||
export interface ProductQuery {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
categoryId?: string;
|
||||
brandId?: string;
|
||||
collectionId?: string;
|
||||
minGram?: number;
|
||||
maxGram?: number;
|
||||
sort?: 'newest' | 'oldest' | 'title';
|
||||
}
|
||||
|
||||
/**
|
||||
* One page of products. `total` is the size of the FILTERED set, not of the
|
||||
* page, so it both drives the "N محصول" count and tells the list when to stop
|
||||
* asking for more.
|
||||
*/
|
||||
export interface ProductPage {
|
||||
rows: ProductSummary[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
/** Filter options, built from the catalogue server-side. */
|
||||
export interface CatalogFacets {
|
||||
categories: CategorySummary[];
|
||||
brands: BrandSummary[];
|
||||
collections: CollectionSummary[];
|
||||
/** Decimal strings, like every other weight this API returns. */
|
||||
gramRange: { min: string; max: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds `?a=1&b=2`, dropping anything unset so the server sees a bare request
|
||||
* rather than `?search=undefined` — which would be a filter for the literal
|
||||
* text "undefined".
|
||||
*/
|
||||
function toQueryString(query: object): string {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value !== undefined && value !== '') params.set(key, String(value));
|
||||
}
|
||||
const s = params.toString();
|
||||
return s ? `?${s}` : '';
|
||||
}
|
||||
|
||||
export interface LedgerEntrySummary {
|
||||
id: string;
|
||||
currency: string;
|
||||
@@ -173,6 +223,22 @@ export interface OrderItemDetail {
|
||||
discountPercentSnapshot: string;
|
||||
computedWeightGrams: string;
|
||||
flatRialCharge: string;
|
||||
/**
|
||||
* The product and variant this line is for, included by the server.
|
||||
*
|
||||
* Optional because an order fetched by an older client — or a cached response
|
||||
* from before the server included it — has no such field. The apps fall back
|
||||
* to a plain label rather than showing nothing.
|
||||
*
|
||||
* It used to be looked up in the browser against the whole catalogue, which
|
||||
* both downloaded ~16k products for a few row labels and rendered blank for a
|
||||
* product that had since left the storefront.
|
||||
*/
|
||||
productVariant?: {
|
||||
id: string;
|
||||
title: string;
|
||||
product: { id: string; title: string; images: Array<{ url: string }> };
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface OrderDetail {
|
||||
@@ -346,8 +412,54 @@ function iamAppUrl(path: string) {
|
||||
}
|
||||
|
||||
export const api = {
|
||||
/**
|
||||
* The WHOLE active catalogue in one response.
|
||||
*
|
||||
* The real catalogue reaches ~16k products, so prefer the paged/targeted
|
||||
* calls below; this stays for the screens that genuinely work over the full
|
||||
* list and is what the route answers when asked for nothing in particular.
|
||||
*/
|
||||
listProducts: () => request<ProductSummary[]>('/catalog/products'),
|
||||
|
||||
/** One page of the storefront, filtered and counted server-side. */
|
||||
listProductsPaged: (query: ProductQuery = {}) =>
|
||||
request<ProductPage>(`/catalog/products${toQueryString(query)}`),
|
||||
|
||||
/**
|
||||
* One product, instead of downloading the catalogue to `find()` it.
|
||||
* 404s for a product that is not on the storefront (inactive, or no photo).
|
||||
*/
|
||||
getProduct: (id: string) => request<ProductSummary>(`/catalog/products/${encodeURIComponent(id)}`),
|
||||
|
||||
/**
|
||||
* Specific products by id — the cart, favourites, order items.
|
||||
*
|
||||
* Chunked because the route caps `ids` at 100 and drops the rest: a caller
|
||||
* that asked for more would silently get a partial answer, and callers treat
|
||||
* "missing from the answer" as "no longer on the storefront". Favourites
|
||||
* already run to the high hundreds in the imported data (874 rows, FAV-01),
|
||||
* so this is a live case, not a theoretical one.
|
||||
*/
|
||||
getProductsByIds: async (ids: string[]): Promise<ProductSummary[]> => {
|
||||
const unique = [...new Set(ids)].filter(Boolean);
|
||||
if (unique.length === 0) return [];
|
||||
const chunks = Array.from({ length: Math.ceil(unique.length / ID_CHUNK) }, (_, i) =>
|
||||
unique.slice(i * ID_CHUNK, (i + 1) * ID_CHUNK),
|
||||
);
|
||||
const pages = await Promise.all(
|
||||
chunks.map((chunk) =>
|
||||
request<ProductPage>(`/catalog/products?ids=${chunk.map(encodeURIComponent).join(',')}`),
|
||||
),
|
||||
);
|
||||
return pages.flatMap((p) => p.rows);
|
||||
},
|
||||
|
||||
/**
|
||||
* The filter sheet's options (categories, brands, collections, gram range),
|
||||
* which used to be derived by scanning the whole catalogue in the browser.
|
||||
*/
|
||||
getCatalogFacets: () => request<CatalogFacets>('/catalog/products/facets'),
|
||||
|
||||
/**
|
||||
* Step 1 of login — ports henza_server ApiMobileController::userAuth.
|
||||
* Always resolves `{ sent: true }`, even for an unregistered number, so the
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
language (see components/home/* and HomeView). Ported originally from
|
||||
legacy henza_pwa/src/components/product/orderItem.vue.
|
||||
|
||||
Adapted: product/variant title comes from a client-side lookup (see
|
||||
useOrderItemProduct.ts) since the real order-items response only carries
|
||||
productVariantId.
|
||||
The product/variant title and photo come WITH the order (the server includes
|
||||
each item's variant and product). They used to be looked up in the browser
|
||||
against the whole catalogue, which downloaded ~16k products to label a few
|
||||
rows and left the row blank for a product that had since left the storefront.
|
||||
|
||||
DROPPED the "توضیحات: -" row the previous version rendered: our OrderItem
|
||||
schema has no note field (legacy's per-item note was a real column there;
|
||||
@@ -46,18 +47,17 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import Icon from '../icon/Icon.vue'
|
||||
import { useOrderItemProduct, type VariantLookup } from '../../composables/useOrderItemProduct'
|
||||
import { orderItemLookup } from '../../composables/useOrderItemProduct'
|
||||
import type { OrderItemDetail } from '../../api/client'
|
||||
|
||||
const props = defineProps<{ value: OrderItemDetail }>()
|
||||
const { lookup: lookupVariant } = useOrderItemProduct()
|
||||
const lookup = ref<VariantLookup | undefined>()
|
||||
|
||||
onMounted(async () => {
|
||||
lookup.value = await lookupVariant(props.value.productVariantId)
|
||||
})
|
||||
// The server sends these with the order, so there is nothing to fetch and
|
||||
// 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))
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
|
||||
@@ -1,16 +1,37 @@
|
||||
// The real GET /customers/:id/orders endpoint returns OrderItem rows with
|
||||
// only `productVariantId` (no nested product/variant title) — this joins
|
||||
// against the shared catalogue (stores/catalogStore.ts) client-side rather
|
||||
// than touching apps/api to add an include.
|
||||
import { useCatalogStore, type VariantLookup } from '../stores/catalogStore'
|
||||
// The product and variant titles shown on one line of an order.
|
||||
//
|
||||
// These now arrive WITH the order: GET customer/me/orders includes each item's
|
||||
// variant, its product and the product's first image.
|
||||
//
|
||||
// Before that, this joined against the shared catalogue in the browser. Two
|
||||
// problems, both fixed by the include: it fetched the whole catalogue (~16k
|
||||
// products in the real data) to label a few rows, and a product that had since
|
||||
// left the storefront was absent from the catalogue, so the row rendered blank —
|
||||
// precisely for old orders, which is where order history matters most.
|
||||
import type { OrderItemDetail } from '../api/client'
|
||||
|
||||
export type { VariantLookup }
|
||||
export interface VariantLookup {
|
||||
productTitle: string
|
||||
variantTitle: string
|
||||
imageUrl?: string
|
||||
}
|
||||
|
||||
/** Reads the labels off the item itself; no request, no catalogue. */
|
||||
export function orderItemLookup(item: OrderItemDetail): VariantLookup | undefined {
|
||||
const variant = item.productVariant
|
||||
if (!variant) return undefined
|
||||
return {
|
||||
productTitle: variant.product.title,
|
||||
variantTitle: variant.title,
|
||||
imageUrl: variant.product.images[0]?.url,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kept as a composable so the call sites read unchanged. Synchronous now —
|
||||
* `lookup` is resolved from the item, so it returns a value rather than a
|
||||
* promise, and callers no longer need to await anything on mount.
|
||||
*/
|
||||
export function useOrderItemProduct() {
|
||||
const catalog = useCatalogStore()
|
||||
const lookup = async (productVariantId: string): Promise<VariantLookup | undefined> => {
|
||||
if (!catalog.loaded) await catalog.load()
|
||||
return catalog.variantLookup.get(productVariantId)
|
||||
}
|
||||
return { lookup }
|
||||
return { lookup: orderItemLookup }
|
||||
}
|
||||
|
||||
@@ -1,26 +1,31 @@
|
||||
// Adapted from legacy singleProduct.vue's own fetch (GET /product/:id).
|
||||
// The new API has no single-product-detail endpoint (only GET
|
||||
// /catalog/products, the flat list), so the product is found in the shared
|
||||
// catalogue (stores/catalogStore.ts). When the catalogue is already cached,
|
||||
// opening a product costs no request at all.
|
||||
//
|
||||
// Reads ONE product from GET /catalog/products/:id.
|
||||
//
|
||||
// It used to fetch the shared catalogue and `find()` the row in it, because the
|
||||
// API had no single-product route. With the real catalogue reaching ~16k
|
||||
// products that meant opening any product could download all of them, so the
|
||||
// route now exists (plan step 2) and this is the first caller moved onto it.
|
||||
//
|
||||
// `initial` seeds the page with the ProductSummary the tapped card already
|
||||
// holds — the same shape, from the same endpoint — so the detail view renders
|
||||
// complete on its first frame instead of mounting blank and jumping when the
|
||||
// fetch lands. The lookup still runs, silently, and only replaces the product
|
||||
// if the catalogue's copy actually differs (stock moved since the list loaded).
|
||||
// fetch lands. The read still runs, silently, and only replaces the product if
|
||||
// the server's copy actually differs (stock moved since the list loaded).
|
||||
//
|
||||
// The silent refresh waits until the opening morph (ProductMorph.vue, ≤420ms)
|
||||
// is over: if the catalogue has to come from the server, parsing it — or
|
||||
// swapping the product and re-rendering — mid-animation drops frames.
|
||||
// is over: swapping the product and re-rendering mid-animation drops frames.
|
||||
import { ref } from 'vue'
|
||||
import type { ProductSummary } from '../api/client'
|
||||
import { useCatalogStore } from '../stores/catalogStore'
|
||||
import { api, UnauthorizedError, type ProductSummary } from '../api/client'
|
||||
|
||||
const SEEDED_REFRESH_DELAY_MS = 600
|
||||
|
||||
/** A 404 from the API client, which throws `API <path> failed (404): …`. */
|
||||
function isNotFound(e: unknown): boolean {
|
||||
return e instanceof Error && !(e instanceof UnauthorizedError) && /\(404\)/.test(e.message)
|
||||
}
|
||||
|
||||
export function useProductDetail(id: string, initial?: ProductSummary | null) {
|
||||
const catalog = useCatalogStore()
|
||||
const loading = ref(false)
|
||||
const flag = ref(Boolean(initial))
|
||||
const notFound = ref(false)
|
||||
@@ -31,17 +36,30 @@ export function useProductDetail(id: string, initial?: ProductSummary | null) {
|
||||
if (seeded) await new Promise((resolve) => setTimeout(resolve, SEEDED_REFRESH_DELAY_MS))
|
||||
else loading.value = true
|
||||
try {
|
||||
const products = await catalog.load()
|
||||
const found = products.find((p) => p.id === id) ?? null
|
||||
const found = await api.getProduct(id)
|
||||
if (seeded) {
|
||||
if (found && JSON.stringify(found) !== JSON.stringify(productRef.value)) productRef.value = found
|
||||
if (JSON.stringify(found) !== JSON.stringify(productRef.value)) productRef.value = found
|
||||
} else {
|
||||
productRef.value = found
|
||||
notFound.value = !found
|
||||
}
|
||||
flag.value = true
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
// Only a real 404 means "no such product". Anything else — offline, a
|
||||
// 500 — must not render the "not found" page: before this route existed
|
||||
// a failed catalogue fetch left `notFound` false, and that is still the
|
||||
// right answer, because the product may well exist.
|
||||
if (isNotFound(e)) {
|
||||
if (!seeded) {
|
||||
productRef.value = null
|
||||
notFound.value = true
|
||||
}
|
||||
// Seeded and now 404: the product was pulled from the storefront while
|
||||
// the page was open. The card's copy stays on screen rather than
|
||||
// blanking under the person mid-read; the cart refuses it at checkout.
|
||||
} else {
|
||||
console.log(e)
|
||||
}
|
||||
flag.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
// Ported from legacy henza_pwa/src/store/cartStore.ts, adapted to our string
|
||||
// ids and flat (non-paginated) product list. Persisted the same way (a single
|
||||
// localStorage key), just via Pinia's own state instead of @vueuse's
|
||||
// useStorage wrapping a hand-rolled class.
|
||||
// ids. Persisted the same way (a single localStorage key), just via Pinia's own
|
||||
// state instead of @vueuse's useStorage wrapping a hand-rolled class.
|
||||
import { defineStore } from 'pinia'
|
||||
import type { ProductSummary } from '../api/client'
|
||||
import { useCatalogStore } from './catalogStore'
|
||||
import { api, type ProductSummary } from '../api/client'
|
||||
|
||||
export interface CartItem {
|
||||
pid: string
|
||||
@@ -34,16 +32,33 @@ export const useCartStore = defineStore('cart', {
|
||||
items: loadFromStorage() as CartItem[],
|
||||
}),
|
||||
actions: {
|
||||
// Re-syncs cart entries against fresh product data (e.g. price/weight
|
||||
// changes since the item was added), dropping entries whose
|
||||
// product/variant no longer exists — same intent as legacy's init().
|
||||
/**
|
||||
* Re-syncs cart entries against fresh product data (e.g. price/weight
|
||||
* changes since the item was added), dropping entries whose
|
||||
* product/variant no longer exists — same intent as legacy's init().
|
||||
*
|
||||
* Asks for exactly the products in the cart. It used to load the entire
|
||||
* catalogue to look up two or three of them, which with ~16k products meant
|
||||
* downloading all of them every time the app opened.
|
||||
*
|
||||
* A failed request leaves the cart alone: it is the person's own data, held
|
||||
* in localStorage, and dropping items because the network blinked would be
|
||||
* worse than showing a slightly stale price.
|
||||
*/
|
||||
async init(force = false) {
|
||||
void force // kept for callers; every call now goes to the server anyway
|
||||
if (this.items.length === 0) return
|
||||
try {
|
||||
const products = await useCatalogStore().load(force)
|
||||
const ids = [...new Set(this.items.map((i) => i.pid))]
|
||||
const products = await api.getProductsByIds(ids)
|
||||
const byId = new Map(products.map((p) => [p.id, p]))
|
||||
const refreshed: CartItem[] = []
|
||||
for (const item of this.items) {
|
||||
const product = products.find((p) => p.id === item.pid)
|
||||
const product = byId.get(item.pid)
|
||||
const variant = product?.variants.find((v) => v.id === item.vid)
|
||||
// A product the server no longer lists (deactivated, or its photo was
|
||||
// removed) comes back missing, which is the same signal the catalogue
|
||||
// lookup gave before: drop the line.
|
||||
if (product && variant) {
|
||||
refreshed.push({ ...item, product })
|
||||
}
|
||||
|
||||
@@ -9,12 +9,6 @@ import { computed, ref, shallowRef } from 'vue'
|
||||
import { api, type ProductSummary } from '../api/client'
|
||||
import { KEYS, useQueryCache } from './queryCache'
|
||||
|
||||
export interface VariantLookup {
|
||||
productTitle: string
|
||||
variantTitle: string
|
||||
imageUrl?: string
|
||||
}
|
||||
|
||||
export const useCatalogStore = defineStore('catalog', () => {
|
||||
const cache = useQueryCache()
|
||||
// shallowRef: the list is replaced wholesale, never mutated in place.
|
||||
@@ -31,19 +25,11 @@ export const useCatalogStore = defineStore('catalog', () => {
|
||||
return [...seen.values()]
|
||||
})
|
||||
|
||||
const variantLookup = computed(() => {
|
||||
const map = new Map<string, VariantLookup>()
|
||||
for (const product of products.value) {
|
||||
for (const variant of product.variants) {
|
||||
map.set(variant.id, {
|
||||
productTitle: product.title,
|
||||
variantTitle: variant.title,
|
||||
imageUrl: product.images[0]?.url,
|
||||
})
|
||||
}
|
||||
}
|
||||
return map
|
||||
})
|
||||
// `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 })
|
||||
@@ -61,5 +47,5 @@ export const useCatalogStore = defineStore('catalog', () => {
|
||||
cache.invalidate(KEYS.catalog)
|
||||
}
|
||||
|
||||
return { products, loaded, categories, variantLookup, load, byId, invalidate }
|
||||
return { products, loaded, categories, load, byId, invalidate }
|
||||
})
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
// add a bounce: opening a shared product link would land on home instead of the
|
||||
// product. Replicating it would port the breakage, not the behaviour (D4).
|
||||
import { defineStore } from 'pinia'
|
||||
import { useCatalogStore } from './catalogStore'
|
||||
import { api } from '../api/client'
|
||||
import shippingArt from '../assets/img/shipping-and-delivery.png'
|
||||
import competitionArt from '../assets/img/sports-and-competition.png'
|
||||
import brandArt from '../assets/img/montzar-logo.png'
|
||||
@@ -76,15 +76,18 @@ export const useConfigStore = defineStore('config', {
|
||||
this.cats = cats
|
||||
},
|
||||
// Legacy's store.init() fanned out to /config, /promotion, /ignore, /price.
|
||||
// Here the only live source is the product catalogue, from which the real
|
||||
// category list is derived.
|
||||
// The catalogue comes from catalogStore, so calling this next to another
|
||||
// catalogue read costs no second request.
|
||||
// Here the live source is the catalogue's own category list.
|
||||
//
|
||||
// Reads GET /catalog/products/facets, a small aggregate. It used to load the
|
||||
// whole catalogue and derive the categories from it in the browser — up to
|
||||
// ~16k products fetched to populate one row of chips, and wrong beyond the
|
||||
// first page once the list is paged, because a category whose products all
|
||||
// sit further down would vanish from the home screen.
|
||||
async init(force = false) {
|
||||
void force // the facets are a small read; there is no cached copy to bypass
|
||||
try {
|
||||
const catalog = useCatalogStore()
|
||||
await catalog.load(force)
|
||||
this.cats = catalog.categories.map((c) => ({ ...c, icon: { image: brandArt } }))
|
||||
const { categories } = await api.getCatalogFacets()
|
||||
this.cats = categories.map((c) => ({ ...c, icon: { image: brandArt } }))
|
||||
this.loaded = true
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
|
||||
@@ -247,14 +247,23 @@ function goNewest() {
|
||||
// The three reads are independent, so they run side by side. Each one is
|
||||
// cached until pull-to-refresh asks the server again.
|
||||
async function loadData(force = false) {
|
||||
const [catalogResult, priceResult, slidesResult] = await Promise.allSettled([
|
||||
// memory.init() reads the same cached catalogue, so this is one request.
|
||||
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),
|
||||
memory.init(force),
|
||||
reads.goldPrice({ force }),
|
||||
reads.homeSlider({ force }),
|
||||
])
|
||||
|
||||
if (catalogResult.status === 'rejected') console.log(catalogResult.reason)
|
||||
if (catsResult.status === 'rejected') console.log(catsResult.reason)
|
||||
goldPrice.value = priceResult.status === 'fulfilled' ? priceResult.value : null
|
||||
// No admin slides configured, or the endpoint failed — the category-derived
|
||||
// hero below covers this silently, same as before this existed.
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
restyled into the redesigned design language (see components/home/* and
|
||||
HomeView).
|
||||
|
||||
Real, not stubbed: favorites are the local favoritesStore (per-device, no
|
||||
backend persistence — see favoritesStore.ts from an earlier batch), filtered
|
||||
against the real product catalog client-side. No pagination — the whole
|
||||
catalog is fetched once (same "small catalog" approach as everywhere else).
|
||||
That logic is carried over unchanged; only the chrome and the card change.
|
||||
Favourites are server-side (FAV-01: GET/POST customer/me/favorites), and the
|
||||
products behind them are fetched BY ID — the saved ids go straight to
|
||||
`?ids=…`, one request for the lot.
|
||||
|
||||
It used to fetch the whole catalogue and filter it in the browser, which with
|
||||
~16k products meant downloading all of them to show a handful. It was also
|
||||
wrong beyond the first page once paging existed: a favourite outside the
|
||||
loaded slice would simply not appear.
|
||||
-->
|
||||
<template>
|
||||
<div class="favs">
|
||||
@@ -35,7 +38,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import Icon from '../../components/icon/Icon.vue'
|
||||
import PageBar from '../../components/ui/PageBar.vue'
|
||||
@@ -43,26 +46,41 @@ import Spinner from '../../components/Spinner.vue'
|
||||
import SectionHead from '../../components/home/SectionHead.vue'
|
||||
import ProductCard from '../../components/product/ProductCard.vue'
|
||||
import { useFavoritesStore } from '../../stores/favoritesStore'
|
||||
import { useCatalogStore } from '../../stores/catalogStore'
|
||||
import { api, type ProductSummary } from '../../api/client'
|
||||
|
||||
const router = useRouter()
|
||||
const favorites = useFavoritesStore()
|
||||
const catalog = useCatalogStore()
|
||||
const loading = ref(false)
|
||||
const allProducts = computed(() => catalog.products)
|
||||
const fetched = ref<ProductSummary[]>([])
|
||||
|
||||
const favoriteProducts = computed(() => allProducts.value.filter((p) => favorites.isFavorite(p.id)))
|
||||
/**
|
||||
* Still filtered against the store, so un-hearting a product removes its card
|
||||
* at once without waiting for a refetch — the fetched list is the source of the
|
||||
* product DATA, the store is the source of truth for what is saved.
|
||||
*/
|
||||
const favoriteProducts = computed(() => fetched.value.filter((p) => favorites.isFavorite(p.id)))
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = !catalog.loaded
|
||||
async function load() {
|
||||
const ids = favorites.ids
|
||||
if (ids.length === 0) {
|
||||
fetched.value = []
|
||||
return
|
||||
}
|
||||
loading.value = fetched.value.length === 0
|
||||
try {
|
||||
await catalog.load()
|
||||
fetched.value = await api.getProductsByIds(ids)
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
|
||||
// The ids arrive from the server just after sign-in, and change when the person
|
||||
// hearts something elsewhere in the app — both land here as a new id list.
|
||||
watch(() => favorites.ids.join(','), load)
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
|
||||
Reference in New Issue
Block a user