@mohasinac/appkit 4.42.4 → 4.42.6
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/features/addresses/repository/addresses.repository.d.ts +13 -0
- package/dist/features/addresses/repository/addresses.repository.js +13 -0
- package/dist/features/admin/components/AdminBundleEditorView.js +41 -2
- package/dist/features/categories/schemas/bundle-form.d.ts +32 -32
- package/dist/features/categories/schemas/bundle-form.js +63 -10
- package/dist/features/events/repository/events.repository.js +25 -1
- package/dist/features/layout/NavbarLayout.js +23 -2
- package/dist/features/media/messages/en.json +67 -63
- package/dist/seed/users-seed-data.js +41 -0
- package/package.json +1 -1
|
@@ -19,6 +19,19 @@ export declare class AddressesRepository extends BaseRepository<AddressDocument>
|
|
|
19
19
|
createWithId(id: string, data: Partial<AddressDocument>): Promise<AddressDocument>;
|
|
20
20
|
update(id: string, data: Partial<AddressDocument>): Promise<AddressDocument>;
|
|
21
21
|
listByOwner(ownerType: AddressOwnerType, ownerId: string): Promise<AddressDocument[]>;
|
|
22
|
+
/**
|
|
23
|
+
* 🛑 Requires the composite index `(ownerType ASC, createdAt DESC)`.
|
|
24
|
+
*
|
|
25
|
+
* The pre-existing `(ownerType, ownerId, createdAt DESC)` index does NOT serve
|
|
26
|
+
* this query: Firestore matches an index by its field sequence, and `ownerId`
|
|
27
|
+
* sits between the equality and the sort, so a query that filters on
|
|
28
|
+
* `ownerType` alone and orders by `createdAt` has no usable prefix. Without the
|
|
29
|
+
* two-field index this throws FAILED_PRECONDITION, which `mapToHttpError`
|
|
30
|
+
* renders as a **409** — and `/admin/address-clusters` then showed "Failed to
|
|
31
|
+
* load clusters." *and* "No shared addresses found." at the same time, so the
|
|
32
|
+
* page read as "there are no clusters" rather than as an error. Added
|
|
33
|
+
* 2026-10-02; see Recurrent Root Cause #2.
|
|
34
|
+
*/
|
|
22
35
|
listByOwnerType(ownerType: AddressOwnerType, limit?: number): Promise<AddressDocument[]>;
|
|
23
36
|
countByOwner(ownerType: AddressOwnerType, ownerId: string): Promise<number>;
|
|
24
37
|
createForOwner(ownerType: AddressOwnerType, ownerId: string, input: AddressCreateInput): Promise<AddressDocument>;
|
|
@@ -60,6 +60,19 @@ export class AddressesRepository extends BaseRepository {
|
|
|
60
60
|
throw new DatabaseError(`Failed to list addresses for ${ownerType}:${ownerId}`, error);
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* 🛑 Requires the composite index `(ownerType ASC, createdAt DESC)`.
|
|
65
|
+
*
|
|
66
|
+
* The pre-existing `(ownerType, ownerId, createdAt DESC)` index does NOT serve
|
|
67
|
+
* this query: Firestore matches an index by its field sequence, and `ownerId`
|
|
68
|
+
* sits between the equality and the sort, so a query that filters on
|
|
69
|
+
* `ownerType` alone and orders by `createdAt` has no usable prefix. Without the
|
|
70
|
+
* two-field index this throws FAILED_PRECONDITION, which `mapToHttpError`
|
|
71
|
+
* renders as a **409** — and `/admin/address-clusters` then showed "Failed to
|
|
72
|
+
* load clusters." *and* "No shared addresses found." at the same time, so the
|
|
73
|
+
* page read as "there are no clusters" rather than as an error. Added
|
|
74
|
+
* 2026-10-02; see Recurrent Root Cause #2.
|
|
75
|
+
*/
|
|
63
76
|
async listByOwnerType(ownerType, limit = 500) {
|
|
64
77
|
try {
|
|
65
78
|
const snapshot = await this.getCollection()
|
|
@@ -91,8 +91,27 @@ export function AdminBundleEditorView({ bundleId, onSaved, onDeleted, scope = "a
|
|
|
91
91
|
const brandsQuery = useQuery({
|
|
92
92
|
queryKey: ["admin", "brands", "picker"],
|
|
93
93
|
queryFn: async () => {
|
|
94
|
+
/*
|
|
95
|
+
* 🛑 `res.items`, NOT `res.data.items` — `apiClient` ALREADY unwraps the
|
|
96
|
+
* envelope (`ApiClient.ts` ends `return data.data as T`), so what arrives
|
|
97
|
+
* here is `{ items, total, page, pageSize, totalPages, hasMore }` and
|
|
98
|
+
* there is no second `.data` to reach through.
|
|
99
|
+
*
|
|
100
|
+
* This read was `res?.data?.items ?? []` behind a hand-written
|
|
101
|
+
* `as { data?: { items?: CategoryDocument[] } }`. The cast cannot be wrong
|
|
102
|
+
* at runtime — it only silenced the one check that would have caught it —
|
|
103
|
+
* and `?? []` turned the miss into an empty list rather than an error. So
|
|
104
|
+
* the Brand select offered ONLY "No specific brand", for every admin,
|
|
105
|
+
* forever: verified in production 2026-10-02 by polling the live
|
|
106
|
+
* `<select name="brandSlug">` for 15s (optionCount stayed 1) while
|
|
107
|
+
* `GET /api/admin/brands` returned 200 with 5 brand rows in `data.items`.
|
|
108
|
+
*
|
|
109
|
+
* Nothing was wrong with the endpoint, the query, the memo or its
|
|
110
|
+
* dependency array — I checked each before concluding. Root Cause #98's
|
|
111
|
+
* family: a cast at a boundary asserting a shape that is not there.
|
|
112
|
+
*/
|
|
94
113
|
const res = (await apiClient.get(ADMIN_ENDPOINTS.BRANDS));
|
|
95
|
-
return res?.
|
|
114
|
+
return res?.items ?? [];
|
|
96
115
|
},
|
|
97
116
|
});
|
|
98
117
|
const brandOptions = React.useMemo(() => [
|
|
@@ -105,8 +124,28 @@ export function AdminBundleEditorView({ bundleId, onSaved, onDeleted, scope = "a
|
|
|
105
124
|
const bundleQuery = useQuery({
|
|
106
125
|
queryKey: ["bundle", scope, bundleId],
|
|
107
126
|
queryFn: async () => {
|
|
127
|
+
/*
|
|
128
|
+
* 🛑 The response IS the bundle — do not reach through a second `.data`.
|
|
129
|
+
*
|
|
130
|
+
* Same defect as `brandsQuery` above and far more dangerous. This was
|
|
131
|
+
* `res?.data ?? null` behind `as { data?: CategoryDocument }`, so every
|
|
132
|
+
* edit of an existing bundle resolved to `null`, `bundleToForm(null)`
|
|
133
|
+
* returned `EMPTY_FORM`, and the editor rendered BLANK — then saving it
|
|
134
|
+
* would have written that blank over a live bundle.
|
|
135
|
+
*
|
|
136
|
+
* Verified in production 2026-10-02: /admin/bundles/
|
|
137
|
+
* bundle-every-generation-starter-pack/edit polled for 14s with
|
|
138
|
+
* `input[name="name"]` and `input[name="priceRupees"]` both `""` and no
|
|
139
|
+
* member count, while `GET /api/admin/bundles/{id}` returned 200 with the
|
|
140
|
+
* real bundle (`{ success, data: <bundle> }`, no nested `data`). I did not
|
|
141
|
+
* press Save.
|
|
142
|
+
*
|
|
143
|
+
* Root Cause #98: an `ActionResult`/envelope spread as if it were the
|
|
144
|
+
* payload, where the destructive half is the status field silently
|
|
145
|
+
* defaulting on the way back out.
|
|
146
|
+
*/
|
|
108
147
|
const res = (await apiClient.get(endpoints.byId(encodeURIComponent(bundleId))));
|
|
109
|
-
return res
|
|
148
|
+
return res ?? null;
|
|
110
149
|
},
|
|
111
150
|
enabled: isEdit,
|
|
112
151
|
});
|
|
@@ -10,11 +10,11 @@ export declare const BUNDLE_RULE_TYPE_OPTIONS: {
|
|
|
10
10
|
*/
|
|
11
11
|
export declare const bundleFormSchema: z.ZodEffects<z.ZodObject<{
|
|
12
12
|
name: z.ZodString;
|
|
13
|
-
priceRupees: z.ZodEffects<z.
|
|
13
|
+
priceRupees: z.ZodEffects<z.ZodNumber, number, number>;
|
|
14
14
|
description: z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>;
|
|
15
15
|
ruleType: z.ZodEnum<["static", "dynamic"]>;
|
|
16
|
-
productIds: z.ZodArray<z.ZodString, "many"
|
|
17
|
-
dynamicRule: z.ZodObject<{
|
|
16
|
+
productIds: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
|
|
17
|
+
dynamicRule: z.ZodOptional<z.ZodObject<{
|
|
18
18
|
type: z.ZodLiteral<"dynamic">;
|
|
19
19
|
filter: z.ZodObject<{
|
|
20
20
|
categorySlug: z.ZodOptional<z.ZodString>;
|
|
@@ -54,17 +54,20 @@ export declare const bundleFormSchema: z.ZodEffects<z.ZodObject<{
|
|
|
54
54
|
};
|
|
55
55
|
limit: number;
|
|
56
56
|
orderBy?: "price-asc" | "price-desc" | "createdAt-desc" | undefined;
|
|
57
|
-
}
|
|
57
|
+
}>>;
|
|
58
58
|
coverImage: z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>;
|
|
59
59
|
brandSlug: z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>;
|
|
60
60
|
isActive: z.ZodBoolean;
|
|
61
61
|
}, "strip", z.ZodTypeAny, {
|
|
62
62
|
name: string;
|
|
63
63
|
isActive: boolean;
|
|
64
|
-
|
|
65
|
-
priceRupees: string;
|
|
64
|
+
priceRupees: number;
|
|
66
65
|
ruleType: "static" | "dynamic";
|
|
67
|
-
|
|
66
|
+
brandSlug?: string | undefined;
|
|
67
|
+
description?: string | undefined;
|
|
68
|
+
coverImage?: string | undefined;
|
|
69
|
+
productIds?: string[] | undefined;
|
|
70
|
+
dynamicRule?: {
|
|
68
71
|
type: "dynamic";
|
|
69
72
|
filter: {
|
|
70
73
|
listingType?: "standard" | "pre-order" | "prize-draw" | undefined;
|
|
@@ -74,17 +77,17 @@ export declare const bundleFormSchema: z.ZodEffects<z.ZodObject<{
|
|
|
74
77
|
};
|
|
75
78
|
limit: number;
|
|
76
79
|
orderBy?: "price-asc" | "price-desc" | "createdAt-desc" | undefined;
|
|
77
|
-
};
|
|
78
|
-
brandSlug?: string | undefined;
|
|
79
|
-
description?: string | undefined;
|
|
80
|
-
coverImage?: string | undefined;
|
|
80
|
+
} | undefined;
|
|
81
81
|
}, {
|
|
82
82
|
name: string;
|
|
83
83
|
isActive: boolean;
|
|
84
|
-
|
|
85
|
-
priceRupees: string;
|
|
84
|
+
priceRupees: number;
|
|
86
85
|
ruleType: "static" | "dynamic";
|
|
87
|
-
|
|
86
|
+
brandSlug?: string | undefined;
|
|
87
|
+
description?: string | undefined;
|
|
88
|
+
coverImage?: string | undefined;
|
|
89
|
+
productIds?: string[] | undefined;
|
|
90
|
+
dynamicRule?: {
|
|
88
91
|
type: "dynamic";
|
|
89
92
|
filter: {
|
|
90
93
|
listingType?: "standard" | "pre-order" | "prize-draw" | undefined;
|
|
@@ -94,17 +97,17 @@ export declare const bundleFormSchema: z.ZodEffects<z.ZodObject<{
|
|
|
94
97
|
};
|
|
95
98
|
limit: number;
|
|
96
99
|
orderBy?: "price-asc" | "price-desc" | "createdAt-desc" | undefined;
|
|
97
|
-
};
|
|
98
|
-
brandSlug?: string | undefined;
|
|
99
|
-
description?: string | undefined;
|
|
100
|
-
coverImage?: string | undefined;
|
|
100
|
+
} | undefined;
|
|
101
101
|
}>, {
|
|
102
102
|
name: string;
|
|
103
103
|
isActive: boolean;
|
|
104
|
-
|
|
105
|
-
priceRupees: string;
|
|
104
|
+
priceRupees: number;
|
|
106
105
|
ruleType: "static" | "dynamic";
|
|
107
|
-
|
|
106
|
+
brandSlug?: string | undefined;
|
|
107
|
+
description?: string | undefined;
|
|
108
|
+
coverImage?: string | undefined;
|
|
109
|
+
productIds?: string[] | undefined;
|
|
110
|
+
dynamicRule?: {
|
|
108
111
|
type: "dynamic";
|
|
109
112
|
filter: {
|
|
110
113
|
listingType?: "standard" | "pre-order" | "prize-draw" | undefined;
|
|
@@ -114,17 +117,17 @@ export declare const bundleFormSchema: z.ZodEffects<z.ZodObject<{
|
|
|
114
117
|
};
|
|
115
118
|
limit: number;
|
|
116
119
|
orderBy?: "price-asc" | "price-desc" | "createdAt-desc" | undefined;
|
|
117
|
-
};
|
|
118
|
-
brandSlug?: string | undefined;
|
|
119
|
-
description?: string | undefined;
|
|
120
|
-
coverImage?: string | undefined;
|
|
120
|
+
} | undefined;
|
|
121
121
|
}, {
|
|
122
122
|
name: string;
|
|
123
123
|
isActive: boolean;
|
|
124
|
-
|
|
125
|
-
priceRupees: string;
|
|
124
|
+
priceRupees: number;
|
|
126
125
|
ruleType: "static" | "dynamic";
|
|
127
|
-
|
|
126
|
+
brandSlug?: string | undefined;
|
|
127
|
+
description?: string | undefined;
|
|
128
|
+
coverImage?: string | undefined;
|
|
129
|
+
productIds?: string[] | undefined;
|
|
130
|
+
dynamicRule?: {
|
|
128
131
|
type: "dynamic";
|
|
129
132
|
filter: {
|
|
130
133
|
listingType?: "standard" | "pre-order" | "prize-draw" | undefined;
|
|
@@ -134,9 +137,6 @@ export declare const bundleFormSchema: z.ZodEffects<z.ZodObject<{
|
|
|
134
137
|
};
|
|
135
138
|
limit: number;
|
|
136
139
|
orderBy?: "price-asc" | "price-desc" | "createdAt-desc" | undefined;
|
|
137
|
-
};
|
|
138
|
-
brandSlug?: string | undefined;
|
|
139
|
-
description?: string | undefined;
|
|
140
|
-
coverImage?: string | undefined;
|
|
140
|
+
} | undefined;
|
|
141
141
|
}>;
|
|
142
142
|
export type BundleFormValues = z.infer<typeof bundleFormSchema>;
|
|
@@ -73,10 +73,39 @@ export const bundleFormSchema = z
|
|
|
73
73
|
section: "basics", sectionLabel: "Bundle", sectionRequired: true,
|
|
74
74
|
quick: true, order: 1, row: "pair", label: "Name",
|
|
75
75
|
}),
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
76
|
+
/*
|
|
77
|
+
* 🛑 `z.coerce.number()`, NOT `z.string()`, because `kind: "number"` below
|
|
78
|
+
* decides the runtime type and the schema does not get a vote.
|
|
79
|
+
*
|
|
80
|
+
* `build-sections.tsx`'s number control is
|
|
81
|
+
* `onChange={(v) => set(v === "" ? undefined : Number(v))}` — it writes a
|
|
82
|
+
* NUMBER into the draft on every keystroke. This field used to be
|
|
83
|
+
* `z.string().regex(...)`, so a save with 1500 visibly in the box was
|
|
84
|
+
* refused with "This field is required" sitting directly under it, and the
|
|
85
|
+
* summary read "Bundle: This field is required" — naming the section and no
|
|
86
|
+
* field. A bundle therefore could not be created at all. Verified in
|
|
87
|
+
* production 2026-10-02 by walking up from the role=alert element to its
|
|
88
|
+
* owner, `input[name="priceRupees"]`.
|
|
89
|
+
*
|
|
90
|
+
* Do NOT "fix" this by removing the coercion in `build-sections.tsx`:
|
|
91
|
+
* ~10 of the 30 `kind: "number"` fields are plain `z.number()` and depend on
|
|
92
|
+
* it. Two other fields still declare `z.string()` under the same control and
|
|
93
|
+
* have the same latent defect — `purchasedItemNumber` (admin-user-form) and
|
|
94
|
+
* `maxBudget` (item-request-create-form); neither is driven by a test yet,
|
|
95
|
+
* so neither was changed blind.
|
|
96
|
+
*
|
|
97
|
+
* `coerce` keeps every existing caller working: `EMPTY_FORM.priceRupees` is
|
|
98
|
+
* `""` (→ the control sends `undefined` → "This field is required", which is
|
|
99
|
+
* the right message for an empty price), `bundleToForm` supplies a string,
|
|
100
|
+
* and the submit still does `Number(form.priceRupees)`.
|
|
101
|
+
*
|
|
102
|
+
* The two-decimal rule is kept as a refine over `String(n)` rather than
|
|
103
|
+
* `multipleOf(0.01)`, which is not float-safe.
|
|
104
|
+
*/
|
|
105
|
+
priceRupees: annotate(z.coerce
|
|
106
|
+
.number({ message: BUNDLE_COPY.adminEditor.errors.priceInvalid })
|
|
107
|
+
.positive(BUNDLE_COPY.adminEditor.errors.priceInvalid)
|
|
108
|
+
.refine((n) => Number.isFinite(n) && /^\d+(\.\d{1,2})?$/.test(String(n)), BUNDLE_COPY.adminEditor.errors.priceInvalid), {
|
|
80
109
|
section: "basics", quick: true, order: 2, row: "pair",
|
|
81
110
|
kind: "number", label: "Bundle price (₹)",
|
|
82
111
|
help: "What the buyer pays for the whole bundle — normally less than the members' total.",
|
|
@@ -95,12 +124,12 @@ export const bundleFormSchema = z
|
|
|
95
124
|
* because an empty picker on a DYNAMIC bundle is correct — the resolver
|
|
96
125
|
* fills the mirror. A flat `.min(3)` would block every dynamic save.
|
|
97
126
|
*/
|
|
98
|
-
productIds: annotate(z.array(z.string()), {
|
|
127
|
+
productIds: annotate(z.array(z.string()).optional(), {
|
|
99
128
|
section: "members", order: 2, row: "full", kind: "list",
|
|
100
129
|
label: `${BUNDLE_COPY.adminEditor.ruleTypeStatic} (${BUNDLE_MIN_ITEMS}–${BUNDLE_MAX_ITEMS})`,
|
|
101
130
|
when: (v) => v.ruleType !== "dynamic",
|
|
102
131
|
}),
|
|
103
|
-
dynamicRule: annotate(dynamicRuleSchema, {
|
|
132
|
+
dynamicRule: annotate(dynamicRuleSchema.optional(), {
|
|
104
133
|
section: "members", order: 3, row: "full",
|
|
105
134
|
label: BUNDLE_COPY.adminEditor.dynamic.title,
|
|
106
135
|
help: BUNDLE_COPY.adminEditor.dynamic.hint,
|
|
@@ -130,20 +159,44 @@ export const bundleFormSchema = z
|
|
|
130
159
|
* them — a two-member bundle saved, and `listBundleMembers` rendered it as
|
|
131
160
|
* a "bundle" of two.
|
|
132
161
|
*/
|
|
162
|
+
/*
|
|
163
|
+
* 🛑 Both member sources are `.optional()` because a hidden control is not
|
|
164
|
+
* SENT, and the two are mutually hidden by `ruleType`. Declaring either as
|
|
165
|
+
* required made the branch that hides it impossible to save: a static
|
|
166
|
+
* bundle submitted no `dynamicRule`, zod raised `invalid_type`, and
|
|
167
|
+
* `zodErrorMap` rendered it as "Bundle members: This field is required" on
|
|
168
|
+
* a form whose picker visibly held three products. Admin bundle CREATION
|
|
169
|
+
* was impossible in both directions. Found by
|
|
170
|
+
* `money-flows/blockers--admin` → `cross-store-group-refused`, which could
|
|
171
|
+
* not reach the cross-store guard it exists to test.
|
|
172
|
+
*
|
|
173
|
+
* Requiredness therefore lives HERE, per branch, where `ruleType` is known.
|
|
174
|
+
*/
|
|
175
|
+
if (v.ruleType === "dynamic") {
|
|
176
|
+
if (!v.dynamicRule) {
|
|
177
|
+
ctx.addIssue({
|
|
178
|
+
code: z.ZodIssueCode.custom,
|
|
179
|
+
path: ["dynamicRule"],
|
|
180
|
+
message: "Describe the query that resolves this bundle's members.",
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
133
185
|
if (v.ruleType !== "static")
|
|
134
186
|
return;
|
|
135
|
-
|
|
187
|
+
const productIds = v.productIds ?? [];
|
|
188
|
+
if (productIds.length < BUNDLE_MIN_ITEMS) {
|
|
136
189
|
ctx.addIssue({
|
|
137
190
|
code: z.ZodIssueCode.custom,
|
|
138
191
|
path: ["productIds"],
|
|
139
|
-
message: `Select at least ${BUNDLE_MIN_ITEMS} products (currently ${
|
|
192
|
+
message: `Select at least ${BUNDLE_MIN_ITEMS} products (currently ${productIds.length}).`,
|
|
140
193
|
});
|
|
141
194
|
}
|
|
142
|
-
else if (
|
|
195
|
+
else if (productIds.length > BUNDLE_MAX_ITEMS) {
|
|
143
196
|
ctx.addIssue({
|
|
144
197
|
code: z.ZodIssueCode.custom,
|
|
145
198
|
path: ["productIds"],
|
|
146
|
-
message: `Maximum ${BUNDLE_MAX_ITEMS} products allowed (currently ${
|
|
199
|
+
message: `Maximum ${BUNDLE_MAX_ITEMS} products allowed (currently ${productIds.length}).`,
|
|
147
200
|
});
|
|
148
201
|
}
|
|
149
202
|
});
|
|
@@ -114,7 +114,31 @@ class EventRepository extends BaseRepository {
|
|
|
114
114
|
createdAt: now,
|
|
115
115
|
updatedAt: now,
|
|
116
116
|
});
|
|
117
|
-
|
|
117
|
+
/*
|
|
118
|
+
* 🛑 This used to be `.add(data)` — a raw write that ran NEITHER half of
|
|
119
|
+
* the repository's own contract, and broke two things at once:
|
|
120
|
+
*
|
|
121
|
+
* 1. `applyWriteHooks` never fired, so `buildSearchTxtFor` never wrote
|
|
122
|
+
* SEARCH_TXT — and `list()` filters on
|
|
123
|
+
* `where(SEARCH_TXT, "array-contains", head)`. Every event created
|
|
124
|
+
* through the UI was therefore invisible to admin event search,
|
|
125
|
+
* permanently, while seeded events (whose tokens the seeder writes)
|
|
126
|
+
* searched fine. The hook's docstring above claims it is derived on
|
|
127
|
+
* every write path; it was not derived on this one.
|
|
128
|
+
* 2. `.add()` mints a Firestore auto-id, so the document id was e.g.
|
|
129
|
+
* `SJHlHFAs5BHMLQwH8MdM` while every seeded event uses its slug.
|
|
130
|
+
* Events are "pure slugs (id === slug)" per the slug table, and the
|
|
131
|
+
* public URL and any slug lookup depend on it.
|
|
132
|
+
*
|
|
133
|
+
* `.create()` rather than `.set()`: it REJECTS an existing id, so a
|
|
134
|
+
* duplicate title surfaces as a conflict instead of silently
|
|
135
|
+
* overwriting someone else's event.
|
|
136
|
+
*
|
|
137
|
+
* Found by tester run-3 batch 98, page-wiring/reachability--admin →
|
|
138
|
+
* lottery-can-be-created-without-seeding. Root Cause #9's shape.
|
|
139
|
+
*/
|
|
140
|
+
const ref = this.getCollection().doc(slug);
|
|
141
|
+
await ref.create(this.applyWriteHooks(data));
|
|
118
142
|
const created = await ref.get();
|
|
119
143
|
serverLogger.info("Event created", { eventId: ref.id, type: input.type });
|
|
120
144
|
return this.mapDoc(created);
|
|
@@ -4,6 +4,7 @@ import { useRef, useState, useEffect, useCallback } from "react";
|
|
|
4
4
|
import Link from "next/link";
|
|
5
5
|
import { Div, Li, Nav, Row, Span, Ul } from "../../ui";
|
|
6
6
|
import { NavbarChevron } from "../../ui/components/NavbarChevron";
|
|
7
|
+
import { findActiveNavItem } from "../../_internal/client/features/layout/navActive";
|
|
7
8
|
const __O = {
|
|
8
9
|
hidden: "overflow-hidden",
|
|
9
10
|
};
|
|
@@ -48,8 +49,28 @@ export function NavbarLayout({ items, activeHref, id = "main-navbar", ariaLabel
|
|
|
48
49
|
};
|
|
49
50
|
}, [updateArrows, items]);
|
|
50
51
|
const scroll = (dir) => scrollRef.current?.scrollBy({ left: dir === "left" ? -240 : 240, behavior: "smooth" });
|
|
52
|
+
// 🛑 Resolve the active item with `findActiveNavItem`, NOT `activeHref === item.href`.
|
|
53
|
+
//
|
|
54
|
+
// Strict equality is what this used, and it meant the top navbar marked
|
|
55
|
+
// nothing at all on any detail page: /products marked "Products", but
|
|
56
|
+
// /products/product-beyblade-original-dranzer-s marked nothing — verified in
|
|
57
|
+
// production 2026-10-02, where the Products and Home links had byte-identical
|
|
58
|
+
// computed styles (colour rgb(91,91,99), weight 500, transparent background,
|
|
59
|
+
// 0px bottom border), so there was no visual marker either. A visitor who
|
|
60
|
+
// drills from a listing into an item loses their place in the navigation.
|
|
61
|
+
//
|
|
62
|
+
// The sidebars and the mobile drawer were always correct, because they
|
|
63
|
+
// already called this helper — which is why the mobile bottom nav highlighted
|
|
64
|
+
// "Auctions" on /auctions while the desktop navbar above it highlighted
|
|
65
|
+
// nothing. One algorithm, four surfaces.
|
|
66
|
+
//
|
|
67
|
+
// `findActiveNavItem` prefix-matches (`activeHref.startsWith(`${href}/`)`) and
|
|
68
|
+
// resolves ties by LONGEST href, so a root entry like "/" cannot light up on
|
|
69
|
+
// every page and a more specific entry always beats a shorter one.
|
|
70
|
+
const activeItem = findActiveNavItem(items, activeHref);
|
|
71
|
+
const isItemActive = (item) => activeItem?.href === item.href;
|
|
51
72
|
if (inline) {
|
|
52
|
-
return (_jsx(Ul, { "aria-label": ariaLabel, className: "hidden xl:flex items-center justify-center gap-[var(--appkit-space-0-5)] xl:gap-[var(--appkit-space-1)] w-full", children: items.map((item) => (_jsx(Li, { children: renderItem ? (renderItem(item,
|
|
73
|
+
return (_jsx(Ul, { "aria-label": ariaLabel, className: "hidden xl:flex items-center justify-center gap-[var(--appkit-space-0-5)] xl:gap-[var(--appkit-space-1)] w-full", children: items.map((item) => (_jsx(Li, { children: renderItem ? (renderItem(item, isItemActive(item))) : (_jsx(DefaultNavItem, { item: item, isActive: isItemActive(item) })) }, item.href))) }));
|
|
53
74
|
}
|
|
54
|
-
return (_jsx(Nav, { border: "subtle", id: id, "aria-label": ariaLabel, className: "hidden lg:block bg-[var(--appkit-color-bg)]/95 backdrop-blur-md border-b", children: _jsxs(Row, { className: "container mx-auto sm:px-[var(--appkit-space-6)] lg:px-[var(--appkit-space-8)] max-w-[1920px] h-10", padding: "x-md", align: "center", reverse: "hand", children: [_jsxs(Div, { className: `relative flex-1 ${__O.hidden}`, children: [canScrollLeft && (_jsx(NavbarChevron, { direction: "left", onClick: () => scroll("left") })), _jsx(Ul, { ref: scrollRef, className: "flex items-center justify-center gap-[var(--appkit-space-0-5)] lg:gap-[var(--appkit-space-1)] overflow-x-auto [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]", children: items.map((item) => (_jsx(Li, { className: "shrink-0", children: renderItem ? (renderItem(item,
|
|
75
|
+
return (_jsx(Nav, { border: "subtle", id: id, "aria-label": ariaLabel, className: "hidden lg:block bg-[var(--appkit-color-bg)]/95 backdrop-blur-md border-b", children: _jsxs(Row, { className: "container mx-auto sm:px-[var(--appkit-space-6)] lg:px-[var(--appkit-space-8)] max-w-[1920px] h-10", padding: "x-md", align: "center", reverse: "hand", children: [_jsxs(Div, { className: `relative flex-1 ${__O.hidden}`, children: [canScrollLeft && (_jsx(NavbarChevron, { direction: "left", onClick: () => scroll("left") })), _jsx(Ul, { ref: scrollRef, className: "flex items-center justify-center gap-[var(--appkit-space-0-5)] lg:gap-[var(--appkit-space-1)] overflow-x-auto [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]", children: items.map((item) => (_jsx(Li, { className: "shrink-0", children: renderItem ? (renderItem(item, isItemActive(item))) : (_jsx(DefaultNavItem, { item: item, isActive: isItemActive(item) })) }, item.href))) }), canScrollRight && (_jsx(NavbarChevron, { direction: "right", onClick: () => scroll("right") }))] }), rightSlot && _jsx(Div, { className: "shrink-0", children: rightSlot })] }) }));
|
|
55
76
|
}
|
|
@@ -1,63 +1,67 @@
|
|
|
1
|
-
{
|
|
2
|
-
"mediaEditor": {
|
|
3
|
-
"cropTitle": "Crop Image",
|
|
4
|
-
"cropInstruction": "Drag to reposition. Use the zoom slider or +/- keys to zoom.",
|
|
5
|
-
"cropPreviewAlt": "Preview",
|
|
6
|
-
"cropZoom": "Zoom",
|
|
7
|
-
"cropZoomIn": "Zoom in",
|
|
8
|
-
"cropZoomOut": "Zoom out",
|
|
9
|
-
"
|
|
10
|
-
"
|
|
11
|
-
"
|
|
12
|
-
"
|
|
13
|
-
"
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
"
|
|
48
|
-
"
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
"
|
|
52
|
-
"
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
"
|
|
56
|
-
"
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
"
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
|
|
63
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"mediaEditor": {
|
|
3
|
+
"cropTitle": "Crop Image",
|
|
4
|
+
"cropInstruction": "Drag to reposition. Use the zoom slider or +/- keys to zoom.",
|
|
5
|
+
"cropPreviewAlt": "Preview",
|
|
6
|
+
"cropZoom": "Zoom",
|
|
7
|
+
"cropZoomIn": "Zoom in",
|
|
8
|
+
"cropZoomOut": "Zoom out",
|
|
9
|
+
"cropRotateLeft": "Rotate left 90°",
|
|
10
|
+
"cropRotateRight": "Rotate right 90°",
|
|
11
|
+
"cropFlipH": "Flip horizontally",
|
|
12
|
+
"cropFlipV": "Flip vertically",
|
|
13
|
+
"cropWarningTitle": "Low zoom",
|
|
14
|
+
"cropWarningMessage": "Zoom in for better quality.",
|
|
15
|
+
"cropPosition": "Position",
|
|
16
|
+
"cropReset": "Reset",
|
|
17
|
+
"cropSave": "Save Crop",
|
|
18
|
+
"cropCancel": "Cancel",
|
|
19
|
+
"trimTitle": "Trim Video",
|
|
20
|
+
"trimInstruction": "Set the start and end times, then click Trim.",
|
|
21
|
+
"trimStart": "Start",
|
|
22
|
+
"trimEnd": "End",
|
|
23
|
+
"trimDuration": "Duration: {duration}s",
|
|
24
|
+
"trimQuality": "Quality",
|
|
25
|
+
"trimQualityLow": "Low",
|
|
26
|
+
"trimQualityMedium": "Medium",
|
|
27
|
+
"trimQualityHigh": "High",
|
|
28
|
+
"trimSave": "Trim Video",
|
|
29
|
+
"trimSkip": "Skip",
|
|
30
|
+
"trimming": "Trimming…",
|
|
31
|
+
"thumbnailTitle": "Select Thumbnail",
|
|
32
|
+
"thumbnailInstruction": "Seek to the frame you want to use as the thumbnail.",
|
|
33
|
+
"thumbnailCapture": "Use This Frame",
|
|
34
|
+
"thumbnailSkip": "Skip",
|
|
35
|
+
"thumbnailCapturing": "Capturing…",
|
|
36
|
+
"thumbnailUploading": "Uploading…",
|
|
37
|
+
"cropSuccess": "Image cropped",
|
|
38
|
+
"trimSuccess": "Video trimmed",
|
|
39
|
+
"thumbnailSuccess": "Thumbnail saved",
|
|
40
|
+
"cropError": "Failed to crop image",
|
|
41
|
+
"trimError": "Failed to trim video",
|
|
42
|
+
"thumbnailError": "Failed to capture thumbnail",
|
|
43
|
+
"editImage": "Edit / Crop",
|
|
44
|
+
"editVideo": "Trim / Thumbnail"
|
|
45
|
+
},
|
|
46
|
+
"camera": {
|
|
47
|
+
"takePhoto": "Take Photo",
|
|
48
|
+
"startRecording": "Start Recording",
|
|
49
|
+
"stopRecording": "Stop Recording",
|
|
50
|
+
"flipCamera": "Flip Camera",
|
|
51
|
+
"switchToCamera": "Use Camera",
|
|
52
|
+
"switchToUpload": "Upload File",
|
|
53
|
+
"cameraError": "Camera unavailable",
|
|
54
|
+
"permissionDenied": "Camera permission denied. Please allow access in your browser settings.",
|
|
55
|
+
"notSupported": "Camera is not supported on this device",
|
|
56
|
+
"starting": "Starting camera..."
|
|
57
|
+
},
|
|
58
|
+
"upload": {
|
|
59
|
+
"change": "Change",
|
|
60
|
+
"remove": "Remove",
|
|
61
|
+
"clickToUpload": "Click to upload",
|
|
62
|
+
"uploading": "Uploading…",
|
|
63
|
+
"uploadingProgress": "Uploading {progress}%…",
|
|
64
|
+
"replaceFile": "Replace file",
|
|
65
|
+
"chooseFile": "Choose file to upload"
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -553,4 +553,45 @@ export const usersSeedData = [
|
|
|
553
553
|
createdAt: daysAgo(60),
|
|
554
554
|
updatedAt: daysAgo(2),
|
|
555
555
|
},
|
|
556
|
+
/*
|
|
557
|
+
* ── Disabled buyer: the ONLY account with `disabled: true` ──
|
|
558
|
+
*
|
|
559
|
+
* 🛑 This persona exists so that cases about a banned/disabled login are
|
|
560
|
+
* testable WITHOUT disabling a real one. Measured 2026-10-01: of the 19
|
|
561
|
+
* seeded users, `disabled: true` occurred ZERO times, so every such case was
|
|
562
|
+
* permanently unrunnable — `users` is PRESERVE-tier (never wiped, never
|
|
563
|
+
* restored, merge-write on reseed), making "disable someone to test it" the
|
|
564
|
+
* one piece of permanent damage a test run can do.
|
|
565
|
+
*
|
|
566
|
+
* Nothing but a sign-in attempt should ever touch it: no orders, no store,
|
|
567
|
+
* no public profile. Keep `disabled: true` — flipping it to false silently
|
|
568
|
+
* removes the only fixture these cases have.
|
|
569
|
+
*
|
|
570
|
+
* Needed by money-flows/blockers--guest → banned-account-blocked, and the
|
|
571
|
+
* soft-ban cases declined for the same reason. Same class as Root Cause #90:
|
|
572
|
+
* a case reports "nothing here" because its fixture was never seeded.
|
|
573
|
+
*/
|
|
574
|
+
{
|
|
575
|
+
uid: "user-qa-disabled",
|
|
576
|
+
email: "qa.disabled@letitrip.in",
|
|
577
|
+
phoneNumber: `${_ph}9999900099`,
|
|
578
|
+
phoneVerified: false,
|
|
579
|
+
displayName: "QA Disabled Account",
|
|
580
|
+
role: USER_FIELDS.ROLE_VALUES.USER,
|
|
581
|
+
emailVerified: true,
|
|
582
|
+
disabled: true,
|
|
583
|
+
publicProfile: {
|
|
584
|
+
isPublic: false,
|
|
585
|
+
showEmail: false,
|
|
586
|
+
showPhone: false,
|
|
587
|
+
showOrders: false,
|
|
588
|
+
showWishlist: false,
|
|
589
|
+
bio: "Fixture account — deliberately disabled so ban/disabled-login cases are testable.",
|
|
590
|
+
location: "Indore, Madhya Pradesh",
|
|
591
|
+
},
|
|
592
|
+
stats: { totalOrders: 0, auctionsWon: 0, itemsSold: 0, reviewsCount: 0 },
|
|
593
|
+
metadata: { lastSignInTime: daysAgo(30), creationTime: daysAgo(90).toISOString(), loginCount: 1 },
|
|
594
|
+
createdAt: daysAgo(90),
|
|
595
|
+
updatedAt: daysAgo(30),
|
|
596
|
+
},
|
|
556
597
|
];
|