@mohasinac/appkit 4.33.1 → 4.34.1

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 (104) hide show
  1. package/dist/_internal/server/features/checkout/actions.js +9 -1
  2. package/dist/_internal/server/features/checkout/expand-order-items.js +9 -0
  3. package/dist/_internal/server/features/media/contextGuards.d.ts +3 -3
  4. package/dist/_internal/server/features/media/contextGuards.js +19 -5
  5. package/dist/_internal/server/features/orders/actions.d.ts +9 -0
  6. package/dist/_internal/server/features/orders/actions.js +47 -4
  7. package/dist/_internal/server/features/orders/adapters.js +10 -0
  8. package/dist/_internal/server/features/refunds/actions.d.ts +9 -1
  9. package/dist/_internal/server/features/refunds/actions.js +36 -3
  10. package/dist/_internal/server/features/seo/sitemap.js +2 -2
  11. package/dist/_internal/server/features/site-settings/adapters.js +1 -1
  12. package/dist/_internal/server/functions/scheduled.d.ts +1 -2
  13. package/dist/_internal/server/functions/scheduled.js +8 -9
  14. package/dist/_internal/server/jobs/core/testerSandboxCleanup.d.ts +15 -0
  15. package/dist/_internal/server/jobs/core/testerSandboxCleanup.js +15 -6
  16. package/dist/_internal/server/jobs/handlers/index.d.ts +0 -1
  17. package/dist/_internal/server/jobs/handlers/index.js +0 -1
  18. package/dist/_internal/shared/features/orders/refund-copy.d.ts +12 -0
  19. package/dist/_internal/shared/features/orders/refund-copy.js +12 -0
  20. package/dist/_internal/shared/features/orders/return-reasons.d.ts +59 -0
  21. package/dist/_internal/shared/features/orders/return-reasons.js +96 -0
  22. package/dist/_internal/shared/features/orders/schema.d.ts +30 -0
  23. package/dist/_internal/shared/features/orders/schema.js +20 -0
  24. package/dist/_internal/shared/features/products/schema.d.ts +12 -0
  25. package/dist/_internal/shared/features/products/schema.js +4 -1
  26. package/dist/_internal/shared/media/limits.d.ts +14 -0
  27. package/dist/_internal/shared/media/limits.js +29 -0
  28. package/dist/client.d.ts +9 -1
  29. package/dist/client.js +19 -1
  30. package/dist/features/admin/components/AdminProductEditorView.js +6 -1
  31. package/dist/features/admin/components/AdminSiteSettingsView.js +2 -6
  32. package/dist/features/admin/components/index.d.ts +0 -2
  33. package/dist/features/admin/components/index.js +0 -1
  34. package/dist/features/admin/schemas/admin-editor-forms.js +1 -1
  35. package/dist/features/admin/schemas/firestore.d.ts +0 -2
  36. package/dist/features/auctions/components/AuctionDetailPageView.js +4 -3
  37. package/dist/features/auth/schemas/firestore.d.ts +1 -0
  38. package/dist/features/contact/components/ContactForm.js +46 -31
  39. package/dist/features/media/upload/MediaUploadList.d.ts +16 -1
  40. package/dist/features/media/upload/MediaUploadList.js +58 -8
  41. package/dist/features/orders/components/RefundRequestView.d.ts +26 -10
  42. package/dist/features/orders/components/RefundRequestView.js +108 -24
  43. package/dist/features/orders/schemas/firestore.d.ts +42 -0
  44. package/dist/features/orders/types/index.d.ts +24 -0
  45. package/dist/features/pre-orders/components/PreOrderDetailPageView.js +5 -3
  46. package/dist/features/products/components/ProductDetailPageView.js +14 -2
  47. package/dist/features/products/components/ProductFeatureBadges.d.ts +9 -1
  48. package/dist/features/products/components/ProductFeatureBadges.js +17 -1
  49. package/dist/features/products/components/ProductForm.js +46 -10
  50. package/dist/features/products/components/ProductGrid.js +10 -1
  51. package/dist/features/products/constants/final-sale.d.ts +40 -0
  52. package/dist/features/products/constants/final-sale.js +40 -0
  53. package/dist/features/products/index.d.ts +1 -0
  54. package/dist/features/products/index.js +1 -0
  55. package/dist/features/products/schemas/firestore.d.ts +19 -2
  56. package/dist/features/products/schemas/firestore.js +4 -0
  57. package/dist/features/products/types/index.d.ts +2 -0
  58. package/dist/features/seller/components/SellerProductShell.d.ts +7 -0
  59. package/dist/features/seller/components/SellerProductShell.js +82 -11
  60. package/dist/features/seller/index.d.ts +1 -0
  61. package/dist/features/seller/index.js +1 -0
  62. package/dist/features/seller/schemas/product-form.d.ts +304 -0
  63. package/dist/features/seller/schemas/product-form.js +132 -0
  64. package/dist/features/seller/utils/product-draft-mapping.d.ts +117 -0
  65. package/dist/features/seller/utils/product-draft-mapping.js +147 -0
  66. package/dist/features/shell/SectionForm.js +62 -7
  67. package/dist/features/shell/form-sections.js +4 -0
  68. package/dist/features/tester/repository/tester-checklist-item.repository.d.ts +16 -1
  69. package/dist/features/tester/repository/tester-checklist-item.repository.js +32 -6
  70. package/dist/features/tester/seed-data/events-tester-seed-data.js +77 -5
  71. package/dist/features/tester/seed-data/offers-tester-seed-data.js +23 -9
  72. package/dist/features/tester/seed-data/products-tester-seed-data.js +65 -49
  73. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +182 -0
  74. package/dist/features/tester/seed-data/tester-window.d.ts +25 -0
  75. package/dist/features/tester/seed-data/tester-window.js +66 -0
  76. package/dist/index.d.ts +6 -3
  77. package/dist/index.js +12 -3
  78. package/dist/next/routing/route-map.d.ts +2 -0
  79. package/dist/next/routing/route-map.js +1 -0
  80. package/dist/schemas/registry.d.ts +271 -0
  81. package/dist/schemas/registry.js +9 -0
  82. package/dist/seed/_helpers/final-sale-fixtures.d.ts +35 -0
  83. package/dist/seed/_helpers/final-sale-fixtures.js +61 -0
  84. package/dist/seed/products-art-seed-data.js +9 -1
  85. package/dist/seed/products-auctions-seed-data.js +2 -1
  86. package/dist/seed/products-classifieds-seed-data.js +2 -1
  87. package/dist/seed/products-digital-codes-seed-data.js +2 -1
  88. package/dist/seed/products-live-items-seed-data.js +2 -1
  89. package/dist/seed/products-preorders-seed-data.d.ts +2 -138
  90. package/dist/seed/products-preorders-seed-data.js +2 -1
  91. package/dist/seed/products-prize-draws-seed-data.js +2 -1
  92. package/dist/seed/products-standard-seed-data.js +2 -1
  93. package/dist/seed/products-stickers-seed-data.js +9 -1
  94. package/dist/seed/users-seed-data.js +107 -4
  95. package/dist/server.d.ts +6 -2
  96. package/dist/server.js +15 -2
  97. package/dist/styles.css +1 -1
  98. package/dist/tailwind-utilities.css +1 -1
  99. package/dist/tokens/motion.d.ts +12 -0
  100. package/dist/tokens/motion.js +12 -0
  101. package/dist/ui/components/CollapsibleSection.d.ts +18 -1
  102. package/dist/ui/components/CollapsibleSection.js +20 -3
  103. package/dist/ui/components/Motion.js +17 -1
  104. package/package.json +1 -1
