@mohasinac/appkit 4.38.0 → 4.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/_internal/client/features/digital-code/CodeRevealPanel.d.ts +20 -1
  2. package/dist/_internal/client/features/digital-code/CodeRevealPanel.js +21 -1
  3. package/dist/_internal/client/features/digital-code/DigitalContentPoolManager.d.ts +40 -0
  4. package/dist/_internal/client/features/digital-code/DigitalContentPoolManager.js +67 -0
  5. package/dist/_internal/server/features/checkout/actions.js +18 -0
  6. package/dist/_internal/server/features/digital-code/download.d.ts +60 -0
  7. package/dist/_internal/server/features/digital-code/download.js +128 -0
  8. package/dist/_internal/server/features/digital-code/pool.d.ts +101 -0
  9. package/dist/_internal/server/features/digital-code/pool.js +208 -0
  10. package/dist/_internal/server/features/digital-code/upload.d.ts +51 -0
  11. package/dist/_internal/server/features/digital-code/upload.js +144 -0
  12. package/dist/_internal/server/jobs/core/cleanupRtdbEvents.js +40 -0
  13. package/dist/_internal/server/jobs/core/countersReconcile.js +153 -40
  14. package/dist/_internal/server/jobs/core/onCategoryWrite.js +33 -0
  15. package/dist/_internal/server/jobs/core/onProductWrite.js +93 -25
  16. package/dist/_internal/server/notifications/send-recorder.d.ts +79 -0
  17. package/dist/_internal/server/notifications/send-recorder.js +126 -0
  18. package/dist/client.d.ts +2 -0
  19. package/dist/client.js +6 -0
  20. package/dist/features/blog/components/BlogIndexPageView.js +10 -1
  21. package/dist/features/blog/repository/blog.repository.d.ts +14 -0
  22. package/dist/features/blog/repository/blog.repository.js +23 -1
  23. package/dist/features/categories/components/CategoryDetailPageView.js +1 -1
  24. package/dist/features/categories/components/CategoryTree.js +1 -1
  25. package/dist/features/categories/repository/categories.repository.d.ts +34 -2
  26. package/dist/features/categories/repository/categories.repository.js +31 -9
  27. package/dist/features/contact/email.js +36 -1
  28. package/dist/features/orders/repository/orders.repository.d.ts +21 -1
  29. package/dist/features/orders/repository/orders.repository.js +26 -9
  30. package/dist/features/products/repository/products.repository.d.ts +35 -0
  31. package/dist/features/products/repository/products.repository.js +116 -3
  32. package/dist/features/products/schemas/firestore.d.ts +37 -0
  33. package/dist/features/seller/actions/seller-actions.js +4 -13
  34. package/dist/features/seller/components/SellerProductShell.d.ts +7 -1
  35. package/dist/features/seller/components/SellerProductShell.js +11 -2
  36. package/dist/features/stores/api/[storeSlug]/reviews/route.d.ts +6 -1
  37. package/dist/features/stores/api/[storeSlug]/reviews/route.js +89 -30
  38. package/dist/features/stores/components/StoresIndexPageView.js +17 -1
  39. package/dist/features/tester/seed-data/authored/admin__users-trust.js +1 -1
  40. package/dist/features/tester/seed-data/authored/buying__offers.js +1 -1
  41. package/dist/features/tester/seed-data/authored/buying__user-dashboard-extras.js +6 -6
  42. package/dist/features/tester/utils/phases.js +78 -12
  43. package/dist/providers/db-firebase/rtdb-paths.d.ts +7 -0
  44. package/dist/providers/db-firebase/rtdb-paths.js +7 -0
  45. package/dist/server.d.ts +3 -0
  46. package/dist/server.js +12 -0
  47. package/dist/ui/components/FilePickerButton.d.ts +32 -0
  48. package/dist/ui/components/FilePickerButton.js +23 -0
  49. package/dist/ui/index.d.ts +2 -0
  50. package/dist/ui/index.js +7 -0
  51. package/package.json +1 -1
@@ -7,15 +7,27 @@
7
7
  * export { storeReviewsGET as GET } from "@mohasinac/feat-stores";
