@mohasinac/appkit 4.32.6 → 4.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/dist/_internal/server/features/products/actions.js +29 -3
  2. package/dist/_internal/server/features/products/list-public.d.ts +1 -1
  3. package/dist/_internal/server/features/products/list-public.js +10 -2
  4. package/dist/_internal/server/features/stores/denormalize.d.ts +44 -0
  5. package/dist/_internal/server/features/stores/denormalize.js +58 -0
  6. package/dist/_internal/server/features/stores/index.d.ts +1 -0
  7. package/dist/_internal/server/features/stores/index.js +1 -0
  8. package/dist/_internal/server/jobs/core/jobRunners.js +5 -0
  9. package/dist/_internal/server/jobs/core/storeNameBackfill.d.ts +27 -0
  10. package/dist/_internal/server/jobs/core/storeNameBackfill.js +90 -0
  11. package/dist/_internal/shared/features/products/to-product-item.js +7 -0
  12. package/dist/client.d.ts +1 -1
  13. package/dist/client.js +4 -1
  14. package/dist/features/admin/actions/admin-actions.js +11 -1
  15. package/dist/features/admin/components/AdminSectionsView.js +17 -20
  16. package/dist/features/admin/components/sections/adminSectionsBuildParse.js +31 -14
  17. package/dist/features/admin/components/sections/adminSectionsTypes.d.ts +37 -7
  18. package/dist/features/admin/components/sections/adminSectionsTypes.js +11 -7
  19. package/dist/features/auctions/components/AuctionDetailPageView.js +56 -36
  20. package/dist/features/auctions/components/MarketplaceAuctionCard.d.ts +9 -0
  21. package/dist/features/auctions/components/MarketplaceAuctionCard.js +3 -2
  22. package/dist/features/blog/components/BlogFeaturedCard.d.ts +7 -1
  23. package/dist/features/blog/components/BlogFeaturedCard.js +3 -3
  24. package/dist/features/cart/repository/cart.repository.d.ts +27 -6
  25. package/dist/features/cart/repository/cart.repository.js +52 -21
  26. package/dist/features/cart/utils/guest-cart.js +5 -0
  27. package/dist/features/cart/utils/pending-ops.d.ts +15 -0
  28. package/dist/features/cart/utils/pending-ops.js +24 -0
  29. package/dist/features/homepage/components/BlogArticlesSection.d.ts +7 -1
  30. package/dist/features/homepage/components/BlogArticlesSection.js +5 -3
  31. package/dist/features/homepage/components/CTABannerSection.d.ts +18 -1
  32. package/dist/features/homepage/components/CTABannerSection.js +22 -4
  33. package/dist/features/homepage/components/CustomerReviewsSection.d.ts +7 -1
  34. package/dist/features/homepage/components/CustomerReviewsSection.js +2 -2
  35. package/dist/features/homepage/components/FAQSection.js +2 -11
  36. package/dist/features/homepage/components/FeaturedAuctionsSection.d.ts +3 -1
  37. package/dist/features/homepage/components/FeaturedAuctionsSection.js +5 -3
  38. package/dist/features/homepage/components/FeaturedBundlesSection.d.ts +7 -1
  39. package/dist/features/homepage/components/FeaturedBundlesSection.js +6 -5
  40. package/dist/features/homepage/components/FeaturedPreOrdersSection.d.ts +3 -1
  41. package/dist/features/homepage/components/FeaturedPreOrdersSection.js +4 -2
  42. package/dist/features/homepage/components/FeaturedProductsSection.d.ts +3 -1
  43. package/dist/features/homepage/components/FeaturedProductsSection.js +5 -3
  44. package/dist/features/homepage/components/HeroCarousel.d.ts +23 -2
  45. package/dist/features/homepage/components/HeroCarousel.js +22 -9
  46. package/dist/features/homepage/components/HomepageCustomerReviewsSection.d.ts +6 -1
  47. package/dist/features/homepage/components/HomepageCustomerReviewsSection.js +11 -3
  48. package/dist/features/homepage/components/MarketplaceHomepageView.d.ts +9 -1
  49. package/dist/features/homepage/components/MarketplaceHomepageView.js +7 -4
  50. package/dist/features/homepage/components/StatsCounterSection.d.ts +6 -1
  51. package/dist/features/homepage/components/StatsCounterSection.js +3 -3
  52. package/dist/features/homepage/constants/faq-category-labels.d.ts +15 -0
  53. package/dist/features/homepage/constants/faq-category-labels.js +23 -0
  54. package/dist/features/homepage/constants/section-copy.d.ts +107 -0
  55. package/dist/features/homepage/constants/section-copy.js +96 -0
  56. package/dist/features/homepage/lib/section-renderer.d.ts +16 -1
  57. package/dist/features/homepage/lib/section-renderer.js +58 -37
  58. package/dist/features/homepage/repository/homepage-sections.repository.d.ts +23 -0
  59. package/dist/features/homepage/repository/homepage-sections.repository.js +40 -0
  60. package/dist/features/homepage/schemas/firestore.d.ts +120 -44
  61. package/dist/features/media/MediaImage.d.ts +10 -1
  62. package/dist/features/media/MediaImage.js +6 -3
  63. package/dist/features/pre-orders/components/MarketplacePreorderCard.js +3 -2
  64. package/dist/features/products/components/CardStoreLine.d.ts +32 -0
  65. package/dist/features/products/components/CardStoreLine.js +14 -0
  66. package/dist/features/products/components/MarketplacePrizeDrawCard.js +2 -1
  67. package/dist/features/products/components/ProductGrid.js +3 -6
  68. package/dist/features/products/schemas/firestore.d.ts +17 -0
  69. package/dist/features/products/types/index.d.ts +2 -0
  70. package/dist/features/seller/actions/seller-actions.d.ts +17 -0
  71. package/dist/features/seller/actions/seller-actions.js +54 -0
  72. package/dist/features/stores/components/StoreAuctionsListing.js +3 -0
  73. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +119 -0
  74. package/dist/seed/carousel-slides-seed-data.js +154 -22
  75. package/dist/seed/carousels-seed-data.js +4 -1
  76. package/dist/seed/homepage-sections-seed-data.js +337 -254
  77. package/dist/seed/products-preorders-seed-data.d.ts +1 -0
  78. package/dist/server.d.ts +1 -0
  79. package/dist/server.js +4 -0
  80. package/dist/styles.css +1 -1
  81. package/dist/tailwind-utilities.css +1 -1
  82. package/package.json +1 -1