@@ -0,0 +1,117 @@
1
+ import type { SellerProductDraft } from "../components/SellerProductShell";
2
+ /**
3
+ * Seller listing form draft <-> product write payload.
4
+ *
5
+ * ## The bug this closes
6
+ *
7
+ * `SellerProductDraft` is FLAT — `classifiedCity`, `liveSpecies`,
8
+ * `digitalCodeDelivery`, `printSize` — because that is the shape a form wants:
9
+ * one key per control, no nesting to thread through `onChange`.
10
+ *
11
+ * `productCreateSchema` is NESTED — `classified.meetupArea.city`,
12
+ * `liveItem.species`, `digitalCode.codeDeliveryMethod`, `printMeta.size` —
13
+ * because that is the shape `ProductDocument` stores.
14
+ *
15
+ * Nothing translated between them. Every `store/*\/new/page.tsx` spread the
16
+ * draft straight into `createSellerProductAction`, and `productBaseSchema` is
17
+ * a plain `z.object()` with no `.passthrough()`, so **every flat per-type key
18
+ * was stripped before the write**. A seller filled in a classified's meetup
19
+ * city, a live animal's species and permitted jurisdictions, a digital code's
20
+ * delivery method, or an art print's size and edition — pressed Publish, got a
21
+ * success toast — and the listing was saved without the fields that made it
22
+ * that KIND of listing.
23
+ *
24
+ * ## Why this lives in the ACTION, not in the pages
25
+ *
26
+ * There are ~10 `new/page.tsx` and ~10 `[id]/edit/page.tsx` files and every
27
+ * one of them does `{ ...draft, listingType, status }`. Applying the mapping
28
+ * at each is 20 chances to forget, and a new listing type's page would start
29
+ * out broken in exactly the way this fixes. `createSellerProductAction` and
30
+ * `sellerUpdateProductAction` are the single choke point every seller write
31
+ * already passes through, so the mapping happens once and cannot be bypassed
32
+ * by a page that did not know about it (the lesson of Root Cause #75).
33
+ *
34
+ * ## Round-trip
35
+ *
36
+ * `draftToProductInput` and `productToDraft` are a matched pair in one file so
37
+ * they cannot drift: a field added to one and not the other is visible in a
38
+ * single screen of code. The edit pages need the inverse to seed the form from
39
+ * a stored document.
40
+ */
41
+ /**
42
+ * The parts of a stored product this mapper reads.
43
+ *
44
+ * Declared structurally rather than importing `ProductDocument`: the inbound
45
+ * value comes from a server action's `JsonValue` payload where dates are
46
+ * strings, and every block is optional because documents written before the
47
+ * nested shapes existed carry none of them.
48
+ */
49
+ export interface StoredProductBlocks {
50
+ finalSale?: boolean;
51
+ returnPolicy?: string;
52
+ classified?: {
53
+ meetupArea?: {
54
+ city?: string;
55
+ locality?: string;
56
+ pincode?: string;
57
+ };
58
+ acceptsShipping?: boolean;
59
+ negotiable?: boolean;
60
+ };
61
+ digitalCode?: {
62
+ codeDeliveryMethod?: string;
63
+ codePoolSize?: number;
64
+ redemptionInstructions?: string;
65
+ expiresAt?: string;
66
+ };
67
+ liveItem?: {
68
+ species?: string;
69
+ ageMonths?: number;
70
+ sex?: string;
71
+ careInfo?: string;
72
+ transport?: {
73
+ method?: string;
74
+ handlingFee?: number;
75
+ };
76
+ jurisdictionAllowed?: string[];
77
+ cites?: string;
78
+ };
79
+ printMeta?: {
80
+ size?: string;
81
+ material?: string;
82
+ finish?: string;
83
+ editionSize?: number;
84
+ };
85
+ }
86
+ /**
87
+ * The nested blocks this mapper WRITES, plus whatever else the draft carried.
88
+ * `ProductWritePayload` is deliberately not `ProductDocument`: the draft holds
89
+ * form-shaped values (ISO date strings, a `MediaField` video) that the write
90
+ * schema coerces, so typing it as the document would be a lie the compiler
91
+ * would then make everyone cast around.
92
+ */
93
+ export interface ProductWritePayload {
94
+ classified?: NonNullable<StoredProductBlocks["classified"]>;
95
+ digitalCode?: NonNullable<StoredProductBlocks["digitalCode"]>;
96
+ liveItem?: NonNullable<StoredProductBlocks["liveItem"]>;
97
+ printMeta?: NonNullable<StoredProductBlocks["printMeta"]>;
98
+ [key: string]: unknown;
99
+ }
100
+ /**
101
+ * Fold the draft's flat per-type keys into the nested blocks the write schema
102
+ * expects. Every other key passes through untouched.
103
+ *
104
+ * A block is emitted only when the draft actually carries something for it, so
105
+ * saving a standard listing does not write an empty `classified: {}`.
106
+ */
107
+ export declare function draftToProductInput(draft: SellerProductDraft & {
108
+ [key: string]: unknown;
109
+ }): ProductWritePayload;
110
+ /**
111
+ * The inverse — seed the form from a stored document.
112
+ *
113
+ * Tolerant of documents written before the nested blocks existed: a missing
114
+ * block yields undefined flat keys, which render as empty inputs rather than
115
+ * throwing.
116
+ */
117
+ export declare function productToDraft(product: StoredProductBlocks): Partial<SellerProductDraft>;
@@ -0,0 +1,147 @@
1
+ function pick(v) {
2
+ return v === undefined || v === null || v === "" ? undefined : v;
3
+ }
4
+ /** Drop keys whose value is undefined so they never reach Firestore. */
5
+ function compact(obj) {
6
+ const out = {};
7
+ for (const [k, v] of Object.entries(obj)) {
8
+ if (v !== undefined)
9
+ out[k] = v;
10
+ }
11
+ return out;
12
+ }
13
+ /**
14
+ * The flat draft keys this mapper OWNS.
15
+ *
16
+ * Stripped from the payload after being folded into their nested homes — the
17
+ * write schema does not name them, so leaving them in would be harmless today
18
+ * and confusing forever ("is `classifiedCity` stored or not?").
19
+ */
20
+ const OWNED_FLAT_KEYS = [
21
+ "classifiedCity", "classifiedLocality", "classifiedPincode",
22
+ "classifiedContactMethod", "classifiedAcceptsShipping", "classifiedNegotiable",
23
+ "digitalCodeDelivery", "digitalCodePoolSize",
24
+ "digitalCodeRedemptionInstructions", "digitalCodeExpiresAt",
25
+ "liveSpecies", "liveAgeMonths", "liveSex", "liveCareInfo",
26
+ "liveTransportMethod", "liveHandlingFee", "liveJurisdictions", "liveCites",
27
+ "printSize", "printMaterial", "printFinish", "printEditionSize",
28
+ ];
29
+ /**
30
+ * Fold the draft's flat per-type keys into the nested blocks the write schema
31
+ * expects. Every other key passes through untouched.
32
+ *
33
+ * A block is emitted only when the draft actually carries something for it, so
34
+ * saving a standard listing does not write an empty `classified: {}`.
35
+ */
36
+ export function draftToProductInput(draft) {
37
+ const out = { ...draft };
38
+ for (const k of OWNED_FLAT_KEYS)
39
+ delete out[k];
40
+ // ── classified ────────────────────────────────────────────────────────────
41
+ if (pick(draft.classifiedCity) !== undefined) {
42
+ out.classified = compact({
43
+ meetupArea: compact({
44
+ city: draft.classifiedCity,
45
+ locality: pick(draft.classifiedLocality),
46
+ pincode: pick(draft.classifiedPincode),
47
+ }),
48
+ acceptsShipping: draft.classifiedAcceptsShipping,
49
+ negotiable: draft.classifiedNegotiable,
50
+ });
51
+ }
52
+ // ── digital code ──────────────────────────────────────────────────────────
53
+ if (pick(draft.digitalCodeDelivery) !== undefined) {
54
+ out.digitalCode = compact({
55
+ codeDeliveryMethod: draft.digitalCodeDelivery,
56
+ codePoolSize: pick(draft.digitalCodePoolSize),
57
+ redemptionInstructions: pick(draft.digitalCodeRedemptionInstructions),
58
+ expiresAt: pick(draft.digitalCodeExpiresAt),
59
+ });
60
+ }
61
+ // ── live item ─────────────────────────────────────────────────────────────
62
+ if (pick(draft.liveSpecies) !== undefined) {
63
+ out.liveItem = compact({
64
+ species: draft.liveSpecies,
65
+ ageMonths: pick(draft.liveAgeMonths),
66
+ sex: pick(draft.liveSex),
67
+ careInfo: pick(draft.liveCareInfo),
68
+ /*
69
+ * `transport` is a REQUIRED object on the schema, and `method` inside it
70
+ * is required too — so a live listing whose form left the transport
71
+ * method blank must still produce the object, or the whole `liveItem`
72
+ * block fails to parse and takes the species with it. "courier" is the
73
+ * form's own default.
74
+ */
75
+ transport: compact({
76
+ method: draft.liveTransportMethod ?? "courier",
77
+ handlingFee: pick(draft.liveHandlingFee),
78
+ }),
79
+ // Required, min 1. Left empty the schema rejects the listing with a
80
+ // real message — which is correct: shipping a live animal into a
81
+ // jurisdiction that forbids it is what the field exists to prevent.
82
+ jurisdictionAllowed: draft.liveJurisdictions ?? [],
83
+ // `cites` is a STRING permit number on the document, while the form
84
+ // carries a boolean "CITES applies" checkbox. Only the affirmative case
85
+ // means anything, and it means "documented" until a number is entered.
86
+ cites: draft.liveCites ? "documented" : undefined,
87
+ });
88
+ }
89
+ // ── print meta (art + stickers share one block) ───────────────────────────
90
+ const printEntries = compact({
91
+ size: pick(draft.printSize),
92
+ material: pick(draft.printMaterial),
93
+ finish: pick(draft.printFinish),
94
+ editionSize: pick(draft.printEditionSize),
95
+ });
96
+ if (Object.keys(printEntries).length > 0)
97
+ out.printMeta = printEntries;
98
+ return out;
99
+ }
100
+ /**
101
+ * The inverse — seed the form from a stored document.
102
+ *
103
+ * Tolerant of documents written before the nested blocks existed: a missing
104
+ * block yields undefined flat keys, which render as empty inputs rather than
105
+ * throwing.
106
+ */
107
+ export function productToDraft(product) {
108
+ const { classified, digitalCode, liveItem, printMeta } = product;
109
+ return compact({
110
+ /*
111
+ * Flat fields the edit pages' hand-written `initialValues` blocks omit.
112
+ *
113
+ * Not a nesting mismatch like the rest of this function — just fields
114
+ * nobody added to ten near-identical literals. `finalSale` matters most:
115
+ * unseeded it renders as `undefined`, which `isFinalSale` reads as TRUE,
116
+ * so a listing the seller had opted into returns would open its own edit
117
+ * form showing "final sale". The stored value survives an untouched save
118
+ * (an absent key is stripped, and the update is partial), but the form was
119
+ * telling the seller something untrue about their own listing.
120
+ */
121
+ finalSale: product.finalSale,
122
+ returnPolicy: product.returnPolicy,
123
+ classifiedCity: classified?.meetupArea?.city,
124
+ classifiedLocality: classified?.meetupArea?.locality,
125
+ classifiedPincode: classified?.meetupArea?.pincode,
126
+ classifiedAcceptsShipping: classified?.acceptsShipping,
127
+ classifiedNegotiable: classified?.negotiable,
128
+ digitalCodeDelivery: digitalCode?.codeDeliveryMethod,
129
+ digitalCodePoolSize: digitalCode?.codePoolSize,
130
+ digitalCodeRedemptionInstructions: digitalCode?.redemptionInstructions,
131
+ digitalCodeExpiresAt: digitalCode?.expiresAt,
132
+ liveSpecies: liveItem?.species,
133
+ liveAgeMonths: liveItem?.ageMonths,
134
+ liveSex: liveItem?.sex,
135
+ liveCareInfo: liveItem?.careInfo,
136
+ liveTransportMethod: liveItem?.transport?.method,
137
+ liveHandlingFee: liveItem?.transport?.handlingFee,
138
+ liveJurisdictions: liveItem?.jurisdictionAllowed,
139
+ // String -> boolean, mirroring the outbound direction: any recorded permit
140
+ // means the checkbox is ticked.
141
+ liveCites: liveItem?.cites ? true : undefined,
142
+ printSize: printMeta?.size,
143
+ printMaterial: printMeta?.material,
144
+ printFinish: printMeta?.finish,
145
+ printEditionSize: printMeta?.editionSize,
146
+ });
147
+ }
@@ -9,6 +9,7 @@ import { FormShellContext } from "../../ui/forms/FormShell";
9
9
  import { useFormBottomActions } from "../layout/hooks/useFormBottomActions";
