@mohasinac/appkit 4.14.0 → 4.15.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 (157) hide show
  1. package/dist/_internal/server/features/auctions/actions.js +2 -2
  2. package/dist/_internal/server/features/cart/service.d.ts +3 -3
  3. package/dist/_internal/server/features/checkout/actions.js +19 -0
  4. package/dist/_internal/server/features/checkout/source-context.d.ts +5 -0
  5. package/dist/_internal/server/features/checkout/source-context.js +157 -0
  6. package/dist/_internal/server/features/offers/adapters.d.ts +103 -0
  7. package/dist/_internal/server/features/offers/adapters.js +88 -0
  8. package/dist/_internal/server/features/offers/index.d.ts +2 -0
  9. package/dist/_internal/server/features/offers/index.js +1 -0
  10. package/dist/_internal/server/features/orders/adapters.js +24 -0
  11. package/dist/_internal/server/jobs/core/offerExpiry.js +10 -10
  12. package/dist/_internal/shared/actions/action-registry.js +3 -1
  13. package/dist/_internal/shared/features/auctions/schema.d.ts +14 -2
  14. package/dist/_internal/shared/features/auctions/schema.js +13 -1
  15. package/dist/_internal/shared/features/cart/schema.d.ts +14 -9
  16. package/dist/_internal/shared/features/cart/schema.js +15 -0
  17. package/dist/_internal/shared/features/products/schema.d.ts +2 -2
  18. package/dist/_internal/shared/history/history.d.ts +22 -0
  19. package/dist/_internal/shared/history/history.js +41 -1
  20. package/dist/client.d.ts +13 -2
  21. package/dist/client.js +20 -2
  22. package/dist/constants/field-names.d.ts +8 -6
  23. package/dist/constants/field-names.js +48 -6
  24. package/dist/features/account/components/UserBidsView.js +12 -4
  25. package/dist/features/account/components/UserReturnsView.d.ts +3 -0
  26. package/dist/features/account/components/UserReturnsView.js +17 -1
  27. package/dist/features/admin/components/AdminAuditLogView.js +1 -0
  28. package/dist/features/admin/components/AdminBidsView.js +15 -2
  29. package/dist/features/admin/components/AdminBlogEditorView.js +65 -33
  30. package/dist/features/admin/components/AdminFeatureEditorView.js +35 -6
  31. package/dist/features/admin/components/AdminNavEditorView.js +15 -5
  32. package/dist/features/admin/components/AdminNewsletterView.js +18 -2
  33. package/dist/features/admin/components/AdminOffersView.js +62 -18
  34. package/dist/features/admin/components/AdminPaymentMethodsView.js +24 -4
  35. package/dist/features/admin/components/AdminScammerEditorView.d.ts +2 -1
  36. package/dist/features/admin/components/AdminScammerEditorView.js +3 -13
  37. package/dist/features/admin/components/AdminScammersView.js +3 -8
  38. package/dist/features/admin/components/AdminSectionsView.js +32 -23
  39. package/dist/features/admin/components/AdminSessionsView.js +17 -2
  40. package/dist/features/admin/components/ViewAuditLogEntryModal.js +1 -0
  41. package/dist/features/admin/components/sections/adminSectionsTypes.d.ts +4 -2
  42. package/dist/features/admin/components/sections/adminSectionsTypes.js +45 -27
  43. package/dist/features/admin/constants/filter-tabs.d.ts +3 -0
  44. package/dist/features/admin/constants/filter-tabs.js +1 -0
  45. package/dist/features/admin/schemas/homepage-section-form.d.ts +22 -0
  46. package/dist/features/admin/schemas/homepage-section-form.js +68 -0
  47. package/dist/features/admin/schemas/nav-item-form.d.ts +74 -0
  48. package/dist/features/admin/schemas/nav-item-form.js +72 -0
  49. package/dist/features/auctions/components/AuctionBidsTable.d.ts +10 -1
  50. package/dist/features/auctions/components/AuctionBidsTable.js +22 -4
  51. package/dist/features/auctions/components/bid-detail-fields.d.ts +29 -0
  52. package/dist/features/auctions/components/bid-detail-fields.js +103 -0
  53. package/dist/features/audit-log/schemas/firestore.d.ts +14 -0
  54. package/dist/features/audit-log/schemas/firestore.js +7 -0
  55. package/dist/features/blog/schemas/blog-form.d.ts +80 -0
  56. package/dist/features/blog/schemas/blog-form.js +100 -0
  57. package/dist/features/events/components/AdminEventEditorView.js +32 -54
  58. package/dist/features/events/components/AdminEventEntriesView.js +22 -3
  59. package/dist/features/events/schemas/event-form.d.ts +113 -0
  60. package/dist/features/events/schemas/event-form.js +82 -0
  61. package/dist/features/grouped/schemas/grouped-listing-form.d.ts +101 -0
  62. package/dist/features/grouped/schemas/grouped-listing-form.js +89 -0
  63. package/dist/features/homepage/actions/homepage-section-actions.d.ts +11 -3
  64. package/dist/features/homepage/actions/homepage-section-actions.js +33 -1
  65. package/dist/features/homepage/schemas/index.d.ts +31 -0
  66. package/dist/features/homepage/schemas/index.js +24 -0
  67. package/dist/features/orders/components/OrderStatusTimeline.d.ts +5 -1
  68. package/dist/features/orders/components/OrderStatusTimeline.js +39 -20
  69. package/dist/features/orders/repository/orders.repository.js +5 -0
  70. package/dist/features/orders/schemas/firestore.d.ts +36 -1
  71. package/dist/features/orders/types/index.d.ts +35 -7
  72. package/dist/features/products/components/GroupSettingsPanel.js +5 -1
  73. package/dist/features/products/schemas/index.d.ts +2 -2
  74. package/dist/features/products/schemas/product-features.validators.d.ts +73 -0
  75. package/dist/features/products/schemas/product-features.validators.js +63 -0
  76. package/dist/features/scams/constants/scammer-status-display.d.ts +28 -0
  77. package/dist/features/scams/constants/scammer-status-display.js +79 -0
  78. package/dist/features/seller/actions/offer-actions.js +32 -2
  79. package/dist/features/seller/components/OfferPhaseTimeline.d.ts +59 -0
  80. package/dist/features/seller/components/OfferPhaseTimeline.js +107 -0
  81. package/dist/features/seller/components/SellerBidsView.js +11 -4
  82. package/dist/features/seller/components/SellerCouponEditorView.js +17 -17
  83. package/dist/features/seller/components/SellerOffersView.js +2 -1
  84. package/dist/features/seller/components/SellerPayoutSettingsView.js +38 -14
  85. package/dist/features/seller/repository/offer.repository.d.ts +69 -8
  86. package/dist/features/seller/repository/offer.repository.js +127 -23
  87. package/dist/features/seller/schemas/coupon-form.d.ts +72 -0
  88. package/dist/features/seller/schemas/coupon-form.js +116 -0
  89. package/dist/features/seller/schemas/firestore.d.ts +56 -2
  90. package/dist/features/seller/schemas/firestore.js +27 -0
  91. package/dist/features/seller/schemas/offer-forms.d.ts +21 -0
  92. package/dist/features/seller/schemas/offer-forms.js +21 -0
  93. package/dist/features/shell/build-sections.d.ts +63 -0
  94. package/dist/features/shell/build-sections.js +207 -0
  95. package/dist/features/shell/field-ui-meta.d.ts +115 -0
  96. package/dist/features/shell/field-ui-meta.js +324 -0
  97. package/dist/features/shell/index.d.ts +4 -0
  98. package/dist/features/shell/index.js +2 -0
  99. package/dist/features/status-history/components/StatusTimeline.d.ts +74 -0
  100. package/dist/features/status-history/components/StatusTimeline.js +46 -0
  101. package/dist/features/status-history/index.d.ts +11 -0
  102. package/dist/features/status-history/index.js +10 -0
  103. package/dist/features/store-extensions/components/ReviewDecisionModal.d.ts +27 -0
  104. package/dist/features/store-extensions/components/ReviewDecisionModal.js +82 -0
  105. package/dist/features/store-extensions/index.d.ts +12 -0
  106. package/dist/features/store-extensions/index.js +12 -0
  107. package/dist/features/store-extensions/schemas/admin-notification-form.d.ts +36 -0
  108. package/dist/features/store-extensions/schemas/admin-notification-form.js +114 -0
  109. package/dist/features/store-extensions/schemas/analytics-forms.d.ts +57 -0
  110. package/dist/features/store-extensions/schemas/analytics-forms.js +158 -0
  111. package/dist/features/store-extensions/schemas/custom-role-form.d.ts +85 -0
  112. package/dist/features/store-extensions/schemas/custom-role-form.js +118 -0
  113. package/dist/features/store-extensions/schemas/firestore.d.ts +21 -1
  114. package/dist/features/store-extensions/schemas/firestore.js +60 -0
  115. package/dist/features/store-extensions/schemas/google-config-form.d.ts +53 -0
  116. package/dist/features/store-extensions/schemas/google-config-form.js +105 -0
  117. package/dist/features/store-extensions/schemas/item-request-create-form.d.ts +28 -0
  118. package/dist/features/store-extensions/schemas/item-request-create-form.js +80 -0
  119. package/dist/features/store-extensions/schemas/listing-template-form.d.ts +78 -0
  120. package/dist/features/store-extensions/schemas/listing-template-form.js +87 -0
  121. package/dist/features/store-extensions/schemas/moderation-review-form.d.ts +49 -0
  122. package/dist/features/store-extensions/schemas/moderation-review-form.js +101 -0
  123. package/dist/features/store-extensions/schemas/payout-method-form.d.ts +153 -0
  124. package/dist/features/store-extensions/schemas/payout-method-form.js +155 -0
  125. package/dist/features/store-extensions/schemas/report-create-form.d.ts +25 -0
  126. package/dist/features/store-extensions/schemas/report-create-form.js +79 -0
  127. package/dist/features/store-extensions/schemas/report-review-form.d.ts +47 -0
  128. package/dist/features/store-extensions/schemas/report-review-form.js +111 -0
  129. package/dist/features/store-extensions/schemas/shipping-config-form.d.ts +148 -0
  130. package/dist/features/store-extensions/schemas/shipping-config-form.js +104 -0
  131. package/dist/features/store-extensions/schemas/store-category-form.d.ts +54 -0
  132. package/dist/features/store-extensions/schemas/store-category-form.js +73 -0
  133. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +326 -0
  134. package/dist/index.d.ts +12 -2
  135. package/dist/index.js +12 -2
  136. package/dist/next/routing/route-map.d.ts +16 -2
  137. package/dist/next/routing/route-map.js +9 -2
  138. package/dist/schemas/firestore-value.d.ts +8 -0
  139. package/dist/schemas/firestore-value.js +44 -0
  140. package/dist/schemas/index.d.ts +1 -1
  141. package/dist/schemas/index.js +1 -1
  142. package/dist/schemas/registry.d.ts +634 -1
  143. package/dist/schemas/registry.js +64 -1
  144. package/dist/seed/bids-seed-data.js +30 -0
  145. package/dist/seed/offers-seed-data.js +212 -7
  146. package/dist/seed/orders-seed-data.js +336 -1
  147. package/dist/seed/products-auctions-seed-data.js +41 -0
  148. package/dist/server.d.ts +2 -0
  149. package/dist/server.js +5 -0
  150. package/dist/styles.css +1 -1
  151. package/dist/tailwind-utilities.css +1 -1
  152. package/dist/ui/components/RecordDetailModal.d.ts +10 -1
  153. package/dist/ui/components/RecordDetailModal.js +2 -2
  154. package/package.json +2 -1
  155. package/scripts/node-esm-loader.mjs +65 -0
  156. package/scripts/seed-cli-loader.mjs +6 -49
  157. package/scripts/seed-cli.mjs +1 -1