8
8
  * ```
9
9
  *
10
- * Aggregates approved reviews from up to 20 published products in a store.
10
+ * Aggregates every approved review filed against a store, by `storeId`.
11
11
  * Returns: reviews[], averageRating, totalReviews, ratingDistribution.
12
12
  *
13
+ * It used to aggregate "up to 20 published products by itemsSold" and fan out a
14
+ * review query per product — and since no product carries `itemsSold`, that
15
+ * `orderBy` matched nothing and the endpoint returned zero reviews for every
16
+ * store. See the block comment at the query for the full account.
17
+ *
13
18
  * Requires `db` registered in providers.config via `registerProviders()`.
14
19
  * Collections: "stores", "products", "reviews"
15
20
  */
16
21
  import { NextResponse } from "next/server.js";
17
22
  import { getProviders } from "../../../../../contracts";
18
23
  import { normalizeError } from "../../../../../errors/normalize";
24
+ /**
25
+ * Upper bound on the reviews scanned to build a store's aggregate. The average
26
+ * rating and the distribution are computed over this window, so it must be
27
+ * generous — but it is still a bound, because the alternative is an unbounded
28
+ * read on a public, cacheable endpoint (Rule #6).
29
+ */
30
+ const STORE_REVIEW_SCAN_CAP = 500;
19
31
  // --- GET /api/stores/[storeSlug]/reviews --------------------------------------
20
32
  export async function GET(request, context) {
21
33
  try {
@@ -44,40 +56,53 @@ export async function GET(request, context) {
44
56
  if (!store) {
45
57
  return NextResponse.json({ success: false, error: "Store not found" }, { status: 404 });
46
58
  }
47
- // Fetch up to 20 published products for this seller
48
- const productsRepo = db.getRepository("products");
49
- const productsResult = await productsRepo.findAll({
50
- filters: `storeId==${store.id},status==published`,
51
- sort: "itemsSold",
52
- order: "desc",
53
- perPage: 20,
54
- });
55
- const products = productsResult.data;
56
- // Fetch approved reviews for each product in parallel (cap 50 per product)
59
+ /*
60
+ * 🛑 REVIEWS ARE FETCHED BY `storeId`, IN ONE QUERY. Do not route this back
61
+ * through products.
62
+ *
63
+ * It used to select "the store's top 20 products by `itemsSold`" and then
64
+ * fan out one review query per product. Two independent defects, and the
65
+ * second hid the first:
66
+ *
67
+ * - **`orderBy("itemsSold")` silently drops every document that lacks the
68
+ * field**, exactly like the documented `!=` trap. Measured on production
69
+ * 2026-09-14: **0 of 95 products carry `itemsSold`**, so that query
70
+ * returned an empty array — and therefore so did the whole endpoint.
71
+ * Every store's reviews tab had been empty since it shipped, answering
72
+ * HTTP 200 with `totalReviews: 0` while 79 approved reviews sat in
73
+ * Firestore. Nothing errored; there was no symptom to report beyond "this
74
+ * store has no reviews yet", which reads as a fact about the store.
75
+ * - Even populated, a 20-product window is a SAMPLE, not the store: 14
76
+ * distinct products carry reviews here and the store has 65 listings, so
77
+ * an arbitrary slice would under-report the average rating and the
78
+ * distribution — the two numbers buyers actually judge a seller by.
79
+ *
80
+ * `ReviewDocument` carries `storeId` (74/79 here belong to this store), so
81
+ * the store's reviews are one equality away. This also turns 21 Firestore
82
+ * queries into 1, well inside Rule #6's ~3-round-trip budget.
83
+ */
57
84
  const reviewsRepo = db.getRepository("reviews");
58
- const reviewArrays = await Promise.all(products.map((p) => reviewsRepo
59
- .findAll({
60
- filters: `productId==${p.id},status==approved`,
85
+ const reviewsResult = await reviewsRepo.findAll({
86
+ filters: `storeId==${store.id},status==approved`,
61
87
  sort: "createdAt",
62
88
  order: "desc",
63
- perPage: 50,
64
- })
65
- .then((r) => r.data)));
89
+ perPage: STORE_REVIEW_SCAN_CAP,
90
+ });
91
+ const allFlat = reviewsResult.data;
66
92
  // Compute aggregate metrics from ALL reviews (unfiltered)
67
93
  const ratingDistribution = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 };
68
94
  let ratingSum = 0;
69
95
  let totalReviews = 0;
70
- for (const reviews of reviewArrays) {
71
- for (const review of reviews) {
72
- totalReviews++;
73
- ratingSum += review.rating;
74
- ratingDistribution[review.rating] = (ratingDistribution[review.rating] ?? 0) + 1;
75
- }
96
+ for (const review of allFlat) {
97
+ totalReviews++;
98
+ ratingSum += review.rating;
99
+ ratingDistribution[review.rating] = (ratingDistribution[review.rating] ?? 0) + 1;
76
100
  }
77
101
  const averageRating = totalReviews > 0 ? ratingSum / totalReviews : 0;
78
- // Flatten all reviews
79
- const productMap = new Map(products.map((p) => [p.id, p]));
80
- const allFlat = reviewArrays.flat();
102
+ // Titles and images are enriched further down, from only the products the
103
+ // reviews on the CURRENT PAGE name. Every review already carries a
104
+ // denormalised `productTitle`, so a miss still renders a usable card.
105
+ const productsRepo = db.getRepository("products");
81
106
  // Apply sort
82
107
  const sorted = [...allFlat].sort((a, b) => {
83
108
  if (sort === "createdAt" || sort === "+createdAt") {
@@ -99,10 +124,20 @@ export async function GET(request, context) {
99
124
  // Apply search
100
125
  if (q) {
101
126
  filtered = filtered.filter((r) => {
102
- const title = (r.title ?? "").toLowerCase();
103
- const body = (r.body ?? "").toLowerCase();
127
+ const rv = r;
128
+ const title = (rv.title ?? "").toLowerCase();
129
+ /*
130
+ * `comment` FIRST. The stored field is `comment` — verified against all
131
+ * 79 production review documents, none of which has a `body`. This read
132
+ * `body` alone, so searching a store's reviews only ever matched the
133
+ * title or the product name, never a word the reviewer actually wrote.
134
+ * `body` is kept as a fallback because it is what CLAUDE.md's reviews
135
+ * row and several types call it; the two spellings have drifted and only
136
+ * the data settles which is real.
137
+ */
138
+ const text = (rv.comment ?? rv.body ?? "").toLowerCase();
104
139
  const pt = (r.productTitle ?? "").toLowerCase();
105
- return title.includes(q) || body.includes(q) || pt.includes(q);
140
+ return title.includes(q) || text.includes(q) || pt.includes(q);
106
141
  });
107
142
  }
108
143
  // Apply date range
@@ -124,7 +159,31 @@ export async function GET(request, context) {
124
159
  const totalFiltered = filtered.length;
125
160
  const totalPages = Math.max(1, Math.ceil(totalFiltered / pageSize));
126
161
  const pageSlice = filtered.slice((page - 1) * pageSize, page * pageSize);
127
- // Enrich with product title + main image
162
+ // Enrich with product title + main image — only the ids on this page.
163
+ const pageProductIds = [...new Set(pageSlice.map((r) => r.productId).filter(Boolean))];
164
+ /*
165
+ * 🛑 DOCUMENT GETS, not a filter. The generic `findAll` on this provider
166
+ * parses a clause as `.where(field, op, coerceValue(value))` and has no
167
+ * pipe-join OR handling — so `id==a|b|c` would query for the LITERAL string
168
+ * "a|b|c" and match nothing, silently, which is the exact defect class this
169
+ * whole route is being fixed for. (Pipe-joins are a `sieveQuery` feature;
170
+ * this repository is not that path.) `id` is also not a stored field.
171
+ *
172
+ * Bounded by `pageSize`, capped at 50 above and typically ~12.
173
+ */
174
+ const productMap = new Map();
175
+ const fetched = await Promise.all(pageProductIds.map((id) => productsRepo.findById(id).catch((err) => {
176
+ // Named and logged, not swallowed: enrichment is genuinely optional
177
+ // (every review carries a denormalised productTitle), but a silent
178
+ // null here is indistinguishable from "that product was deleted" —
179
+ // Root Cause #59's shape, which is what this whole route was fixed for.
180
+ void normalizeError(err);
181
+ console.warn(`[feat-stores] products.findById failed for ${id}`);
182
+ return null;
183
+ })));
184
+ for (const p of fetched)
185
+ if (p)
186
+ productMap.set(p.id, p);
128
187
  const reviewsWithProduct = pageSlice.map((review) => ({
129
188
  ...review,
130
189
  productTitle: productMap.get(review.productId)?.title ?? review.productTitle,
@@ -13,7 +13,23 @@ export async function StoresIndexPageView({ searchParams = {} }) {
13
13
  const sort = sp(searchParams, "sort") || "-createdAt";
14
14
  const page = Number(sp(searchParams, "page")) || 1;
15
15
  const pageSize = Number(sp(searchParams, "pageSize")) || 24;
16
- const result = await safeRead(() => storeRepository.listStores({ page, pageSize, sorts: sort }, true), { route: "/stores", key: "stores.listStores", fallback: null });
16
+ /*
17
+ * 🛑 `q` MUST be read here, and passed as the third argument exactly as
18
+ * /api/stores does — `listStores(model, true, { search })`.
19
+ *
20
+ * It was not, so /stores?q=anything rendered the FULL store list: measured
21
+ * live, `?q=zzzznope` returned two real store cards and no empty state. The
22
+ * search box looked like it worked because a real term also returns rows —
23
+ * only a nonsense term exposes a filter that is not filtering.
24
+ *
25
+ * And it could not self-correct on the client: this result is handed to
26
+ * <StoresIndexListing initialData=...>, and every public listing hook sets
27
+ * `staleTime: Infinity` when given SSR data, so the unfiltered first paint is
28
+ * frozen for that query key. Root Cause #30 — the SSR builder has to compute
29
+ * the same filters the client would, because it never gets a second chance.
30
+ */
31
+ const q = sp(searchParams, "q").trim();
32
+ const result = await safeRead(() => storeRepository.listStores({ page, pageSize, sorts: sort }, true, q ? { search: q } : undefined), { route: "/stores", key: "stores.listStores", fallback: null });
17
33
  /*
18
34
  * The API already filters this; the SSR page called the repository directly
19
35
  * and did not, so the first paint rendered a card for `store-tester-sandbox`
@@ -213,7 +213,7 @@ export const authored = {
213
213
  },
214
214
  "checklist-admin-users-trust-support-tickets-triage-admin": {
215
215
  roles: ["admin"],
216
- startPage: "/admin/support",
216
+ startPage: "/admin/support-tickets",
217
217
  steps: [
218
218
  "Sign in as admin@letitrip.in / TempPass123!.",
219
219
  "Open the admin support tickets surface and read every column.",
@@ -468,7 +468,7 @@ export const authored = {
468
468
  },
469
469
  "checklist-buying-offers-ticket-resolution-timestamp-stamped": {
470
470
  roles: ["admin"],
471
- startPage: "/admin/support",
471
+ startPage: "/admin/support-tickets",
472
472
  steps: [
473
473
  "Sign in as admin@letitrip.in / TempPass123!.",
474
474
  "Open the admin support tickets surface and open an open ticket.",
@@ -52,7 +52,7 @@ export const authored = {
52
52
  },
53
53
  "checklist-buying-user-dashboard-extras-catalogue-crud": {
54
54
  roles: ["buyer"],
55
- startPage: "/user",
55
+ startPage: "/user/catalogue",
56
56
  steps: [
57
57
  "Sign in as rehan.sheikh@gmail.com / TempPass123!.",
58
58
  "Open the personal catalogue surface and read the existing items.",
@@ -90,7 +90,7 @@ export const authored = {
90
90
  },
91
91
  "checklist-buying-user-dashboard-extras-my-prize-draws": {
92
92
  roles: ["buyer"],
93
- startPage: "/user",
93
+ startPage: "/user/prize-draws",
94
94
  steps: [
95
95
  "Sign in as tester@letitrip.in / TempPass123!.",
96
96
  "Open the buyer's prize-draws surface and read every row.",
@@ -106,7 +106,7 @@ export const authored = {
106
106
  },
107
107
  "checklist-buying-user-dashboard-extras-my-digital-codes": {
108
108
  roles: ["buyer"],
109
- startPage: "/user",
109
+ startPage: "/user/digital-codes",
110
110
  steps: [
111
111
  "Sign in as tester@letitrip.in / TempPass123!.",
112
112
  "Open the buyer's digital-codes surface and read every row.",
@@ -232,7 +232,7 @@ export const authored = {
232
232
  },
233
233
  "checklist-buying-user-dashboard-extras-my-returns": {
234
234
  roles: ["buyer"],
235
- startPage: "/user",
235
+ startPage: "/user/returns",
236
236
  steps: [
237
237
  "Sign in as tester@letitrip.in / TempPass123!.",
238
238
  "Open the buyer's returns surface and read every row.",
@@ -247,7 +247,7 @@ export const authored = {
247
247
  },
248
248
  "checklist-buying-user-dashboard-extras-my-reviews": {
249
249
  roles: ["buyer"],
250
- startPage: "/user",
250
+ startPage: "/user/reviews",
251
251
  steps: [
252
252
  "Sign in as rehan.sheikh@gmail.com / TempPass123!.",
253
253
  "Open the buyer's reviews surface and read every row.",
@@ -264,7 +264,7 @@ export const authored = {
264
264
  },
265
265
  "checklist-buying-user-dashboard-extras-user-personal-listings-search-sort": {
266
266
  roles: ["buyer"],
267
- startPage: "/user",
267
+ startPage: "/user/digital-codes",
268
268
  steps: [
269
269
  "Sign in as rehan.sheikh@gmail.com / TempPass123!.",
270
270
  "Open the personal catalogue surface and note the unfiltered item count.",
@@ -25,22 +25,88 @@ export const DEFAULT_TESTER_PHASE_SIZE = 25;
25
25
  * already be in catalog order (e.g. sorted by the `order` field).
26
26
  */
27
27
  export function assignDefaultPhases(itemsInCatalogOrder, targetPhaseSize = DEFAULT_TESTER_PHASE_SIZE) {
28
- const phaseNumbers = new Array(itemsInCatalogOrder.length);
28
+ /*
29
+ * 🛑 PAGES ARE INTERLEAVED ACROSS GROUPS, NOT PACKED IN CATALOGUE ORDER.
30
+ *
31
+ * This used to walk the catalogue straight through, so a group's phase number
32
+ * was an accident of where its `...group(...)` spread sat in a 6,200-line
33
+ * literal. `admin` is the 9th spread, so its 231 cases landed at phases
34
+ * **25–32** — and any run that stops early is therefore GUARANTEED to lose
35
+ * the largest, most privileged group in its entirety. The same held for every
36
+ * infrastructure group in the tail: page-wiring (32–33), cta-layout (33),
37
+ * addresses and search-and-nav (34).
38
+ *
39
+ * That is not hypothetical. Run run-1789300124915 stopped at phase 17 of 34
40
+ * and ten of fourteen groups had ZERO coverage — 527 cases, including all 233
41
+ * admin ones — while the run looked about half done.
42
+ *
43
+ * Round-robin over groups fixes the shape rather than the symptom: an early
44
+ * stop now costs a proportional slice of every group instead of the whole
45
+ * tail. Groups are visited largest-first within each round so the big ones do
46
+ * not bunch up at the end as the small ones run out.
47
+ *
48
+ * A phase may now span several groups. That is fine for a tester session —
49
+ * it is ~25 cases either way — and every read path already groups by the
50
+ * STORED `item.phase`, never by position.
51
+ *
52
+ * 🛑 The RETURN IS STILL POSITIONAL. Items keep their catalogue order and
53
+ * their `order` field; only the phase LABEL changes. Nothing downstream
54
+ * reorders, and `fetch-cases` still sorts by (phase, order) to build batches.
55
+ */
56
+ const pageOrder = [];
57
+ const pageSize = new Map();
58
+ const pageGroup = new Map();
59
+ for (const item of itemsInCatalogOrder) {
60
+ const pageId = `${item.groupKey}␟${item.pageKey}`;
61
+ if (!pageSize.has(pageId)) {
62
+ pageOrder.push(pageId);
63
+ pageSize.set(pageId, 0);
64
+ pageGroup.set(pageId, item.groupKey);
65
+ }
66
+ pageSize.set(pageId, pageSize.get(pageId) + 1);
67
+ }
68
+ // Queues of pages per group, each still in catalogue order within its group.
69
+ const byGroup = new Map();
70
+ for (const pageId of pageOrder) {
71
+ const g = pageGroup.get(pageId);
72
+ if (!byGroup.has(g))
73
+ byGroup.set(g, []);
74
+ byGroup.get(g).push(pageId);
75
+ }
76
+ const groupsLargestFirst = [...byGroup.entries()]
77
+ .map(([g, pages]) => ({
78
+ g,
79
+ pages,
80
+ cases: pages.reduce((n, p) => n + pageSize.get(p), 0),
81
+ }))
82
+ .sort((a, b) => b.cases - a.cases || a.g.localeCompare(b.g))
83
+ .map((e) => e.g);
84
+ const cursor = new Map(groupsLargestFirst.map((g) => [g, 0]));
85
+ const interleaved = [];
86
+ let remaining = pageOrder.length;
87
+ while (remaining > 0) {
88
+ for (const g of groupsLargestFirst) {
89
+ const i = cursor.get(g);
90
+ const pages = byGroup.get(g);
91
+ if (i >= pages.length)
92
+ continue;
93
+ interleaved.push(pages[i]);
94
+ cursor.set(g, i + 1);
95
+ remaining--;
96
+ }
97
+ }
98
+ // Pack the interleaved page sequence into phases, never splitting a page.
99
+ const phaseOfPage = new Map();
29
100
  let currentPhase = 1;
30
101
  let currentPhaseCount = 0;
31
- let lastPageKey = null;
32
- for (let i = 0; i < itemsInCatalogOrder.length; i++) {
33
- const item = itemsInCatalogOrder[i];
34
- const pageId = `${item.groupKey}␟${item.pageKey}`;
35
- const isNewPage = pageId !== lastPageKey;
36
- // Only allow a phase boundary between pages, never mid-page.
37
- if (isNewPage && currentPhaseCount >= targetPhaseSize) {
102
+ for (const pageId of interleaved) {
103
+ const size = pageSize.get(pageId);
104
+ if (currentPhaseCount > 0 && currentPhaseCount >= targetPhaseSize) {
38
105
  currentPhase += 1;
39
106
  currentPhaseCount = 0;
40
107
  }
41
- phaseNumbers[i] = currentPhase;
42
- currentPhaseCount += 1;
43
- lastPageKey = pageId;
108
+ phaseOfPage.set(pageId, currentPhase);
109
+ currentPhaseCount += size;
44
110
  }
45
- return phaseNumbers;
111
+ return itemsInCatalogOrder.map((item) => phaseOfPage.get(`${item.groupKey}␟${item.pageKey}`));
46
112
  }
@@ -19,4 +19,11 @@ export declare const RTDB_PATHS: {
19
19
  readonly AUTH_EVENTS: "auth_events";
20
20
  readonly PAYMENT_EVENTS: "payment_events";
21
21
  readonly BULK_EVENTS: "bulk_events";
22
+ /**
23
+ * Tester-only. A one-line ping per email decision so the harness can poll
24
+ * instead of reading a real mailbox — see `send-recorder.ts` for why, and for
25
+ * the gate that keeps real users' mail out of it. Pruned by
26
+ * `cleanupRtdbEvents` alongside the other three.
27
+ */
28
+ readonly EMAIL_EVENTS: "email_events";
22
29
  };
@@ -19,4 +19,11 @@ export const RTDB_PATHS = {
19
19
  AUTH_EVENTS: "auth_events",
20
20
  PAYMENT_EVENTS: "payment_events",
21
21
  BULK_EVENTS: "bulk_events",
22
+ /**
23
+ * Tester-only. A one-line ping per email decision so the harness can poll
24
+ * instead of reading a real mailbox — see `send-recorder.ts` for why, and for
25
+ * the gate that keeps real users' mail out of it. Pruned by
26
+ * `cleanupRtdbEvents` alongside the other three.
27
+ */
28
+ EMAIL_EVENTS: "email_events",
22
29
  };
package/dist/server.d.ts CHANGED
@@ -556,6 +556,9 @@ export { getDigitalCodeForDetail } from "./_internal/server/features/digital-cod
556
556
  export { toClientDigitalCode, type DigitalCodeClientShape } from "./_internal/server/features/digital-code/adapters";
557
557
  export { buildDigitalCodeMetadata, type DigitalCodeMetadataOptions } from "./_internal/server/features/digital-code/metadata";
558
558
  export { renderDigitalCodeOg, renderDigitalCodeOgImage, renderDigitalCodeOgFromDoc, type DigitalCodeOgData } from "./_internal/server/features/digital-code/og";
559
+ export { addPoolEntries, listPoolEntries, deletePoolEntry, recountPool, assertCanUploadContentKind, DIGITAL_CONTENT_PREFIX, type AddPoolEntryInput, } from "./_internal/server/features/digital-code/pool";
560
+ export { signDigitalContentUpload, finalizeDigitalContentUpload, contentKindForMime, type SignedDigitalContent, type FinalizedDigitalContent, } from "./_internal/server/features/digital-code/upload";
561
+ export { resolveCodeAssetForOrder, codeAssetHeaders, CODE_DOWNLOAD_STATUSES, type CodeAssetResolution, } from "./_internal/server/features/digital-code/download";
559
562
  export { getLiveItemForDetail } from "./_internal/server/features/live/data";
560
563
  export { toClientLiveItem, type LiveItemClientShape } from "./_internal/server/features/live/adapters";
561
564
  export { buildLiveItemMetadata, type LiveItemMetadataOptions } from "./_internal/server/features/live/metadata";
package/dist/server.js CHANGED
@@ -1444,6 +1444,18 @@ export { getDigitalCodeForDetail } from "./_internal/server/features/digital-cod
1444
1444
  export { toClientDigitalCode } from "./_internal/server/features/digital-code/adapters";
1445
1445
  export { buildDigitalCodeMetadata } from "./_internal/server/features/digital-code/metadata";
1446
1446
  export { renderDigitalCodeOg, renderDigitalCodeOgImage, renderDigitalCodeOgFromDoc } from "./_internal/server/features/digital-code/og";
1447
+ /*
1448
+ * The digital-CONTENT pool: the half of this feature that had no writer at all
1449
+ * until 2026-09-14, so every digital-code purchase delivered nothing.
1450
+ *
1451
+ * Server-only, and that is structural rather than tidy — `pool` and `download`
1452
+ * both reach `getAdminDb`/`getAdminStorage`, and `upload` mints signed URLs.
1453
+ * None of it may appear in `index.ts` or `client.ts` (the Turbopack
1454
+ * client-bundle trap, Root Cause #6/#24).
1455
+ */
1456
+ export { addPoolEntries, listPoolEntries, deletePoolEntry, recountPool, assertCanUploadContentKind, DIGITAL_CONTENT_PREFIX, } from "./_internal/server/features/digital-code/pool";
1457
+ export { signDigitalContentUpload, finalizeDigitalContentUpload, contentKindForMime, } from "./_internal/server/features/digital-code/upload";
1458
+ export { resolveCodeAssetForOrder, codeAssetHeaders, CODE_DOWNLOAD_STATUSES, } from "./_internal/server/features/digital-code/download";
1447
1459
  // ── Live-item listing — data + adapters + metadata + OG ──────────────────────
1448
1460
  export { getLiveItemForDetail } from "./_internal/server/features/live/data";
1449
1461
  export { toClientLiveItem } from "./_internal/server/features/live/adapters";
@@ -0,0 +1,32 @@
1
+ import React from "react";
2
+ import type { ButtonProps } from "./Button";
3
+ /**
4
+ * A button that opens the native file picker.
5
+ *
6
+ * 🛑 THE RAW `<input type="file">` BELONGS HERE, and only here. Rule #9 bans raw
7
+ * form elements in product code and points every case at a primitive; there was
8
+ * no primitive for a file picker, so the one feature that needed one
9
+ * (`DigitalContentPoolManager`) had to carry a raw input plus a suppression
10
+ * marker — and `audit-no-suppression-comments` is strict-zero and says, in its
11
+ * own header, "do NOT add a new marker or exception". The only close available
12
+ * was to extend the primitive layer, which is exactly what Root Cause #22 says
13
+ * to do before reaching for a marker.
14
+ *
15
+ * Deliberately NOT built on `MediaUploadField`. That component drives the public
16
+ * `/api/media/*` pipeline, which ends by minting a `/media/{shortId}` URL served
17
+ * with no authentication — correct for product photography, catastrophic for a
18
+ * paid download. This primitive hands the caller a `File` and has no opinion
19
+ * about where the bytes go.
20
+ *
21
+ * The input is visually hidden rather than styled, because a styled file input
22
+ * cannot be made to match `<Button>` across browsers — Safari and Firefox each
23
+ * render their own immovable "Choose file" text inside it.
24
+ */
25
+ export interface FilePickerButtonProps extends Omit<ButtonProps, "onClick" | "type" | "children"> {
26
+ /** Called with the chosen file. Never called with null. */
27
+ onFile: (file: File) => void;
28
+ /** Native `accept` string, e.g. `"image/*"`. Omit to accept anything. */
29
+ accept?: string;
30
+ children: React.ReactNode;
31
+ }
32
+ export declare function FilePickerButton({ onFile, accept, children, ...buttonProps }: FilePickerButtonProps): React.JSX.Element;
@@ -0,0 +1,23 @@
1
+ "use client";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useId, useRef } from "react";
4
+ import { Button } from "./Button";
5
+ // No forwardRef: appkit's <Button> does not accept a ref, so there is nothing
6
+ // to forward one to. Adding the plumbing would be a prop that silently does
7
+ // nothing, which is worse than its absence.
8
+ export function FilePickerButton({ onFile, accept, children, ...buttonProps }) {
9
+ const inputRef = useRef(null);
10
+ const inputId = useId();
11
+ return (_jsxs(_Fragment, { children: [_jsx(Button, { ...buttonProps, type: "button", onClick: () => inputRef.current?.click(), children: children }), _jsx("input", { ref: inputRef, id: inputId, type: "file", accept: accept, hidden: true, onChange: (ev) => {
12
+ const file = ev.target.files?.[0];
13
+ /*
14
+ * Reset BEFORE handing the file on. Without it, picking the same file
15
+ * twice in a row fires no change event the second time — the value is
16
+ * unchanged — and the control silently does nothing, which reads as a
17
+ * broken button rather than as a no-op.
18
+ */
19
+ ev.target.value = "";
20
+ if (file)
21
+ onFile(file);
22
+ } })] }));
23
+ }
@@ -33,6 +33,8 @@ export type { SkeletonProps } from "./components/Skeleton";
33
33
  export { Skeleton } from "./components/Skeleton";
