@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.
- package/dist/_internal/server/features/checkout/actions.js +9 -1
- package/dist/_internal/server/features/checkout/expand-order-items.js +9 -0
- package/dist/_internal/server/features/media/contextGuards.d.ts +3 -3
- package/dist/_internal/server/features/media/contextGuards.js +19 -5
- package/dist/_internal/server/features/orders/actions.d.ts +9 -0
- package/dist/_internal/server/features/orders/actions.js +47 -4
- package/dist/_internal/server/features/orders/adapters.js +10 -0
- package/dist/_internal/server/features/refunds/actions.d.ts +9 -1
- package/dist/_internal/server/features/refunds/actions.js +36 -3
- package/dist/_internal/server/features/seo/sitemap.js +2 -2
- package/dist/_internal/server/features/site-settings/adapters.js +1 -1
- package/dist/_internal/server/functions/scheduled.d.ts +1 -2
- package/dist/_internal/server/functions/scheduled.js +8 -9
- package/dist/_internal/server/jobs/core/testerSandboxCleanup.d.ts +15 -0
- package/dist/_internal/server/jobs/core/testerSandboxCleanup.js +15 -6
- package/dist/_internal/server/jobs/handlers/index.d.ts +0 -1
- package/dist/_internal/server/jobs/handlers/index.js +0 -1
- package/dist/_internal/shared/features/orders/refund-copy.d.ts +12 -0
- package/dist/_internal/shared/features/orders/refund-copy.js +12 -0
- package/dist/_internal/shared/features/orders/return-reasons.d.ts +59 -0
- package/dist/_internal/shared/features/orders/return-reasons.js +96 -0
- package/dist/_internal/shared/features/orders/schema.d.ts +30 -0
- package/dist/_internal/shared/features/orders/schema.js +20 -0
- package/dist/_internal/shared/features/products/schema.d.ts +12 -0
- package/dist/_internal/shared/features/products/schema.js +4 -1
- package/dist/_internal/shared/media/limits.d.ts +14 -0
- package/dist/_internal/shared/media/limits.js +29 -0
- package/dist/client.d.ts +9 -1
- package/dist/client.js +19 -1
- package/dist/features/admin/components/AdminProductEditorView.js +6 -1
- package/dist/features/admin/components/AdminSiteSettingsView.js +2 -6
- package/dist/features/admin/components/index.d.ts +0 -2
- package/dist/features/admin/components/index.js +0 -1
- package/dist/features/admin/schemas/admin-editor-forms.js +1 -1
- package/dist/features/admin/schemas/firestore.d.ts +0 -2
- package/dist/features/auctions/components/AuctionDetailPageView.js +4 -3
- package/dist/features/auth/schemas/firestore.d.ts +1 -0
- package/dist/features/contact/components/ContactForm.js +46 -31
- package/dist/features/media/upload/MediaUploadList.d.ts +16 -1
- package/dist/features/media/upload/MediaUploadList.js +58 -8
- package/dist/features/orders/components/RefundRequestView.d.ts +26 -10
- package/dist/features/orders/components/RefundRequestView.js +108 -24
- package/dist/features/orders/schemas/firestore.d.ts +42 -0
- package/dist/features/orders/types/index.d.ts +24 -0
- package/dist/features/pre-orders/components/PreOrderDetailPageView.js +5 -3
- package/dist/features/products/components/ProductDetailPageView.js +14 -2
- package/dist/features/products/components/ProductFeatureBadges.d.ts +9 -1
- package/dist/features/products/components/ProductFeatureBadges.js +17 -1
- package/dist/features/products/components/ProductForm.js +46 -10
- package/dist/features/products/components/ProductGrid.js +10 -1
- package/dist/features/products/constants/final-sale.d.ts +40 -0
- package/dist/features/products/constants/final-sale.js +40 -0
- package/dist/features/products/index.d.ts +1 -0
- package/dist/features/products/index.js +1 -0
- package/dist/features/products/schemas/firestore.d.ts +19 -2
- package/dist/features/products/schemas/firestore.js +4 -0
- package/dist/features/products/types/index.d.ts +2 -0
- package/dist/features/seller/components/SellerProductShell.d.ts +7 -0
- package/dist/features/seller/components/SellerProductShell.js +82 -11
- package/dist/features/seller/index.d.ts +1 -0
- package/dist/features/seller/index.js +1 -0
- package/dist/features/seller/schemas/product-form.d.ts +304 -0
- package/dist/features/seller/schemas/product-form.js +132 -0
- package/dist/features/seller/utils/product-draft-mapping.d.ts +117 -0
- package/dist/features/seller/utils/product-draft-mapping.js +147 -0
- package/dist/features/shell/SectionForm.js +62 -7
- package/dist/features/shell/form-sections.js +4 -0
- package/dist/features/tester/repository/tester-checklist-item.repository.d.ts +16 -1
- package/dist/features/tester/repository/tester-checklist-item.repository.js +32 -6
- package/dist/features/tester/seed-data/events-tester-seed-data.js +77 -5
- package/dist/features/tester/seed-data/offers-tester-seed-data.js +23 -9
- package/dist/features/tester/seed-data/products-tester-seed-data.js +65 -49
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +182 -0
- package/dist/features/tester/seed-data/tester-window.d.ts +25 -0
- package/dist/features/tester/seed-data/tester-window.js +66 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.js +12 -3
- package/dist/next/routing/route-map.d.ts +2 -0
- package/dist/next/routing/route-map.js +1 -0
- package/dist/schemas/registry.d.ts +271 -0
- package/dist/schemas/registry.js +9 -0
- package/dist/seed/_helpers/final-sale-fixtures.d.ts +35 -0
- package/dist/seed/_helpers/final-sale-fixtures.js +61 -0
- package/dist/seed/products-art-seed-data.js +9 -1
- package/dist/seed/products-auctions-seed-data.js +2 -1
- package/dist/seed/products-classifieds-seed-data.js +2 -1
- package/dist/seed/products-digital-codes-seed-data.js +2 -1
- package/dist/seed/products-live-items-seed-data.js +2 -1
- package/dist/seed/products-preorders-seed-data.d.ts +2 -138
- package/dist/seed/products-preorders-seed-data.js +2 -1
- package/dist/seed/products-prize-draws-seed-data.js +2 -1
- package/dist/seed/products-standard-seed-data.js +2 -1
- package/dist/seed/products-stickers-seed-data.js +9 -1
- package/dist/seed/users-seed-data.js +107 -4
- package/dist/server.d.ts +6 -2
- package/dist/server.js +15 -2
- package/dist/styles.css +1 -1
- package/dist/tailwind-utilities.css +1 -1
- package/dist/tokens/motion.d.ts +12 -0
- package/dist/tokens/motion.js +12 -0
- package/dist/ui/components/CollapsibleSection.d.ts +18 -1
- package/dist/ui/components/CollapsibleSection.js +20 -3
- package/dist/ui/components/Motion.js +17 -1
- 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
|
|
82
|
-
|
|
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
|
-
|
|
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
|
-
|
|
231
|
-
|
|
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:
|
|
237
|
-
|
|
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
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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 —
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* WHAT: Exports eventsTesterSeedData —
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
129
|
+
expiresAt: windowOffset(LAPSES_MID_RUN),
|
|
116
130
|
acceptedAt: hoursAgo(0),
|
|
117
131
|
respondedAt: hoursAgo(0),
|
|
118
|
-
checkoutDeadline:
|
|
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:
|
|
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:
|
|
185
|
+
expiresAt: windowOffset(OUTLASTS_RUN),
|
|
172
186
|
createdAt: hoursAgo(2),
|
|
173
187
|
updatedAt: hoursAgo(2),
|
|
174
188
|
isTestData: true,
|