@mohasinac/appkit 4.53.2 → 4.54.0-next.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 (179) hide show
  1. package/dist/.build-stamp.json +5 -5
  2. package/dist/_internal/server/features/auth/capabilities.js +7 -5
  3. package/dist/_internal/server/features/checkout/supplier-identity.js +28 -6
  4. package/dist/_internal/server/features/products/listing-tab-counts.js +81 -25
  5. package/dist/_internal/server/features/search/index-build/sources.js +43 -2
  6. package/dist/_internal/server/jobs/core/jobRunners.js +0 -10
  7. package/dist/_internal/server/jobs/core/onOrderCreate.js +10 -104
  8. package/dist/_internal/shared/actions/action-registry.d.ts +1 -1
  9. package/dist/_internal/shared/actions/action-registry.js +128 -0
  10. package/dist/_internal/shared/features/categories/bundle-copy.d.ts +1 -1
  11. package/dist/_internal/shared/features/site-settings/supplier-of-record.d.ts +56 -0
  12. package/dist/_internal/shared/features/site-settings/supplier-of-record.js +57 -0
  13. package/dist/_internal/shared/listing-types/tiers.d.ts +94 -0
  14. package/dist/_internal/shared/listing-types/tiers.js +150 -0
  15. package/dist/cli/index.js +0 -2
  16. package/dist/client.d.ts +21 -2
  17. package/dist/client.js +23 -4
  18. package/dist/constants/api-endpoints.d.ts +21 -0
  19. package/dist/constants/api-endpoints.js +7 -0
  20. package/dist/features/account/components/UserOrdersView.js +26 -7
  21. package/dist/features/addresses/utils/postal-autofill.d.ts +39 -0
  22. package/dist/features/addresses/utils/postal-autofill.js +54 -0
  23. package/dist/features/admin/components/AdminStoreEditorView.js +73 -34
  24. package/dist/features/admin/components/OwnerQuickActions.d.ts +29 -0
  25. package/dist/features/admin/components/OwnerQuickActions.js +142 -0
  26. package/dist/features/admin/schemas/admin-editor-forms.d.ts +1 -1
  27. package/dist/features/admin/schemas/admin-editor-forms.js +4 -1
  28. package/dist/features/auctions/components/MarketplaceAuctionCard.d.ts +11 -1
  29. package/dist/features/auctions/components/MarketplaceAuctionCard.js +9 -2
  30. package/dist/features/auth/permissions/constants.d.ts +29 -2
  31. package/dist/features/auth/permissions/constants.js +68 -1
  32. package/dist/features/blog/components/BlogFeaturedCard.d.ts +11 -1
  33. package/dist/features/blog/components/BlogFeaturedCard.js +9 -2
  34. package/dist/features/cart/components/CartDrawer.js +3 -2
  35. package/dist/features/cart/components/CartGroupLineRow.js +2 -1
  36. package/dist/features/cart/hooks/useAddToCart.js +10 -5
  37. package/dist/features/cart/utils/pending-ops.d.ts +11 -1
  38. package/dist/features/cart/utils/pending-ops.js +14 -2
  39. package/dist/features/categories/components/BrandDetailPageView.js +2 -1
  40. package/dist/features/categories/components/CategoryDetailPageView.js +2 -1
  41. package/dist/features/categories/components/CategoryGrid.d.ts +11 -1
  42. package/dist/features/categories/components/CategoryGrid.js +9 -2
  43. package/dist/features/checkout/actions/checkout-value-otp-actions.d.ts +10 -5
  44. package/dist/features/checkout/actions/checkout-value-otp-actions.js +2 -88
  45. package/dist/features/events/components/EventCard.d.ts +11 -1
  46. package/dist/features/events/components/EventCard.js +9 -2
  47. package/dist/features/layout/AppLayoutShell.js +2 -1
  48. package/dist/features/layout/BottomActions.d.ts +1 -0
  49. package/dist/features/layout/BottomActions.js +330 -0
  50. package/dist/features/layout/BottomChrome.d.ts +45 -0
  51. package/dist/features/layout/BottomChrome.js +5 -17
  52. package/dist/features/layout/SideActionDock.js +36 -13
  53. package/dist/features/layout/index.d.ts +2 -7
  54. package/dist/features/layout/index.js +7 -7
  55. package/dist/features/layout/useIsDesktop.d.ts +36 -0
  56. package/dist/features/layout/useIsDesktop.js +48 -0
  57. package/dist/features/media/messages/en.json +67 -67
  58. package/dist/features/orders/components/YourPurchaseStrip.d.ts +24 -0
  59. package/dist/features/orders/components/YourPurchaseStrip.js +77 -0
  60. package/dist/features/orders/components/index.d.ts +2 -1
  61. package/dist/features/orders/components/index.js +1 -1
  62. package/dist/features/orders/utils/order-announcement-message.d.ts +18 -0
  63. package/dist/features/orders/utils/order-announcement-message.js +16 -0
  64. package/dist/features/orders/utils/payment-proof-message.d.ts +20 -0
  65. package/dist/features/orders/utils/payment-proof-message.js +17 -0
  66. package/dist/features/parts/schemas/firestore.d.ts +227 -0
  67. package/dist/features/parts/schemas/firestore.js +6 -6
  68. package/dist/features/pre-orders/components/MarketplacePreorderCard.d.ts +11 -1
  69. package/dist/features/pre-orders/components/MarketplacePreorderCard.js +10 -3
  70. package/dist/features/products/api/[id]/route.js +8 -1
  71. package/dist/features/products/api/route.js +16 -4
  72. package/dist/features/products/components/AvailabilityScopeHint.js +1 -1
  73. package/dist/features/products/components/InteractiveProductCard.d.ts +7 -1
  74. package/dist/features/products/components/InteractiveProductCard.js +16 -4
  75. package/dist/features/products/components/MarketplaceBundleCard.d.ts +11 -1
  76. package/dist/features/products/components/MarketplaceBundleCard.js +9 -2
  77. package/dist/features/products/components/MarketplacePrizeDrawCard.d.ts +11 -1
  78. package/dist/features/products/components/MarketplacePrizeDrawCard.js +9 -2
  79. package/dist/features/products/components/ProductDetailPageView.js +27 -2
  80. package/dist/features/products/components/ProductGrid.d.ts +14 -1
  81. package/dist/features/products/components/ProductGrid.js +22 -5
  82. package/dist/features/products/components/ProductsIndexListing.js +2 -2
  83. package/dist/features/reviews/api/[id]/route.js +83 -23
  84. package/dist/features/reviews/components/PurchaseReviewAffordance.d.ts +6 -0
  85. package/dist/features/reviews/components/PurchaseReviewAffordance.js +42 -0
  86. package/dist/features/reviews/components/ReviewFormDrawer.d.ts +34 -0
  87. package/dist/features/reviews/components/ReviewFormDrawer.js +127 -0
  88. package/dist/features/reviews/components/index.d.ts +4 -0
  89. package/dist/features/reviews/components/index.js +2 -0
  90. package/dist/features/seller/actions/seller-actions.d.ts +1 -1
  91. package/dist/features/seller/actions/seller-actions.js +49 -3
  92. package/dist/features/seller/components/SellerPayoutSettingsView.js +30 -2
  93. package/dist/features/seller/components/SellerProductShell.d.ts +17 -1
  94. package/dist/features/seller/hooks/useStoreListingEntitlements.d.ts +8 -0
  95. package/dist/features/seller/hooks/useStoreListingEntitlements.js +70 -0
  96. package/dist/features/stores/components/InteractiveStoreCard.d.ts +11 -1
  97. package/dist/features/stores/components/InteractiveStoreCard.js +9 -2
  98. package/dist/features/stores/components/index.d.ts +0 -2
  99. package/dist/features/stores/components/index.js +0 -1
  100. package/dist/features/stores/schemas/index.d.ts +1 -1
  101. package/dist/features/stores/schemas/index.js +10 -1
  102. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +403 -0
  103. package/dist/index.d.ts +8 -27
  104. package/dist/index.js +14 -35
  105. package/dist/next/routing/route-map.d.ts +2 -0
  106. package/dist/next/routing/route-map.js +9 -0
  107. package/dist/providers/shipping-shiprocket/index.d.ts +98 -0
  108. package/dist/providers/shipping-shiprocket/index.js +353 -0
  109. package/dist/schemas/registry.d.ts +2 -2
  110. package/dist/security/csp.d.ts +16 -1
  111. package/dist/security/csp.js +5 -3
  112. package/dist/seed/_helpers/category-forest.js +27 -0
  113. package/dist/seed/_helpers/category-parts-glossary-map.js +1816 -86
  114. package/dist/seed/_helpers/category-parts.d.ts +1 -1
  115. package/dist/seed/_helpers/category-parts.js +13412 -3020
  116. package/dist/seed/categories-seed-data.js +48 -6
  117. package/dist/seed/homepage-sections-seed-data.js +41 -28
  118. package/dist/seed/model-composition-seed-data.js +6 -8
  119. package/dist/seed/part-glossary/chunk0.d.ts +9 -0
  120. package/dist/seed/part-glossary/chunk0.js +35044 -0
  121. package/dist/seed/part-glossary/chunk1.d.ts +9 -0
  122. package/dist/seed/part-glossary/chunk1.js +39955 -0
  123. package/dist/seed/part-glossary/chunk2.d.ts +9 -0
  124. package/dist/seed/part-glossary/chunk2.js +50335 -0
  125. package/dist/seed/part-glossary/chunk3.d.ts +9 -0
  126. package/dist/seed/part-glossary/chunk3.js +52386 -0
  127. package/dist/seed/part-glossary/chunk4.d.ts +9 -0
  128. package/dist/seed/part-glossary/chunk4.js +50549 -0
  129. package/dist/seed/part-glossary-seed-data.d.ts +1 -1
  130. package/dist/seed/part-glossary-seed-data.js +23 -47483
  131. package/dist/server.d.ts +4 -8
  132. package/dist/server.js +8 -20
  133. package/dist/styles.css +516 -358
  134. package/dist/tailwind-utilities.css +1 -1
  135. package/dist/tokens/themes/index.d.ts +3 -1
  136. package/dist/tokens/themes/index.js +11 -1
  137. package/dist/tokens/themes/technical-dark.d.ts +30 -0
  138. package/dist/tokens/themes/technical-dark.js +103 -0
  139. package/dist/tokens/themes/technical-light.d.ts +22 -0
  140. package/dist/tokens/themes/technical-light.js +89 -0
  141. package/dist/tokens/tokens.css +155 -0
  142. package/dist/ui/components/GatedPrice.js +30 -3
  143. package/dist/ui/components/GatedPrice.style.css +22 -19
  144. package/dist/ui/components/QuickCreateModal.style.css +78 -78
  145. package/dist/ui/forms/FormShell.style.css +260 -260
  146. package/package.json +1 -1
  147. package/dist/_internal/server/jobs/core/whatsappCatalogImport.d.ts +0 -27
  148. package/dist/_internal/server/jobs/core/whatsappCatalogImport.js +0 -172
  149. package/dist/_internal/server/jobs/core/whatsappNotify.d.ts +0 -35
  150. package/dist/_internal/server/jobs/core/whatsappNotify.js +0 -140
  151. package/dist/features/admin/components/AdminWhatsAppGuideView.d.ts +0 -2
  152. package/dist/features/admin/components/AdminWhatsAppGuideView.js +0 -35
  153. package/dist/features/orders/components/MarketplaceOrderCard.d.ts +0 -36
  154. package/dist/features/orders/components/MarketplaceOrderCard.js +0 -57
  155. package/dist/features/stores/components/StoreWhatsAppGuideView.d.ts +0 -3
  156. package/dist/features/stores/components/StoreWhatsAppGuideView.js +0 -12
  157. package/dist/features/whatsapp-bot/api/send-status/route.d.ts +0 -12
  158. package/dist/features/whatsapp-bot/api/send-status/route.js +0 -38
  159. package/dist/features/whatsapp-bot/api/webhook/route.d.ts +0 -12
  160. package/dist/features/whatsapp-bot/api/webhook/route.js +0 -43
  161. package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.d.ts +0 -6
  162. package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.js +0 -270
  163. package/dist/features/whatsapp-bot/components/WhatsAppChatButton.d.ts +0 -9
  164. package/dist/features/whatsapp-bot/components/WhatsAppChatButton.js +0 -7
  165. package/dist/features/whatsapp-bot/components/index.d.ts +0 -3
  166. package/dist/features/whatsapp-bot/components/index.js +0 -2
  167. package/dist/features/whatsapp-bot/helpers/payment-proof-message.d.ts +0 -14
  168. package/dist/features/whatsapp-bot/helpers/payment-proof-message.js +0 -17
  169. package/dist/features/whatsapp-bot/helpers/whatsapp.d.ts +0 -52
  170. package/dist/features/whatsapp-bot/helpers/whatsapp.js +0 -283
  171. package/dist/features/whatsapp-bot/index.d.ts +0 -3
  172. package/dist/features/whatsapp-bot/index.js +0 -3
  173. package/dist/features/whatsapp-bot/manifest.d.ts +0 -2
  174. package/dist/features/whatsapp-bot/manifest.js +0 -13
  175. package/dist/features/whatsapp-bot/messages/en.json +0 -7
  176. package/dist/features/whatsapp-bot/server.d.ts +0 -7
  177. package/dist/features/whatsapp-bot/server.js +0 -7
  178. package/dist/features/whatsapp-bot/types/index.d.ts +0 -126
  179. package/dist/features/whatsapp-bot/types/index.js +0 -1