@@ -0,0 +1,29 @@
1
+ import type { BidDocument } from "../schemas/firestore";
2
+ /** Which portal is looking. Decides whether bidder identity is shown. */
3
+ export type BidDetailViewer = "buyer" | "seller" | "admin";
4
+ type FieldRow = {
5
+ label: string;
6
+ value: string;
7
+ };
8
+ /**
9
+ * A bid's status as a badge.
10
+ *
11
+ * `forfeited` is deliberately distinguished from `cancelled`: a forfeited bid
12
+ * WON and then let the payment window lapse, which is the seller's problem and
13
+ * the buyer's fault; a cancelled one never won anything. Collapsing them would
14
+ * hide exactly the distinction a support query is about.
15
+ */
16
+ export declare function bidStatusBadge(status: string | undefined): {
17
+ label: string;
18
+ tone: string;
19
+ };
20
+ /**
21
+ * The scalar rows for a bid's detail modal.
22
+ *
23
+ * `bidderName` appears only for seller and admin. A buyer opening their OWN
24
+ * bid does not need to be told their own name, and the same component is used
25
+ * by all three, so gating here rather than at each call site is what stops a
26
+ * future copy-paste showing one buyer another's identity.
27
+ */
28
+ export declare function buildBidDetailFields(bid: BidDocument, viewer: BidDetailViewer): FieldRow[];
29
+ export {};
@@ -0,0 +1,103 @@
1
+ /*
2
+ * WHY: All three bid listings — buyer, seller and admin — were dead ends. A
3
+ * bid could be SEEN in a list and never opened, so the amount, the
4
+ * auction it belongs to, when it was placed and whether it was outbid,
5
+ * won or forfeited were only ever visible as a table row. `bid` is also
6
+ * one of the three `relatedType` values with no per-record page in ANY
7
+ * role, so a bid notification had nowhere to link.
8
+ * WHAT: The `<RecordDetailModal>` field list for a bid, built once.
9
+ *
10
+ * ## One builder, three portals
11
+ *
12
+ * The three views differ only in whether they may show the bidder's identity:
13
+ * a buyer sees their own bid, a seller sees who bid on their auction, and an
14
+ * admin sees everything. Writing the field list three times would be three
15
+ * chances to leak a bidder's name into the buyer view or to forget a field in
16
+ * one portal — the shape of Root Cause #38, one layer up.
17
+ *
18
+ * `maskPublicBid` already exists for the PUBLIC bid history and is unchanged;
19
+ * this is the dashboard equivalent, where the viewer is authenticated and the
20
+ * question is which role they hold.
21
+ *
22
+ * EXPORTS:
23
+ * buildBidDetailFields, bidStatusBadge, type BidDetailViewer
24
+ *
25
+ * @tag domain:auctions
26
+ * @tag layer:feature-util
27
+ * @tag pattern:none
28
+ * @tag access:isomorphic
29
+ * @tag consumers:UserBidsView,SellerBidsView,AdminBidsView
30
+ * @tag sideEffects:none
31
+ */
32
+ const money = (amount, currency = "INR") => amount == null
33
+ ? "—"
34
+ : new Intl.NumberFormat("en-IN", { style: "currency", currency, maximumFractionDigits: 2 }).format(amount);
35
+ const when = (value) => {
36
+ if (!value)
37
+ return "—";
38
+ const d = value instanceof Date ? value : new Date(String(value));
39
+ return Number.isNaN(d.getTime()) ? "—" : d.toLocaleString();
40
+ };
41
+ /**
42
+ * A bid's status as a badge.
43
+ *
44
+ * `forfeited` is deliberately distinguished from `cancelled`: a forfeited bid
45
+ * WON and then let the payment window lapse, which is the seller's problem and
46
+ * the buyer's fault; a cancelled one never won anything. Collapsing them would
47
+ * hide exactly the distinction a support query is about.
48
+ */
49
+ export function bidStatusBadge(status) {
50
+ switch (status) {
51
+ case "won":
52
+ return { label: "Won", tone: "success" };
53
+ case "active":
54
+ return { label: "Leading", tone: "info" };
55
+ case "outbid":
56
+ return { label: "Outbid", tone: "warning" };
57
+ case "forfeited":
58
+ return { label: "Forfeited — payment window lapsed", tone: "danger" };
59
+ case "cancelled":
60
+ return { label: "Cancelled", tone: "default" };
61
+ case "lost":
62
+ return { label: "Lost", tone: "default" };
63
+ default:
64
+ return { label: status ?? "Unknown", tone: "default" };
65
+ }
66
+ }
67
+ /**
68
+ * The scalar rows for a bid's detail modal.
69
+ *
70
+ * `bidderName` appears only for seller and admin. A buyer opening their OWN
71
+ * bid does not need to be told their own name, and the same component is used
72
+ * by all three, so gating here rather than at each call site is what stops a
73
+ * future copy-paste showing one buyer another's identity.
74
+ */
75
+ export function buildBidDetailFields(bid, viewer) {
76
+ const rows = [
77
+ { label: "Auction", value: bid.productTitle || bid.productId },
78
+ { label: "Bid amount", value: money(bid.bidAmount, bid.currency) },
79
+ ];
80
+ // Only meaningful when the bid raised a previous one.
81
+ if (bid.previousBidAmount != null) {
82
+ rows.push({ label: "Previous high bid", value: money(bid.previousBidAmount, bid.currency) });
83
+ }
84
+ if (bid.autoMaxBid != null) {
85
+ rows.push({ label: "Auto-bid limit", value: money(bid.autoMaxBid, bid.currency) });
86
+ }
87
+ rows.push({ label: "Placed", value: when(bid.bidDate ?? bid.createdAt) });
88
+ if (viewer !== "buyer") {
89
+ rows.push({ label: "Bidder", value: bid.userName || bid.userId });
90
+ }
91
+ // A buyout is a real bid, not a bid-free purchase — surfacing it here is
92
+ // what makes "why is there no ladder on this auction" answerable.
93
+ if (bid.isBuyout) {
94
+ rows.push({ label: "Placed via", value: "Buy Now" });
95
+ }
96
+ // The order this bid became. Present only once the win was actually paid,
97
+ // which is exactly the question "did they ever pay?" asks.
98
+ if (bid.orderId) {
99
+ rows.push({ label: "Order", value: bid.orderId });
100
+ }
101
+ rows.push({ label: "Bid ID", value: bid.id });
102
+ return rows;
103
+ }
@@ -23,6 +23,13 @@ export declare const AdminAuditActionValues: {
23
23
  readonly PAYOUT_MARK_PAID: "payout_mark_paid";
24
24
  readonly STORE_STATUS_CHANGE: "store_status_change";
25
25
  readonly USER_ROLE_CHANGE: "user_role_change";
26
+ /**
27
+ * Admin cancelled an offer. The store keeps sole authority to accept,
28
+ * counter and decline; cancelling is the ONE write an admin may make, and
29
+ * it is an escalation — reason required, and recorded both here and in the
30
+ * offer's own `statusHistory`.
31
+ */
32
+ readonly OFFER_CANCEL: "offer_cancel";
26
33
  };
