@mohasinac/appkit 4.35.3 → 4.36.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 (27) hide show
  1. package/dist/_internal/server/jobs/handlers/onShipmentAllocationSync.js +73 -6
  2. package/dist/features/products/hooks/useProducts.js +40 -3
  3. package/dist/features/tester/schemas/firestore.d.ts +29 -0
  4. package/dist/features/tester/seed-data/authored/_types.d.ts +24 -0
  5. package/dist/features/tester/seed-data/authored/account-auth__profile-settings.js +2 -0
  6. package/dist/features/tester/seed-data/authored/account-auth__signup-login.js +22 -0
  7. package/dist/features/tester/seed-data/authored/admin__bug-hunter-rewards.d.ts +2 -0
  8. package/dist/features/tester/seed-data/authored/admin__bug-hunter-rewards.js +106 -0
  9. package/dist/features/tester/seed-data/authored/admin__site-system.js +2 -0
  10. package/dist/features/tester/seed-data/authored/buying__my-orders.js +2 -0
  11. package/dist/features/tester/seed-data/authored/buying__offers.js +2 -0
  12. package/dist/features/tester/seed-data/authored/content-discovery__notifications.js +2 -0
  13. package/dist/features/tester/seed-data/authored/content-discovery__search.js +48 -19
  14. package/dist/features/tester/seed-data/authored/index.js +6 -0
  15. package/dist/features/tester/seed-data/authored/public-pages__auth-error-pages.js +4 -0
  16. package/dist/features/tester/seed-data/authored/selling__listing-a-product.js +0 -16
  17. package/dist/features/tester/seed-data/authored/selling__product-upload-details.d.ts +2 -0
  18. package/dist/features/tester/seed-data/authored/selling__product-upload-details.js +150 -0
  19. package/dist/features/tester/seed-data/authored/selling__quick-add-minimum-details.d.ts +2 -0
  20. package/dist/features/tester/seed-data/authored/selling__quick-add-minimum-details.js +154 -0
  21. package/dist/features/tester/seed-data/authored/selling__seller-orders.js +2 -0
  22. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +111 -6
  23. package/dist/react/contexts/SessionContext.d.ts +13 -0
  24. package/dist/react/contexts/SessionContext.js +15 -0
  25. package/dist/ui/components/ImageLightbox.js +24 -1
  26. package/package.json +1 -1
  27. package/scripts/seed-cli.mjs +784 -752
@@ -34,6 +34,7 @@ import { authored as a_admin__catalog_listings } from "./admin__catalog-listings
34
34
  import { authored as a_admin__orders_fulfillment } from "./admin__orders-fulfillment";
35
35
  import { authored as a_admin__users_trust } from "./admin__users-trust";
36
36
  import { authored as a_admin__site_system } from "./admin__site-system";
37
+ import { authored as a_admin__bug_hunter_rewards } from "./admin__bug-hunter-rewards";
37
38
  import { authored as a_admin__category_brand_authoring } from "./admin__category-brand-authoring";
38
39
  import { authored as a_admin__uncovered_admin_pages } from "./admin__uncovered-admin-pages";
39
40
  import { authored as a_admin__bans_and_trust } from "./admin__bans-and-trust";
@@ -119,6 +120,8 @@ import { authored as a_selling__seller_orders } from "./selling__seller-orders";
119
120
  import { authored as a_selling__seller_marketing_extras } from "./selling__seller-marketing-extras";
120
121
  import { authored as a_selling__listing_a_product } from "./selling__listing-a-product";
121
122
  import { authored as a_selling__seller_listing_types } from "./selling__seller-listing-types";
123
+ import { authored as a_selling__product_upload_details } from "./selling__product-upload-details";
124
+ import { authored as a_selling__quick_add_minimum_details } from "./selling__quick-add-minimum-details";
122
125
  import { authored as a_seo__canonical_and_host } from "./seo__canonical-and-host";
123
126
  import { authored as a_seo__sitemap_and_robots } from "./seo__sitemap-and-robots";
124
127
  import { authored as a_seo__page_metadata } from "./seo__page-metadata";