@@ -11,8 +11,21 @@
11
11
  import { NextResponse } from "next/server.js";
12
12
  import { z } from "zod";
13
13
  import { mediaUrlSchema } from "../../../../validation/schemas.js";
14
- import { getProviders } from "../../../../contracts/index.js";
15
14
  import { createRouteHandler } from "../../../../next/api/routeHandler.js";
15
+ /*
16
+ * 🛑 The real repository, not `db.getRepository<Review>("reviews")`.
17
+ *
18
+ * The generic FirebaseRepository knows nothing about this collection, so every
19
+ * read and write through it skipped ReviewRepository's overrides:
20
+ *
21
+ * - `update` encrypts `userName` (PII) and rebuilds `searchTxt`. Writing
22
+ * through the generic repo left the tokens describing the OLD text, so an
23
+ * edited review was findable only by what it used to say.
24
+ * - `mapDoc` decrypts `userName` and strips `helpfulVoterIds`. Reading
25
+ * through the generic repo returned the name as raw `enc:v1:…` ciphertext
26
+ * and published the voter list.
27
+ */
28
+ import { reviewRepository } from "../../repository/reviews.repository.js";
16
29
  import { normalizeError } from "../../../../errors/normalize.js";
17
30
  import { isAdminUser, isModeratorUser } from "../../../auth/role-predicates.js";