27
34
  export type AdminAuditAction = (typeof AdminAuditActionValues)[keyof typeof AdminAuditActionValues];
28
35
  export interface AdminAuditLogDocument extends BaseDocument {
@@ -55,5 +62,12 @@ export declare const ADMIN_AUDIT_LOG_FIELDS: {
55
62
  readonly PAYOUT_MARK_PAID: "payout_mark_paid";
56
63
  readonly STORE_STATUS_CHANGE: "store_status_change";
57
64
  readonly USER_ROLE_CHANGE: "user_role_change";
65
+ /**
66
+ * Admin cancelled an offer. The store keeps sole authority to accept,
67
+ * counter and decline; cancelling is the ONE write an admin may make, and
68
+ * it is an escalation — reason required, and recorded both here and in the
69
+ * offer's own `statusHistory`.
70
+ */
71
+ readonly OFFER_CANCEL: "offer_cancel";
58
72
  };
59
73
  };
@@ -21,6 +21,13 @@ export const AdminAuditActionValues = {
21
21
  PAYOUT_MARK_PAID: "payout_mark_paid",
22
22
  STORE_STATUS_CHANGE: "store_status_change",
23
23
  USER_ROLE_CHANGE: "user_role_change",
24
+ /**
25
+ * Admin cancelled an offer. The store keeps sole authority to accept,
26
+ * counter and decline; cancelling is the ONE write an admin may make, and
27
+ * it is an escalation — reason required, and recorded both here and in the
28
+ * offer's own `statusHistory`.
29
+ */
30
+ OFFER_CANCEL: "offer_cancel",
24
31
  };