@@ -141,6 +144,7 @@ export const AUTHORED_CASES = {
141
144
  ...a_admin__orders_fulfillment,
142
145
  ...a_admin__users_trust,
143
146
  ...a_admin__site_system,
147
+ ...a_admin__bug_hunter_rewards,
144
148
  ...a_admin__category_brand_authoring,
145
149
  ...a_admin__uncovered_admin_pages,
146
150
  ...a_admin__bans_and_trust,
@@ -226,6 +230,8 @@ export const AUTHORED_CASES = {
226
230
  ...a_selling__seller_marketing_extras,
227
231
  ...a_selling__listing_a_product,
228
232
  ...a_selling__seller_listing_types,
233
+ ...a_selling__product_upload_details,
234
+ ...a_selling__quick_add_minimum_details,
229
235
  ...a_seo__canonical_and_host,
230
236
  ...a_seo__sitemap_and_robots,
231
237
  ...a_seo__page_metadata,
@@ -36,6 +36,8 @@ export const authored = {
36
36
  endResult: "The account exists and persists. This case creates a real row that teardown does not remove, which is why the address is numbered rather than fixed.",
37
37
  },
38
38
  "checklist-public-pages-auth-error-pages-forgot-reset-password-pages": {
39
+ requiresHumanChannel: true,
40
+ humanChannelReason: "real email inbox",
39
41
  roles: ["guest"],
40
42
  startPage: "/auth/forgot-password",
41
43
  steps: [
@@ -58,6 +60,8 @@ export const authored = {
58
60
  endResult: "The password is back at TempPass123! by the final step. This case shares divya.funko@gmail.com with the single-use-link case in signup-login and with nothing else, deliberately — the seeder never resets an existing password, so a changed one outlives every future re-seed.",
59
61
  },
60
62
  "checklist-public-pages-auth-error-pages-verify-email-page": {
63
+ requiresHumanChannel: true,
64
+ humanChannelReason: "real email inbox",
61
65
  roles: ["guest"],
62
66
  startPage: "/auth/register",
63
67
  steps: [
@@ -168,22 +168,6 @@ export const authored = {
168
168
  expectedUiState: "Every preview shows its picture at the same size as its neighbours, at both widths. An empty white or bordered box where a photo should be is the failure, and it hits every clickable image tile in the app at once.",
169
169
  endResult: "Leave the editor without saving.",
170
170
  },
171
- "checklist-selling-listing-a-product-media-upload-images-capped-at-5": {
172
- roles: ["seller"],
173
- startPage: "/store/products/new",
174
- steps: [
175
- "Sign in as tyson@beybladearena.in / TempPass123!.",
176
- "Open /store/products/new and read the stated image limit before uploading anything.",
177
- "Upload public/test-media/sample-image.png repeatedly, counting the previews after each.",
178
- "Continue until an upload is refused.",
179
- "Read the message and the preview count at that point.",
180
- "Compare the count against the limit the form stated.",
181
- ],
182
- inputs: { image: "public/test-media/sample-image.png" },
183
- expectedBehaviour: "The number the form STATES is the number it enforces. This case exists to catch a disagreement between them: a label saying five against a limit of ten, or the reverse, is a promise the form does not keep — and the checklist label itself says five while the shared limits module is the authority.",
184
- expectedUiState: "Uploads are accepted up to the stated limit and the next is refused with a readable message. The refusal count equals the stated limit exactly. If the two disagree, record BOTH numbers — which one is wrong is a judgement for the reader, not for the tester.",
185
- endResult: "Leave the editor without saving.",
186
- },
187
171
  "checklist-selling-listing-a-product-media-upload-video-duration": {
188
172
  roles: ["seller"],
189
173
  startPage: "/store/products/new",
@@ -0,0 +1,2 @@
1
+ import type { AuthoredCase } from "./_types";
2
+ export declare const authored: Record<string, AuthoredCase>;
@@ -0,0 +1,150 @@
1
+ /*
2
+ * WHY: Authored six-part procedures for the selling/product-upload-details page.
3
+ * WHAT: 6 case(s), keyed by full checklist id.
4
+ *
5
+ * The seller product form has THREE separate media controls and they are easy to
6
+ * conflate. Grounded in SellerProductShell.tsx `StepMedia` (:342-497):
7
+ *
8
+ * Main image ImageUpload, label "Main Image", enableAdvancedCrop,
9
+ * cropAspectRatio 1, max 10 MB, index 1
10
+ * Gallery MediaUploadList, label "Gallery (up to 10 images + 1 video)",
11
+ * maxItems 11, maxImages 10, maxVideos 1,
12
+ * maxSizeMB 50, indices 2..11
13
+ * Video panel MediaUploadField, YouTube / external URL / trim / poster
14
+ *
15
+ * The existing pages already cover the CAPS (selling/media-limits: 10th uploads,
16
+ * 11th refused, second video refused) and the basics (selling/listing-a-product:
17
+ * upload, preview, remove, crop, multiple). This page covers what neither does:
18
+ * refusal copy, type refusal, ordering, index collision, and the video path that
19
+ * was returning a 500 until 2026-09.
20
+ *
21
+ * @tag domain:tester
22
+ * @tag layer:seed
23
+ * @tag pattern:none
24
+ * @tag access:server-only
25
+ * @tag consumers:seed-data/authored/index.ts
26
+ * @tag sideEffects:none
27
+ */
28
+ import { MEDIA_ENDPOINTS } from "../../../../constants/api-endpoints";
29
+ /*
30
+ * The endpoint comes from the registry, not a literal.
31
+ *
32
+ * These cases genuinely need to name it — the tester is told to open the
33
+ * network panel and read that exact request, and "the signing request" would be
34
+ * too vague for R8, which requires literal values. But a pasted copy of the path
35
+ * is a second definition that silently rots the day the route moves, leaving a
36
+ * tester hunting a request that no longer exists and reporting a bug that is
37
+ * really a stale case. Interpolating keeps it precise AND single-sourced.
38
+ */
39
+ const SIGN = MEDIA_ENDPOINTS.SIGN;
40
+ const SIGN_IN_SELLER = "Sign in as tyson@beybladearena.in / TempPass123!.";
41
+ export const authored = {
42
+ "checklist-selling-product-upload-details-main-image-crop-applies": {
43
+ roles: ["seller"],
44
+ startPage: "/store/products/new",
45
+ steps: [
46
+ SIGN_IN_SELLER,
47
+ "Open /store/products/new and click 'Show all fields (advanced)' to reach the sectioned form.",
48
+ "Open the Media section and choose public/test-media/sample-image.png for 'Main Image'.",
49
+ "Wait for the crop dialog to appear and note the shape of the crop box.",
50
+ "Drag the crop box to cover clearly only the LEFT half of the picture, then confirm.",
51
+ "Read the thumbnail that appears afterwards and compare what it shows against the original.",
52
+ ],
53
+ inputs: { image: "public/test-media/sample-image.png" },
54
+ expectedBehaviour: "The crop is applied to the bytes that are uploaded, not merely previewed. A cropper that shows a selection and then stores the original is worse than no cropper — the seller believes they framed the photo and the marketplace shows something else. The box is locked square because the main image is rendered square on every card.",
55
+ expectedUiState: "A crop dialog opens before any upload begins, and its box is square and stays square when dragged. The resulting thumbnail shows the LEFT half that was selected, not the whole picture.",
56
+ expectedData: { cropAspectRatio: 1 },
57
+ endResult: "Leave without saving. Re-open the form: no half-uploaded image is left attached to a draft.",
58
+ },
59
+ "checklist-selling-product-upload-details-oversize-image-refused-client-side": {
60
+ roles: ["seller"],
61
+ startPage: "/store/products/new",
62
+ steps: [
63
+ SIGN_IN_SELLER,
64
+ "Open /store/products/new and switch to the advanced form.",
65
+ "Open the Media section and read the size limit stated next to 'Main Image'.",
66
+ "Choose public/test-media/oversized.png for the Main Image and start a timer. (The batch generates this file — it is >10 MB of incompressible noise and deliberately not committed.)",
67
+ "Read the message that appears and note how long it took.",
68
+ `Open the browser network panel and check whether any request to ${SIGN} was made.`,
69
+ ],
70
+ inputs: { image: "public/test-media/oversized.png", limitMb: 10 },
71
+ expectedBehaviour: "A file over the limit is refused BEFORE any bytes leave the browser. The point is the seller's time: a 12 MB upload that travels, then finalises, then fails validation has taken a minute to tell them something the form already knew when they picked the file. The limit is also stated up front rather than only in the refusal.",
72
+ expectedUiState: `The refusal is immediate (well under a second) and reads 'File size must be less than 10MB', naming the actual size. No ${SIGN} request appears in the network panel. The field keeps whatever image it had before.`,
73
+ expectedData: { signRequestsMade: 0 },
74
+ endResult: "Leave without saving; nothing was uploaded.",
75
+ },
76
+ "checklist-selling-product-upload-details-disallowed-type-refused": {
77
+ roles: ["seller"],
78
+ startPage: "/store/products/new",
79
+ steps: [
80
+ SIGN_IN_SELLER,
81
+ "Open /store/products/new and switch to the advanced form.",
82
+ "Open the Media section and choose public/test-media/sample-vector.svg for the Main Image.",
83
+ "Read the message that appears.",
84
+ "Now choose public/test-media/sample-image.png for the same field.",
85
+ "Read whether it is accepted.",
86
+ ],
87
+ inputs: { rejected: "public/test-media/sample-vector.svg", accepted: "public/test-media/sample-image.png" },
88
+ expectedBehaviour: "SVG is deliberately outside the allowed image types — it is a document format that can carry script, and it is served from our own origin. The refusal happens on type, not on extension alone, and the control recovers: refusing one file must not leave the field wedged.",
89
+ expectedUiState: "The SVG is refused with a message naming the accepted types. Immediately afterwards the PNG uploads normally in the same field — no reload needed.",
90
+ endResult: "Leave without saving.",
91
+ },
92
+ "checklist-selling-product-upload-details-gallery-order-persists": {
93
+ roles: ["seller"],
94
+ startPage: "/store/products/new",
95
+ steps: [
96
+ SIGN_IN_SELLER,
97
+ "Open /store/products/new and switch to the advanced form.",
98
+ "Open the Media section and add three gallery images one at a time: public/test-media/sample-image.png, then public/test-media/sample-image-2.png, then public/test-media/sample-image-3.png.",
99
+ "Note the order the three thumbnails appear in, left to right.",
100
+ "Drag the THIRD thumbnail to the first position.",
101
+ "Fill Title 'Gallery Order Probe', Description 'Checking that gallery ordering survives a save and reload.', Category 'Beyblade Burst', Price 499, then Publish.",
102
+ "Re-open the product for editing and read the gallery order again.",
103
+ ],
104
+ inputs: {
105
+ images: "public/test-media/sample-image.png + sample-image-2.png + sample-image-3.png",
106
+ title: "Gallery Order Probe",
107
+ price: 499,
108
+ },
109
+ expectedBehaviour: "Gallery order is the seller's editorial decision about which angle a buyer sees second, so it is stored, not just displayed. Order that survives in the form but resets on reload means the array is being rebuilt from upload time rather than from the arrangement.",
110
+ expectedUiState: "After the drag the moved image is first. After publish and re-open it is STILL first, in the same order as before the save.",
111
+ expectedData: { firstGalleryImage: "public/test-media/sample-image-3.png" },
112
+ endResult: "The published product's gallery order matches what was arranged. Delete the product afterwards.",
113
+ },
114
+ "checklist-selling-product-upload-details-main-image-does-not-collide-with-gallery": {
115
+ roles: ["seller"],
116
+ startPage: "/store/products/new",
117
+ steps: [
118
+ SIGN_IN_SELLER,
119
+ "Open /store/products/new and switch to the advanced form.",
120
+ "Open the Media section and set the Main Image to public/test-media/sample-image.png, accepting the crop.",
121
+ "Add public/test-media/sample-image-2.png as the first GALLERY image.",
122
+ "Read both previews and confirm they show different pictures.",
123
+ "Publish with Title 'Slot Collision Probe', Description 'Main image and first gallery image must not overwrite each other.', Category 'Beyblade Burst', Price 499.",
124
+ "Open the product's public page and read the main image and the first gallery thumbnail.",
125
+ ],
126
+ inputs: { mainImage: "public/test-media/sample-image.png", firstGalleryImage: "public/test-media/sample-image-2.png", price: 499 },
127
+ expectedBehaviour: "The main image occupies index 1 and the gallery starts at index 2. Filenames here are content-derived with no timestamp, so two uploads that computed the same index would write the same storage path and the second would silently overwrite the first — the seller would see one of their two pictures simply vanish.",
128
+ expectedUiState: "The main image and the first gallery thumbnail show DIFFERENT pictures, both in the form and on the published page. Neither is blank.",
129
+ expectedData: { distinctImages: 2 },
130
+ endResult: "Both images survive a reload of the public page. Delete the product afterwards.",
131
+ },
132
+ "checklist-selling-product-upload-details-video-upload-succeeds": {
133
+ roles: ["seller"],
134
+ startPage: "/store/products/new",
135
+ steps: [
136
+ SIGN_IN_SELLER,
137
+ "Open /store/products/new and switch to the advanced form.",
138
+ "Open the Media section and open the video panel's 'Upload' tab.",
139
+ "Choose public/test-media/sample-video.mp4 and wait for it to finish.",
140
+ `Open the browser network panel and read the status of the ${SIGN} request.`,
141
+ "Read whether a poster frame and a duration appear.",
142
+ "Publish with Title 'Video Upload Probe', Description 'Checking the seller video upload path end to end.', Category 'Beyblade Burst', Price 499, then open the public page and play the video.",
143
+ ],
144
+ inputs: { video: "public/test-media/sample-video.mp4", price: 499 },
145
+ expectedBehaviour: "The dedicated video panel uploads through the same signed-URL flow as the gallery. This path returned HTTP 500 until 2026-09 because the panel omitted the category from the upload context and the filename generator threw on it — so every seller video upload from this panel failed while the gallery path worked. This case is that defect's regression test.",
146
+ expectedUiState: `${SIGN} returns 200, not 400 or 500. A poster frame and a duration appear. The published page plays the video.`,
147
+ expectedData: { signStatus: 200 },
148
+ endResult: "The video survives a reload of the public page. Delete the product afterwards.",
149
+ },
150
+ };
@@ -0,0 +1,2 @@
1
+ import type { AuthoredCase } from "./_types";
2
+ export declare const authored: Record<string, AuthoredCase>;
@@ -0,0 +1,154 @@
1
+ /*
2
+ * WHY: Authored six-part procedures for the selling/quick-add-minimum-details page.
3
+ * WHAT: 6 case(s), keyed by full checklist id.
4
+ *
5
+ * 🛑 THE QUICK FORM AND "THE FIELDS VISIBLE ON WHATSAPP" ARE THE SAME SET. That is
6
+ * what makes this one page rather than two.
7
+ *
8
+ * `/store/products/new` does NOT open the sectioned form. SellerProductShell.tsx
9
+ * :1339 picks `formMode = "quick"` whenever mode is create and listingType is
10
+ * standard, so the default is QuickProductForm — six fields: title*, category*,
11
+ * price*, mainImage*, description, stockQuantity. The sectioned form is behind
12
+ * "Show all fields (advanced)".
13
+ *
14
+ * And the WhatsApp catalog push (catalog-sync/route.ts:70-86) sends exactly
15
+ * title, price, mainImage, condition and stockQuantity plus a derived slug and
16
+ * link. That is the quick form's field set. A seller who fills only the short
17
+ * form has, by construction, filled everything a WhatsApp buyer will ever see.
18
+ *
19
+ * 🛑 TWO CASES HERE ARE EXPECTED TO FAIL, and their expectations are written as
20
+ * the CORRECT behaviour rather than the current one. A case that documents a bug
21
+ * as intended passes forever and locks the defect in. Both are known:
22
+ *
23
+ * condition-options-are-all-saveable the form offers Like New / Good / Fair
24
+ * (SellerProductShell.tsx:228-235) but
25
+ * productBaseSchema accepts only new /
26
+ * used / refurbished / broken
27
+ * (request-schemas.ts:155)
28
+ * description-requirement-is-honest QuickProductForm labels description
29
+ * "Brief description (optional)" and never
30
+ * validates it, while the server requires
31
+ * >= 20 characters (:122-128)
32
+ *
33
+ * @tag domain:tester
34
+ * @tag layer:seed
35
+ * @tag pattern:none
36
+ * @tag access:server-only
37
+ * @tag consumers:seed-data/authored/index.ts
38
+ * @tag sideEffects:none
39
+ */
40
+ const SIGN_IN_SELLER = "Sign in as tyson@beybladearena.in / TempPass123!.";
41
+ export const authored = {
42
+ "checklist-selling-quick-add-minimum-details-quick-form-is-the-default": {
43
+ roles: ["seller"],
44
+ startPage: "/store/products/new",
45
+ steps: [
46
+ SIGN_IN_SELLER,
47
+ "Open /store/products/new without clicking anything else.",
48
+ "Read the heading and count the input fields on screen.",
49
+ "List the field labels in order.",
50
+ "Read whether a control offering the full form is present, and read its label.",
51
+ "Open /store/art/new and count its fields for comparison.",
52
+ ],
53
+ expectedBehaviour: "Creating an ordinary product opens the SHORT form, because most listings need six fields and the sectioned form asks for thirty. The full form stays one click away rather than being the default. Non-standard types (art, auctions) open the sectioned form directly, since they carry type-specific fields the short form has no room for.",
54
+ expectedUiState: "Six fields: Product Name, Category, Price, Product Image, Description, Stock Quantity — and a control reading 'Show all fields (advanced)'. /store/art/new shows the sectioned form instead.",
55
+ expectedData: { quickFormFields: 6 },
56
+ endResult: "Read-only; leave without saving.",
57
+ },
58
+ "checklist-selling-quick-add-minimum-details-publish-with-the-minimum": {
59
+ roles: ["seller"],
60
+ startPage: "/store/products/new",
61
+ steps: [
62
+ SIGN_IN_SELLER,
63
+ "Open /store/products/new.",
64
+ "Enter Product Name 'Minimum Details Probe'.",
65
+ "Pick Category 'Beyblade Burst'.",
66
+ "Enter Price 499.",
67
+ "Upload public/test-media/sample-image.png as the Product Image.",
68
+ "Enter Description 'A short-form listing created with only the required fields present.'",
69
+ "Click Publish and read what happens.",
70
+ "Open the product's public page and read the title, price and image.",
71
+ ],
72
+ inputs: {
73
+ title: "Minimum Details Probe",
74
+ category: "Beyblade Burst",
75
+ price: 499,
76
+ image: "public/test-media/sample-image.png",
77
+ description: "A short-form listing created with only the required fields present.",
78
+ },
79
+ expectedBehaviour: "The short form can produce a real, published, buyable listing on its own. If it cannot, it is a decoy: the seller fills it, is refused, and has to learn the long form anyway — having already done the work twice.",
80
+ expectedUiState: "Publish succeeds with no inline errors and no error toast. The public page renders the title, ₹499 and the uploaded image.",
81
+ expectedData: { published: true },
82
+ endResult: "The listing survives a reload of the public page and appears in /store/products. Delete it afterwards.",
83
+ },
84
+ "checklist-selling-quick-add-minimum-details-whatsapp-shows-the-same-fields": {
85
+ roles: ["seller"],
86
+ startPage: "/store/whatsapp",
87
+ steps: [
88
+ SIGN_IN_SELLER,
89
+ "Publish 'Minimum Details Probe' from /store/products/new as in the previous case, with Price 499.",
90
+ "Open /store/whatsapp and find the catalog preview.",
91
+ "Locate the tile for 'Minimum Details Probe'.",
92
+ "Read every piece of information the tile shows.",
93
+ "Compare that list against the fields the quick form asked for.",
94
+ ],
95
+ inputs: { title: "Minimum Details Probe", price: 499 },
96
+ expectedBehaviour: "What WhatsApp shows is a subset of what the short form collects — title, price, image, condition and stock. This is the reason the short form is the right shape: a seller who fills only it has already supplied everything a WhatsApp buyer will see, so nothing is missing from the channel most of them arrive through.",
97
+ expectedUiState: "The tile shows the image, the name and ₹499. Nothing on it is blank or a placeholder, and nothing it shows was absent from the quick form.",
98
+ endResult: "Read-only against the catalog preview. Delete the probe listing afterwards.",
99
+ },
100
+ "checklist-selling-quick-add-minimum-details-advanced-flip-keeps-values": {
101
+ roles: ["seller"],
102
+ startPage: "/store/products/new",
103
+ steps: [
104
+ SIGN_IN_SELLER,
105
+ "Open /store/products/new.",
106
+ "Enter Product Name 'Flip Probe', Price 750, and Description 'Checking that switching to the full form keeps what was already typed.'",
107
+ "Pick Category 'Beyblade Burst'.",
108
+ "Click 'Show all fields (advanced)'.",
109
+ "Read the Title, Price, Description and Category fields in the sectioned form.",
110
+ "Read whether any value is blank that was filled a moment ago.",
111
+ ],
112
+ inputs: { title: "Flip Probe", price: 750, category: "Beyblade Burst" },
113
+ expectedBehaviour: "The flip changes the presentation, not the draft. Losing typed values on the way to the full form punishes the seller for outgrowing the short one, and teaches them to start with the long form every time — which defeats the point of having a short one.",
114
+ expectedUiState: "Title reads 'Flip Probe', Price 750, Category 'Beyblade Burst' and the description is intact in the sectioned form's Basic and Pricing sections.",
115
+ expectedData: { valuesPreserved: 4 },
116
+ endResult: "Leave without saving.",
117
+ },
118
+ "checklist-selling-quick-add-minimum-details-condition-options-are-all-saveable": {
119
+ roles: ["seller"],
120
+ startPage: "/store/products/new",
121
+ steps: [
122
+ SIGN_IN_SELLER,
123
+ "Open /store/products/new and click 'Show all fields (advanced)'.",
124
+ "Open the Basic section and read every option in the Condition dropdown, writing them all down.",
125
+ "Fill Title 'Condition Probe', Description 'Checking that every condition the form offers can actually be saved.', Category 'Beyblade Burst', Price 499 and a Main Image.",
126
+ "Select Condition 'Like New' and click Publish.",
127
+ "Read the result — inline error, toast, or success.",
128
+ "Repeat with 'Good', then with 'Fair', then with 'New', reading the result each time.",
129
+ ],
130
+ inputs: { title: "Condition Probe", price: 499, conditions: "Like New, Good, Fair, New" },
131
+ expectedBehaviour: "Every option the dropdown offers can be saved. A select that lists a value the server rejects is a trap with no way out — the seller picks the option that describes their item honestly and is refused, with nothing on screen saying which options are real.",
132
+ expectedUiState: "Publishing succeeds for EVERY option in the list, including Like New, Good and Fair. Any option that produces a validation failure is the finding — record which ones and quote the message.",
133
+ expectedData: { saveableConditions: "all options listed in the dropdown" },
134
+ endResult: "Each successful publish is visible on the public page with the chosen condition. Delete the listings afterwards.",
135
+ },
136
+ "checklist-selling-quick-add-minimum-details-description-requirement-is-honest": {
137
+ roles: ["seller"],
138
+ startPage: "/store/products/new",
139
+ steps: [
140
+ SIGN_IN_SELLER,
141
+ "Open /store/products/new.",
142
+ "Read the Description field's label and note exactly whether it says required or optional.",
143
+ "Fill Product Name 'Short Description Probe', Category 'Beyblade Burst', Price 499 and a Product Image.",
144
+ "Type exactly 'Nice bey' in Description — eight characters.",
145
+ "Click Publish and read what happens, and WHERE it appears.",
146
+ "If it was refused, read whether the message appeared on the Description field itself or somewhere else.",
147
+ ],
148
+ inputs: { title: "Short Description Probe", description: "Nice bey", price: 499 },
149
+ expectedBehaviour: "The form tells the truth about what it requires, and it tells it at the field. Either the description really is optional and a short one publishes, or it is required and the label says so with the minimum length stated. What must not happen is a field marked optional being refused by the server after the seller commits — that is the form making a promise the save does not keep.",
150
+ expectedUiState: "Label and behaviour agree. If a minimum length applies, the label states it and a short entry is caught inline on the Description field BEFORE publishing. A toast appearing after Publish, with the field itself showing no error, is the failure.",
151
+ expectedData: { errorShownInline: true },
152
+ endResult: "Either the listing publishes, or nothing is created and the form still holds what was typed. Delete anything created.",
153
+ },
154
+ };
@@ -176,6 +176,8 @@ export const authored = {
176
176
  endResult: "Read-only; nothing persists.",
177
177
  },
178
178
  "checklist-selling-seller-orders-seller-new-order-notification-reaches-seller": {
179
+ requiresHumanChannel: true,
180
+ humanChannelReason: "real email inbox",
179
181
  roles: ["buyer", "seller"],
180
182
  startPage: "/products/product-beyblade-burst-valkyrie",
181
183
  steps: [
@@ -62,6 +62,25 @@ function group(groupKey, groupLabel, pages, opts) {
62
62
  expectedUiState: c.expectedUiState ?? authored?.expectedUiState,
63
63
  expectedData: c.expectedData ?? authored?.expectedData,
64
64
  endResult: c.endResult ?? authored?.endResult,
65
+ /*
66
+ * 🛑 This mapping is HAND-PICKED, so a field the overlay carries but
67
+ * nobody lists here dies right at this line — Root Cause #38, which the
68
+ * header of this very file warns about.
69
+ *
70
+ * `needsReview`/`reviewNote` were exactly that, from the day they were
71
+ * added until 2026-09-13: `fetch-cases.mjs` reads them off the Firestore
72
+ * item, SKILL.md documents them, and `record-verdicts.mjs` renders
73
+ * "the case's own author flagged it for review" in the report — but
74
+ * nothing ever put them ON the document, so the flag was permanently
75
+ * false and that report line had never printed once. Four hops correct,
76
+ * one hop missing, no error anywhere.
77
+ *
78
+ * When adding a field to AuthoredCase, add it HERE in the same commit.
79
+ */
80
+ needsReview: c.needsReview ?? authored?.needsReview,
81
+ reviewNote: c.reviewNote ?? authored?.reviewNote,
82
+ requiresHumanChannel: c.requiresHumanChannel ?? authored?.requiresHumanChannel,
83
+ humanChannelReason: c.humanChannelReason ?? authored?.humanChannelReason,
65
84
  // Every case gets a link: its own href, or the page's default —
66
85
  // guarantees "Go test this ->" always has somewhere real to send
67
86
  // the tester, even for cases nobody bothered to link individually.
@@ -2107,12 +2126,24 @@ const rawTesterChecklistItems = [
2107
2126
  description: "Fixed 2026-08-21 — a fresh upload can briefly 404 while the Firestore doc and Storage object finish propagating; the thumbnail now retries with backoff instead of latching a permanent broken-image placeholder. Try uploading 2-3 images back-to-back on a slower connection to see the retry in action.",
2108
2127
  href: "/store/products/new",
2109
2128
  },
2110
- {
2111
- key: "media-upload-images-capped-at-5",
2112
- label: "The product gallery upload UI caps at 5 images (not 10) and its label reads \"up to 5\"",
2113
- description: "Fixed 2026-08-21 — the UI previously advertised/allowed up to 10 images while the server schema only ever accepted 5, so a 6th+ image silently failed validation with no clear reason.",
2114
- href: "/store/products/new",
2115
- },
2129
+ /*
2130
+ * REMOVED 2026-09-11 — `media-upload-images-capped-at-5`.
2131
+ *
2132
+ * It asserted the gallery caps at "5 images (not 10)". The cap was
2133
+ * raised to 10 (`PRODUCT_MAX_IMAGES`, shared/media/limits.ts:37) and the
2134
+ * `selling/media-limits` page asserts the opposite in its own label:
2135
+ * "The 10th gallery image uploads — the 5th used to fail with a 400".
2136
+ *
2137
+ * So this case documented the OLD behaviour as the expectation. It
2138
+ * would fail against correct code, and anyone "fixing" the product to
2139
+ * satisfy it would re-break ten-image support. Its id encoded the wrong
2140
+ * number too, so correcting the label alone would still mislead.
2141
+ *
2142
+ * The invariant it really tested — the stated limit equals the enforced
2143
+ * limit — survives in `media-eleventh-image-refused-client-side`, which
2144
+ * requires the refusal to name the limit ("You can upload up to 10
2145
+ * images."). Nothing was lost.
2146
+ */
2116
2147
  {
2117
2148
  key: "media-upload-video-duration",
2118
2149
  label: "Attaching a directly-uploaded video file during listing captures its duration automatically and saves without a validation error; attaching a YouTube or external video URL also saves fine without needing a duration",
@@ -2605,6 +2636,80 @@ const rawTesterChecklistItems = [
2605
2636
  },
2606
2637
  ],
2607
2638
  },
2639
+ {
2640
+ pageKey: "product-upload-details",
2641
+ pageLabel: "Product media — crop, refusals, ordering and the video path",
2642
+ href: "/store/products/new",
2643
+ cases: [
2644
+ {
2645
+ key: "main-image-crop-applies",
2646
+ label: "The main-image crop is applied to what gets UPLOADED, not just previewed",
2647
+ description: "BEFORE: a cropper that shows a selection and stores the original is worse than none — the seller believes they framed the photo and the marketplace shows something else. AFTER: crop to the left half, confirm, and the stored thumbnail shows the left half.",
2648
+ },
2649
+ {
2650
+ key: "oversize-image-refused-client-side",
2651
+ label: "An image over 10 MB is refused BEFORE any bytes leave the browser",
2652
+ description: "BEFORE: a 12 MB upload that travels, finalises and then fails validation has taken a minute to say what the form knew when the file was picked. AFTER: immediate refusal reading the size limit, and no /api/media/sign request at all.",
2653
+ },
2654
+ {
2655
+ key: "disallowed-type-refused",
2656
+ label: "An SVG is refused, and the field still works immediately afterwards",
2657
+ description: "SVG is outside the allowed image types on purpose — a document format that can carry script, served from our own origin. Refusing one file must not wedge the control: the next PNG has to upload without a reload.",
2658
+ },
2659
+ {
2660
+ key: "gallery-order-persists",
2661
+ label: "Re-ordering gallery images survives Publish and re-opening the editor",
2662
+ description: "Gallery order is the seller decision about which angle a buyer sees second, so it is stored rather than merely displayed. Order that holds in the form and resets on reload means the array is rebuilt from upload time instead of from the arrangement.",
2663
+ },
2664
+ {
2665
+ key: "main-image-does-not-collide-with-gallery",
2666
+ label: "The main image and the first gallery image do not overwrite each other",
2667
+ description: "Filenames are content-derived with no timestamp, so two uploads computing the same index write the same storage path and the second silently replaces the first — the seller watches one of their two pictures vanish. Main image is index 1; the gallery starts at 2.",
2668
+ },
2669
+ {
2670
+ key: "video-upload-succeeds",
2671
+ label: "A video uploaded from the dedicated video panel saves and plays",
2672
+ description: "BEFORE: /api/media/sign returned 500 because the panel omitted the category from the upload context and the filename generator threw on it — every seller video upload from this panel failed while the gallery path worked. AFTER: sign returns 200, a poster frame and duration appear, and the published page plays it.",
2673
+ },
2674
+ ],
2675
+ },
2676
+ {
2677
+ pageKey: "quick-add-minimum-details",
2678
+ pageLabel: "Quick add — the short form, and what WhatsApp shows",
2679
+ href: "/store/products/new",
2680
+ cases: [
2681
+ {
2682
+ key: "quick-form-is-the-default",
2683
+ label: "Creating an ordinary product opens the SHORT form, with the full form one click away",
2684
+ description: "Most listings need six fields and the sectioned form asks for thirty. /store/products/new opens Quick add; Show all fields (advanced) reaches the rest. Non-standard types open the sectioned form directly because they carry fields the short form has no room for.",
2685
+ },
2686
+ {
2687
+ key: "publish-with-the-minimum",
2688
+ label: "The short form alone can publish a real, buyable listing",
2689
+ description: "If it cannot, it is a decoy: the seller fills it, is refused, and has to learn the long form anyway — having done the work twice.",
2690
+ },
2691
+ {
2692
+ key: "whatsapp-shows-the-same-fields",
2693
+ label: "Everything WhatsApp shows was already asked for by the short form",
2694
+ description: "The catalog push sends title, price, image, condition and stock. That is the quick form field set, which is why the short form is the right shape — a seller who fills only it has supplied everything a WhatsApp buyer will see.",
2695
+ },
2696
+ {
2697
+ key: "advanced-flip-keeps-values",
2698
+ label: "Switching to the full form keeps everything already typed",
2699
+ description: "Losing values on the way to the full form punishes the seller for outgrowing the short one, and teaches them to start with the long form every time — which defeats the point of having a short one.",
2700
+ },
2701
+ {
2702
+ key: "condition-options-are-all-saveable",
2703
+ label: "Every Condition option the form offers can actually be saved",
2704
+ description: "BEFORE: the dropdown lists Like New, Good and Fair while the server accepts only new/used/refurbished/broken, so picking the option that honestly describes the item is refused with nothing on screen saying which options are real. AFTER: every listed option publishes.",
2705
+ },
2706
+ {
2707
+ key: "description-requirement-is-honest",
2708
+ label: "The Description field label agrees with what the save actually requires",
2709
+ description: "BEFORE: the field reads Brief description (optional) and is never validated client-side, while the server demands 20+ characters — so a field marked optional is refused after the seller commits, as a toast, with no error on the field. AFTER: label and behaviour agree and a short entry is caught inline.",
2710
+ },
2711
+ ],
2712
+ },
2608
2713
  {
2609
2714
  pageKey: "media-limits",
2610
2715
  pageLabel: "Media \u2014 ten images and one video, on every listing type",
@@ -95,6 +95,19 @@ export interface SessionProviderProps {
95
95
  }
96
96
  export declare function SessionProvider({ children, initialUser, endpoints: endpointOverrides, onSignOutInvalidate, signOutInvalidationKeys, }: SessionProviderProps): React.JSX.Element;
97
97
  export declare function useSession(): SessionContextValue;
98
+ /**
99
+ * The session if a provider is mounted, `null` if not — never throws.
100
+ *
101
+ * `useSession` throwing is right for a component that genuinely cannot work
102
+ * signed-out. It is wrong for library code that merely wants to ADJUST for the
103
+ * viewer, because requiring a provider there turns an optional refinement into a
104
+ * breaking contract change for every consumer (Root Cause #20).
105
+ *
106
+ * Use this only where "no provider" has a sensible, fail-closed answer — e.g.
107
+ * `useProducts`, which treats an absent session as "not a tester" and keeps the
108
+ * public cached view.
109
+ */
110
+ export declare function useOptionalSession(): SessionContextValue | null;
98
111
  export declare function useAuth(): {
99
112
  user: SessionUser | null;
100
113
  loading: boolean;
@@ -394,6 +394,21 @@ export function useSession() {
394
394
  }
395
395
  return context;
396
396
  }
397
+ /**
398
+ * The session if a provider is mounted, `null` if not — never throws.
399
+ *
400
+ * `useSession` throwing is right for a component that genuinely cannot work
401
+ * signed-out. It is wrong for library code that merely wants to ADJUST for the
402
+ * viewer, because requiring a provider there turns an optional refinement into a
403
+ * breaking contract change for every consumer (Root Cause #20).
404
+ *
405
+ * Use this only where "no provider" has a sensible, fail-closed answer — e.g.
406
+ * `useProducts`, which treats an absent session as "not a tester" and keeps the
407
+ * public cached view.
408
+ */
409
+ export function useOptionalSession() {
410
+ return useContext(SessionContext) ?? null;
411
+ }
397
412
  export function useAuth() {
398
413
  const { user, loading, refreshUser } = useSession();
399
414
  return { user, loading, refreshUser };
@@ -83,7 +83,26 @@ export function ImageLightbox({ images, activeIndex, onClose, onNavigate, showTh
83
83
  adjustZoom(e.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP);
84
84
  }, [adjustZoom]);
85
85
  const handleKeyDown = useCallback((e) => {
86
+ /*
87
+ * Escape unwinds ONE level: fullscreen first, the lightbox second.
88
+ *
89
+ * Unconditionally calling `onClose()` meant Escape in fullscreen tore the
90
+ * whole lightbox down while the browser was natively exiting fullscreen at
91
+ * the same moment — the user asked to leave fullscreen and lost the image
92
+ * they were looking at. The Exit-fullscreen BUTTON behaved correctly, which
93
+ * is what made this read as a keyboard quirk rather than a bug.
94
+ *
95
+ * Exiting explicitly rather than relying on the browser's own handling: the
96
+ * `fullscreenchange` listener above is what clears `isFullscreen`, so going
97
+ * through the same path keeps the button label and this state in agreement
98
+ * whichever way the user leaves.
99
+ */
86
100
  if (e.key === "Escape") {
101
+ if (isFullscreen) {
102
+ const doc = document;
103
+ (document.exitFullscreen?.() ?? doc.webkitExitFullscreen?.())?.catch?.(() => { });
104
+ return;
105
+ }
87
106
  onClose();
88
107
  return;
89
108
  }
@@ -112,7 +131,11 @@ export function ImageLightbox({ images, activeIndex, onClose, onNavigate, showTh
112
131
  setRotation(0);
113
132
  return;
114
133
  }
115
- }, [onClose, navigate, adjustZoom]);
134
+ },
135
+ // `isFullscreen` is a real dependency now — Escape branches on it, and a
136
+ // stale closure would send the user back to the pre-fix behaviour exactly
137
+ // when they are in fullscreen.
138
+ [onClose, navigate, adjustZoom, isFullscreen]);
116
139
  // Focus overlay on open for keyboard to work
117
140
  useEffect(() => {
118
141
  if (isOpen)