10
10
  import { useSectionState } from "../account/hooks/useCollapsedSections";
11
11
  import { FormSchemaContext } from "./FormShell";
12
+ import { EASE_OUT_MS } from "../../tokens/motion";
12
13
  /** Sections in render order: required first, original order preserved within each group. */
13
14
  export function orderSections(sections) {
14
15
  return [...sections.filter((s) => s.required), ...sections.filter((s) => !s.required)];
@@ -78,8 +79,34 @@ export function scrollToSection(id) {
78
79
  return;
79
80
  requestAnimationFrame(() => {
80
81
  const el = document.getElementById(sectionAnchorId(id));
81
- el?.scrollIntoView({ behavior: "smooth", block: "start" });
82
- el?.querySelector("[aria-invalid='true']")?.focus();
82
+ if (!el)
83
+ return;
84
+ /*
85
+ * The header is already at its final position, so scrolling can happen on
86
+ * the next frame.
87
+ */
88
+ el.scrollIntoView({ behavior: "smooth", block: "start" });
89
+ /*
90
+ * Focusing the offending field cannot.
91
+ *
92
+ * `<Collapse>` animates height 0 -> auto over EASE_OUT_MS, and Framer
93
+ * drives that with rAF rather than a CSS transition — so there is no
94
+ * `transitionend` to wait for, and one `requestAnimationFrame` lands while
95
+ * the panel is still ~0px tall inside `overflow: hidden`. Focusing there
96
+ * makes the browser scroll-anchor against a box that is about to grow,
97
+ * which is why "jump to error" could leave the field off-screen.
98
+ *
99
+ * A required section renders with no <Collapse> at all, so it is already
100
+ * at full height and this simply fires a tick later than it needs to.
101
+ */
102
+ const reduced = typeof window !== "undefined" &&
103
+ typeof window.matchMedia === "function" &&
104
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches;
105
+ const focusInvalid = () => el.querySelector("[aria-invalid='true']")?.focus();
106
+ if (reduced)
107
+ focusInvalid();
108
+ else
109
+ window.setTimeout(focusInvalid, EASE_OUT_MS);
83
110
  });
84
111
  }
85
112
  export function SectionForm({ sections, values, onChange, onSubmit, submitLabel = "Save", isLoading = false, schema, openIds: controlledOpenIds, onOpenChange, expandMode = "multi", hideActions = false, destructiveAction, onValidationChange, onCancel, cancelLabel, bottomBar = true, }) {
@@ -112,7 +139,20 @@ export function SectionForm({ sections, values, onChange, onSubmit, submitLabel
112
139
  setUncontrolledOpenIds(ids);
113
140
  onOpenChange?.(ids);
114
141
  }, [controlledOpenIds, onOpenChange]);
115
- const fieldToSectionIndex = useMemo(() => buildFieldToSectionIndex(ordered), [ordered]);
142
+ /*
143
+ * `ordered` depends on `values`, so it is a NEW array on every keystroke.
144
+ * Anything derived from it must key on the visible section SET instead, or
145
+ * the effect below re-fires per character — running a second full-tree
146
+ * `safeParse` on top of the one `handleFieldChange` already performs, and
147
+ * rebuilding every downstream callback with it.
148
+ *
149
+ * `ordered` itself must keep recomputing: a `when()` predicate can flip
150
+ * mid-typing and a section can appear or disappear. It is only the DERIVED
151
+ * values that are stable across that.
152
+ */
153
+ const visibleSectionKey = ordered.map((s) => s.id).join("|");
154
+ // eslint-disable-next-line react-hooks/exhaustive-deps
155
+ const fieldToSectionIndex = useMemo(() => buildFieldToSectionIndex(ordered), [visibleSectionKey]);
116
156
  // Keep the latest callback without making runValidation depend on it — the
117
157
  // caller usually passes an inline arrow, which would otherwise rebuild the
118
158
  // validator on every render and re-fire the effect below in a loop.
@@ -227,14 +267,29 @@ export function SectionForm({ sections, values, onChange, onSubmit, submitLabel
227
267
  }, [fieldErrors]);
228
268
  return (_jsxs(Stack, { gap: "md", children: [ordered.map((section) => {
229
269
  const isRequired = section.required === true;
230
- // A required section is always open — it holds the fields without
231
- // which the record cannot be saved, so hiding it is never useful.
270
+ /*
271
+ * A required section is always open — it holds the fields without
272
+ * which the record cannot be saved, so hiding it is never useful.
273
+ *
274
+ * `collapsible={!isRequired}` below is what makes that VISIBLE: the
275
+ * header renders as a plain heading with no control. Previously this
276
+ * flag only forced `isCollapsed` false while the header still drew a
277
+ * chevron button, so the section was permanently open AND appeared
278
+ * broken. `onToggle` no longer needs its `if (!isRequired)` guard,
279
+ * because a static header has nothing to click.
280
+ */
232
281
  const isCollapsed = isRequired
233
282
  ? false
234
283
  : !(openIds.includes(section.id) || forceOpenIds.includes(section.id));
235
284
  const errorCount = errorCountFor(section);
236
- return (_jsx(CollapsibleSection, { id: sectionAnchorId(section.id), title: _jsxs(Row, { align: "center", gap: "xs", children: [_jsx(Span, { weight: "semibold", size: "sm", children: section.heading ?? section.label }), isRequired && _jsx(Span, { size: "xs", color: "muted", children: "Required" })] }), isCollapsed: isCollapsed, onToggle: () => { if (!isRequired)
237
- toggle(section.id); }, keepMounted: section.keepMounted, renderHeaderExtra: errorCount > 0
285
+ return (_jsx(CollapsibleSection, { id: sectionAnchorId(section.id), title:
286
+ /*
287
+ * `as="span"` matters: a collapsible section's header renders
288
+ * this INSIDE a <button>, and <Row>'s default <div> made that
289
+ * <button><div><span><div> — invalid, since a button may only
290
+ * contain phrasing content. Same variant props, legal element.
291
+ */
292
+ _jsxs(Row, { as: "span", align: "center", gap: "xs", className: "min-w-0", children: [_jsx(Span, { weight: "semibold", size: "sm", children: section.heading ?? section.label }), isRequired && _jsx(Span, { size: "xs", color: "muted", children: "Required" })] }), isCollapsed: isCollapsed, collapsible: !isRequired, onToggle: () => toggle(section.id), keepMounted: section.keepMounted, renderHeaderExtra: errorCount > 0
238
293
  ? () => (_jsxs(Badge, { variant: "danger", children: [errorCount, " ", errorCount === 1 ? "issue" : "issues"] }))
239
294
  : undefined, children: section.render({ values, onChange: handleFieldChange, errors: fieldErrors }) }, section.id));
240
295
  }), !hideActions && (
@@ -92,6 +92,10 @@ export const SECTION_GROUP_DEFAULTS = {
92
92
  // logistics
93
93
  address: "logistics", shipping: "logistics", delivery: "logistics",
94
94
  routing: "logistics",
95
+ // Returns sit with logistics rather than with pricing: the seller is
96
+ // describing what happens to the goods after delivery, and the section
97
+ // reads directly after Shipping on the listing form.
98
+ returns: "logistics",
95
99
  // relationships
96
100
  owner: "relationships", members: "relationships", link: "relationships",
97
101
  google: "relationships", sync: "relationships", subscribe: "relationships",
@@ -15,9 +15,24 @@ export declare class TesterChecklistItemRepository extends BaseRepository<Tester
15
15
  /** Reopens a fixed, bug-confirmed case as a new, active version for retest.
16
16
  * The old item stays disabled forever with its bug-hunter credit intact. */
17
17
  reopenAsNewVersion(oldItemId: string): Promise<TesterChecklistItemDocument>;
18
+ /**
19
+ * Ids of automated (non-human) accounts, excluded from the PUBLIC leaderboard.
20
+ *
21
+ * Read as a bare collection query rather than through `userRepository` on purpose:
22
+ * no repository in this codebase imports another, and this needs a key set, not
23
+ * user documents. `.select()` with no fields returns refs only, so the read is as
24
+ * cheap as Firestore allows, and `isBot == true` is a single-field equality served
25
+ * by the automatic index — no composite index to declare.
26
+ */
27
+ private botHunterIds;
18
28
  /** Single-query, in-memory aggregation of bug credits per hunter — mirrors
19
29
  * EventEntryRepository.getLeaderboard()'s shape. Includes old/disabled/
20
- * superseded items, since bug credit is permanent. */
30
+ * superseded items, since bug credit is permanent.
31
+ *
32
+ * Bot accounts are aggregated out (not merely ranked last): the board exists to
33
+ * credit people, and a runner working all 943 cases would otherwise dominate it.
34
+ * The credit still lives on the item, so admin triage and the item's own
35
+ * `bugHunterName` are unaffected. */
21
36
  getBugHunterLeaderboard(limit?: number): Promise<BugHunterLeaderboardEntry[]>;
22
37
  }
23
38
  export declare const testerChecklistItemRepository: TesterChecklistItemRepository;
@@ -1,6 +1,7 @@
1
1
  import { BaseRepository, parseSieveDateValue } from "../../../providers/db-firebase";
2
2
  import { buildSearchTxt } from "../../../utils/search-txt";
3
3
  import { DatabaseError } from "../../../errors";
4
+ import { USER_COLLECTION } from "../../auth/schemas/firestore";
4
5
  import { TESTER_CHECKLIST_ITEM_COLLECTION, TESTER_CHECKLIST_ITEM_FIELDS, createChecklistItemId, } from "../schemas/firestore";
5
6
  export class TesterChecklistItemRepository extends BaseRepository {
6
7
  constructor() {
@@ -86,18 +87,43 @@ export class TesterChecklistItemRepository extends BaseRepository {
86
87
  await this.update(old.id, { supersededByItemId: newId });
87
88
  return newItem;
88
89
  }
90
+ /**
91
+ * Ids of automated (non-human) accounts, excluded from the PUBLIC leaderboard.
92
+ *
93
+ * Read as a bare collection query rather than through `userRepository` on purpose:
94
+ * no repository in this codebase imports another, and this needs a key set, not
95
+ * user documents. `.select()` with no fields returns refs only, so the read is as
96
+ * cheap as Firestore allows, and `isBot == true` is a single-field equality served
97
+ * by the automatic index — no composite index to declare.
98
+ */
99
+ async botHunterIds() {
100
+ const snapshot = await this.db
101
+ .collection(USER_COLLECTION)
102
+ .where("isBot", "==", true)
103
+ .select()
104
+ .get();
105
+ return new Set(snapshot.docs.map((d) => d.id));
106
+ }
89
107
  /** Single-query, in-memory aggregation of bug credits per hunter — mirrors
90
108
  * EventEntryRepository.getLeaderboard()'s shape. Includes old/disabled/
91
- * superseded items, since bug credit is permanent. */
109
+ * superseded items, since bug credit is permanent.
110
+ *
111
+ * Bot accounts are aggregated out (not merely ranked last): the board exists to
112
+ * credit people, and a runner working all 943 cases would otherwise dominate it.
113
+ * The credit still lives on the item, so admin triage and the item's own
114
+ * `bugHunterName` are unaffected. */
92
115
  async getBugHunterLeaderboard(limit = 50) {
93
- const snapshot = await this.db
94
- .collection(this.collection)
95
- .where(TESTER_CHECKLIST_ITEM_FIELDS.BUG_CONFIRMED, "==", true)
96
- .get();
116
+ const [snapshot, botIds] = await Promise.all([
117
+ this.db
118
+ .collection(this.collection)
119
+ .where(TESTER_CHECKLIST_ITEM_FIELDS.BUG_CONFIRMED, "==", true)
120
+ .get(),
121
+ this.botHunterIds(),
122
+ ]);
97
123
  const byHunter = new Map();
98
124
  for (const doc of snapshot.docs) {
99
125
  const item = this.mapDoc(doc);
100
- if (!item.bugHunterId)
126
+ if (!item.bugHunterId || botIds.has(item.bugHunterId))
101
127
  continue;
102
128
  const entry = byHunter.get(item.bugHunterId) ?? {
103
129
  name: item.bugHunterName ?? "Unknown tester",
@@ -1,11 +1,17 @@
1
1
  /*
2
- * WHY: Shared tester sandbox — 1 disposable test event with both raffle and spin-wheel
3
- * fields populated, so testers can exercise the events/raffle/spin flow end to end.
4
- * Auto-expires after 7 days (testerSandboxCleanup).
5
- * WHAT: Exports eventsTesterSeedData — 1 Partial<EventDocument> tagged isTestData:true.
2
+ * WHY: Shared tester sandbox — disposable test events covering spin-wheel, both raffle
3
+ * variants, a coupon offer, and a lottery with a pre-booked slot, so testers can
4
+ * exercise each flow end to end.
5
+ * WHAT: Exports eventsTesterSeedData — 5 Partial<EventDocument> tagged isTestData:true.
6
+ *
7
+ * The lottery's draw window is expressed via tester-window.ts rather than a fixed
8
+ * duration, so an automated run can shorten it and still watch the window close.
9
+ * The other four stay long-lived on purpose: they are meant to read as ACTIVE
10
+ * events for a human tester who seeds today and returns tomorrow. A fixture that
11
+ * must expire mid-session should be added as its own row, not by re-timing these.
6
12
  *
7
13
  * EXPORTS:
8
- * eventsTesterSeedData — Array of 1 Partial<EventDocument> for the seed runner
14
+ * eventsTesterSeedData — Array of 5 Partial<EventDocument> for the seed runner
9
15
  *
10
16
  * @tag domain:events,tester
11
17
  * @tag layer:seed
@@ -17,6 +23,7 @@
17
23
  import { EVENT_FIELDS } from "../../../constants/field-names";
18
24
  import { seedPhoto } from "../../../seed/_helpers/media";
19
25
  import { testDataExpiresAt } from "./tester-ttl";
26
+ import { windowMinutes, windowOffset } from "./tester-window";
20
27
  const TEST_EVENT_COVER_ALT = "Test event cover";
21
28
  const TEST_RAFFLE_PRIZE = "Test prize — not a real reward";
22
29
  export const eventsTesterSeedData = [
@@ -118,4 +125,69 @@ export const eventsTesterSeedData = [
118
125
  createdAt: new Date(),
119
126
  updatedAt: new Date(),
120
127
  },
128
+ // ── Lottery, with one slot ALREADY BOOKED ───────────────────────────────────
129
+ // This fixture exists for exactly two checklist cases, both marked 🛑:
130
+ // page-wiring/data-loss/lottery-edit-preserves-bookings
131
+ // page-wiring/data-loss/lottery-booked-slot-cannot-be-deleted
132
+ // Neither could be tested before, because no lottery fixture existed anywhere —
133
+ // lottery events could previously only come from a hand-run seed.
134
+ //
135
+ // 🛑 Slot 3's booking IS the fixture. A booked slot carries a buyer id, a display
136
+ // name and a lottery number, and the bug being guarded silently cleared all three
137
+ // while returning a success message. Do not "tidy" this into an all-unbooked
138
+ // lottery — an unbooked slot cannot demonstrate the loss, and the case would then
139
+ // pass against the very bug it exists to catch.
140
+ //
141
+ // price/weight are server-only (toClientLotterySlot strips them). Uniform pricing
142
+ // means every slot is weight 50 by definition — see computeWeight().
143
+ {
144
+ id: "event-tester-sandbox-lottery",
145
+ slug: "tester-sandbox-lottery",
146
+ type: EVENT_FIELDS.TYPE_VALUES.LOTTERY,
147
+ title: "Tester Sandbox — Lottery",
148
+ description: "Disposable test lottery for the tester QA program. Slot 3 is pre-booked: edit an unrelated slot, save, reopen, and confirm slot 3 is STILL booked to the same buyer with the same lottery number.",
149
+ status: EVENT_FIELDS.STATUS_VALUES.ACTIVE,
150
+ startsAt: new Date(),
151
+ endsAt: windowOffset(1),
152
+ coverImage: {
153
+ type: "image",
154
+ url: seedPhoto("event-cover-tester-sandbox-lottery-20260101", 1200, 600),
155
+ alt: TEST_EVENT_COVER_ALT,
156
+ },
157
+ tags: ["test"],
158
+ lotteryConfig: {
159
+ totalSlots: 6,
160
+ pricingMode: "uniform",
161
+ uniformPrice: 100,
162
+ // Must outlast the run, or late batches meet a closed lottery. windowOffset(1)
163
+ // is the run's end, so the draw window is expressed in the same units.
164
+ drawWindowDurationMinutes: Math.ceil(windowMinutes()),
165
+ maxPullsPerTransaction: 1,
166
+ maxPullsPerUser: 1,
167
+ slots: [
168
+ { slotNumber: 1, name: "Test Prize — Dragoon Storm", price: 100, weight: 50, isBooked: false, image: seedPhoto("lottery-slot-tester-1-20260101", 600, 600) },
169
+ { slotNumber: 2, name: "Test Prize — Dranzer S", price: 100, weight: 50, isBooked: false, image: seedPhoto("lottery-slot-tester-2-20260101", 600, 600) },
170
+ {
171
+ slotNumber: 3,
172
+ name: "Test Prize — Draciel Shield",
173
+ price: 100,
174
+ weight: 50,
175
+ isBooked: true,
176
+ bookedByUserId: "user-ash-trainer",
177
+ bookedByDisplayName: "Mock User 4",
178
+ bookedByUserLotteryNumber: 1,
179
+ image: seedPhoto("lottery-slot-tester-3-20260101", 600, 600),
180
+ },
181
+ { slotNumber: 4, name: "Test Prize — Driger F", price: 100, weight: 50, isBooked: false },
182
+ { slotNumber: 5, name: "Test Prize — Wolborg", price: 100, weight: 50, isBooked: false },
183
+ { slotNumber: 6, name: "Test Prize — Trygle", price: 100, weight: 50, isBooked: false },
184
+ ],
185
+ },
186
+ stats: { totalEntries: 1, approvedEntries: 1, flaggedEntries: 0 },
187
+ createdBy: "user-admin-letitrip",
188
+ isTestData: true,
189
+ testDataExpiresAt: testDataExpiresAt(),
190
+ createdAt: new Date(),
191
+ updatedAt: new Date(),
192
+ },
121
193
  ];
@@ -11,7 +11,7 @@
11
11
  * drive it straight through the Offers checkout lane.
12
12
  *
13
13
  * Every date is Date.now()-relative (per CLAUDE.md's tester-fixture standards)
14
- * so the 4-hour testerSandboxRefresh re-arms it instead of letting it go stale.
14
+ * so each seed run re-arms it instead of letting it go stale.
15
15
  *
16
16
  * EXPORTS:
17
17
  * offersTesterSeedData — Array of 1 Partial<OfferDocument> for the seed runner
@@ -25,9 +25,23 @@
25
25
  */
26
26
  import { OfferStatusValues } from "../../seller/schemas/firestore";
27
27
  import { testDataExpiresAt } from "./tester-ttl";
28
+ import { windowOffset } from "./tester-window";
28
29
  const NOW = new Date();
29
30
  const hoursAgo = (n) => new Date(NOW.getTime() - n * 3600000);
30
- const hoursFromNow = (n) => new Date(NOW.getTime() + n * 3600000);
31
+ /*
32
+ * Deadlines expressed as fractions of the testing window rather than fixed hours.
33
+ *
34
+ * "An accepted offer past its checkoutDeadline cannot be checked out" is a real
35
+ * checklist case, and a fixed 36-hour deadline makes it untestable in any session
36
+ * shorter than a day and a half. Fractions scale with TESTER_WINDOW_MINUTES, so the
37
+ * lapse actually happens while someone is watching.
38
+ *
39
+ * OUTLASTS_RUN is deliberately greater than 1: the "still live" offers must NOT
40
+ * lapse mid-run, or every other offer case starts failing for the wrong reason.
41
+ */
42
+ const OUTLASTS_RUN = 2;
43
+ const LAPSES_MID_RUN = 1 / 3;
44
+ const CHECKOUT_LAPSES_MID_RUN = 1 / 6;
31
45
  export const offersTesterSeedData = [
32
46
  {
33
47
  id: "offer-tester-sandbox-accepted",
@@ -52,11 +66,11 @@ export const offersTesterSeedData = [
52
66
  status: OfferStatusValues.ACCEPTED,
53
67
  buyerNote: "Sandbox offer — already accepted so testers can drive checkout.",
54
68
  sellerNote: "Accepted for QA sandbox testing.",
55
- expiresAt: hoursFromNow(24),
69
+ expiresAt: windowOffset(OUTLASTS_RUN),
56
70
  acceptedAt: hoursAgo(1),
57
71
  respondedAt: hoursAgo(1),
58
72
  // Well inside the 48h window so the lane is live for a whole test session.
59
- checkoutDeadline: hoursFromNow(36),
73
+ checkoutDeadline: windowOffset(OUTLASTS_RUN),
60
74
  createdAt: hoursAgo(2),
61
75
  updatedAt: hoursAgo(1),
62
76
  isTestData: true,
@@ -83,7 +97,7 @@ export const offersTesterSeedData = [
83
97
  status: OfferStatusValues.COUNTERED,
84
98
  buyerNote: "Sandbox offer — seller has countered, accept it to test the counter path.",
85
99
  sellerNote: "Counter: ₹1,150.",
86
- expiresAt: hoursFromNow(24),
100
+ expiresAt: windowOffset(OUTLASTS_RUN),
87
101
  respondedAt: hoursAgo(1),
88
102
  createdAt: hoursAgo(2),
89
103
  updatedAt: hoursAgo(1),
@@ -112,10 +126,10 @@ export const offersTesterSeedData = [
112
126
  status: OfferStatusValues.ACCEPTED,
113
127
  buyerNote: "Sandbox offer — accepted with a short checkout window.",
114
128
  sellerNote: "Accepted. Pay soon!",
115
- expiresAt: hoursFromNow(1),
129
+ expiresAt: windowOffset(LAPSES_MID_RUN),
116
130
  acceptedAt: hoursAgo(0),
117
131
  respondedAt: hoursAgo(0),
118
- checkoutDeadline: new Date(NOW.getTime() + 30 * 60000),
132
+ checkoutDeadline: windowOffset(CHECKOUT_LAPSES_MID_RUN),
119
133
  createdAt: hoursAgo(1),
120
134
  updatedAt: hoursAgo(0),
121
135
  isTestData: true,
@@ -141,7 +155,7 @@ export const offersTesterSeedData = [
141
155
  currency: "INR",
142
156
  status: OfferStatusValues.PENDING,
143
157
  buyerNote: "Sandbox offer — still pending, withdraw it to test that path.",
144
- expiresAt: hoursFromNow(24),
158
+ expiresAt: windowOffset(OUTLASTS_RUN),
145
159
  createdAt: hoursAgo(1),
146
160
  updatedAt: hoursAgo(1),
147
161
  isTestData: true,
@@ -168,7 +182,7 @@ export const offersTesterSeedData = [
168
182
  currency: "INR",
169
183
  status: OfferStatusValues.PENDING,
170
184
  buyerNote: "Sandbox inbound offer — accept, counter, or decline it from your store dashboard.",
171
- expiresAt: hoursFromNow(24),
185
+ expiresAt: windowOffset(OUTLASTS_RUN),
172
186
  createdAt: hoursAgo(2),
173
187
  updatedAt: hoursAgo(2),
174
188
  isTestData: true,