25
32
  export const ADMIN_AUDIT_LOG_FIELDS = {
26
33
  ACTOR_UID: "actorUid",
@@ -0,0 +1,80 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * The blog form's own schema. Distinct from `blogPostSchema` (blog/schemas),
4
+ * which requires an `id` this draft cannot have before its first save and
5
+ * models `coverImage` as a `MediaField` where the form holds a plain URL.
6
+ *
7
+ * ## Why `.passthrough()` is gone
8
+ *
9
+ * It used to name FOUR of the draft's fourteen fields and rely on passthrough
10
+ * to keep the other ten alive. That is the single most dangerous shape in this
11
+ * codebase — `z.object()` strips unknown keys, so the moment anyone tightened
12
+ * it, ten fields would vanish on the next save with no error. Verified safe to
13
+ * remove here: every one of the fourteen is now named, and the save payload
14
+ * (below) maps each explicitly rather than spreading the draft, so there is no
15
+ * extra key for passthrough to have been protecting.
16
+ *
17
+ * ## Section, row and control come from the field itself
18
+ *
19
+ * 🛑 `annotate()` must be the OUTERMOST call — it keys a WeakMap by schema
20
+ * instance and every zod wrapper returns a new one, so
21
+ * `annotate(z.string(), …).optional()` silently loses the metadata and drops
22
+ * the field into the "advanced" fallback. `audit-field-ui-meta` blocks it.
23
+ */
24
+ export declare const blogDraftSchema: z.ZodObject<{
25
+ title: z.ZodString;
26
+ /**
27
+ * NO `blog-` prefix. Surfaced by `roundtrip-diff` in W4: the schema demanded
28
+ * `/^blog-/`, and **all 20 stored posts fail it** — every seeded post holds
29
+ * the bare slug (`spot-genuine-takara-tomy-beyblade`) while only the
30
+ * DOCUMENT ID carries the prefix (`generateBlogPostId` adds it).
31
+ *
32
+ * `BlogRepository.findBySlug` queries this field directly, so the public URL
33
+ * is the bare slug. The regex therefore broke the editor from both ends:
34
+ * opening any existing post flagged its slug invalid, and "fixing" it to
35
+ * satisfy the rule would rewrite the stored slug and 404 the live URL.
36
+ */
37
+ slug: z.ZodString;
38
+ excerpt: z.ZodString;
39
+ content: z.ZodString;
40
+ coverImage: z.ZodString;
41
+ youtubeId: z.ZodString;
42
+ category: z.ZodEnum<["news", "tips", "guides", "updates", "community"]>;
43
+ tags: z.ZodArray<z.ZodString, "many">;
44
+ metaTitle: z.ZodString;
45
+ metaDescription: z.ZodUnion<[z.ZodString, z.ZodLiteral<"">]>;
46
+ status: z.ZodEnum<["draft", "published", "archived"]>;
47
+ publishedAt: z.ZodString;
48
+ authorName: z.ZodString;
49
+ isFeatured: z.ZodBoolean;
50
+ }, "strip", z.ZodTypeAny, {
51
+ status: "draft" | "published" | "archived";
52
+ title: string;
53
+ content: string;
54
+ youtubeId: string;
55
+ slug: string;
56
+ category: "news" | "tips" | "guides" | "updates" | "community";
57
+ tags: string[];
58
+ publishedAt: string;
59
+ isFeatured: boolean;
60
+ excerpt: string;
61
+ coverImage: string;
62
+ authorName: string;
63
+ metaTitle: string;
64
+ metaDescription: string;
65
+ }, {
66
+ status: "draft" | "published" | "archived";
67
+ title: string;
68
+ content: string;
69
+ youtubeId: string;
70
+ slug: string;
71
+ category: "news" | "tips" | "guides" | "updates" | "community";
72
+ tags: string[];
73
+ publishedAt: string;
74
+ isFeatured: boolean;
75
+ excerpt: string;
76
+ coverImage: string;
77
+ authorName: string;
78
+ metaTitle: string;
79
+ metaDescription: string;
80
+ }>;
@@ -0,0 +1,100 @@
1
+ /*
2
+ * WHY: The blog form's schema used to live inside `AdminBlogEditorView.tsx`.
3
+ * Registering it in `SCHEMAS.forms` from there would have made
4
+ * `schemas/registry.ts` import a `.tsx` component — and `routeHandler.ts`
5
+ * imports SCHEMAS, so every API route would have pulled the admin blog
6
+ * editor's whole React tree into the server bundle. Schemas belong in
7
+ * `schemas/`; that is the layering this restores.
8
+ * WHAT: The blog editor's form schema, with its UI metadata.
9
+ *
10
+ * EXPORTS:
11
+ * blogDraftSchema
12
+ *
13
+ * @tag domain:blog
14
+ * @tag layer:schema
15
+ * @tag pattern:none
16
+ * @tag access:isomorphic
17
+ * @tag consumers:AdminBlogEditorView,SCHEMAS.forms
18
+ * @tag sideEffects:none
19
+ */
20
+ import { z } from "zod";
21
+ import { annotate } from "../../shell/field-ui-meta";
22
+ import { blogPostCategorySchema } from "./index";
23
+ /**
24
+ * The blog form's own schema. Distinct from `blogPostSchema` (blog/schemas),
25
+ * which requires an `id` this draft cannot have before its first save and
26
+ * models `coverImage` as a `MediaField` where the form holds a plain URL.
27
+ *
28
+ * ## Why `.passthrough()` is gone
29
+ *
30
+ * It used to name FOUR of the draft's fourteen fields and rely on passthrough
31
+ * to keep the other ten alive. That is the single most dangerous shape in this
32
+ * codebase — `z.object()` strips unknown keys, so the moment anyone tightened
33
+ * it, ten fields would vanish on the next save with no error. Verified safe to
34
+ * remove here: every one of the fourteen is now named, and the save payload
35
+ * (below) maps each explicitly rather than spreading the draft, so there is no
36
+ * extra key for passthrough to have been protecting.
37
+ *
38
+ * ## Section, row and control come from the field itself
39
+ *
40
+ * 🛑 `annotate()` must be the OUTERMOST call — it keys a WeakMap by schema
41
+ * instance and every zod wrapper returns a new one, so
42
+ * `annotate(z.string(), …).optional()` silently loses the metadata and drops
43
+ * the field into the "advanced" fallback. `audit-field-ui-meta` blocks it.
44
+ */
45
+ export const blogDraftSchema = z.object({
46
+ title: annotate(z.string().min(1, "Title is required"), {
47
+ section: "content", sectionLabel: "Content", sectionRequired: true,
48
+ quick: true, order: 1, row: "full",
49
+ }),
50
+ /**
51
+ * NO `blog-` prefix. Surfaced by `roundtrip-diff` in W4: the schema demanded
52
+ * `/^blog-/`, and **all 20 stored posts fail it** — every seeded post holds
53
+ * the bare slug (`spot-genuine-takara-tomy-beyblade`) while only the
54
+ * DOCUMENT ID carries the prefix (`generateBlogPostId` adds it).
55
+ *
56
+ * `BlogRepository.findBySlug` queries this field directly, so the public URL
57
+ * is the bare slug. The regex therefore broke the editor from both ends:
58
+ * opening any existing post flagged its slug invalid, and "fixing" it to
59
+ * satisfy the rule would rewrite the stored slug and 404 the live URL.
60
+ */
61
+ slug: annotate(z.string().min(1, "Slug is required"), {
62
+ section: "content", quick: true, order: 2, row: "full",
63
+ // T1 calculated: derived from the title on CREATE only, then frozen.
64
+ // Recomputing on rename would break every inbound link and bookmark —
65
+ // categories, brands and bundles all establish that convention.
66
+ tier: "t1-derive",
67
+ help: "Auto-filled from the title. Changing it after publishing breaks existing links.",
68
+ }),
69
+ excerpt: annotate(z.string().max(300, "Keep the excerpt under 300 characters."), {
70
+ section: "content", order: 3, row: "full",
71
+ }),
72
+ content: annotate(z.string().min(1, "Content is required"), {
73
+ section: "content", quick: true, order: 4, row: "full",
74
+ }),
75
+ coverImage: annotate(z.string(), {
76
+ section: "media", sectionLabel: "Media", order: 1, row: "full", kind: "media",
77
+ }),
78
+ youtubeId: annotate(z.string(), {
79
+ section: "media", order: 2, row: "full",
80
+ help: "A YouTube video id or watch URL — rendered as a privacy-mode embed.",
81
+ }),
82
+ category: annotate(blogPostCategorySchema, {
83
+ section: "seo", sectionLabel: "SEO & Tags", quick: true, order: 1, row: "pair",
84
+ }),
85
+ tags: annotate(z.array(z.string()), {
86
+ section: "seo", order: 2, row: "full", kind: "list",
87
+ }),
88
+ metaTitle: annotate(z.string().max(60, "Keep the meta title under 60 characters."), {
89
+ section: "seo", order: 3, row: "full", tier: "t1-derive",
90
+ }),
91
+ metaDescription: annotate(z.string().max(160, "Keep the meta description under 160 characters.").or(z.literal("")), { section: "seo", order: 4, row: "full", tier: "t1-derive" }),
92
+ status: annotate(z.enum(["draft", "published", "archived"]), {
93
+ section: "publish", sectionLabel: "Publish", quick: true, order: 1, row: "pair",
94
+ }),
95
+ publishedAt: annotate(z.string(), {
96
+ section: "publish", order: 2, row: "pair", kind: "date",
97
+ }),
98
+ authorName: annotate(z.string(), { section: "publish", order: 3, row: "pair" }),
99
+ isFeatured: annotate(z.boolean(), { section: "publish", order: 4, row: "quarter" }),
100
+ });
@@ -2,14 +2,14 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { normalizeError } from "../../../errors/normalize";
4
4
  import { useApiMutation } from "@mohasinac/appkit/client";
5
- import React, { useEffect, useMemo } from "react";
6
- import { z } from "zod";
5
+ import React, { useEffect } from "react";
7
6
  import { useQuery } from "@tanstack/react-query";
8
7
  import { Alert, Button, Div, Grid, Heading, Input, Label, RichTextEditor, Row, Select, Stack, StackedViewShell, TagInput, Text, Textarea, Toggle, } from "../../../ui";
9
8
  import { FormShellContext, useFormShellState, applyZodIssues, FormErrorSummary } from "../../../ui/forms";
10
9
  import { apiClient } from "../../../http";
11
10
  import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints";
12
- import { StepForm } from "../../shell";
11
+ import { SectionForm, useSectionFormNav } from "../../shell";
12
+ import { eventDraftSchema } from "../schemas/event-form";
13
13
  import { ProductInlineSelect } from "../../seller/components/ProductInlineSelect";
14
14
  import { CouponInlineSelect } from "../../seller/components/CouponInlineSelect";
15
15
  // Covers the cross-type required fields plus the two conditional rules the
@@ -19,27 +19,6 @@ import { CouponInlineSelect } from "../../seller/components/CouponInlineSelect";
19
19
  // form-builder arrays, raffle/spin config) are deliberately left unvalidated
20
20
  // — modeling their full conditional shape correctly would need much deeper
21
21
  // per-type domain rules than this pass is scoped for.
22
- const eventDraftSchema = z.object({
23
- type: z.string(),
24
- title: z.string().min(1, "Title is required"),
25
- startsAt: z.string().min(1, "Start date is required"),
26
- endsAt: z.string().min(1, "End date is required"),
27
- couponId: z.string(),
28
- displayCode: z.string(),
29
- pollOptions: z.array(z.object({ id: z.string(), label: z.string() })),
30
- }).passthrough().superRefine((v, ctx) => {
31
- if (v.type === "offer") {
32
- if (!v.couponId.trim()) {
33
- ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["couponId"], message: "Coupon is required for offer events" });
34
- }
35
- if (!v.displayCode.trim()) {
36
- ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["displayCode"], message: "Display code is required for offer events" });
37
- }
38
- }
39
- if (v.type === "poll" && v.pollOptions.filter((o) => o.label.trim()).length < 2) {
40
- ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["pollOptions"], message: "Poll events require at least 2 options" });
41
- }
42
- });
43
22
  const __P = {
44
23
  p4: "p-[var(--appkit-space-4)]",
45
24
  };