18
31
  const ERR_REVIEW_NOT_FOUND = "Review not found";
@@ -20,12 +33,14 @@ const ERR_REVIEW_NOT_FOUND = "Review not found";
20
33
  export async function GET(_request, context) {
21
34
  try {
22
35
  const { id } = await context.params;
23
- const { db } = getProviders();
24
- if (!db) {
25
- return NextResponse.json({ success: false, error: "Database provider not registered" }, { status: 503 });
26
- }
27
- const repo = db.getRepository("reviews");
28
- const review = await repo.findById(id);
36
+ /*
37
+ * Through `reviewRepository`, so `mapDoc` runs: `userName` is decrypted
38
+ * and `helpfulVoterIds` is stripped. The generic repo did neither, so this
39
+ * public endpoint returned the reviewer's name as raw `enc:v1:…`
40
+ * ciphertext and published the list of everyone who had voted the review
41
+ * helpful.
42
+ */
43
+ const review = await reviewRepository.findById(id);
29
44
  if (!review) {
30
45
  return NextResponse.json({ success: false, error: ERR_REVIEW_NOT_FOUND }, { status: 404 });
31
46
  }
@@ -39,6 +54,19 @@ export async function GET(_request, context) {
39
54
  }
40
55
  export { GET as reviewItemGET };
41
56
  // ---- PATCH /api/reviews/[id] -------------------------------------------------
57
+ /*
58
+ * 🛑 `.strict()`, not `.passthrough()`.
59
+ *
60
+ * It was passthrough, so any unlisted key reached the write — including
61
+ * `userId`, `verified`, `helpfulCount` and `featured`. A reviewer could have
62
+ * marked their own review a verified purchase, or featured it on the homepage,
63
+ * by adding one field to the request body. Nothing in the handler below looked
64
+ * at the extra keys; they went straight into Firestore.
65
+ *
66
+ * `.strict()` rejects them with a 400 naming the field rather than silently
67
+ * dropping them, which is the honest answer to a caller sending something this
68
+ * endpoint does not own.
69
+ */
42
70
  const reviewUpdateSchema = z
43
71
  .object({
44
72
  rating: z
@@ -53,10 +81,28 @@ const reviewUpdateSchema = z
53
81
  title: z.string().min(1).max(100).optional(),
54
82
  comment: z.string().min(1).max(2000).optional(),
55
83
  images: z.array(mediaUrlSchema).max(10).optional(),
56
- video: mediaUrlSchema.optional(),
57
84
  status: z.enum(["pending", "approved", "rejected"]).optional(),
58
85
  })
59
- .passthrough();
86
+ .strict();
87
+ /*
88
+ * 🛑 `video` is deliberately NOT accepted here, and that is a fix rather than
89
+ * an omission.
90
+ *
91
+ * It used to be declared as a bare `mediaUrlSchema` — a URL string — while
92
+ * `ReviewDocument.video` is a `ReviewVideoField` object. So a caller sending
93
+ * the real shape failed validation, and a caller sending a string wrote a
94
+ * string into a field every renderer reads as an object, leaving `video.url`
95
+ * undefined and the player with nothing.
96
+ *
97
+ * Accepting the object instead would still be wrong on this route: the URL
98
+ * would arrive as an unfinalised `tmp/` reference, because nothing here calls
99
+ * `finalizeStagedMediaUrl`. `updateReview` (features/reviews/actions) is the
100
+ * path that owns review media — it finalises the upload and builds the full
101
+ * field — and `REVIEW_UPDATABLE_FIELDS` already documents the updatable set as
102
+ * rating / title / comment / images, with no video in it.
103
+ *
104
+ * One owner for media writes, and this is not it.
105
+ */
60
106
  export const reviewItemPATCH = createRouteHandler({
61
107
  auth: true,
62
108
  schema: reviewUpdateSchema,
@@ -64,11 +110,7 @@ export const reviewItemPATCH = createRouteHandler({
64
110
  const url = new URL(request.url);
65
111
  const segments = url.pathname.split("/");
66
112
  const id = segments[segments.length - 1];
67
- const { db } = getProviders();
68
- if (!db)
69
- return NextResponse.json({ success: false, error: "DB not configured" }, { status: 503 });
70
- const repo = db.getRepository("reviews");
71
- const review = await repo.findById(id);
113
+ const review = await reviewRepository.findById(id);
72
114
  if (!review)
73
115
  return NextResponse.json({ success: false, error: ERR_REVIEW_NOT_FOUND }, { status: 404 });
74
116
  const isOwner = review.userId === user.uid;
@@ -77,13 +119,34 @@ export const reviewItemPATCH = createRouteHandler({
77
119
  if (!isOwner && !isModerator && !isAdmin) {
78
120
  return NextResponse.json({ success: false, error: "Forbidden" }, { status: 403 });
79
121
  }
80
- // Owners can only edit content fields, not status
122
+ /*
123
+ * Owners edit content, not moderation state. Pinning `status` rather than
124
+ * rejecting the field is deliberate and pre-existing: an approved review
125
+ * edited by its author stays approved and is NOT sent back for
126
+ * re-moderation.
127
+ */
81
128
  const patch = isOwner && !isModerator && !isAdmin
82
129
  ? { ...body, status: review.status }
83
130
  : body;
84
- const updated = await repo.update(id, {
131
+ /*
132
+ * 🛑 `hasImages` is DERIVED and has to move with `images`, or the two
133
+ * disagree. It is a real queryable field (`hasImages` is `canFilter` in
134
+ * SIEVE_FIELDS and backs the "with photos" review filter), so a review
135
+ * whose last photo was removed stayed in that filter for ever, and one
136
+ * that gained its first was absent from it.
137
+ */
138
+ const nextImages = body?.images;
139
+ const derived = nextImages !== undefined ? { hasImages: nextImages.length > 0 } : {};
140
+ /*
141
+ * 🛑 A `Date`, not an ISO string. Everything else in this codebase writes
142
+ * `Date` to `updatedAt`, and `reviewRepository`'s own Sieve config marks it
143
+ * sortable — so a string here sorts lexicographically beside real
144
+ * timestamps and silently orders wrong.
145
+ */
146
+ const updated = await reviewRepository.update(id, {
85
147
  ...patch,
86
- updatedAt: new Date().toISOString(),
148
+ ...derived,
149
+ updatedAt: new Date(),
87
150
  });
88
151
  return NextResponse.json({ success: true, data: updated });
89
152
  },
@@ -95,11 +158,8 @@ export const reviewItemDELETE = createRouteHandler({
95
158
  const url = new URL(request.url);
96
159
  const segments = url.pathname.split("/");
97
160
  const id = segments[segments.length - 1];
98
- const { db } = getProviders();
99
- if (!db)
100
- return NextResponse.json({ success: false, error: "DB not configured" }, { status: 503 });
101
- const repo = db.getRepository("reviews");
102
- const review = await repo.findById(id);
161
+ // Same repository as GET and PATCH — one collection, one repository.
162
+ const review = await reviewRepository.findById(id);
103
163
  if (!review)
104
164
  return NextResponse.json({ success: false, error: ERR_REVIEW_NOT_FOUND }, { status: 404 });
105
165
  const isOwner = review.userId === user.uid;
@@ -108,7 +168,7 @@ export const reviewItemDELETE = createRouteHandler({
108
168
  if (!isOwner && !isModerator && !isAdmin) {
109
169
  return NextResponse.json({ success: false, error: "Forbidden" }, { status: 403 });
110
170
  }
111
- await repo.delete(id);
171
+ await reviewRepository.delete(id);
112
172
  return NextResponse.json({ success: true });
113
173
  },
114
174
  });
@@ -0,0 +1,6 @@
1
+ export interface PurchaseReviewAffordanceProps {
2
+ productId: string;
3
+ storeId?: string;
4
+ className?: string;
5
+ }
6
+ export declare function PurchaseReviewAffordance({ productId, storeId, className, }: PurchaseReviewAffordanceProps): import("react").JSX.Element;
@@ -0,0 +1,42 @@
1
+ "use client";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ /**
4
+ * The strip and the review drawer, connected.
5
+ *
6
+ * Exists because the two halves need shared client state (which review is
7
+ * open) while the product detail page that mounts them is an async Server
8
+ * Component. One small client island is cheaper than making the detail view
9
+ * take two render-prop slots and thread state between them.
10
+ *
11
+ * @tag domain:reviews
12
+ * @tag layer:components
13
+ * @tag access:client
14
+ */
15
+ import { useCallback, useState } from "react";
16
+ import { useQueryClient } from "@tanstack/react-query";
17
+ import { YourPurchaseStrip } from "../../orders/components/YourPurchaseStrip.js";
18
+ import { ReviewFormDrawer } from "./ReviewFormDrawer.js";
19
+ export function PurchaseReviewAffordance({ productId, storeId, className, }) {
20
+ const queryClient = useQueryClient();
21
+ const [open, setOpen] = useState(false);
22
+ const [editingId, setEditingId] = useState(undefined);
23
+ /*
24
+ * Invalidate the strip's own query, not just the reviews list. The strip is
25
+ * what shows "Your review: 4/5", so saving without invalidating it leaves
26
+ * the buyer looking at their old rating and reasonably concluding the edit
27
+ * did not take.
28
+ */
29
+ const handleSaved = useCallback(() => {
30
+ void queryClient.invalidateQueries({
31
+ queryKey: ["user", "purchases", productId],
32
+ });
33
+ void queryClient.invalidateQueries({ queryKey: ["reviews"] });
34
+ }, [queryClient, productId]);
35
+ return (_jsxs(_Fragment, { children: [_jsx(YourPurchaseStrip, { productId: productId, className: className, onEditReview: (reviewId) => {
36
+ setEditingId(reviewId);
37
+ setOpen(true);
38
+ }, onWriteReview: () => {
39
+ setEditingId(undefined);
40
+ setOpen(true);
41
+ } }), _jsx(ReviewFormDrawer, { isOpen: open, onClose: () => setOpen(false), reviewId: editingId, productId: productId, storeId: storeId, onSaved: handleSaved })] }));
42
+ }
@@ -0,0 +1,34 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * Mirrors the PATCH route's schema, which is `.strict()`. A field this form can
4
+ * send that the route does not accept is a 400 naming it — which is the point:
5
+ * the two were allowed to disagree before, and the route silently accepted
6
+ * anything.
7
+ */
8
+ export declare const reviewFormSchema: z.ZodObject<{
9
+ rating: z.ZodNumber;
10
+ title: z.ZodString;
11
+ comment: z.ZodString;
12
+ }, "strip", z.ZodTypeAny, {
13
+ title: string;
14
+ rating: number;
15
+ comment: string;
16
+ }, {
17
+ title: string;
18
+ rating: number;
19
+ comment: string;
20
+ }>;
21
+ export type ReviewFormValues = z.infer<typeof reviewFormSchema>;
22
+ export interface ReviewFormDrawerProps {
23
+ isOpen: boolean;
24
+ onClose: () => void;
25
+ /** Present = editing that review. Absent = writing a new one. */
26
+ reviewId?: string;
27
+ /** Required when writing; the review is filed against this product. */
28
+ productId?: string;
29
+ storeId?: string;
30
+ initialValues?: Partial<ReviewFormValues>;
31
+ /** Called after a successful save, so the caller can refetch. */
32
+ onSaved?: () => void;
33
+ }
34
+ export declare function ReviewFormDrawer({ isOpen, onClose, reviewId, productId, storeId, initialValues, onSaved, }: ReviewFormDrawerProps): import("react").JSX.Element;
@@ -0,0 +1,127 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ /**
4
+ * Write or edit a review.
5
+ *
6
+ * ── This is the first review form the application has ever had ────────────
7
+ *
8
+ * 🛑 Measured before building: `useCreateReview` had ZERO component consumers,
9
+ * `ReviewForm` did not exist, `ViewReviewModal` is read-only, `/user/reviews`
10
+ * was index-only with no detail or edit route, and the ACTIONS registry had no
11
+ * REVIEW bucket. The repo already knew — a tester case records
12
+ * "createReviewAction, useCreateReview and the UI_TEXT.WRITE_REVIEW string all
13
+ * exist, but no .tsx consumes any of them". So there was a complete write path
14
+ * with nothing able to reach it.
15
+ *
16
+ * ── Field names, because the obvious guesses are all wrong ────────────────
17
+ *
18
+ * body → `comment` (NOT `body`)
19
+ * owner → `userId` (NOT `buyerId`)
20
+ * verified → `verified` (there is no `isVerifiedPurchase`)
21
+ * reply → `sellerReply`
22
+ *
23
+ * `REVIEW_UPDATABLE_FIELDS` is already `["rating","title","comment","images"]`,
24
+ * so that is exactly what this form owns — and exactly what the PATCH route
25
+ * accepts now that it is `.strict()`.
26
+ *
27
+ * 🛑 Images are deliberately NOT edited here. The PATCH route does not finalise
28
+ * staged media, so a URL submitted through it would persist as a `tmp/`
29
+ * reference; `updateReview` owns review media. Rather than ship a picker whose
30
+ * uploads silently rot, this form edits the text and the rating.
31
+ *
32
+ * @tag domain:reviews
33
+ * @tag layer:components
34
+ * @tag access:client
35
+ */
36
+ import { useEffect, useState } from "react";
37
+ import { z } from "zod";
38
+ import { apiClient } from "../../../http/index.js";
39
+ import { ACTIONS } from "../../../_internal/shared/actions/action-registry.js";
40
+ import { REVIEW_ENDPOINTS } from "../../../constants/api-endpoints.js";
41
+ import { Button, FieldInput, FieldTextarea, Form, Row, SideDrawer, Stack, Text, } from "../../../ui/index.js";
42
+ import { FormErrorSummary } from "../../../ui/forms/FormErrorSummary.js";
43
+ // Keys errors by the FULL dotted path, so a nested issue cannot collapse onto
44
+ // one map entry and lose all but the last.
45
+ import { applyZodIssues } from "../../../ui/forms/apply-zod-issues.js";
46
+ import { Icon } from "../../../ui/components/Icon.js";
47
+ import { IconButton } from "../../../ui/components/IconButton.js";
48
+ import { normalizeError } from "../../../errors/normalize.js";
49
+ import { toUserMessage } from "../../../errors/error-display-map.js";
50
+ /**
51
+ * Mirrors the PATCH route's schema, which is `.strict()`. A field this form can
52
+ * send that the route does not accept is a 400 naming it — which is the point:
53
+ * the two were allowed to disagree before, and the route silently accepted
54
+ * anything.
55
+ */
56
+ export const reviewFormSchema = z.object({
57
+ rating: z
58
+ .number()
59
+ .int()
60
+ .min(1, "Pick a rating from 1 to 5")
61
+ .max(5, "Pick a rating from 1 to 5"),
62
+ title: z.string().trim().min(1, "Give your review a title").max(100),
63
+ comment: z
64
+ .string()
65
+ .trim()
66
+ .min(1, "Say something about the item")
67
+ .max(2000, "Keep it under 2000 characters"),
68
+ });
69
+ const EMPTY = { rating: 0, title: "", comment: "" };
70
+ export function ReviewFormDrawer({ isOpen, onClose, reviewId, productId, storeId, initialValues, onSaved, }) {
71
+ const editing = Boolean(reviewId);
72
+ const [values, setValues] = useState({
73
+ ...EMPTY,
74
+ ...initialValues,
75
+ });
76
+ const [busy, setBusy] = useState(false);
77
+ const [formError, setFormError] = useState(null);
78
+ /*
79
+ * Re-seed when the drawer is reopened for a DIFFERENT review. Without this
80
+ * the second review a buyer edits in one session shows the first one's text
81
+ * — the component stays mounted, so initial state alone is not enough.
82
+ */
83
+ useEffect(() => {
84
+ if (isOpen)
85
+ setValues({ ...EMPTY, ...initialValues });
86
+ // eslint-disable-next-line react-hooks/exhaustive-deps
87
+ }, [isOpen, reviewId]);
88
+ async function submit({ setFieldError, clearErrors, markSubmitAttempted, }) {
89
+ clearErrors();
90
+ setFormError(null);
91
+ markSubmitAttempted?.();
92
+ const parsed = reviewFormSchema.safeParse(values);
93
+ if (!parsed.success) {
94
+ applyZodIssues(parsed.error.issues, setFieldError);
95
+ return;
96
+ }
97
+ setBusy(true);
98
+ try {
99
+ if (reviewId) {
100
+ await apiClient.patch(REVIEW_ENDPOINTS.BY_ID(reviewId), parsed.data);
101
+ }
102
+ else {
103
+ await apiClient.post(REVIEW_ENDPOINTS.LIST, {
104
+ ...parsed.data,
105
+ productId,
106
+ storeId,
107
+ });
108
+ }
109
+ onSaved?.();
110
+ onClose();
111
+ }
112
+ catch (err) {
113
+ const normalized = normalizeError(err);
114
+ /*
115
+ * 🛑 toUserMessage, never the server's own message. A raw `?? error`
116
+ * fallback is what rendered a Node MODULE_NOT_FOUND with a require
117
+ * stack inside a bid modal (Root Cause #86); this resolves through
118
+ * errors.codes.* and terminates at UNKNOWN.
119
+ */
120
+ setFormError(toUserMessage(normalized.code ?? "UNKNOWN"));
121
+ }
122
+ finally {
123
+ setBusy(false);
124
+ }
125
+ }
126
+ return (_jsx(SideDrawer, { isOpen: isOpen, onClose: onClose, title: editing ? "Edit your review" : "Write a review", children: _jsx(Form, { schema: reviewFormSchema, onSubmit: (e) => e.preventDefault(), className: "space-y-4", children: ({ setFieldError, clearErrors, markSubmitAttempted }) => (_jsxs(Stack, { gap: "md", children: [_jsx(FormErrorSummary, {}), _jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "sm", weight: "medium", children: "Rating" }), _jsx(Row, { gap: "xs", align: "center", children: [1, 2, 3, 4, 5].map((n) => (_jsx(IconButton, { "aria-label": `${n} star${n === 1 ? "" : "s"}`, "aria-pressed": values.rating === n, size: "md", variant: "ghost", icon: _jsx(Icon, { name: "review", size: "lg", filled: n <= values.rating, tone: n <= values.rating ? "warning" : "faint" }), onClick: () => setValues((v) => ({ ...v, rating: n })) }, n))) })] }), _jsx(FieldInput, { name: "title", label: "Title", required: true, value: values.title, onChange: (title) => setValues((v) => ({ ...v, title })) }), _jsx(FieldTextarea, { name: "comment", label: "Your review", required: true, rows: 6, value: values.comment, onChange: (comment) => setValues((v) => ({ ...v, comment })) }), formError && (_jsx(Text, { size: "sm", color: "error", role: "alert", children: formError })), _jsx(Button, { type: "submit", isLoading: busy, action: editing ? ACTIONS.REVIEW.edit : ACTIONS.REVIEW.write, onClick: () => void submit({ setFieldError, clearErrors, markSubmitAttempted }) })] })) }) }));
127
+ }
@@ -11,3 +11,7 @@ export type { ReviewsIndexListingProps } from "./ReviewsIndexListing";
11
11
  export { ReviewsListingPanel } from "./ReviewsListingPanel";
12
12
  export type { ReviewsListingPanelProps, ReviewsSource } from "./ReviewsListingPanel";
13
13
  export type { ReviewDetailPageViewProps } from "./ReviewDetailPageView";
14
+ export { ReviewFormDrawer, reviewFormSchema } from "./ReviewFormDrawer";
15
+ export type { ReviewFormDrawerProps, ReviewFormValues } from "./ReviewFormDrawer";
16
+ export { PurchaseReviewAffordance } from "./PurchaseReviewAffordance";
17
+ export type { PurchaseReviewAffordanceProps } from "./PurchaseReviewAffordance";
@@ -4,3 +4,5 @@ export { ReviewSummary } from "./ReviewSummary.js";
4
4
  export { ViewReviewModal } from "./ReviewModal.js";
5
5
  export { ReviewsIndexListing } from "./ReviewsIndexListing.js";
6
6
  export { ReviewsListingPanel } from "./ReviewsListingPanel.js";
7
+ export { ReviewFormDrawer, reviewFormSchema } from "./ReviewFormDrawer.js";
8
+ export { PurchaseReviewAffordance } from "./PurchaseReviewAffordance.js";
@@ -130,7 +130,7 @@ export declare function bulkSellerOrder(userId: string, userRole: string, userDi
130
130
  * only safe shape — a caller-supplied `storeId` would let a seller file a
131
131
  * listing under someone else's store.
132
132
  */
133
- export declare function createSellerProduct(userId: string, userName: string, userEmail: string, input: Record<string, JsonValue>): Promise<void>;
133
+ export declare function createSellerProduct(userId: string, userName: string, userEmail: string, input: Record<string, JsonValue>, userRole?: string): Promise<void>;
134
134
  export declare function getSellerStore(userId: string): Promise<StoreDocument | null>;
135
135
  export declare function getSellerShipping(userId: string): Promise<import("../../auth").SellerShippingConfig | null>;
136
136
  export declare function getSellerPayoutSettings(userId: string): Promise<SellerPayoutDetails | {
@@ -13,6 +13,7 @@ import { normalizeError } from "../../../errors/normalize.js";
13
13
  import { enqueueJob } from "../../jobs/actions/enqueue-job.js";
14
14
  import { isAdminUser, isStrictSellerUser, isTesterUser } from "../../auth/role-predicates.js";
15
15
  import { ApiError, AuthorizationError, NotFoundError, ValidationError, } from "../../../errors/index.js";
16
+ import { assertListingTypeAllowed } from "../../../_internal/shared/listing-types/tiers.js";
16
17
  import { userRepository } from "../../auth/repository/user.repository.js";
17
18
  import { storeRepository } from "../../stores/repository/store.repository.js";
18
19
  import { productRepository } from "../../products/repository/products.repository.js";
@@ -427,7 +428,13 @@ export async function bulkSellerOrder(userId, userRole, userDisplayName, userEma
427
428
  * only safe shape — a caller-supplied `storeId` would let a seller file a
428
429
  * listing under someone else's store.
429
430
  */
430
- export async function createSellerProduct(userId, userName, userEmail, input) {
431
+ export async function createSellerProduct(userId, userName, userEmail, input,
432
+ /*
433
+ * 🛑 Defaults to `"seller"`, which FAILS CLOSED. A caller that forgets to
434
+ * pass a role gets the gate, not a bypass — the opposite default would make
435
+ * every un-updated call site an admin.
436
+ */
437
+ userRole = "seller") {
431
438
  const store = await storeRepository.findByOwnerId(userId);
432
439
  // Refuse rather than write an orphan. A listing with no store is invisible on
433
440
  // its own storefront, forms no order group at checkout, and cannot be paid
@@ -436,6 +443,19 @@ export async function createSellerProduct(userId, userName, userEmail, input) {
436
443
  if (!store) {
437
444
  throw new NotFoundError("No store found for this account. Create your store first.");
438
445
  }
446
+ /*
447
+ * The advanced-listing-type gate, at the choke point every create funnels
448
+ * through.
449
+ *
450
+ * It lives HERE rather than only in the consumer action because this is the
451
+ * one place all creates meet, and because the store document is already in
452
+ * scope — so the check costs no extra Firestore read (Rule #6). The caller's
453
+ * own gate is a nicety; this one is the guarantee.
454
+ */
455
+ assertListingTypeAllowed(input.listingType, {
456
+ isPrivileged: userRole === "admin",
457
+ capabilities: store.capabilities,
458
+ });
439
459
  const finalizedData = await finalizeProductMediaReferences(input);
440
460
  await productRepository.create({
441
461
  ...finalizedData,
@@ -592,11 +612,37 @@ export async function sellerUpdateProduct(userId, userRole, productId, input) {
592
612
  if (!existing)
593
613
  throw new NotFoundError("Product not found");
594
614
  // existing.storeId is the store slug, not the seller's Firebase UID.
595
- if (userRole !== "admin") {
596
- const store = await storeRepository.findByOwnerId(userId);
615
+ const isPrivileged = userRole === "admin";
616
+ let store = null;
617
+ if (!isPrivileged) {
618
+ store = await storeRepository.findByOwnerId(userId);
597
619
  if (!store || existing.storeId !== store.id)
598
620
  throw new AuthorizationError("You do not own this product");
599
621
  }
622
+ /*
623
+ * 🛑 The UPDATE path had no listing-type gate at all, which made the
624
+ * create-time one bypassable in a single request: create a `standard`
625
+ * listing, then PATCH `listingType: "auction"` onto it and the type stuck.
626
+ * Gating only creates is gating nothing.
627
+ *
628
+ * 🛑 But it fires only on a CHANGE of type, and that is load-bearing. Every
629
+ * per-type edit page spreads its own literal into the payload
630
+ * (`store/art/[id]/edit` sends `listingType: "art"`), so gating on the
631
+ * submitted value alone would lock a seller out of editing listings they
632
+ * already own the moment the capability was turned off — or an admin revoked
633
+ * it. The rule is "you may not create or convert INTO an advanced type",
634
+ * never "you may not touch one".
635
+ *
636
+ * Reuses the store already loaded for the ownership check above, so a
637
+ * non-admin update pays no extra read. An admin skips both.
638
+ */
639
+ if (typeof input.listingType === "string" &&
640
+ input.listingType !== existing.listingType) {
641
+ assertListingTypeAllowed(input.listingType, {
642
+ isPrivileged,
643
+ capabilities: store?.capabilities,
644
+ });
645
+ }
600
646
  if (typeof input.status === "string" &&
601
647
  input.status !== "published" &&
602
648
  existing.status === "published") {
@@ -88,6 +88,23 @@ const payoutSettingsDraftSchema = z.object({
88
88
  */
89
89
  supportsShippingProtection: annotate(z.boolean(), { section: "preferences", order: 5, row: "quarter" }),
90
90
  supportsSaleProtection: annotate(z.boolean(), { section: "preferences", order: 6, row: "quarter" }),
91
+ /*
92
+ * Advanced listing types — pre-orders, prize draws, classifieds, digital
93
+ * codes, live items, art and stickers. Everyone can already list
94
+ * `standard`, `spares` and `auction`.
95
+ *
96
+ * 🛑 Alongside payout settings for the same reason the protections are:
97
+ * each of these carries an obligation, not just a form. A pre-order takes
98
+ * money for stock that does not exist yet, a prize draw is a regulated
99
+ * promotion, a classified happens off-platform with no escrow, a
100
+ * digital-code listing must be delivered from a pool the seller has to keep
101
+ * stocked, and a live listing is an animal. A new seller should meet those
102
+ * deliberately rather than by discovering a nav item.
103
+ *
104
+ * OFF by default, and the copy has to say what enabling ACCEPTS — the same
105
+ * reason the protection toggles say what declining costs.
106
+ */
107
+ advancedListingTypesEnabled: annotate(z.boolean(), { section: "preferences", order: 7, row: "quarter" }),
91
108
  }).superRefine((v, ctx) => {
92
109
  if (v.method === "upi") {
93
110
  if (!v.upiId.trim()) {
@@ -128,6 +145,13 @@ const DEFAULT_DRAFT = {
128
145
  // OFF by default — matches StoreDocument, and absence must read as declined.
129
146
  supportsShippingProtection: false,
130
147
  supportsSaleProtection: false,
148
+ /*
149
+ * OFF by default, which is also the direction the server gate fails. A
150
+ * default of `true` here would be worse than cosmetic: this draft is what
151
+ * gets PATCHed, so it would silently GRANT the capability on the next
152
+ * unrelated save of this form.
153
+ */
154
+ advancedListingTypesEnabled: false,
131
155
  };
132
156
  export function SellerPayoutSettingsView({ apiBase = SELLER_ENDPOINTS.PAYOUT_SETTINGS }) {
133
157
  const [draft, setDraft] = useState(DEFAULT_DRAFT);
@@ -175,6 +199,9 @@ export function SellerPayoutSettingsView({ apiBase = SELLER_ENDPOINTS.PAYOUT_SET
175
199
  pricesIncludeGst: res?.data?.pricesIncludeGst ?? true,
176
200
  supportsShippingProtection: res?.data?.supportsShippingProtection ?? false,
177
201
  supportsSaleProtection: res?.data?.supportsSaleProtection ?? false,
202
+ // Absent means NOT entitled. Defaulting this to `true` would be a
203
+ // silent GRANT on the next save, since this draft is what gets sent.
204
+ advancedListingTypesEnabled: res?.data?.advancedListingTypesEnabled ?? false,
178
205
  });
179
206
  })
180
207
  .catch(console.error)
@@ -205,6 +232,7 @@ export function SellerPayoutSettingsView({ apiBase = SELLER_ENDPOINTS.PAYOUT_SET
205
232
  pricesIncludeGst: draft.pricesIncludeGst,
206
233
  supportsShippingProtection: draft.supportsShippingProtection,
207
234
  supportsSaleProtection: draft.supportsSaleProtection,
235
+ advancedListingTypesEnabled: draft.advancedListingTypesEnabled,
208
236
  // Flat, matching the route's schema. A nested `taxInfo` object was
209
237
  // stripped by `z.object()` and never reached Firestore.
210
238
  gstin: draft.gstin.trim(),
@@ -271,8 +299,8 @@ export function SellerPayoutSettingsView({ apiBase = SELLER_ENDPOINTS.PAYOUT_SET
271
299
  {
272
300
  id: "preferences",
273
301
  label: "Preferences",
274
- fields: ["autoPayout", "minimumThreshold", "emiEnabled", "pricesIncludeGst", "supportsShippingProtection", "supportsSaleProtection"],
275
- render: ({ values, onChange }) => (_jsxs(Stack, { gap: "md", children: [_jsx(Heading, { level: 3, className: "mb-2", children: "Payout Preferences" }), _jsx(Toggle, { checked: values.autoPayout, onChange: (checked) => onChange({ autoPayout: checked }), label: "Enable auto-payout \u2014 automatically transfer earnings on schedule", disabled: busy }), _jsx(FormField, { name: "minimumThreshold", label: "Minimum Payout Threshold (\u20B9)", type: "number", value: values.minimumThreshold, onChange: (v) => onChange({ minimumThreshold: v }), placeholder: "e.g. 500", helpText: "Payouts are held until your balance exceeds this amount. Leave blank for no minimum.", disabled: busy }), _jsxs(Div, { className: `${__P.p3} bg-[var(--appkit-color-surface-alt)] border border-[var(--appkit-color-border)]`, rounded: "lg", children: [_jsx(Text, { className: "mb-1", size: "sm", weight: "medium", children: "Payout Schedule" }), _jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "sm", children: "Auto-payouts run every Monday for the previous week's settled orders. Manual payouts can be requested from the Payouts page at any time." })] }), _jsx(Toggle, { checked: values.emiEnabled, onChange: (checked) => onChange({ emiEnabled: checked }), label: "Offer EMI financing on eligible orders", disabled: busy }), _jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "sm", children: "Only shown to buyers when platform-wide EMI is also enabled and their cart subtotal from your store exceeds the site's minimum order value. Orders on an EMI plan hold your payout until every installment is paid, unless you mark the item \"ship before EMI completes\"." }), _jsx(Toggle, { checked: values.pricesIncludeGst, onChange: (checked) => onChange({ pricesIncludeGst: checked }), label: "The prices I enter already include GST", disabled: busy }), _jsxs(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "sm", children: ["Buyers always see a tax-inclusive price, so this only changes how your own entry is read. Leave it", " ", _jsx(Span, { weight: "semibold", children: "on" }), " and a \u20B9999 listing at 5% GST means the buyer pays \u20B9999 and you earn \u20B9951.43 \u2014 the \u20B947.57 is GST we collect and remit for you. Turn it", " ", _jsx(Span, { weight: "semibold", children: "off" }), " and \u20B9999 is treated as your ex-GST price: the listing is shown at \u20B91,048.95 and you earn \u20B9999."] }), _jsx(Heading, { level: 3, className: "mb-2 mt-4", children: "Buyer Protections" }), _jsxs(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "sm", children: ["Two separate covers a buyer can pay for at checkout. Supporting one earns a safety badge on your listings;", " ", _jsx(Span, { weight: "semibold", children: "declining shows buyers a risk notice instead" }), " \u2014 there is no neutral option, because a buyer who is told nothing cannot tell a seller who declined from one who was never asked."] }), _jsx(Toggle, { checked: values.supportsShippingProtection, onChange: (checked) => onChange({ supportsShippingProtection: checked }), label: "Offer shipment protection \u2014 loss or damage in transit", disabled: busy }), _jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "sm", children: "The parcel never arrived or arrived damaged. We pursue the courier, not you, and the buyer is refunded from the funds we hold \u2014 so a genuine transit loss costs you the item, not an argument." }), _jsx(Toggle, { checked: values.supportsSaleProtection, onChange: (checked) => onChange({ supportsSaleProtection: checked }), label: "Offer sale protection \u2014 item not as described", disabled: busy }), _jsxs(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "sm", children: ["A no-questions-asked refund on the item itself. Be clear about what this means:", " ", _jsx(Span, { weight: "semibold", children: "a claim we inspect and find genuine is charged back to you" }), " ", "\u2014 that is the cover. A claim we find to be a change of mind is absorbed by us, which is what the buyer's fee pays for. Not offered on your final-sale listings, since \"no returns\" and \"no questions asked\" cannot both be true."] }), _jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "sm", children: "Changing this does not re-price listings you have already published. It applies to products you save from now on." })] })),
302
+ fields: ["autoPayout", "minimumThreshold", "emiEnabled", "pricesIncludeGst", "supportsShippingProtection", "supportsSaleProtection", "advancedListingTypesEnabled"],
303
+ render: ({ values, onChange }) => (_jsxs(Stack, { gap: "md", children: [_jsx(Heading, { level: 3, className: "mb-2", children: "Payout Preferences" }), _jsx(Toggle, { checked: values.autoPayout, onChange: (checked) => onChange({ autoPayout: checked }), label: "Enable auto-payout \u2014 automatically transfer earnings on schedule", disabled: busy }), _jsx(FormField, { name: "minimumThreshold", label: "Minimum Payout Threshold (\u20B9)", type: "number", value: values.minimumThreshold, onChange: (v) => onChange({ minimumThreshold: v }), placeholder: "e.g. 500", helpText: "Payouts are held until your balance exceeds this amount. Leave blank for no minimum.", disabled: busy }), _jsxs(Div, { className: `${__P.p3} bg-[var(--appkit-color-surface-alt)] border border-[var(--appkit-color-border)]`, rounded: "lg", children: [_jsx(Text, { className: "mb-1", size: "sm", weight: "medium", children: "Payout Schedule" }), _jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "sm", children: "Auto-payouts run every Monday for the previous week's settled orders. Manual payouts can be requested from the Payouts page at any time." })] }), _jsx(Toggle, { checked: values.emiEnabled, onChange: (checked) => onChange({ emiEnabled: checked }), label: "Offer EMI financing on eligible orders", disabled: busy }), _jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "sm", children: "Only shown to buyers when platform-wide EMI is also enabled and their cart subtotal from your store exceeds the site's minimum order value. Orders on an EMI plan hold your payout until every installment is paid, unless you mark the item \"ship before EMI completes\"." }), _jsx(Toggle, { checked: values.pricesIncludeGst, onChange: (checked) => onChange({ pricesIncludeGst: checked }), label: "The prices I enter already include GST", disabled: busy }), _jsxs(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "sm", children: ["Buyers always see a tax-inclusive price, so this only changes how your own entry is read. Leave it", " ", _jsx(Span, { weight: "semibold", children: "on" }), " and a \u20B9999 listing at 5% GST means the buyer pays \u20B9999 and you earn \u20B9951.43 \u2014 the \u20B947.57 is GST we collect and remit for you. Turn it", " ", _jsx(Span, { weight: "semibold", children: "off" }), " and \u20B9999 is treated as your ex-GST price: the listing is shown at \u20B91,048.95 and you earn \u20B9999."] }), _jsx(Heading, { level: 3, className: "mb-2 mt-4", children: "Buyer Protections" }), _jsxs(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "sm", children: ["Two separate covers a buyer can pay for at checkout. Supporting one earns a safety badge on your listings;", " ", _jsx(Span, { weight: "semibold", children: "declining shows buyers a risk notice instead" }), " \u2014 there is no neutral option, because a buyer who is told nothing cannot tell a seller who declined from one who was never asked."] }), _jsx(Toggle, { checked: values.supportsShippingProtection, onChange: (checked) => onChange({ supportsShippingProtection: checked }), label: "Offer shipment protection \u2014 loss or damage in transit", disabled: busy }), _jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "sm", children: "The parcel never arrived or arrived damaged. We pursue the courier, not you, and the buyer is refunded from the funds we hold \u2014 so a genuine transit loss costs you the item, not an argument." }), _jsx(Toggle, { checked: values.supportsSaleProtection, onChange: (checked) => onChange({ supportsSaleProtection: checked }), label: "Offer sale protection \u2014 item not as described", disabled: busy }), _jsxs(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "sm", children: ["A no-questions-asked refund on the item itself. Be clear about what this means:", " ", _jsx(Span, { weight: "semibold", children: "a claim we inspect and find genuine is charged back to you" }), " ", "\u2014 that is the cover. A claim we find to be a change of mind is absorbed by us, which is what the buyer's fee pays for. Not offered on your final-sale listings, since \"no returns\" and \"no questions asked\" cannot both be true."] }), _jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "sm", children: "Changing this does not re-price listings you have already published. It applies to products you save from now on." }), _jsx(Toggle, { checked: values.advancedListingTypesEnabled, onChange: (checked) => onChange({ advancedListingTypesEnabled: checked }), label: "Enable advanced listing types", disabled: busy }), _jsxs(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "sm", children: ["Adds pre-orders, prize draws, classifieds, digital codes, live items, art and stickers alongside the products, spares and auctions you can already list. Each carries its own obligation:", " ", _jsx(Span, { weight: "semibold", children: "a pre-order takes payment for stock you do not have yet, a prize draw is a regulated promotion, a classified happens off-platform with no escrow protection, a digital code must be delivered from a pool you keep stocked, and a live listing is an animal." }), " ", "Turning this off later does not unpublish listings you have already created \u2014 it only stops you making new ones."] })] })),
276
304
  },
