@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.
- package/dist/_internal/client/features/digital-code/CodeRevealPanel.d.ts +20 -1
- package/dist/_internal/client/features/digital-code/CodeRevealPanel.js +21 -1
- package/dist/_internal/client/features/digital-code/DigitalContentPoolManager.d.ts +40 -0
- package/dist/_internal/client/features/digital-code/DigitalContentPoolManager.js +67 -0
- package/dist/_internal/server/features/checkout/actions.js +18 -0
- package/dist/_internal/server/features/digital-code/download.d.ts +60 -0
- package/dist/_internal/server/features/digital-code/download.js +128 -0
- package/dist/_internal/server/features/digital-code/pool.d.ts +101 -0
- package/dist/_internal/server/features/digital-code/pool.js +208 -0
- package/dist/_internal/server/features/digital-code/upload.d.ts +51 -0
- package/dist/_internal/server/features/digital-code/upload.js +144 -0
- package/dist/_internal/server/jobs/core/cleanupRtdbEvents.js +40 -0
- package/dist/_internal/server/jobs/core/countersReconcile.js +153 -40
- package/dist/_internal/server/jobs/core/onCategoryWrite.js +33 -0
- package/dist/_internal/server/jobs/core/onProductWrite.js +93 -25
- package/dist/_internal/server/notifications/send-recorder.d.ts +79 -0
- package/dist/_internal/server/notifications/send-recorder.js +126 -0
- package/dist/client.d.ts +2 -0
- package/dist/client.js +6 -0
- package/dist/features/blog/components/BlogIndexPageView.js +10 -1
- package/dist/features/blog/repository/blog.repository.d.ts +14 -0
- package/dist/features/blog/repository/blog.repository.js +23 -1
- package/dist/features/categories/components/CategoryDetailPageView.js +1 -1
- package/dist/features/categories/components/CategoryTree.js +1 -1
- package/dist/features/categories/repository/categories.repository.d.ts +34 -2
- package/dist/features/categories/repository/categories.repository.js +31 -9
- package/dist/features/contact/email.js +36 -1
- package/dist/features/orders/repository/orders.repository.d.ts +21 -1
- package/dist/features/orders/repository/orders.repository.js +26 -9
- package/dist/features/products/repository/products.repository.d.ts +35 -0
- package/dist/features/products/repository/products.repository.js +116 -3
- package/dist/features/products/schemas/firestore.d.ts +37 -0
- package/dist/features/seller/actions/seller-actions.js +4 -13
- package/dist/features/seller/components/SellerProductShell.d.ts +7 -1
- package/dist/features/seller/components/SellerProductShell.js +11 -2
- package/dist/features/stores/api/[storeSlug]/reviews/route.d.ts +6 -1
- package/dist/features/stores/api/[storeSlug]/reviews/route.js +89 -30
- package/dist/features/stores/components/StoresIndexPageView.js +17 -1
- package/dist/features/tester/seed-data/authored/admin__users-trust.js +1 -1
- package/dist/features/tester/seed-data/authored/buying__offers.js +1 -1
- package/dist/features/tester/seed-data/authored/buying__user-dashboard-extras.js +6 -6
- package/dist/features/tester/utils/phases.js +78 -12
- package/dist/providers/db-firebase/rtdb-paths.d.ts +7 -0
- package/dist/providers/db-firebase/rtdb-paths.js +7 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +12 -0
- package/dist/ui/components/FilePickerButton.d.ts +32 -0
- package/dist/ui/components/FilePickerButton.js +23 -0
- package/dist/ui/index.d.ts +2 -0
- package/dist/ui/index.js +7 -0
- 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
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
59
|
-
.
|
|
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:
|
|
64
|
-
})
|
|
65
|
-
|
|
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
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
103
|
-
const
|
|
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) ||
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
42
|
-
currentPhaseCount +=
|
|
43
|
-
lastPageKey = pageId;
|
|
108
|
+
phaseOfPage.set(pageId, currentPhase);
|
|
109
|
+
currentPhaseCount += size;
|
|
44
110
|
}
|
|
45
|
-
return
|
|
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
|
+
}
|
package/dist/ui/index.d.ts
CHANGED
|
@@ -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";
|