@@ -6,6 +6,32 @@ import { isAdminUser } from "../../../../features/auth/role-predicates";
6
6
  import { productInputSchema, productUpdateSchema, auctionInputSchema, preOrderInputSchema, setFeaturedSchema, setStatusSchema } from "../../../shared/features/products/schema";
7
7
  import { assertProductOwnership, assertStatusTransition, assertPrizeDrawNotLocked, assertPrizeDrawWonItemsImmutable, } from "./service";
8
8
  import { ValidationError } from "../../../shared/errors/index";
9
+ import { NotFoundError } from "../../../../errors";
10
+ import { storeRepository } from "../../../../features/stores/repository/store.repository";
11
+ /**
12
+ * The store fields every listing must carry, resolved from the acting seller.
13
+ *
14
+ * All three create actions used to write `storeId: user.uid` — an Auth UID,
15
+ * where every query in the codebase expects the store SLUG (`storeId` IS the
16
+ * store slug; see the store-identity note in CLAUDE.md). So a listing created
17
+ * here was filed under an id no store page, cart grouping or payout ever
18
+ * matches, and carried no `storeName` for a card to render.
19
+ *
20
+ * `storeName`/`storeSlug` are denormalized here because the public browse query
21
+ * never reads `storeRepository` — a card can only show a seller if the product
22
+ * document already names one.
23
+ */
24
+ async function sellerStoreFields(ownerId) {
25
+ const store = await storeRepository.findByOwnerId(ownerId);
26
+ if (!store) {
27
+ throw new NotFoundError("No store found for this account. Create your store first.");
28
+ }
29
+ return {
30
+ storeId: store.storeSlug,
31
+ storeName: store.storeName,
32
+ storeSlug: store.storeSlug,
33
+ };
34
+ }
9
35
  export async function createProductAction(input) {
10
36
  return wrapAction(async () => {
11
37
  const user = await requireRoleUser(["seller", "admin"]);
@@ -14,7 +40,7 @@ export async function createProductAction(input) {
14
40
  throw new ValidationError(parsed.error.issues[0]?.message ?? "Invalid input");
15
41
  return productRepository.create({
16
42
  ...parsed.data,
17
- storeId: user.uid,
43
+ ...(await sellerStoreFields(user.uid)),
18
44
  status: "draft",
19
45
  listingType: "standard",
20
46
  featured: false,
@@ -29,7 +55,7 @@ export async function createAuctionAction(input) {
29
55
  throw new ValidationError(parsed.error.issues[0]?.message ?? "Invalid input");
30
56
  return productRepository.create({
31
57
  ...parsed.data,
32
- storeId: user.uid,
58
+ ...(await sellerStoreFields(user.uid)),
33
59
  status: "draft",
34
60
  listingType: "auction",
35
61
  featured: false,
@@ -44,7 +70,7 @@ export async function createPreOrderAction(input) {
44
70
  throw new ValidationError(parsed.error.issues[0]?.message ?? "Invalid input");
45
71
  return productRepository.create({
46
72
  ...parsed.data,
47
- storeId: user.uid,
73
+ ...(await sellerStoreFields(user.uid)),
48
74
  status: "draft",
49
75
  listingType: "pre-order",
50
76
  featured: false,
@@ -218,5 +218,5 @@ export declare function listPublicProducts(input: PublicProductListInput, opts?:
218
218
  export declare function listStoreProducts(storeId: string, listingTypes: readonly string[], searchParams?: URLSearchParams | Record<string, string | string[] | undefined>, defaults?: {
219
219
  pageSize?: number;
220
220
  sorts?: string;
221
- }): Promise<PublicProductListResult | null>;
221
+ }, opts?: PublicProductListOptions): Promise<PublicProductListResult | null>;
222
222
  export {};
@@ -751,7 +751,15 @@ function finish(draft) {
751
751
  * something it never had: the URL's own sort/filter/page params reach the SSR
752
752
  * fetch, so the first paint matches what the toolbar says is selected.
753
753
  */
754
- export async function listStoreProducts(storeId, listingTypes, searchParams = {}, defaults) {
754
+ export async function listStoreProducts(storeId, listingTypes, searchParams = {}, defaults,
755
+ /*
756
+ * Forwarded, because dropping it made three capabilities unreachable from
757
+ * every store tab: the viewer (sandbox visibility), `enabledListingTypes` (a
758
+ * type an admin has switched OFF still rendered its tab's rows), and the
759
+ * pinned `now`. The wrapper accepted none of them, so no caller could pass
760
+ * them however much it wanted to.
761
+ */
762
+ opts) {
755
763
  return listPublicProducts({
756
764
  ...parsePublicProductParams(searchParams, {
757
765
  listingTypes,
@@ -762,7 +770,7 @@ export async function listStoreProducts(storeId, listingTypes, searchParams = {}
762
770
  // The route owns the store identity — a `?storeId=` in the URL must not be
763
771
  // able to make one store's tab render another store's inventory.
764
772
  storeId,
765
- });
773
+ }, opts);
766
774
  }
767
775
  /**
768
776
  * Comparable primitive for one row value.
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The one place a product's denormalized store fields are derived.
3
+ *
4
+ * `ProductDocument.storeName` / `.storeSlug` are what every listing card renders
5
+ * as "by …". They have to be denormalized, because the public browse query
6
+ * (`listPublicProducts`) never touches `storeRepository` — a card gets the name
7
+ * only if the document already carries it.
8
+ *
9
+ * They were optional and effectively never written: the seller wizard, the three
10
+ * `create*Action` server actions and the appkit products route all set `storeId`
11
+ * and stopped there, so only seed data and the admin route ever produced a
12
+ * product with a store name on it. A seeded environment therefore looked correct
13
+ * and production did not — every real seller listing rendered a card with no
14
+ * seller on it.
15
+ *
16
+ * `cart-actions.ts` already carries a private copy of this idea (`resolveStoreName`),
17
+ * written to paper over the same gap one layer downstream. That is deliberately
18
+ * left alone: it resolves at ADD time from whatever the product happens to hold,
19
+ * which is still the correct last line of defence for carts built from products
20
+ * that predate this. This module fixes the source instead.
21
+ */
22
+ export interface ProductStoreFields {
23
+ storeName: string;
24
+ storeSlug: string;
25
+ }
26
+ /**
27
+ * Resolve the display fields for one store.
28
+ *
29
+ * Degrades rather than throws — a listing that saves without its seller's
30
+ * display name is a cosmetic defect on a card, while a create that 500s because
31
+ * the store lookup blipped is a seller who cannot list. `storeId` IS the store
32
+ * slug in this codebase (see the store identity note in CLAUDE.md), so falling
33
+ * back to it yields a working link and a readable-if-ugly label, never a blank.
34
+ */
35
+ export declare function resolveStoreFields(storeId: string, route: string): Promise<ProductStoreFields>;
36
+ /**
37
+ * Batch form, for the backfill job and the rename re-sync.
38
+ *
39
+ * Sequential rather than `Promise.all` on purpose: the caller is a background
40
+ * job over a whole catalogue, distinct stores are few (the grouping is what
41
+ * makes this cheap), and a burst of parallel reads is exactly the shape Rule #6
42
+ * asks not to write.
43
+ */
44
+ export declare function resolveStoreFieldsMany(storeIds: Iterable<string>, route: string): Promise<Map<string, ProductStoreFields>>;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The one place a product's denormalized store fields are derived.
3
+ *
4
+ * `ProductDocument.storeName` / `.storeSlug` are what every listing card renders
5
+ * as "by …". They have to be denormalized, because the public browse query
6
+ * (`listPublicProducts`) never touches `storeRepository` — a card gets the name
7
+ * only if the document already carries it.
8
+ *
9
+ * They were optional and effectively never written: the seller wizard, the three
10
+ * `create*Action` server actions and the appkit products route all set `storeId`
11
+ * and stopped there, so only seed data and the admin route ever produced a
12
+ * product with a store name on it. A seeded environment therefore looked correct
13
+ * and production did not — every real seller listing rendered a card with no
14
+ * seller on it.
15
+ *
16
+ * `cart-actions.ts` already carries a private copy of this idea (`resolveStoreName`),
17
+ * written to paper over the same gap one layer downstream. That is deliberately
18
+ * left alone: it resolves at ADD time from whatever the product happens to hold,
19
+ * which is still the correct last line of defence for carts built from products
20
+ * that predate this. This module fixes the source instead.
21
+ */
22
+ import { storeRepository } from "../../../../features/stores/repository/store.repository";
23
+ import { safeRead } from "../../../../errors/safe-read";
24
+ /**
25
+ * Resolve the display fields for one store.
26
+ *
27
+ * Degrades rather than throws — a listing that saves without its seller's
28
+ * display name is a cosmetic defect on a card, while a create that 500s because
29
+ * the store lookup blipped is a seller who cannot list. `storeId` IS the store
30
+ * slug in this codebase (see the store identity note in CLAUDE.md), so falling
31
+ * back to it yields a working link and a readable-if-ugly label, never a blank.
32
+ */
33
+ export async function resolveStoreFields(storeId, route) {
34
+ const store = await safeRead(() => storeRepository.findById(storeId), {
35
+ route,
36
+ key: "stores.findById",
37
+ fallback: null,
38
+ });
39
+ return {
40
+ storeName: store?.storeName ?? storeId,
41
+ storeSlug: store?.storeSlug ?? storeId,
42
+ };
43
+ }
44
+ /**
45
+ * Batch form, for the backfill job and the rename re-sync.
46
+ *
47
+ * Sequential rather than `Promise.all` on purpose: the caller is a background
48
+ * job over a whole catalogue, distinct stores are few (the grouping is what
49
+ * makes this cheap), and a burst of parallel reads is exactly the shape Rule #6
50
+ * asks not to write.
51
+ */
52
+ export async function resolveStoreFieldsMany(storeIds, route) {
53
+ const out = new Map();
54
+ for (const storeId of new Set(storeIds)) {
55
+ out.set(storeId, await resolveStoreFields(storeId, route));
56
+ }
57
+ return out;
58
+ }
@@ -1,4 +1,5 @@
1
1
  export { getStoreForDetail, listStoreProductsInitial, listStoreAuctionsInitial, listStorePreOrdersInitial, listSitemapStores, } from "./data";
2
2
  export { STORES_PAGE_SIZE, STORES_PRODUCTS_PAGE_SIZE, STORES_SITEMAP_LIMIT, STORES_FEATURED_LIMIT, } from "../../../shared/features/stores/config";
3
3
  export { renderStoreOgImage, renderStoreOg, type StoreOgData } from "./og";
4
+ export { resolveStoreFields, resolveStoreFieldsMany, type ProductStoreFields, } from "./denormalize";
4
5
  export { toStoreDetail, toStoreListItem, PUBLIC_STORE_FIELDS, PRIVATE_STORE_FIELDS, type ToStoreDetailOptions, type ToStoreListItemOptions, type StoreProjectionSource, } from "./adapters";
@@ -1,4 +1,5 @@
1
1
  export { getStoreForDetail, listStoreProductsInitial, listStoreAuctionsInitial, listStorePreOrdersInitial, listSitemapStores, } from "./data";
2
2
  export { STORES_PAGE_SIZE, STORES_PRODUCTS_PAGE_SIZE, STORES_SITEMAP_LIMIT, STORES_FEATURED_LIMIT, } from "../../../shared/features/stores/config";
3
3
  export { renderStoreOgImage, renderStoreOg } from "./og";
4
+ export { resolveStoreFields, resolveStoreFieldsMany, } from "./denormalize";
4
5
  export { toStoreDetail, toStoreListItem, PUBLIC_STORE_FIELDS, PRIVATE_STORE_FIELDS, } from "./adapters";
@@ -14,6 +14,7 @@ import { runResetOtpVerification } from "./resetOtpVerification";
14
14
  import { runWhatsAppNotify } from "./whatsappNotify";
15
15
  import { runNewsletterExport } from "./newsletterExport";
16
16
  import { runWhatsAppCatalogImport } from "./whatsappCatalogImport";
17
+ import { runStoreNameBackfill } from "./storeNameBackfill";
17
18
  async function runPayoutsWeeklyJob(_payload, ctx) {
18
19
  const { payoutsCreated, ordersProcessed, payoutIds } = await runWeeklyPayoutEligibility(ctx);
19
20
  return {
@@ -49,6 +50,9 @@ async function runNewsletterExportJob(_payload, ctx) {
49
50
  async function runWhatsAppCatalogImportJob(payload, ctx) {
50
51
  return runWhatsAppCatalogImport({ storeSlug: String(payload.storeSlug ?? "") }, ctx);
51
52
  }
53
+ async function runStoreNameBackfillJob(payload, ctx) {
54
+ return runStoreNameBackfill({ storeId: String(payload.storeId ?? "") }, ctx);
55
+ }
52
56
  export const JOB_RUNNERS = {
53
57
  payoutsWeekly: runPayoutsWeeklyJob,
54
58
  hardBanCascade: runHardBanCascadeJob,
@@ -56,4 +60,5 @@ export const JOB_RUNNERS = {
56
60
  whatsappNotify: runWhatsAppNotifyJob,
57
61
  newsletterExport: runNewsletterExportJob,
58
62
  whatsappCatalogImport: runWhatsAppCatalogImportJob,
63
+ storeNameBackfill: runStoreNameBackfillJob,
59
64
  };
@@ -0,0 +1,27 @@
1
+ /**
2
+ * storeNameBackfill — re-sync a store's denormalized display fields onto its
3
+ * products.
4
+ *
5
+ * `ProductDocument.storeName` / `.storeSlug` are denormalized because the public
6
+ * browse query never touches `storeRepository`: a listing card can only show a
7
+ * seller if the product document already names one. Denormalizing without a
8
+ * re-sync is the mirror-drift trap (Root Cause #42) — a renamed store would keep
9
+ * showing its old name on every card it owns, forever, with nothing reporting a
10
+ * problem.
11
+ *
12
+ * This runs as a JOB rather than inline in the rename request because the write
13
+ * is unbounded: a store with hundreds of listings would blow the 10s Vercel
14
+ * ceiling (Rule #6). It reuses the existing Async Job Primitive
15
+ * (`enqueueJob` → `onJobCreated` → JOB_RUNNERS), so it costs no new Cloud
16
+ * Scheduler job.
17
+ *
18
+ * Idempotent: a product already carrying both correct values is skipped, so a
19
+ * re-run over a synced catalogue performs zero writes.
20
+ */
21
+ import type { JobContext } from "../runtime/types";
22
+ import type { JobRunResult } from "./jobRunners";
23
+ export interface StoreNameBackfillPayload {
24
+ /** The store's slug — which is also its document id, and the products' `storeId`. */
25
+ storeId: string;
26
+ }
27
+ export declare function runStoreNameBackfill(payload: StoreNameBackfillPayload, ctx: JobContext): Promise<JobRunResult>;
@@ -0,0 +1,90 @@
1
+ /**
2
+ * storeNameBackfill — re-sync a store's denormalized display fields onto its
3
+ * products.
4
+ *
5
+ * `ProductDocument.storeName` / `.storeSlug` are denormalized because the public
6
+ * browse query never touches `storeRepository`: a listing card can only show a
7
+ * seller if the product document already names one. Denormalizing without a
8
+ * re-sync is the mirror-drift trap (Root Cause #42) — a renamed store would keep
9
+ * showing its old name on every card it owns, forever, with nothing reporting a
10
+ * problem.
11
+ *
12
+ * This runs as a JOB rather than inline in the rename request because the write
13
+ * is unbounded: a store with hundreds of listings would blow the 10s Vercel
14
+ * ceiling (Rule #6). It reuses the existing Async Job Primitive
15
+ * (`enqueueJob` → `onJobCreated` → JOB_RUNNERS), so it costs no new Cloud
16
+ * Scheduler job.
17
+ *
18
+ * Idempotent: a product already carrying both correct values is skipped, so a
19
+ * re-run over a synced catalogue performs zero writes.
20
+ */
21
+ import { storeRepository } from "../../../../features/stores/repository/store.repository";
22
+ import { productRepository } from "../../../../features/products/repository/products.repository";
23
+ import { normalizeError } from "../../../../errors/normalize";
24
+ /** Firestore caps a batched write at 500 operations. */
25
+ const BATCH_LIMIT = 500;
26
+ export async function runStoreNameBackfill(payload, ctx) {
27
+ const { storeId } = payload;
28
+ const empty = {
29
+ summary: { total: 0, succeeded: 0, skipped: 0, failed: 0 },
30
+ succeeded: [],
31
+ skipped: [],
32
+ failed: [],
33
+ };
34
+ if (!storeId) {
35
+ return { ...empty, failed: [{ id: "-", reason: "storeId is required" }],
36
+ summary: { total: 0, succeeded: 0, skipped: 0, failed: 1 } };
37
+ }
38
+ const store = await storeRepository.findById(storeId);
39
+ if (!store) {
40
+ // Not an error worth failing the job over — a store deleted between the
41
+ // rename and this run is a race, not a fault. Its products are handled by
42
+ // the store-deletion cascade, not here.
43
+ ctx.logger.warn("storeNameBackfill: store not found", { storeId });
44
+ return { ...empty, skipped: [storeId], summary: { total: 0, succeeded: 0, skipped: 1, failed: 0 } };
45
+ }
46
+ const products = await productRepository.findByStore(storeId);
47
+ const stale = products.filter((p) => p.storeName !== store.storeName || p.storeSlug !== store.storeSlug);
48
+ const succeeded = [];
49
+ const failed = [];
50
+ for (let i = 0; i < stale.length; i += BATCH_LIMIT) {
51
+ const chunk = stale.slice(i, i + BATCH_LIMIT);
52
+ const batch = ctx.db.batch();
53
+ for (const product of chunk) {
54
+ batch.update(ctx.db.collection("products").doc(product.id), {
55
+ storeName: store.storeName,
56
+ storeSlug: store.storeSlug,
57
+ updatedAt: ctx.now,
58
+ });
59
+ }
60
+ try {
61
+ await batch.commit();
62
+ succeeded.push(...chunk.map((p) => p.id));
63
+ }
64
+ catch (err) {
65
+ // Per-chunk, not per-job: one failed batch must not discard the chunks
66
+ // that already committed, and the job is idempotent so a retry is safe.
67
+ const reason = normalizeError(err).message;
68
+ failed.push(...chunk.map((p) => ({ id: p.id, reason })));
69
+ }
70
+ }
71
+ const skipped = products.length - stale.length;
72
+ ctx.logger.info("storeNameBackfill: complete", {
73
+ storeId,
74
+ storeName: store.storeName,
75
+ updated: succeeded.length,
76
+ skipped,
77
+ failed: failed.length,
78
+ });
79
+ return {
80
+ summary: {
81
+ total: products.length,
82
+ succeeded: succeeded.length,
83
+ skipped,
84
+ failed: failed.length,
85
+ },
86
+ succeeded,
87
+ skipped: [],
88
+ failed,
89
+ };
90
+ }
@@ -34,6 +34,13 @@ export function toProductItem(doc) {
34
34
  status: doc.status ?? "published",
35
35
  slug: typeof doc.slug === "string" ? doc.slug : undefined,
36
36
  storeName: typeof doc.storeName === "string" ? doc.storeName : undefined,
37
+ // `storeName` alone was carried here, and `storeId` was dropped — the same
38
+ // shape as the `listingType` bug two fields down, one field over. A card
39
+ // needs the id to decide whether it has a seller at all (a name can be
40
+ // absent on a document written before the create paths denormalized it),
41
+ // and the slug to name one without a second read.
42
+ storeId: typeof doc.storeId === "string" ? doc.storeId : undefined,
43
+ storeSlug: typeof doc.storeSlug === "string" ? doc.storeSlug : undefined,
37
44
  rating: typeof doc.rating === "number" ? doc.rating : undefined,
38
45
  reviewCount: typeof doc.reviewCount === "number" ? doc.reviewCount : undefined,
39
46
  // Without this, every related-item card silently linked to the standard
package/dist/client.d.ts CHANGED
@@ -260,7 +260,7 @@ export { StoreAddonsPicker, hasAnyStoreAddon } from "./features/cart/index";
260
260
  export { clientLineTotal } from "./features/cart/utils/line-total";
261
261
  export type { PricedCartLine } from "./features/cart/utils/line-total";
262
262
  export type { CartPriceBreakdownData, CartPriceBreakdownStore, StoreAddonsValue, StoreAddonsRates } from "./features/cart/index";
263
- export { getCartOps, CART_OPS_CHANGE_EVENT } from "./features/cart/utils/pending-ops";
263
+ export { getCartOps, clearCartOps, removeCartOpsFor, CART_OPS_CHANGE_EVENT } from "./features/cart/utils/pending-ops";
264
264
  export type { CartOp } from "./features/cart/utils/pending-ops";
265
265
  export type { CartItem, CartItemMeta, CartData, GuestCartItem } from "./features/cart/index";
266
266
  export type { CartLineKind, CartGroupSource, CartLineMember, } from "./features/cart/schemas/firestore";
package/dist/client.js CHANGED
@@ -302,7 +302,10 @@ export { useLogout, useLogin, useGoogleLogin, useLinkGoogleAccount, useRegister,
302
302
  export { CartView, CartItemRow, CartGroupLineRow, CartSummary, CartPriceBreakdown, CartDrawer, CheckoutView, CheckoutSuccessView, CheckoutAddressStep, useGuestCart, useCartCount, useAddToCart, useCart, useGuestCartMerge, useCartQuery } from "./features/cart/index";
303
303
  export { StoreAddonsPicker, hasAnyStoreAddon } from "./features/cart/index";
304
304
  export { clientLineTotal } from "./features/cart/utils/line-total";
305
- export { getCartOps, CART_OPS_CHANGE_EVENT } from "./features/cart/utils/pending-ops";
305
+ // `clearCartOps` / `removeCartOpsFor` are exported because the cart page has to
306
+ // drain the queue after a removal succeeds server-side — without them a cleared
307
+ // row is re-layered onto the next render and re-POSTed by useSyncManager 30s later.
308
+ export { getCartOps, clearCartOps, removeCartOpsFor, CART_OPS_CHANGE_EVENT } from "./features/cart/utils/pending-ops";
306
309
  export { useAddresses, useCreateAddress, useUpdateAddress, useDeleteAddress, useSetDefaultAddress, useAddress } from "./features/account/index";
307
310
  export { AddressBook, AddressCard, AddressForm } from "./features/account/index";
308
311
  export { useProfile, useUpdateProfile } from "./features/account/index";
@@ -17,8 +17,10 @@ import { getProviders } from "../../../contracts";
17
17
  import { getAdminAuth } from "../../../providers/db-firebase";
18
18
  import { isAdminUser } from "../../auth/role-predicates";
19
19
  import { assertPrizeDrawNotLocked, assertPrizeDrawWonItemsImmutable, } from "../../../_internal/server/features/products/service";
20
+ import { resolveStoreFields } from "../../../_internal/server/features/stores/denormalize";
20
21
  import { recordAdminAction } from "../../../_internal/server/features/audit-log/actions";
21
22
  import { AdminAuditActionValues } from "../../audit-log/schemas/firestore";
23
+ import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints";
22
24
  const ERR_UID_REQUIRED = "uid is required";
23
25
  export async function revokeSession(adminId, sessionId) {
24
26
  if (!sessionId?.trim()) {
@@ -253,10 +255,18 @@ export async function adminUpdateProduct(adminId, id, input) {
253
255
  return updated;
254
256
  }
255
257
  export async function adminCreateProduct(admin, input) {
258
+ // Resolve the real store rather than falling back to the literal "Admin",
259
+ // which is not a seller and renders as one on every listing card. `storeSlug`
260
+ // is denormalized alongside so a card can link without a second read; both
261
+ // re-sync through the storeNameBackfill job when the store is renamed.
262
+ const storeFields = input.storeId
263
+ ? await resolveStoreFields(input.storeId, ADMIN_ENDPOINTS.PRODUCTS)
264
+ : null;
256
265
  const product = await productRepository.create({
257
266
  ...input,
258
267
  storeId: input.storeId,
259
- storeName: input.storeName || "Admin",
268
+ storeName: input.storeName || storeFields?.storeName,
269
+ storeSlug: storeFields?.storeSlug,
260
270
  });
261
271
  serverLogger.info("adminCreateProduct", {
262
272
  adminId: admin.uid,