277
305
  ], [busy, current]);
278
306
  const { openIds, setOpenIds, goToSection, fieldToSectionIndex, sectionMeta } = useSectionFormNav(sections, draft);
@@ -1,6 +1,22 @@
1
1
  import React from "react";
2
2
  import type { MediaField } from "../../media/types";
3
- export type ProductListingMode = "standard" | "auction" | "pre-order" | "prize-draw" | "bundle" | "classified" | "digital-code" | "live" | "art" | "stickers" | "spares";
3
+ import type { ListingType } from "../../products/types/index";
4
+ /**
5
+ * What this shell is editing: any listing type, or a bundle.
6
+ *
7
+ * 🛑 DERIVED from `ListingType`, not restated. This was a hand-written copy of
8
+ * the union plus `"bundle"` — the eleventh such copy in the codebase — and the
9
+ * whole point of `ALL_LISTING_TYPES_MAP` being a `Record<ListingType, true>`
10
+ * is that a new member cannot be silently omitted from a list like this one.
11
+ * Root Cause #61.
12
+ *
13
+ * `"bundle"` is a deliberate EXTRA, not an oversight: bundles stopped being a
14
+ * `listingType` in SB-UNI-D (they are a `categoryType` on `categories` now),
15
+ * but this shell still edits them, which is what `pluginForMode`'s
16
+ * `BUNDLE_PLUGIN_FALLBACK` is for. Spelling it as a union with one added
17
+ * literal says that, where a twelve-item list said nothing.
18
+ */
19
+ export type ProductListingMode = ListingType | "bundle";
4
20
  export interface SellerProductDraft {
5
21
  title?: string;
6
22
  slug?: string;
@@ -0,0 +1,8 @@
1
+ import type { ListingType } from "../../products/types/index";
2
+ export interface StoreListingEntitlements {
3
+ /** Types this store may create. Never empty — basics are always included. */
4
+ allowed: Set<ListingType>;
5
+ advancedEnabled: boolean;
6
+ isLoading: boolean;
7
+ }
8
+ export declare function useStoreListingEntitlements(): StoreListingEntitlements;