@@ -226,7 +205,6 @@ export function AdminEventEditorView({ eventId, onSaved, embedded, allowedTypes,
226
205
  const isEdit = Boolean(eventId);
227
206
  const [draft, setDraft] = React.useState(DEFAULT_DRAFT);
228
207
  const [slugManual, setSlugManual] = React.useState(false);
229
- const [currentStep, setCurrentStep] = React.useState(0);
230
208
  const [saveMessage, setSaveMessage] = React.useState(null);
231
209
  const [triggerMessage, setTriggerMessage] = React.useState(null);
232
210
  const [raffleWinnerName, setRaffleWinnerName] = React.useState("");
@@ -358,44 +336,49 @@ export function AdminEventEditorView({ eventId, onSaved, embedded, allowedTypes,
358
336
  });
359
337
  const isLoading = saveMutation.isPending || eventQuery.isLoading;
360
338
  // --- steps ---
361
- const steps = [
339
+ /**
340
+ * Sections, not steps — every field reachable at once, one submit at the
341
+ * bottom. The old wizard gated Settings behind Details validating, so a
342
+ * missing end date made an event's poll options unreachable.
343
+ *
344
+ * Ids match the schema's `section` annotations exactly, which is what lets
345
+ * `<FormErrorSummary>` jump to the right panel.
346
+ */
347
+ const sections = [
362
348
  {
349
+ id: "details",
350
+ required: true,
351
+ quick: true,
363
352
  label: "Details",
364
353
  fields: ["type", "title", "slug", "description", "startsAt", "endsAt", "tags"],
365
- validate: (values) => {
366
- if (!values.title.trim())
367
- return "Title is required";
368
- if (!values.startsAt)
369
- return "Start date is required";
370
- if (!values.endsAt)
371
- return "End date is required";
372
- return null;
373
- },
354
+ // The per-step `validate` is gone: every rule it stated is already in
355
+ // eventDraftSchema (title/startsAt/endsAt are .min(1)), and it only ever
356
+ // acted as a gate to the next step.
374
357
  render: ({ values, onChange }) => (_jsxs(Stack, { gap: "md", children: [_jsx(Heading, { level: 3, className: "mb-2", children: "Event Details" }), !isEdit && (_jsx(Select, { label: "Event type", value: values.type, options: allowedTypes ? EVENT_TYPE_OPTIONS.filter((o) => allowedTypes.includes(o.value)) : EVENT_TYPE_OPTIONS, onChange: (e) => onChange({ type: e.target.value }) })), _jsx(Input, { label: "Title", value: values.title, onChange: (e) => {
375
358
  const v = e.target.value;
376
359
  onChange({ title: v, ...(!slugManual && { slug: toEventSlug(v) }) });
377
360
  }, placeholder: "Charizard Flash Sale 2026", required: true }), _jsx(Input, { label: "Slug", value: values.slug, onChange: (e) => { setSlugManual(true); onChange({ slug: e.target.value }); }, placeholder: "event-charizard-flash-sale-2026", helperText: "Auto-generated from title. Must start with 'event-'." }), _jsxs(Stack, { gap: "xs", children: [_jsx(Label, { children: "Description" }), _jsx(RichTextEditor, { value: values.description, onChange: (v) => onChange({ description: v }), minHeightClassName: "min-h-[120px]", placeholder: "Describe this event\u2026" })] }), _jsxs(Grid, { cols: 2, gap: "sm", children: [_jsx(Input, { label: "Starts at", type: "datetime-local", value: values.startsAt, onChange: (e) => onChange({ startsAt: e.target.value }), required: true }), _jsx(Input, { label: "Ends at", type: "datetime-local", value: values.endsAt, onChange: (e) => onChange({ endsAt: e.target.value }), required: true })] }), _jsx(TagInput, { label: "Tags", value: values.tags, onChange: (t) => onChange({ tags: t }), placeholder: "e.g. pokemon, sale, 2026" })] })),
378
361
  },
379
362
  {
363
+ id: "media",
380
364
  label: "Media",
365
+ // <Collapse> unmounts its children — without this, collapsing the
366
+ // section mid-upload discards the transfer.
367
+ keepMounted: true,
381
368
  fields: ["coverImageUrl"],
382
369
  render: ({ values, onChange }) => (_jsxs(Stack, { gap: "md", children: [_jsx(Heading, { level: 3, className: "mb-2", children: "Media" }), _jsx(Input, { label: "Cover Image URL (optional)", value: values.coverImageUrl, onChange: (e) => onChange({ coverImageUrl: e.target.value }), placeholder: "https://\u2026", helperText: "Direct link to the event cover image." })] })),
383
370
  },
384
371
  {
372
+ id: "settings",
385
373
  label: "Settings",
386
374
  fields: ["status", "allowGuestParticipation", "discountPercent", "saleBannerText", "couponId", "displayCode", "offerBannerText", "pollOptions", "resultsVisibility", "allowMultiSelect", "allowComment", "maxEntriesPerUser", "hasLeaderboard", "hasPointSystem", "pointsLabel", "entryReviewRequired", "surveyFields", "anonymous", "feedbackFields"],
387
- validate: (values) => {
388
- if (values.type === "offer" && !values.couponId.trim())
389
- return "Coupon ID is required for offer events";
390
- if (values.type === "offer" && !values.displayCode.trim())
391
- return "Display code is required for offer events";
392
- if (values.type === "poll" && values.pollOptions.filter((o) => o.label.trim()).length < 2)
393
- return "Poll events require at least 2 options";
394
- return null;
395
- },
375
+ // Same: the offer-coupon, offer-display-code and poll-minimum-options
376
+ // rules are all stated in eventDraftSchema's superRefine, which now
377
+ // surfaces them inline on the field instead of as a step-blocking banner.
396
378
  render: ({ values, onChange }) => (_jsxs(Stack, { gap: "md", children: [_jsx(Heading, { level: 3, className: "mb-2", children: "Event Settings" }), isEdit && (_jsx(Select, { label: "Status", value: values.status, options: EVENT_STATUS_OPTIONS, onChange: (e) => onChange({ status: e.target.value }) })), values.createdBy && (_jsxs(Stack, { gap: "none", children: [_jsx(Text, { size: "sm", weight: "medium", className: "mb-1", children: "Created by" }), _jsx(Text, { size: "sm", color: "muted", children: values.createdBy })] })), _jsxs(Stack, { gap: "xs", surface: "muted", rounded: "lg", border: "default", padding: "sm", children: [_jsx(Toggle, { checked: values.allowGuestParticipation, onChange: (v) => onChange({ allowGuestParticipation: v }), label: "Allow guest participation (no login required)" }), _jsx(Text, { size: "xs", color: "muted", children: "When enabled, unauthenticated visitors can participate in this event (poll vote, survey/feedback response, raffle entry, or wheel spin), identified by a hashed IP address instead of an account. Applies uniformly to whatever entry mechanism this event type uses." })] }), values.type === "sale" && (_jsxs(Stack, { gap: "xs", surface: "muted", rounded: "lg", border: "default", padding: "sm", children: [_jsx(Text, { size: "sm", weight: "semibold", color: "muted", children: "Sale configuration" }), _jsxs(Grid, { cols: 2, gap: "sm", children: [_jsx(Input, { label: "Discount %", type: "number", value: values.discountPercent, onChange: (e) => onChange({ discountPercent: e.target.value }), placeholder: "10" }), _jsx(Input, { label: "Banner text (optional)", value: values.saleBannerText, onChange: (e) => onChange({ saleBannerText: e.target.value }), placeholder: "Limited time \u2014 ends Sunday!" })] })] })), values.type === "offer" && (_jsxs(Stack, { gap: "xs", surface: "muted", rounded: "lg", border: "default", padding: "sm", children: [_jsx(Text, { size: "sm", weight: "semibold", color: "muted", children: "Offer configuration" }), _jsxs(Grid, { cols: 2, gap: "sm", children: [_jsxs(Div, { children: [_jsx(Text, { size: "sm", weight: "medium", className: "mb-1", children: "Coupon" }), _jsx(CouponInlineSelect, { scope: "admin", value: values.couponId, onChange: (id) => onChange({ couponId: id ?? "" }), placeholder: "Search coupons\u2026", allowCreate: false })] }), _jsx(Input, { label: "Display code", value: values.displayCode, onChange: (e) => onChange({ displayCode: e.target.value }), placeholder: "CHARIZARD25", required: true })] }), _jsx(Input, { label: "Banner text (optional)", value: values.offerBannerText, onChange: (e) => onChange({ offerBannerText: e.target.value }), placeholder: "Use CHARIZARD25 at checkout" })] })), values.type === "poll" && (_jsxs(Stack, { gap: "md", rounded: "xl", border: "default", padding: "md", children: [_jsx(Text, { size: "sm", weight: "semibold", color: "muted", children: "Poll configuration" }), _jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "xs", color: "muted", children: "Options (minimum 2)" }), values.pollOptions.map((opt, idx) => (_jsxs(Row, { gap: "xs", children: [_jsx(Div, { className: "flex-1", children: _jsx(Input, { value: opt.label, onChange: (e) => onChange({ pollOptions: values.pollOptions.map((o) => o.id === opt.id ? { ...o, label: e.target.value } : o) }), placeholder: `Option ${idx + 1}` }) }), values.pollOptions.length > 2 && (_jsx(Button, { variant: "ghost", type: "button", onClick: () => onChange({ pollOptions: values.pollOptions.filter((o) => o.id !== opt.id) }), className: CLS_REMOVE_BTN_PX2, "aria-label": "Remove option", children: "\u00D7" }))] }, opt.id))), _jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: () => onChange({ pollOptions: [...values.pollOptions, { id: `opt${Date.now()}`, label: "" }] }), children: "+ Add option" })] }), _jsx(Select, { label: "Results visibility", value: values.resultsVisibility, options: POLL_VISIBILITY_OPTIONS, onChange: (e) => onChange({ resultsVisibility: e.target.value }) }), _jsxs(Stack, { gap: "sm", children: [_jsx(Toggle, { checked: values.allowMultiSelect, onChange: (v) => onChange({ allowMultiSelect: v }), label: "Allow multi-select" }), _jsx(Toggle, { checked: values.allowComment, onChange: (v) => onChange({ allowComment: v }), label: "Allow comment with vote" })] })] })), values.type === "survey" && (_jsxs(Stack, { gap: "md", rounded: "xl", border: "default", padding: "md", children: [_jsx(Text, { size: "sm", weight: "semibold", color: "muted", children: "Survey configuration" }), _jsxs(Grid, { cols: 2, gap: "sm", children: [_jsx(Input, { label: "Max entries per user", type: "number", value: values.maxEntriesPerUser, onChange: (e) => onChange({ maxEntriesPerUser: e.target.value }), placeholder: "1" }), values.hasPointSystem && (_jsx(Input, { label: "Points label", value: values.pointsLabel, onChange: (e) => onChange({ pointsLabel: e.target.value }), placeholder: "Points" }))] }), _jsxs(Stack, { gap: "xs", children: [_jsx(Toggle, { checked: values.requireLogin, onChange: (v) => onChange({ requireLogin: v }), label: "Require login to participate" }), _jsx(Toggle, { checked: values.hasLeaderboard, onChange: (v) => onChange({ hasLeaderboard: v }), label: "Show leaderboard" }), _jsx(Toggle, { checked: values.hasPointSystem, onChange: (v) => onChange({ hasPointSystem: v }), label: "Enable point system" }), _jsx(Toggle, { checked: values.entryReviewRequired, onChange: (v) => onChange({ entryReviewRequired: v }), label: "Require employee review before entry is approved" })] }), _jsx(FormFieldBuilder, { fields: values.surveyFields, setFields: (f) => onChange({ surveyFields: f }) })] })), values.type === "feedback" && (_jsxs(Stack, { gap: "md", rounded: "xl", border: "default", padding: "md", children: [_jsx(Text, { size: "sm", weight: "semibold", color: "muted", children: "Feedback configuration" }), _jsx(Toggle, { checked: values.anonymous, onChange: (v) => onChange({ anonymous: v }), label: "Allow anonymous submissions" }), _jsx(FormFieldBuilder, { fields: values.feedbackFields, setFields: (f) => onChange({ feedbackFields: f }) })] }))] })),
