@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.
@@ -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?.data?.items ?? [];
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?.data ?? null;
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.ZodString, string, string>;
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
- productIds: string[];
65
- priceRupees: string;
64
+ priceRupees: number;
66
65
  ruleType: "static" | "dynamic";
67
- dynamicRule: {
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
- productIds: string[];
85
- priceRupees: string;
84
+ priceRupees: number;
86
85
  ruleType: "static" | "dynamic";
87
- dynamicRule: {
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
- productIds: string[];
105
- priceRupees: string;
104
+ priceRupees: number;
106
105
  ruleType: "static" | "dynamic";
107
- dynamicRule: {
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
- productIds: string[];
125
- priceRupees: string;
124
+ priceRupees: number;
126
125
  ruleType: "static" | "dynamic";
127
- dynamicRule: {
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
- priceRupees: annotate(z
77
- .string()
78
- .regex(/^\d+(\.\d{1,2})?$/, BUNDLE_COPY.adminEditor.errors.priceInvalid)
79
- .refine((v) => Number(v) > 0, BUNDLE_COPY.adminEditor.errors.priceInvalid), {
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
- if (v.productIds.length < BUNDLE_MIN_ITEMS) {
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 ${v.productIds.length}).`,
192
+ message: `Select at least ${BUNDLE_MIN_ITEMS} products (currently ${productIds.length}).`,
140
193
  });
141
194
  }
142
- else if (v.productIds.length > BUNDLE_MAX_ITEMS) {
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 ${v.productIds.length}).`,
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
- const ref = await this.getCollection().add(data);
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, activeHref === item.href)) : (_jsx(DefaultNavItem, { item: item, isActive: activeHref === item.href })) }, item.href))) }));
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, activeHref === item.href)) : (_jsx(DefaultNavItem, { item: item, isActive: activeHref === item.href })) }, item.href))) }), canScrollRight && (_jsx(NavbarChevron, { direction: "right", onClick: () => scroll("right") }))] }), rightSlot && _jsx(Div, { className: "shrink-0", children: rightSlot })] }) }));
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
- "cropWarningTitle": "Low zoom",
10
- "cropWarningMessage": "Zoom in for better quality.",
11
- "cropPosition": "Position",
12
- "cropReset": "Reset",
13
- "cropSave": "Save Crop",
14
- "cropCancel": "Cancel",
15
- "trimTitle": "Trim Video",
16
- "trimInstruction": "Set the start and end times, then click Trim.",
17
- "trimStart": "Start",
18
- "trimEnd": "End",
19
- "trimDuration": "Duration: {duration}s",
20
- "trimQuality": "Quality",
21
- "trimQualityLow": "Low",
22
- "trimQualityMedium": "Medium",
23
- "trimQualityHigh": "High",
24
- "trimSave": "Trim Video",
25
- "trimSkip": "Skip",
26
- "trimming": "Trimming\u2026",
27
- "thumbnailTitle": "Select Thumbnail",
28
- "thumbnailInstruction": "Seek to the frame you want to use as the thumbnail.",
29
- "thumbnailCapture": "Use This Frame",
30
- "thumbnailSkip": "Skip",
31
- "thumbnailCapturing": "Capturing\u2026",
32
- "thumbnailUploading": "Uploading\u2026",
33
- "cropSuccess": "Image cropped",
34
- "trimSuccess": "Video trimmed",
35
- "thumbnailSuccess": "Thumbnail saved",
36
- "cropError": "Failed to crop image",
37
- "trimError": "Failed to trim video",
38
- "thumbnailError": "Failed to capture thumbnail",
39
- "editImage": "Edit / Crop",
40
- "editVideo": "Trim / Thumbnail"
41
- },
42
- "camera": {
43
- "takePhoto": "Take Photo",
44
- "startRecording": "Start Recording",
45
- "stopRecording": "Stop Recording",
46
- "flipCamera": "Flip Camera",
47
- "switchToCamera": "Use Camera",
48
- "switchToUpload": "Upload File",
49
- "cameraError": "Camera unavailable",
50
- "permissionDenied": "Camera permission denied. Please allow access in your browser settings.",
51
- "notSupported": "Camera is not supported on this device",
52
- "starting": "Starting camera..."
53
- },
54
- "upload": {
55
- "change": "Change",
56
- "remove": "Remove",
57
- "clickToUpload": "Click to upload",
58
- "uploading": "Uploading\u2026",
59
- "uploadingProgress": "Uploading {progress}%\u2026",
60
- "replaceFile": "Replace file",
61
- "chooseFile": "Choose file to upload"
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
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mohasinac/appkit",
3
- "version": "4.42.4",
3
+ "version": "4.42.6",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public"