34
34
  export type { ButtonProps } from "./components/Button";
35
35
  export { Button } from "./components/Button";
36
+ export { FilePickerButton } from "./components/FilePickerButton";
37
+ export type { FilePickerButtonProps } from "./components/FilePickerButton";
36
38
  export type { IconButtonProps } from "./components/IconButton";
37
39
  export { IconButton } from "./components/IconButton";
38
40
  export type { IconProps, IconTone } from "./components/Icon";
package/dist/ui/index.js CHANGED
@@ -19,6 +19,13 @@ export { SiteLogo } from "./components/SiteLogo";
19
19
  export { SiteMark } from "./components/SiteMark";
20
20
  export { Skeleton } from "./components/Skeleton";
21
21
  export { Button } from "./components/Button";
22
+ /*
23
+ * The one primitive allowed to own a raw <input type="file">. Rule #9 sends
24
+ * every raw form element to a primitive and there was none for a file picker,
25
+ * so the only feature that needed one had to carry a suppression marker that
26
+ * audit-no-suppression-comments (strict-zero) correctly refuses.
27
+ */
28
+ export { FilePickerButton } from "./components/FilePickerButton";
22
29
  export { IconButton } from "./components/IconButton";
23
30
  export { Icon } from "./components/Icon";
24
31
  export { ICONS, ICON_SIZE, isIconKey, resolveIcon, } from "./icons/icon-registry";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mohasinac/appkit",
3
- "version": "4.38.0",
3
+ "version": "4.39.0",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public"