397
379
  },
398
380
  {
381
+ id: "raffle",
399
382
  label: "Raffle / Spin",
400
383
  fields: ["hasRaffle", "raffleType", "raffleTopN", "rafflePrize", "rafflePrizeCouponId", "rafflePrizeProductIds", "spinPrizes", "spinMaxPerUser", "spinWindowStart", "spinWindowEnd"],
401
384
  render: ({ values, onChange }) => {
@@ -407,16 +390,11 @@ export function AdminEventEditorView({ eventId, onSaved, embedded, allowedTypes,
407
390
  },
408
391
  ];
409
392
  const alertSection = eventQuery.error ? (_jsx(Alert, { variant: "error", title: "Could not load event", children: eventQuery.error instanceof Error ? eventQuery.error.message : "Unknown error" })) : null;
410
- const fieldToStepIndex = useMemo(() => {
411
- const map = {};
412
- steps.forEach((step, i) => {
413
- step.fields?.forEach((field) => { map[field] = i; });
414
- });
415
- return map;
416
- // eslint-disable-next-line react-hooks/exhaustive-deps
417
- }, [steps.length]);
418
- const wizardShellCtx = useMemo(() => ({ ...shellCtx, fieldToStepIndex, goToStep: (n) => setCurrentStep(n) }), [shellCtx, fieldToStepIndex, setCurrentStep]);
419
- const formContent = (_jsxs(Stack, { gap: "sm", children: [alertSection, _jsxs(FormShellContext.Provider, { value: wizardShellCtx, children: [_jsx(FormErrorSummary, {}), _jsx(StepForm, { steps: steps, values: draft, onChange: update, onComplete: () => { saveMutation.mutate(); }, formId: "admin-event", currentStep: currentStep, onStepChange: setCurrentStep, completeLabel: isEdit ? "Save Changes" : "Create Event", isLoading: isLoading })] }), saveMessage && (_jsx(Alert, { variant: saveMessage.toLowerCase().includes("fail") || saveMessage.toLowerCase().includes("error") ? "error" : "success", title: "Save status", children: saveMessage }))] }));
393
+ // Replaces the hand-rolled fieldToStepIndex + goToStep pair. The hook owns
394
+ // the expand-then-scroll ordering a raw setState cannot: a collapsed panel
395
+ // must MOUNT before scrollIntoView and focus can reach the offending field.
396
+ const { openIds, setOpenIds, goToSection, fieldToSectionIndex, sectionMeta } = useSectionFormNav(sections, draft);
397
+ const formContent = (_jsxs(Stack, { gap: "sm", children: [alertSection, _jsxs(FormShellContext.Provider, { value: shellCtx, children: [_jsx(FormErrorSummary, {}), _jsx(SectionForm, { sections: sections, values: draft, onChange: update, onSubmit: () => { saveMutation.mutate(); }, schema: eventDraftSchema, openIds: openIds, onOpenChange: setOpenIds, submitLabel: isEdit ? "Save Changes" : "Create Event", isLoading: isLoading })] }), saveMessage && (_jsx(Alert, { variant: saveMessage.toLowerCase().includes("fail") || saveMessage.toLowerCase().includes("error") ? "error" : "success", title: "Save status", children: saveMessage }))] }));
420
398
  if (embedded) {
421
399
  return _jsx(Div, { className: `${__O.yAuto} ${__P.p4}`, children: formContent });
422
400
  }
@@ -1,9 +1,9 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { useApiMutation, sieveFilter, SIEVE_OP, sortBy } from "@mohasinac/appkit/client";
4
- import React from "react";
4
+ import React, { useState } from "react";
5
5
  import { useQuery, useQueryClient } from "@tanstack/react-query";
6
- import { Alert, Button, Div, Input, Row, Select, Stack, Text, useToast } from "../../../ui";
6
+ import { Alert, Button, Div, Input, Row, Select, Stack, Text, useToast, RecordDetailModal, } from "../../../ui";
7
7
  import { apiClient } from "../../../http";
8
8
  import { normalizeError } from "../../../errors/normalize";
9
9
  import { ACTIONS } from "../../../_internal/shared/actions/action-registry";
@@ -128,6 +128,10 @@ export function AdminEventEntriesView({ eventId, entriesEndpoint, entryReviewEnd
128
128
  },
129
129
  ];
130
130
  const eventTitle = statsQuery.data?.event?.title;
131
+ // Approve / Waitlist / Cancel were offered on an entry the admin could never
132
+ // read — the poll vote, the comment and the survey answers were all invisible
133
+ // (Root Cause #56, "acting blind"). The row already carries the whole entry.
134
+ const [detail, setDetail] = useState(null);
131
135
  const config = {
132
136
  portal: "admin",
133
137
  title: eventTitle ? `${eventTitle} Entries` : "Event Entries",
@@ -139,6 +143,7 @@ export function AdminEventEntriesView({ eventId, entriesEndpoint, entryReviewEnd
139
143
  endpoint: resolvedEntriesEndpoint,
140
144
  sortOptions: [{ value: sortBy("submittedAt", "DESC"), label: "Most recent" }],
141
145
  columns,
146
+ onRowClick: (row) => setDetail(row),
142
147
  mapRows: (response) => response.items ?? [],
143
148
  getTotal: (response) => response.total ?? (response.items ?? []).length,
144
149
  buildFilters: (state) => state.reviewStatus ? sieveFilter("reviewStatus", SIEVE_OP.EQ, state.reviewStatus) : undefined,
@@ -146,5 +151,19 @@ export function AdminEventEntriesView({ eventId, entriesEndpoint, entryReviewEnd
146
151
  toolbarExtra: eventId ? (_jsx(Button, { variant: "ghost", action: ACTIONS.ADMIN["export-event-entries"], onClick: handleExportReport })) : undefined,
147
152
  renderAboveContent: () => (_jsxs(Stack, { gap: "3", className: "px-[var(--appkit-space-3)] pb-[var(--appkit-space-2)]", children: [!eventId ? (_jsx(Alert, { variant: "warning", title: "Missing event id", children: "Event id is required to load entries." })) : null, statsQuery.error ? (_jsx(Alert, { variant: "error", title: "Could not load event stats", children: statsQuery.error instanceof Error ? statsQuery.error.message : "Unknown error" })) : null, _jsxs(Div, { layout: "grid", gap: "3", className: "grid-cols-1 sm:grid-cols-3", children: [_jsx(Alert, { variant: "info", title: "Total entries", children: String(statsQuery.data?.stats?.totalEntries ?? 0) }), _jsx(Alert, { variant: "success", title: "Approved", children: String(statsQuery.data?.stats?.approvedEntries ?? 0) }), _jsx(Alert, { variant: "warning", title: "Flagged", children: String(statsQuery.data?.stats?.flaggedEntries ?? 0) })] })] })),
148
153
  };
149
- return _jsx(DataListingView, { config: config });
154
+ return (_jsxs(_Fragment, { children: [_jsx(DataListingView, { config: config }), _jsx(RecordDetailModal, { isOpen: detail !== null, onClose: () => setDetail(null), title: detail?.userDisplayName || detail?.userEmail || "Entry", badges: detail ? [{ label: detail.reviewStatus }] : undefined, description: detail?.pollComment, fields: detail
155
+ ? [
156
+ { label: "Entrant", value: detail.userDisplayName ?? detail.userId ?? "—" },
157
+ { label: "Email", value: detail.userEmail ?? "—" },
158
+ { label: "Review status", value: detail.reviewStatus },
159
+ { label: "Points", value: detail.points != null ? String(detail.points) : "—" },
160
+ { label: "Raffle eligible", value: detail.raffleEligible ? "Yes" : "No" },
161
+ { label: "Poll votes", value: detail.pollVotes?.join(", ") || "—" },
162
+ { label: "Reviewed by", value: detail.reviewedBy ?? "—" },
163
+ { label: "Review note", value: detail.reviewNote ?? "—" },
164
+ { label: "Entry ID", value: detail.id },
165
+ ]
166
+ : undefined,
167
+ // The survey/feedback answers — the actual submission being judged.
168
+ metadata: detail?.formResponses })] }));
150
169
  }