@mohasinac/appkit 4.3.2 → 4.4.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 (183) hide show
  1. package/dist/_internal/client/features/maintenance/views/AnalysisRunnerView.js +1 -1
  2. package/dist/_internal/client/features/maintenance/views/CloudLogsListView.js +1 -1
  3. package/dist/_internal/client/features/maintenance/views/MaintenanceDashboardView.js +1 -1
  4. package/dist/_internal/client/features/maintenance/views/ServerErrorDetailView.js +1 -1
  5. package/dist/_internal/client/features/maintenance/views/ServerErrorsListView.js +1 -1
  6. package/dist/_internal/server/jobs/core/assignSpinPrize.d.ts +14 -3
  7. package/dist/_internal/server/jobs/core/assignSpinPrize.js +95 -56
  8. package/dist/_internal/server/jobs/handlers/assignSpinPrize.js +8 -2
  9. package/dist/_internal/shared/actions/action-registry.js +15 -0
  10. package/dist/_internal/shared/features/brands/schema.d.ts +4 -4
  11. package/dist/_internal/shared/features/categories/bundle-schemas.d.ts +4 -4
  12. package/dist/_internal/shared/features/orders/schema.d.ts +8 -8
  13. package/dist/_internal/shared/features/reviews/schema.d.ts +2 -2
  14. package/dist/client.d.ts +36 -0
  15. package/dist/client.js +30 -0
  16. package/dist/constants/api-endpoints.d.ts +6 -0
  17. package/dist/constants/api-endpoints.js +2 -0
  18. package/dist/constants/field-names.d.ts +3 -0
  19. package/dist/constants/field-names.js +4 -0
  20. package/dist/features/about/components/DeveloperView.d.ts +16 -0
  21. package/dist/features/about/components/DeveloperView.js +29 -0
  22. package/dist/features/about/index.d.ts +2 -0
  23. package/dist/features/about/index.js +1 -0
  24. package/dist/features/account/schemas/index.d.ts +16 -16
  25. package/dist/features/addresses/schemas/index.d.ts +8 -8
  26. package/dist/features/admin/components/AdminAddressesView.js +1 -1
  27. package/dist/features/admin/components/AdminAllEventEntriesView.js +2 -2
  28. package/dist/features/admin/components/AdminArtView.js +1 -1
  29. package/dist/features/admin/components/AdminBidsView.js +2 -2
  30. package/dist/features/admin/components/AdminBlogView.js +2 -2
  31. package/dist/features/admin/components/AdminBrandsView.js +2 -2
  32. package/dist/features/admin/components/AdminBundlesView.js +2 -2
  33. package/dist/features/admin/components/AdminCartsView.js +2 -2
  34. package/dist/features/admin/components/AdminCategoriesView.js +2 -2
  35. package/dist/features/admin/components/AdminClassifiedView.js +1 -1
  36. package/dist/features/admin/components/AdminContactView.js +2 -2
  37. package/dist/features/admin/components/AdminCouponsView.js +2 -2
  38. package/dist/features/admin/components/AdminDigitalCodesView.js +1 -1
  39. package/dist/features/admin/components/AdminFaqsView.js +2 -2
  40. package/dist/features/admin/components/AdminFeaturesView.js +1 -1
  41. package/dist/features/admin/components/AdminGroupedListingsView.js +1 -1
  42. package/dist/features/admin/components/AdminHistoryView.js +1 -1
  43. package/dist/features/admin/components/AdminLiveView.js +1 -1
  44. package/dist/features/admin/components/AdminNewsletterView.js +2 -2
  45. package/dist/features/admin/components/AdminNotificationsView.js +2 -2
  46. package/dist/features/admin/components/AdminOrdersView.js +2 -2
  47. package/dist/features/admin/components/AdminPaymentMethodsView.js +1 -1
  48. package/dist/features/admin/components/AdminPayoutsView.js +2 -2
  49. package/dist/features/admin/components/AdminPrizeDrawsView.js +2 -2
  50. package/dist/features/admin/components/AdminProductsView.js +2 -2
  51. package/dist/features/admin/components/AdminReturnRequestsView.js +1 -1
  52. package/dist/features/admin/components/AdminReviewsView.js +2 -2
  53. package/dist/features/admin/components/AdminScammersView.js +2 -2
  54. package/dist/features/admin/components/AdminSessionsView.js +2 -2
  55. package/dist/features/admin/components/AdminStickersView.js +1 -1
  56. package/dist/features/admin/components/AdminStoreAddressesView.js +1 -1
  57. package/dist/features/admin/components/AdminStoresView.js +2 -2
  58. package/dist/features/admin/components/AdminSublistingCategoriesView.js +1 -1
  59. package/dist/features/admin/components/AdminSupportTicketsView.js +2 -2
  60. package/dist/features/admin/components/AdminTeamView.js +2 -2
  61. package/dist/features/admin/components/AdminTesterChecklistView.js +2 -2
  62. package/dist/features/admin/components/AdminUsersView.js +2 -2
  63. package/dist/features/admin/components/AdminWishlistsView.js +1 -1
  64. package/dist/features/admin/hooks/useAdminListing.d.ts +1 -1
  65. package/dist/features/auth/hooks/useAuth.d.ts +1 -1
  66. package/dist/features/blog/actions/blog-actions.d.ts +14 -14
  67. package/dist/features/blog/schemas/index.d.ts +14 -14
  68. package/dist/features/cart/hooks/useAddToCart.d.ts +6 -2
  69. package/dist/features/cart/hooks/useAddToCart.js +20 -3
  70. package/dist/features/cart/schemas/index.d.ts +3 -3
  71. package/dist/features/cart/utils/pending-ops.d.ts +20 -0
  72. package/dist/features/cart/utils/pending-ops.js +22 -0
  73. package/dist/features/catalogue/components/AdminCatalogueApprovalsView.js +1 -1
  74. package/dist/features/categories/components/CategoryBundlesListing.js +1 -1
  75. package/dist/features/categories/components/CategoryFilters.js +1 -1
  76. package/dist/features/categories/components/CategoryTree.js +1 -1
  77. package/dist/features/categories/schemas/index.d.ts +16 -16
  78. package/dist/features/events/actions/event-actions.d.ts +21 -1
  79. package/dist/features/events/actions/event-actions.js +85 -3
  80. package/dist/features/events/components/AdminEventEditorView.js +11 -2
  81. package/dist/features/events/components/AdminEventEntriesView.js +37 -4
  82. package/dist/features/events/components/AdminEventsView.js +2 -2
  83. package/dist/features/events/components/EventLeaderboard.d.ts +12 -2
  84. package/dist/features/events/components/EventLeaderboard.js +4 -1
  85. package/dist/features/events/components/EventRafflesSection.js +2 -2
  86. package/dist/features/events/repository/event-entry.repository.d.ts +39 -0
  87. package/dist/features/events/repository/event-entry.repository.js +153 -0
  88. package/dist/features/events/schemas/firestore.d.ts +28 -1
  89. package/dist/features/events/schemas/firestore.js +9 -0
  90. package/dist/features/events/schemas/index.d.ts +13 -13
  91. package/dist/features/events/types/index.d.ts +26 -0
  92. package/dist/features/faq/actions/faq-actions.d.ts +8 -8
  93. package/dist/features/faq/schemas/index.d.ts +22 -22
  94. package/dist/features/grouped/schemas/index.d.ts +2 -2
  95. package/dist/features/homepage/components/AnnouncementBar.d.ts +9 -1
  96. package/dist/features/homepage/components/AnnouncementBar.js +6 -3
  97. package/dist/features/homepage/components/MarketplaceHomepageView.js +2 -2
  98. package/dist/features/homepage/components/SectionCarousel.js +5 -1
  99. package/dist/features/homepage/components/WelcomeSection.js +2 -2
  100. package/dist/features/homepage/schemas/index.d.ts +2 -2
  101. package/dist/features/layout/BottomNavbar.js +8 -6
  102. package/dist/features/lottery/schemas/zod.d.ts +10 -10
  103. package/dist/features/media/MediaVideo.js +11 -3
  104. package/dist/features/orders/schemas/index.d.ts +52 -52
  105. package/dist/features/products/components/CompareOverlay.d.ts +9 -0
  106. package/dist/features/products/components/CompareOverlay.js +28 -3
  107. package/dist/features/products/components/InteractiveProductCard.js +9 -3
  108. package/dist/features/products/components/MakeOfferForm.js +1 -1
  109. package/dist/features/products/components/PlaceBidForm.js +1 -1
  110. package/dist/features/products/components/PrizeDrawCollage.js +1 -1
  111. package/dist/features/products/components/PrizeDrawsSection.js +2 -2
  112. package/dist/features/products/components/ProductDetailActions.js +3 -1
  113. package/dist/features/products/components/ProductGrid.js +1 -1
  114. package/dist/features/products/components/ProductInfo.js +1 -1
  115. package/dist/features/products/components/ProductsIndexListing.js +12 -2
  116. package/dist/features/products/hooks/useRelatedProducts.js +1 -1
  117. package/dist/features/products/schemas/index.d.ts +10 -10
  118. package/dist/features/promotions/schemas/index.d.ts +8 -8
  119. package/dist/features/reviews/schemas/index.d.ts +12 -12
  120. package/dist/features/scams/schemas/index.d.ts +6 -6
  121. package/dist/features/seller/components/CouponInlineSelect.js +1 -1
  122. package/dist/features/seller/components/SellerArtView.js +2 -2
  123. package/dist/features/seller/components/SellerAuctionsView.js +2 -2
  124. package/dist/features/seller/components/SellerBidsView.js +2 -2
  125. package/dist/features/seller/components/SellerBundlesView.js +1 -1
  126. package/dist/features/seller/components/SellerClassifiedView.js +2 -2
  127. package/dist/features/seller/components/SellerCouponsView.js +2 -2
  128. package/dist/features/seller/components/SellerDigitalCodesView.js +2 -2
  129. package/dist/features/seller/components/SellerGroupedListingsView.js +1 -1
  130. package/dist/features/seller/components/SellerLiveView.js +2 -2
  131. package/dist/features/seller/components/SellerOffersView.js +2 -2
  132. package/dist/features/seller/components/SellerOrdersView.js +2 -2
  133. package/dist/features/seller/components/SellerPayoutMethodsView.js +2 -2
  134. package/dist/features/seller/components/SellerPayoutsView.js +2 -2
  135. package/dist/features/seller/components/SellerPreOrdersView.js +2 -2
  136. package/dist/features/seller/components/SellerPrizeDrawsView.js +2 -2
  137. package/dist/features/seller/components/SellerProductsView.js +2 -2
  138. package/dist/features/seller/components/SellerShippingConfigsView.js +2 -2
  139. package/dist/features/seller/components/SellerStickersView.js +2 -2
  140. package/dist/features/seller/components/SellerStoreCategoriesView.js +1 -1
  141. package/dist/features/seller/components/SublistingInlineSelect.js +1 -1
  142. package/dist/features/seller/hooks/useSellerListingData.d.ts +1 -1
  143. package/dist/features/seller/schemas/index.d.ts +16 -16
  144. package/dist/features/server-errors/schemas/index.d.ts +4 -4
  145. package/dist/features/shell/StepForm.d.ts +1 -1
  146. package/dist/features/shipments/components/AdminShipmentsView.js +2 -2
  147. package/dist/features/stores/components/StoreProductsView.js +1 -1
  148. package/dist/features/stores/components/StoresIndexListing.js +1 -1
  149. package/dist/features/stores/schemas/index.d.ts +30 -30
  150. package/dist/features/support/schemas/index.d.ts +2 -2
  151. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +68 -1
  152. package/dist/features/whatsapp-bot/helpers/payment-proof-message.d.ts +14 -0
  153. package/dist/features/whatsapp-bot/helpers/payment-proof-message.js +17 -0
  154. package/dist/features/whatsapp-bot/helpers/whatsapp.d.ts +2 -8
  155. package/dist/features/whatsapp-bot/helpers/whatsapp.js +4 -11
  156. package/dist/features/wishlist/repository/user-wishlist.repository.d.ts +11 -0
  157. package/dist/features/wishlist/repository/user-wishlist.repository.js +33 -0
  158. package/dist/index.d.ts +7 -12
  159. package/dist/index.js +16 -28
  160. package/dist/next/components/ErrorView.js +1 -1
  161. package/dist/next/components/NotFoundView.js +1 -1
  162. package/dist/next/components/UnauthorizedView.js +1 -1
  163. package/dist/next/routing/route-map.d.ts +4 -0
  164. package/dist/next/routing/route-map.js +2 -0
  165. package/dist/react/contexts/SessionContext.d.ts +1 -1
  166. package/dist/react/hooks/use-action-dispatch.d.ts +1 -1
  167. package/dist/react/hooks/useFormStatePreservation.d.ts +1 -1
  168. package/dist/schemas/registry.d.ts +236 -236
  169. package/dist/schemas/webhooks/razorpay.d.ts +100 -100
  170. package/dist/security/index.d.ts +1 -1
  171. package/dist/security/index.js +1 -1
  172. package/dist/security/rate-limit.d.ts +10 -0
  173. package/dist/security/rate-limit.js +13 -1
  174. package/dist/seed/events-seed-data.js +52 -1
  175. package/dist/server.d.ts +6 -0
  176. package/dist/server.js +8 -0
  177. package/dist/styles.css +25 -1
  178. package/dist/tailwind-utilities.css +1 -1
  179. package/dist/ui/components/HorizontalScroller.d.ts +7 -1
  180. package/dist/ui/components/HorizontalScroller.js +2 -2
  181. package/dist/ui/components/HorizontalScroller.style.css +24 -0
  182. package/dist/validation/schemas.d.ts +12 -12
  183. package/package.json +1 -1
@@ -11,6 +11,7 @@ import { eventRepository } from "../repository/events.repository";
11
11
  import { eventEntryRepository } from "../repository/event-entry.repository";
12
12
  import { ERROR_MESSAGES, AuthorizationError, ValidationError, NotFoundError, } from "../../../errors";
13
13
  import { resolveDate } from "../../../utils";
14
+ import { hashGuestIdentity } from "../../../security/rate-limit";
14
15
  const ERR_EVENT_NOT_FOUND = "Event not found";
15
16
  import { coerceMediaField, getMediaUrl } from "../../../utils";
16
17
  import { finalizeStagedMediaField, finalizeStagedMediaObject, finalizeStagedMediaObjectArray, } from "../../media/finalize";
@@ -148,6 +149,61 @@ export async function getEventLeaderboard(eventId) {
148
149
  return [];
149
150
  return eventEntryRepository.getLeaderboard(event.id);
150
151
  }
152
+ /**
153
+ * Last N spins that actually won a prize, most recent first — powers the
154
+ * public "Last 10 Spin Results" tab on spin_wheel events. Joins each entry's
155
+ * `spinPrizeId` against the event's `spinPrizes` config for a human label
156
+ * (the entries repository has no access to the events collection, mirroring
157
+ * `getEventPollResults`' join pattern above) and marks guest spins so the
158
+ * caller can render a generic "Guest" label instead of a raw hash.
159
+ */
160
+ export async function getEventSpinResults(eventId, limit = 10) {
161
+ const event = await eventRepository.findByIdOrSlug(eventId);
162
+ if (!event || event.type !== "spin_wheel")
163
+ return [];
164
+ const prizeTitleById = new Map((event.spinPrizes ?? []).map((p) => [p.id, p.label]));
165
+ const entries = await eventEntryRepository.getRecentSpinResults(event.id, limit);
166
+ return entries.map((entry) => ({
167
+ id: entry.id,
168
+ userDisplayName: entry.userId ? entry.userDisplayName : undefined,
169
+ isGuest: !entry.userId,
170
+ spinPrizeId: entry.spinPrizeId,
171
+ spinPrizeTitle: entry.spinPrizeId ? prizeTitleById.get(entry.spinPrizeId) : undefined,
172
+ spinWonAt: entry.spinWonAt ? new Date(entry.spinWonAt).toISOString() : undefined,
173
+ }));
174
+ }
175
+ /**
176
+ * Per-option vote tally for a poll event — replaces the voter-ranked
177
+ * leaderboard for event.type === "poll", where ranking "who happened to
178
+ * vote" is meaningless. Mirrors the resultsVisibility gating already used by
179
+ * the /api/events/[id] live-tally response: results are only surfaced when
180
+ * pollConfig.resultsVisibility is "always", or "after_end" once the event
181
+ * has actually ended. ("after_vote" visibility needs per-viewer vote state,
182
+ * which this public/cached read path has no user context for, so it's
183
+ * treated as not-yet-visible here.)
184
+ */
185
+ export async function getEventPollResults(eventId) {
186
+ const event = await eventRepository.findByIdOrSlug(eventId);
187
+ if (!event || event.type !== "poll" || !event.pollConfig)
188
+ return [];
189
+ const visibility = event.pollConfig.resultsVisibility;
190
+ const canShow = visibility === "always" ||
191
+ (visibility === "after_end" && event.status === "ended");
192
+ if (!canShow)
193
+ return [];
194
+ const tally = await eventEntryRepository.getPollResults(event.id);
195
+ const countByOption = new Map(tally.map((t) => [t.optionId, t.count]));
196
+ const total = tally.reduce((sum, t) => sum + t.count, 0) || 1;
197
+ return event.pollConfig.options.map((opt) => {
198
+ const count = countByOption.get(opt.id) ?? 0;
199
+ return {
200
+ optionId: opt.id,
201
+ label: opt.label,
202
+ count,
203
+ percent: Math.round((count / total) * 100),
204
+ };
205
+ });
206
+ }
151
207
  /** Related events — other active events sharing at least one tag, excluding this one. */
152
208
  export async function getRelatedEvents(tags, excludeId, limit = 6) {
153
209
  const docs = await eventRepository.findByTagsOverlap(tags, limit + 1);
@@ -188,7 +244,7 @@ export async function adminGetEventStats(eventId) {
188
244
  };
189
245
  }
190
246
  // --- Public: Enter Event ---------------------------------------------------
191
- export async function enterEvent(eventId, input, user) {
247
+ export async function enterEvent(eventId, input, user, request) {
192
248
  const event = await eventRepository.findByIdOrSlug(eventId);
193
249
  if (!event || event.status !== "active") {
194
250
  throw new NotFoundError(ERROR_MESSAGES.EVENT.ENTRIES_CLOSED);
@@ -202,12 +258,37 @@ export async function enterEvent(eventId, input, user) {
202
258
  if (endsAt && now > endsAt) {
203
259
  throw new ValidationError(ERROR_MESSAGES.EVENT.ENTRIES_CLOSED);
204
260
  }
205
- const requiresLogin = event.type === "poll" ||
261
+ // Baseline per-type login requirement, before the admin's per-event
262
+ // `allowGuestParticipation` override is applied. Anonymous feedback is a
263
+ // distinct opt-in concept (the event itself declares no identity is ever
264
+ // collected) so it stays open regardless of the guest-participation flag.
265
+ const perTypeRequiresLogin = event.type === "poll" ||
206
266
  event.type === "survey" ||
207
- (event.type === "feedback" && !event.feedbackConfig?.anonymous);
267
+ (event.type === "feedback" && !event.feedbackConfig?.anonymous) ||
268
+ event.type === "raffle" ||
269
+ event.type === "spin_wheel";
270
+ // Every event type that reaches enterEvent() is now gated by the single
271
+ // admin-controlled flag — raffle/spin_wheel no longer get accidental free
272
+ // guest access just because they were absent from the old per-type list.
273
+ const requiresLogin = !event.allowGuestParticipation && perTypeRequiresLogin;
208
274
  if (requiresLogin && !user) {
209
275
  throw new AuthorizationError(ERROR_MESSAGES.EVENT.LOGIN_REQUIRED);
210
276
  }
277
+ // Guest path: dedupe by a hashed IP scoped to this event so an anonymous
278
+ // visitor can't submit unlimited entries. The raw IP is never persisted.
279
+ let guestIpHash;
280
+ if (!user && event.allowGuestParticipation) {
281
+ if (!request) {
282
+ // Every real HTTP caller passes the incoming Request — fail closed
283
+ // rather than silently letting an untracked guest entry through.
284
+ throw new AuthorizationError(ERROR_MESSAGES.EVENT.LOGIN_REQUIRED);
285
+ }
286
+ guestIpHash = hashGuestIdentity(resolvedEventId, request);
287
+ const alreadyEntered = await eventEntryRepository.hasGuestEntered(resolvedEventId, guestIpHash);
288
+ if (alreadyEntered) {
289
+ throw new ValidationError(ERROR_MESSAGES.EVENT.ALREADY_ENTERED);
290
+ }
291
+ }
211
292
  if (user && event.type === "survey" && event.surveyConfig) {
212
293
  const userEntryCount = await eventEntryRepository.countUserEntries(resolvedEventId, user.uid);
213
294
  if (userEntryCount >= event.surveyConfig.maxEntriesPerUser) {
@@ -247,6 +328,7 @@ export async function enterEvent(eventId, input, user) {
247
328
  userId: user?.uid,
248
329
  userDisplayName: user?.displayName,
249
330
  userEmail: user?.email,
331
+ guestIpHash,
250
332
  pollVotes: input.pollVotes,
251
333
  pollComment: input.pollComment,
252
334
  formResponses: input.formResponses,
@@ -115,6 +115,7 @@ const DEFAULT_DRAFT = {
115
115
  tags: [],
116
116
  status: "draft",
117
117
  createdBy: "",
118
+ allowGuestParticipation: false,
118
119
  discountPercent: "10",
119
120
  saleBannerText: "",
120
121
  couponId: "",
@@ -184,6 +185,12 @@ function buildEventRaffleFields(draft) {
184
185
  }
185
186
  return fields;
186
187
  }
188
+ // Extracted out of the raffle/spin step's render() so that closure stays
189
+ // under the LARGE_COMPONENT line-count threshold — purely a line-count
190
+ // split, no behavior change from when this was inline.
191
+ function SpinPrizesEditor({ values, onChange, }) {
192
+ return (_jsxs(Stack, { gap: "sm", rounded: "lg", border: "default", padding: "sm", children: [_jsx(Text, { size: "xs", weight: "medium", color: "muted", children: "Spin prizes (weighted random)" }), values.spinPrizes.length === 0 && (_jsx(Text, { size: "xs", color: "muted", children: "No spin prizes yet. Add at least one to enable spinning." })), values.spinPrizes.map((p) => (_jsxs(Grid, { align: "end", gap: "xs", className: "grid-cols-12", children: [_jsx(Div, { className: "col-span-5", children: _jsx(Input, { label: "Label", value: p.label, onChange: (e) => onChange({ spinPrizes: values.spinPrizes.map((sp) => sp.id === p.id ? { ...sp, label: e.target.value } : sp) }), placeholder: "\u20B9100 off" }) }), _jsxs(Div, { className: "col-span-3", children: [_jsx(Text, { size: "xs", weight: "medium", className: "mb-1", children: "Coupon" }), _jsx(CouponInlineSelect, { scope: "admin", value: p.couponId ?? "", onChange: (id) => onChange({ spinPrizes: values.spinPrizes.map((sp) => sp.id === p.id ? { ...sp, couponId: id || undefined } : sp) }), placeholder: "Search\u2026", allowCreate: false })] }), _jsx(Div, { className: "col-span-2", children: _jsx(Input, { label: "Weight", type: "number", value: String(p.weight), onChange: (e) => onChange({ spinPrizes: values.spinPrizes.map((sp) => sp.id === p.id ? { ...sp, weight: Number(e.target.value) || 0 } : sp) }) }) }), _jsx(Row, { centered: true, className: "col-span-1", padding: "b-xs", children: _jsx(Toggle, { checked: p.isActive, onChange: (v) => onChange({ spinPrizes: values.spinPrizes.map((sp) => sp.id === p.id ? { ...sp, isActive: v } : sp) }), label: "" }) }), _jsx(Row, { centered: true, className: "col-span-1", padding: "b-xs", children: _jsx(Button, { variant: "ghost", type: "button", onClick: () => onChange({ spinPrizes: values.spinPrizes.filter((sp) => sp.id !== p.id) }), className: CLS_REMOVE_BTN_LG, "aria-label": "Remove prize", children: "\u00D7" }) })] }, p.id))), _jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: () => onChange({ spinPrizes: [...values.spinPrizes, { id: `prize-${Date.now()}`, label: "", couponId: undefined, weight: 1, isActive: true }] }), children: "+ Add spin prize" }), _jsx(Input, { label: "Max spins per user", type: "number", value: values.spinMaxPerUser, onChange: (e) => onChange({ spinMaxPerUser: e.target.value }), placeholder: "1" }), _jsxs(Grid, { cols: 2, gap: "sm", children: [_jsx(Input, { label: "Spin window start", type: "datetime-local", value: values.spinWindowStart, onChange: (e) => onChange({ spinWindowStart: e.target.value }) }), _jsx(Input, { label: "Spin window end", type: "datetime-local", value: values.spinWindowEnd, onChange: (e) => onChange({ spinWindowEnd: e.target.value }) })] })] }));
193
+ }
187
194
  // --- Component ---------------------------------------------------------------
188
195
  export function AdminEventEditorView({ eventId, onSaved, embedded, allowedTypes, ...rest }) {
189
196
  const isEdit = Boolean(eventId);
@@ -221,6 +228,7 @@ export function AdminEventEditorView({ eventId, onSaved, embedded, allowedTypes,
221
228
  tags: Array.isArray(event.tags) ? event.tags : [],
222
229
  status: event.status || "draft",
223
230
  createdBy: event.createdBy || "",
231
+ allowGuestParticipation: Boolean(event.allowGuestParticipation),
224
232
  discountPercent: String(event.saleConfig?.discountPercent ?? 10),
225
233
  saleBannerText: event.saleConfig?.bannerText || "",
226
234
  couponId: event.offerConfig?.couponId || "",
@@ -269,6 +277,7 @@ export function AdminEventEditorView({ eventId, onSaved, embedded, allowedTypes,
269
277
  startsAt: toISOString(draft.startsAt),
270
278
  endsAt: toISOString(draft.endsAt),
271
279
  coverImageUrl: draft.coverImageUrl || undefined,
280
+ allowGuestParticipation: draft.allowGuestParticipation,
272
281
  ...buildEventTypeConfig(draft),
273
282
  ...buildEventRaffleFields(draft),
274
283
  };
@@ -342,7 +351,7 @@ export function AdminEventEditorView({ eventId, onSaved, embedded, allowedTypes,
342
351
  return "Poll events require at least 2 options";
343
352
  return null;
344
353
  },
345
- 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 })] })), 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 }) })] }))] })),
354
+ 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 }) })] }))] })),
346
355
  },
347
356
  {
348
357
  label: "Raffle / Spin",
@@ -350,7 +359,7 @@ export function AdminEventEditorView({ eventId, onSaved, embedded, allowedTypes,
350
359
  const isRaffleType = values.type === "raffle" || values.type === "spin_wheel";
351
360
  const showRaffleConfig = isRaffleType || values.hasRaffle;
352
361
  const isSpinMode = values.type === "spin_wheel" || values.raffleType === "spin_wheel";
353
- return (_jsxs(Stack, { gap: "md", children: [_jsx(Heading, { level: 3, className: "mb-2", children: "Raffle & Spin Wheel" }), !isRaffleType && (_jsx(Toggle, { checked: values.hasRaffle, onChange: (v) => onChange({ hasRaffle: v }), label: "Attach a raffle to this event" })), !isRaffleType && !values.hasRaffle && (_jsx(Text, { size: "sm", color: "muted", children: "Enable the toggle above to attach a raffle draw to this event. Not applicable for this event type by default." })), showRaffleConfig && (_jsxs(Stack, { gap: "md", rounded: "xl", border: "default", padding: "md", children: [_jsxs(Grid, { cols: 2, gap: "sm", children: [_jsx(Select, { label: "Raffle type", value: values.type === "spin_wheel" ? "spin_wheel" : values.raffleType, options: RAFFLE_TYPE_OPTIONS, onChange: (e) => onChange({ raffleType: e.target.value }), disabled: values.type === "spin_wheel" }), _jsx(Input, { label: "Top N (for top-N raffle types)", type: "number", value: values.raffleTopN, onChange: (e) => onChange({ raffleTopN: e.target.value }), placeholder: "10" })] }), _jsx(Input, { label: "Prize description", value: values.rafflePrize, onChange: (e) => onChange({ rafflePrize: e.target.value }), placeholder: "\u20B92,000 store credit + exclusive Pikachu sticker" }), _jsxs(Div, { children: [_jsx(Text, { size: "sm", weight: "medium", className: "mb-1", children: "Coupon for winner (optional)" }), _jsx(CouponInlineSelect, { scope: "admin", value: values.rafflePrizeCouponId, onChange: (id) => onChange({ rafflePrizeCouponId: id ?? "" }), placeholder: "Search coupons\u2026", allowCreate: false })] }), _jsxs(Div, { children: [_jsx(Text, { size: "sm", weight: "medium", className: "mb-1", children: "Prize products (optional)" }), _jsx(ProductInlineSelect, { scope: "admin", multiple: true, value: values.rafflePrizeProductIds, onChange: (ids) => onChange({ rafflePrizeProductIds: ids }), placeholder: "Search products to feature as prizes\u2026" }), _jsx(Text, { size: "xs", color: "muted", className: "mt-1", children: "Products shown to winners or displayed as the prize showcase." })] }), isSpinMode && (_jsxs(Stack, { gap: "sm", rounded: "lg", border: "default", padding: "sm", children: [_jsx(Text, { size: "xs", weight: "medium", color: "muted", children: "Spin prizes (weighted random)" }), values.spinPrizes.length === 0 && (_jsx(Text, { size: "xs", color: "muted", children: "No spin prizes yet. Add at least one to enable spinning." })), values.spinPrizes.map((p) => (_jsxs(Grid, { align: "end", gap: "xs", className: "grid-cols-12", children: [_jsx(Div, { className: "col-span-5", children: _jsx(Input, { label: "Label", value: p.label, onChange: (e) => onChange({ spinPrizes: values.spinPrizes.map((sp) => sp.id === p.id ? { ...sp, label: e.target.value } : sp) }), placeholder: "\u20B9100 off" }) }), _jsxs(Div, { className: "col-span-3", children: [_jsx(Text, { size: "xs", weight: "medium", className: "mb-1", children: "Coupon" }), _jsx(CouponInlineSelect, { scope: "admin", value: p.couponId ?? "", onChange: (id) => onChange({ spinPrizes: values.spinPrizes.map((sp) => sp.id === p.id ? { ...sp, couponId: id || undefined } : sp) }), placeholder: "Search\u2026", allowCreate: false })] }), _jsx(Div, { className: "col-span-2", children: _jsx(Input, { label: "Weight", type: "number", value: String(p.weight), onChange: (e) => onChange({ spinPrizes: values.spinPrizes.map((sp) => sp.id === p.id ? { ...sp, weight: Number(e.target.value) || 0 } : sp) }) }) }), _jsx(Row, { centered: true, className: "col-span-1", padding: "b-xs", children: _jsx(Toggle, { checked: p.isActive, onChange: (v) => onChange({ spinPrizes: values.spinPrizes.map((sp) => sp.id === p.id ? { ...sp, isActive: v } : sp) }), label: "" }) }), _jsx(Row, { centered: true, className: "col-span-1", padding: "b-xs", children: _jsx(Button, { variant: "ghost", type: "button", onClick: () => onChange({ spinPrizes: values.spinPrizes.filter((sp) => sp.id !== p.id) }), className: CLS_REMOVE_BTN_LG, "aria-label": "Remove prize", children: "\u00D7" }) })] }, p.id))), _jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: () => onChange({ spinPrizes: [...values.spinPrizes, { id: `prize-${Date.now()}`, label: "", couponId: undefined, weight: 1, isActive: true }] }), children: "+ Add spin prize" }), _jsx(Input, { label: "Max spins per user", type: "number", value: values.spinMaxPerUser, onChange: (e) => onChange({ spinMaxPerUser: e.target.value }), placeholder: "1" }), _jsxs(Grid, { cols: 2, gap: "sm", children: [_jsx(Input, { label: "Spin window start", type: "datetime-local", value: values.spinWindowStart, onChange: (e) => onChange({ spinWindowStart: e.target.value }) }), _jsx(Input, { label: "Spin window end", type: "datetime-local", value: values.spinWindowEnd, onChange: (e) => onChange({ spinWindowEnd: e.target.value }) })] })] })), isEdit && (_jsxs(Stack, { gap: "xs", rounded: "lg", padding: "sm", className: CLS_RAFFLE_PANEL, children: [_jsxs(Row, { justify: "between", gap: "sm", children: [_jsx(Text, { size: "sm", weight: "medium", className: CLS_RAFFLE_HEADING, children: raffleWinnerName ? `Winner: ${raffleWinnerName}` : "Raffle not yet triggered" }), _jsx(Button, { type: "button", variant: "primary", size: "sm", onClick: () => triggerRaffleMutation.mutate(), disabled: triggerRaffleMutation.isPending || Boolean(raffleWinnerName), children: triggerRaffleMutation.isPending ? "Triggering…" : raffleWinnerName ? "Already triggered" : "Trigger Raffle Now" })] }), raffleEntryCount !== null && _jsxs(Text, { size: "xs", className: CLS_RAFFLE_BODY, children: ["Pool size: ", raffleEntryCount] }), raffleGithubUrl && _jsxs(Text, { size: "xs", className: `break-all ${CLS_RAFFLE_BODY}`, children: ["Fairness proof: ", raffleGithubUrl] }), triggerMessage && _jsx(Text, { size: "xs", className: CLS_RAFFLE_HEADING, children: triggerMessage })] }))] }))] }));
362
+ return (_jsxs(Stack, { gap: "md", children: [_jsx(Heading, { level: 3, className: "mb-2", children: "Raffle & Spin Wheel" }), !isRaffleType && (_jsx(Toggle, { checked: values.hasRaffle, onChange: (v) => onChange({ hasRaffle: v }), label: "Attach a raffle to this event" })), !isRaffleType && !values.hasRaffle && (_jsx(Text, { size: "sm", color: "muted", children: "Enable the toggle above to attach a raffle draw to this event. Not applicable for this event type by default." })), showRaffleConfig && (_jsxs(Stack, { gap: "md", rounded: "xl", border: "default", padding: "md", children: [_jsxs(Grid, { cols: 2, gap: "sm", children: [_jsx(Select, { label: "Raffle type", value: values.type === "spin_wheel" ? "spin_wheel" : values.raffleType, options: RAFFLE_TYPE_OPTIONS, onChange: (e) => onChange({ raffleType: e.target.value }), disabled: values.type === "spin_wheel" }), _jsx(Input, { label: "Top N (for top-N raffle types)", type: "number", value: values.raffleTopN, onChange: (e) => onChange({ raffleTopN: e.target.value }), placeholder: "10" })] }), _jsx(Input, { label: "Prize description", value: values.rafflePrize, onChange: (e) => onChange({ rafflePrize: e.target.value }), placeholder: "\u20B92,000 store credit + exclusive Pikachu sticker" }), _jsxs(Div, { children: [_jsx(Text, { size: "sm", weight: "medium", className: "mb-1", children: "Coupon for winner (optional)" }), _jsx(CouponInlineSelect, { scope: "admin", value: values.rafflePrizeCouponId, onChange: (id) => onChange({ rafflePrizeCouponId: id ?? "" }), placeholder: "Search coupons\u2026", allowCreate: false })] }), _jsxs(Div, { children: [_jsx(Text, { size: "sm", weight: "medium", className: "mb-1", children: "Prize products (optional)" }), _jsx(ProductInlineSelect, { scope: "admin", multiple: true, value: values.rafflePrizeProductIds, onChange: (ids) => onChange({ rafflePrizeProductIds: ids }), placeholder: "Search products to feature as prizes\u2026" }), _jsx(Text, { size: "xs", color: "muted", className: "mt-1", children: "Products shown to winners or displayed as the prize showcase." })] }), isSpinMode && _jsx(SpinPrizesEditor, { values: values, onChange: onChange }), isEdit && (_jsxs(Stack, { gap: "xs", rounded: "lg", padding: "sm", className: CLS_RAFFLE_PANEL, children: [_jsxs(Row, { justify: "between", gap: "sm", children: [_jsx(Text, { size: "sm", weight: "medium", className: CLS_RAFFLE_HEADING, children: raffleWinnerName ? `Winner: ${raffleWinnerName}` : "Raffle not yet triggered" }), _jsx(Button, { type: "button", variant: "primary", size: "sm", onClick: () => triggerRaffleMutation.mutate(), disabled: triggerRaffleMutation.isPending || Boolean(raffleWinnerName), children: triggerRaffleMutation.isPending ? "Triggering…" : raffleWinnerName ? "Already triggered" : "Trigger Raffle Now" })] }), raffleEntryCount !== null && _jsxs(Text, { size: "xs", className: CLS_RAFFLE_BODY, children: ["Pool size: ", raffleEntryCount] }), raffleGithubUrl && _jsxs(Text, { size: "xs", className: `break-all ${CLS_RAFFLE_BODY}`, children: ["Fairness proof: ", raffleGithubUrl] }), triggerMessage && _jsx(Text, { size: "xs", className: CLS_RAFFLE_HEADING, children: triggerMessage })] }))] }))] }));
354
363
  },
355
364
  },
356
365
  ];
@@ -1,15 +1,18 @@
1
1
  "use client";
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { useApiMutation } from "@mohasinac/appkit/client";
4
4
  import React from "react";
5
5
  import { useQuery, useQueryClient } from "@tanstack/react-query";
6
- import { Alert, Button, Div, Input, Row, Select, Stack, StackedViewShell, Text } from "../../../ui";
6
+ import { Alert, Button, Div, Input, Row, Select, Stack, StackedViewShell, Text, useToast } from "../../../ui";
7
7
  import { apiClient } from "../../../http";
8
+ import { normalizeError } from "../../../errors/normalize";
9
+ import { ACTIONS } from "../../../_internal/shared/actions/action-registry";
8
10
  import { DataTable } from "../../admin/components/DataTable";
9
11
  import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints";
10
12
  const __P = {
11
13
  p4: "p-[var(--appkit-space-4)]",
12
14
  };
15
+ const CLS_RESPONSE_TEXT = "whitespace-pre-wrap break-words";
13
16
  const REVIEW_STATUS_OPTIONS = [
14
17
  { label: "All", value: "all" },
15
18
  { label: "Pending", value: "pending" },
@@ -18,6 +21,7 @@ const REVIEW_STATUS_OPTIONS = [
18
21
  ];
19
22
  export function AdminEventEntriesView({ eventId, entriesEndpoint, entryReviewEndpoint, statsEndpoint, renderHeader, renderStats, renderFilters, renderTable, renderPagination, renderReviewDrawer, ...rest }) {
20
23
  const queryClient = useQueryClient();
24
+ const { showToast } = useToast();
21
25
  const [page, setPage] = React.useState(1);
22
26
  const [pageSize] = React.useState(20);
23
27
  const [statusFilter, setStatusFilter] = React.useState("all");
@@ -122,9 +126,28 @@ export function AdminEventEntriesView({ eventId, entriesEndpoint, entryReviewEnd
122
126
  {
123
127
  key: "actions",
124
128
  header: "Actions",
125
- render: (row) => (_jsxs(Row, { align: "center", gap: "sm", wrap: true, children: [_jsx(Button, { size: "sm", variant: "secondary", disabled: reviewMutation.isPending || row.reviewStatus === "approved", onClick: () => reviewMutation.mutate({ entryId: row.id, status: "approved" }), children: "Approve" }), _jsx(Button, { size: "sm", variant: "outline", disabled: reviewMutation.isPending || row.reviewStatus === "flagged", onClick: () => reviewMutation.mutate({ entryId: row.id, status: "flagged" }), children: "Flag" }), row.formResponses && Object.keys(row.formResponses).length > 0 && (_jsx(Button, { size: "sm", variant: "outline", onClick: () => setExpandedEntryId((prev) => (prev === row.id ? null : row.id)), children: expandedEntryId === row.id ? "Hide" : "Responses" }))] })),
129
+ render: (row) => (_jsxs(Row, { align: "center", gap: "sm", wrap: true, children: [_jsx(Button, { size: "sm", variant: "secondary", disabled: reviewMutation.isPending || row.reviewStatus === "approved", onClick: () => reviewMutation.mutate({ entryId: row.id, status: "approved" }), children: "Approve" }), _jsx(Button, { size: "sm", variant: "outline", disabled: reviewMutation.isPending || row.reviewStatus === "flagged", onClick: () => reviewMutation.mutate({ entryId: row.id, status: "flagged" }), children: "Flag" }), ((row.formResponses && Object.keys(row.formResponses).length > 0) ||
130
+ (row.pollVotes && row.pollVotes.length > 0)) && (_jsx(Button, { size: "sm", variant: "outline", onClick: () => setExpandedEntryId((prev) => (prev === row.id ? null : row.id)), children: expandedEntryId === row.id ? "Hide" : "Responses" }))] })),
126
131
  },
127
132
  ], [reviewMutation, pointsMutation, pointsInputs, expandedEntryId]);
133
+ const handleExportReport = async () => {
134
+ if (!eventId)
135
+ return;
136
+ try {
137
+ const blob = await apiClient.blob(ADMIN_ENDPOINTS.EVENT_ENTRIES_EXPORT(eventId));
138
+ const url = URL.createObjectURL(blob);
139
+ const a = document.createElement("a");
140
+ a.href = url;
141
+ a.download = `event-entries-${eventId}-${new Date().toISOString().slice(0, 10)}.md`;
142
+ a.click();
143
+ URL.revokeObjectURL(url);
144
+ }
145
+ catch (_err) {
146
+ void normalizeError(_err);
147
+ showToast("Report export failed.", "error");
148
+ }
149
+ };
150
+ const exportSection = eventId ? (_jsx(Row, { justify: "end", children: _jsx(Button, { variant: "ghost", action: ACTIONS.ADMIN["export-event-entries"], onClick: handleExportReport }) })) : null;
128
151
  const statsSection = (_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) })] }));
129
152
  const filtersSection = (_jsxs(Div, { layout: "grid", gap: "3", className: "grid-cols-1 md:grid-cols-3", children: [_jsx(Input, { label: "Search entries", value: searchQuery, onChange: (event) => {
130
153
  setPage(1);
@@ -133,14 +156,24 @@ export function AdminEventEntriesView({ eventId, entriesEndpoint, entryReviewEnd
133
156
  setPage(1);
134
157
  setStatusFilter(event.target.value);
135
158
  } })] }));
159
+ const pollOptionLabels = React.useMemo(() => {
160
+ const map = new Map();
161
+ for (const opt of statsQuery.data?.event?.pollConfig?.options ?? []) {
162
+ map.set(opt.id, opt.label);
163
+ }
164
+ return map;
165
+ }, [statsQuery.data]);
136
166
  const expandedEntry = expandedEntryId ? rows.find((r) => r.id === expandedEntryId) : null;
137
- const responsesPanelSection = expandedEntry?.formResponses && Object.keys(expandedEntry.formResponses).length > 0 ? (_jsxs(Stack, { className: `${__P.p4}`, gap: "3", rounded: "xl", surface: "muted", border: "default", children: [_jsxs(Text, { size: "sm", weight: "semibold", color: "primary", children: ["Responses \u2014 ", expandedEntry.userDisplayName || expandedEntry.userId || "Anonymous"] }), _jsx(Stack, { gap: "sm", children: Object.entries(expandedEntry.formResponses).map(([key, value]) => (_jsxs(Stack, { gap: "none", className: "", children: [_jsx(Text, { size: "xs", weight: "medium", color: "muted", children: key }), _jsx(Text, { className: "whitespace-pre-wrap break-words", color: "primary", size: "sm", children: Array.isArray(value) ? value.join(", ") : String(value ?? "—") })] }, key))) })] })) : null;
167
+ const expandedHasFormResponses = Boolean(expandedEntry?.formResponses && Object.keys(expandedEntry.formResponses).length > 0);
168
+ const expandedHasPollVotes = Boolean(expandedEntry?.pollVotes && expandedEntry.pollVotes.length > 0);
169
+ const responsesPanelSection = expandedEntry && (expandedHasFormResponses || expandedHasPollVotes) ? (_jsxs(Stack, { className: `${__P.p4}`, gap: "3", rounded: "xl", surface: "muted", border: "default", children: [_jsxs(Text, { size: "sm", weight: "semibold", color: "primary", children: ["Responses \u2014 ", expandedEntry.userDisplayName || expandedEntry.userId || "Anonymous"] }), expandedHasPollVotes && (_jsxs(Stack, { gap: "sm", children: [_jsx(Text, { size: "xs", weight: "medium", color: "muted", children: "Poll votes" }), _jsx(Text, { className: CLS_RESPONSE_TEXT, color: "primary", size: "sm", children: expandedEntry.pollVotes.map((id) => pollOptionLabels.get(id) ?? id).join(", ") }), expandedEntry.pollComment && (_jsxs(_Fragment, { children: [_jsx(Text, { size: "xs", weight: "medium", color: "muted", children: "Comment" }), _jsx(Text, { className: CLS_RESPONSE_TEXT, color: "primary", size: "sm", children: expandedEntry.pollComment })] }))] })), expandedHasFormResponses && (_jsx(Stack, { gap: "sm", children: Object.entries(expandedEntry.formResponses).map(([key, value]) => (_jsxs(Stack, { gap: "none", className: "", children: [_jsx(Text, { size: "xs", weight: "medium", color: "muted", children: key }), _jsx(Text, { className: CLS_RESPONSE_TEXT, color: "primary", size: "sm", children: Array.isArray(value) ? value.join(", ") : String(value ?? "—") })] }, key))) }))] })) : null;
138
170
  const tableSection = (_jsx(DataTable, { columns: columns, rows: rows, isLoading: entriesQuery.isLoading, currentPage: entriesQuery.data?.page ?? page, totalPages: entriesQuery.data?.totalPages ?? 1, onPageChange: setPage, emptyLabel: "No entries found" }));
139
171
  const eventTitle = statsQuery.data?.event?.title;
140
172
  return (_jsx(StackedViewShell, { portal: "admin", ...rest, title: eventTitle ? `${eventTitle} Entries` : "Event Entries", renderHeader: renderHeader, sections: [
141
173
  !eventId ? (_jsx(Alert, { variant: "warning", title: "Missing event id", children: "Event id is required to load entries." })) : null,
142
174
  entriesQuery.error ? (_jsx(Alert, { variant: "error", title: "Could not load entries", children: entriesQuery.error instanceof Error ? entriesQuery.error.message : "Unknown error" })) : null,
143
175
  statsQuery.error ? (_jsx(Alert, { variant: "error", title: "Could not load event stats", children: statsQuery.error instanceof Error ? statsQuery.error.message : "Unknown error" })) : null,
176
+ exportSection,
144
177
  renderStats?.() ?? statsSection,
145
178
  renderFilters?.() ?? filtersSection,
146
179
  renderTable?.() ?? tableSection,
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { sieveFilter, SIEVE_OP } from "@mohasinac/appkit";
4
- import { sortBy } from "@mohasinac/appkit";
3
+ import { sieveFilter, SIEVE_OP } from "@mohasinac/appkit/client";
4
+ import { sortBy } from "@mohasinac/appkit/client";
5
5
  import React from "react";
6
6
  import { FilterChipGroup, ListingLayout } from "../../../ui";
7
7
  import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints";
@@ -1,5 +1,5 @@
1
1
  import React from "react";
2
- import type { LeaderboardEntry } from "../types";
2
+ import type { LeaderboardEntry, PollResultEntry } from "../types";
3
3
  export interface EventLeaderboardProps {
4
4
  isLoading?: boolean;
5
5
  isEmpty?: boolean;
@@ -9,6 +9,15 @@ export interface EventLeaderboardProps {
9
9
  renderEntry?: (entry: LeaderboardEntry, index: number) => React.ReactNode;
10
10
  /** Entries — used only when renderEntry is provided */
11
11
  entries?: LeaderboardEntry[];
12
+ /**
13
+ * When provided, renders a per-option vote-tally view instead of the
14
+ * ranked voter list — use for event.type === "poll", where a
15
+ * voter-by-points ranking is meaningless. Takes priority over
16
+ * `renderList`/`entries` when set.
17
+ */
18
+ pollResults?: PollResultEntry[];
19
+ /** Render a single poll-result row; used when caller wants item-level control */
20
+ renderPollResult?: (result: PollResultEntry, index: number) => React.ReactNode;
12
21
  /** Empty state node */
13
22
  renderEmpty?: () => React.ReactNode;
14
23
  /** Loading spinner / skeleton */
@@ -17,7 +26,8 @@ export interface EventLeaderboardProps {
17
26
  title?: string;
18
27
  noEntries?: string;
19
28
  points?: string;
29
+ votes?: string;
20
30
  };
21
31
  className?: string;
22
32
  }
23
- export declare function EventLeaderboard({ isLoading, isEmpty, renderList, renderEntry, entries, renderEmpty, renderSkeleton, labels, className, }: EventLeaderboardProps): React.JSX.Element;
33
+ export declare function EventLeaderboard({ isLoading, isEmpty, renderList, renderEntry, entries, pollResults, renderPollResult, renderEmpty, renderSkeleton, labels, className, }: EventLeaderboardProps): React.JSX.Element;
@@ -1,7 +1,7 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import React from "react";
3
3
  import { Div, Row, Span, Stack, Text } from "../../../ui";
4
- export function EventLeaderboard({ isLoading = false, isEmpty = false, renderList, renderEntry, entries = [], renderEmpty, renderSkeleton, labels = {}, className = "", }) {
4
+ export function EventLeaderboard({ isLoading = false, isEmpty = false, renderList, renderEntry, entries = [], pollResults, renderPollResult, renderEmpty, renderSkeleton, labels = {}, className = "", }) {
5
5
  if (isLoading) {
6
6
  if (renderSkeleton)
7
7
  return _jsx(_Fragment, { children: renderSkeleton() });
@@ -12,6 +12,9 @@ export function EventLeaderboard({ isLoading = false, isEmpty = false, renderLis
12
12
  return _jsx(_Fragment, { children: renderEmpty() });
13
13
  return (_jsx(Text, { paddingY: "md", variant: "secondary", size: "sm", align: "start", children: labels.noEntries ?? "No entries yet." }));
14
14
  }
15
+ if (pollResults) {
16
+ return (_jsx(Stack, { className: className, gap: "sm", children: pollResults.map((result, i) => renderPollResult ? (_jsx(React.Fragment, { children: renderPollResult(result, i) }, result.optionId)) : (_jsxs(Div, { layout: "flex", align: "center", justify: "between", rounded: "xl", padding: "sm", className: "border", "data-section": "eventleaderboard-pollresult-div", children: [_jsx(Span, { weight: "medium", children: result.label }), _jsxs(Span, { size: "sm", color: "muted", children: [result.count, " ", labels.votes ?? "votes", " (", result.percent, "%)"] })] }, result.optionId))) }));
17
+ }
15
18
  if (renderList)
16
19
  return _jsx(Div, { className: className, children: renderList() });
17
20
  return (_jsx(Stack, { className: `${className}`, gap: "sm", children: entries.map((entry, i) => renderEntry ? (_jsx(React.Fragment, { children: renderEntry(entry, i) }, entry.userId)) : (_jsxs(Div, { layout: "flex", align: "center", justify: "between", rounded: "xl", padding: "sm", className: "border", "data-section": "eventleaderboard-div-282", children: [_jsxs(Span, { weight: "medium", children: ["#", entry.rank, " ", entry.userDisplayName] }), _jsxs(Span, { children: [entry.totalPoints, " ", labels.points ?? "pts"] })] }, entry.userId))) }));
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { normalizeError } from "../../../errors/normalize";
3
- import { sieveFilter, sieveAnd, SIEVE_OP } from "@mohasinac/appkit";
4
- import { sortBy } from "@mohasinac/appkit";
3
+ import { sieveFilter, sieveAnd, SIEVE_OP } from "@mohasinac/appkit/client";
4
+ import { sortBy } from "@mohasinac/appkit/client";
5
5
  import { Container, Div, Heading, Section, Stack, Text, } from "../../../ui";
6
6
  import { eventRepository } from "../repository/events.repository";
7
7
  import { EventCard } from "./EventCard";
@@ -13,8 +13,47 @@ declare class EventEntryRepository extends BaseRepository<EventEntryDocument> {
13
13
  */
14
14
  list(model: SieveModel): Promise<FirebaseSieveResult<EventEntryDocument>>;
15
15
  hasUserEntered(eventId: string, userId: string): Promise<boolean>;
16
+ /** Guest (unauthenticated) counterpart of `hasUserEntered` — keyed by the hashed IP, never the raw address. */
17
+ hasGuestEntered(eventId: string, guestIpHash: string): Promise<boolean>;
18
+ /**
19
+ * Last N spins that resulted in a prize being assigned, most recent
20
+ * first. Firestore's `orderBy` excludes documents missing the ordered
21
+ * field, so this naturally returns only entries that actually spun
22
+ * (every write path sets `spinWonAt` and `spinPrizeId` together).
23
+ */
24
+ getRecentSpinResults(eventId: string, limit?: number): Promise<EventEntryDocument[]>;
16
25
  countUserEntries(eventId: string, userId: string): Promise<number>;
17
26
  getLeaderboard(eventId: string, limit?: number): Promise<import("../types").LeaderboardEntry[]>;
27
+ /**
28
+ * Raw vote tally per poll option id for a poll-type event. This repository
29
+ * has no access to the events collection, so it cannot resolve option
30
+ * labels — the caller (event-actions.ts, which has the EventDocument's
31
+ * pollConfig.options in hand) joins optionId -> label and computes percent.
32
+ */
33
+ getPollResults(eventId: string): Promise<{
34
+ optionId: string;
35
+ count: number;
36
+ }[]>;
37
+ /**
38
+ * Markdown export for the admin Event Entries view (AdminEventEntriesView
39
+ * "Download Report" button). For a poll event, renders the same
40
+ * per-option vote tally as getPollResults — the caller passes
41
+ * event.pollConfig.options since this repository has no access to the
42
+ * events collection to resolve labels itself. For every other event type
43
+ * (survey included — survey's points-based leaderboard is intentional,
44
+ * see getEventPollResults' doc comment in event-actions.ts), lists
45
+ * individual entries: submitter, submission date, review status, points,
46
+ * and form responses — mirroring what the on-screen table + "Responses"
47
+ * panel already show.
48
+ */
49
+ getExportReport(eventId: string, event: {
50
+ title: string;
51
+ type: string;
52
+ pollOptions?: {
53
+ id: string;
54
+ label: string;
55
+ }[];
56
+ }): Promise<string>;
18
57
  createEntry(input: EventEntryCreateInput): Promise<EventEntryDocument>;
19
58
  reviewEntry(id: string, reviewStatus: EventEntryDocument["reviewStatus"], reviewedBy: string, reviewNote?: string, points?: number): Promise<EventEntryDocument>;
20
59
  }
@@ -4,6 +4,18 @@ import { serverLogger } from "../../../monitoring";
4
4
  import { BaseRepository, prepareForFirestore, } from "../../../providers/db-firebase";
5
5
  import { decryptPiiFields, encryptPiiFields, EVENT_ENTRY_PII_FIELDS, } from "../../../security";
6
6
  import { EVENT_ENTRIES_COLLECTION, EVENT_ENTRY_FIELDS, } from "../schemas";
7
+ /** Extracted from getExportReport's poll branch to stay under the max nesting depth. */
8
+ function formatPollResultLines(pollOptions, countByOption, total) {
9
+ return pollOptions.map((opt) => {
10
+ const count = countByOption.get(opt.id) ?? 0;
11
+ const percent = Math.round((count / total) * 100);
12
+ return `- **${opt.label}** — ${count} vote${count === 1 ? "" : "s"} (${percent}%)`;
13
+ });
14
+ }
15
+ /** Extracted from getExportReport's per-entry loop to stay under the max nesting depth. */
16
+ function formatFormResponseLines(formResponses) {
17
+ return Object.entries(formResponses).map(([key, value]) => ` - ${key}: ${Array.isArray(value) ? value.join(", ") : String(value ?? "—")}`);
18
+ }
7
19
  class EventEntryRepository extends BaseRepository {
8
20
  constructor() {
9
21
  super(EVENT_ENTRIES_COLLECTION);
@@ -45,6 +57,41 @@ class EventEntryRepository extends BaseRepository {
45
57
  throw new DatabaseError(`Failed to check user entry for event ${eventId}`, error);
46
58
  }
47
59
  }
60
+ /** Guest (unauthenticated) counterpart of `hasUserEntered` — keyed by the hashed IP, never the raw address. */
61
+ async hasGuestEntered(eventId, guestIpHash) {
62
+ try {
63
+ const snapshot = await this.getCollection()
64
+ .where(EVENT_ENTRY_FIELDS.EVENT_ID, "==", eventId)
65
+ .where(EVENT_ENTRY_FIELDS.GUEST_IP_HASH, "==", guestIpHash)
66
+ .limit(1)
67
+ .get();
68
+ return !snapshot.empty;
69
+ }
70
+ catch (error) {
71
+ void normalizeError(error);
72
+ throw new DatabaseError(`Failed to check guest entry for event ${eventId}`, error);
73
+ }
74
+ }
75
+ /**
76
+ * Last N spins that resulted in a prize being assigned, most recent
77
+ * first. Firestore's `orderBy` excludes documents missing the ordered
78
+ * field, so this naturally returns only entries that actually spun
79
+ * (every write path sets `spinWonAt` and `spinPrizeId` together).
80
+ */
81
+ async getRecentSpinResults(eventId, limit = 10) {
82
+ try {
83
+ const snapshot = await this.getCollection()
84
+ .where(EVENT_ENTRY_FIELDS.EVENT_ID, "==", eventId)
85
+ .orderBy(EVENT_ENTRY_FIELDS.SPIN_WON_AT, "desc")
86
+ .limit(limit)
87
+ .get();
88
+ return snapshot.docs.map((doc) => this.mapDoc(doc));
89
+ }
90
+ catch (error) {
91
+ void normalizeError(error);
92
+ throw new DatabaseError(`Failed to get recent spin results for event ${eventId}`, error);
93
+ }
94
+ }
48
95
  async countUserEntries(eventId, userId) {
49
96
  try {
50
97
  const snapshot = await this.getCollection()
@@ -100,6 +147,112 @@ class EventEntryRepository extends BaseRepository {
100
147
  throw new DatabaseError(`Failed to get leaderboard for event ${eventId}`, error);
101
148
  }
102
149
  }
150
+ /**
151
+ * Raw vote tally per poll option id for a poll-type event. This repository
152
+ * has no access to the events collection, so it cannot resolve option
153
+ * labels — the caller (event-actions.ts, which has the EventDocument's
154
+ * pollConfig.options in hand) joins optionId -> label and computes percent.
155
+ */
156
+ async getPollResults(eventId) {
157
+ try {
158
+ const snapshot = await this.getCollection()
159
+ .where(EVENT_ENTRY_FIELDS.EVENT_ID, "==", eventId)
160
+ .where(EVENT_ENTRY_FIELDS.REVIEW_STATUS, "==", EVENT_ENTRY_FIELDS.REVIEW_STATUS_VALUES.APPROVED)
161
+ .get();
162
+ const counts = new Map();
163
+ for (const doc of snapshot.docs) {
164
+ const data = doc.data();
165
+ for (const optionId of data.pollVotes ?? []) {
166
+ counts.set(optionId, (counts.get(optionId) ?? 0) + 1);
167
+ }
168
+ }
169
+ return Array.from(counts.entries()).map(([optionId, count]) => ({
170
+ optionId,
171
+ count,
172
+ }));
173
+ }
174
+ catch (error) {
175
+ void normalizeError(error);
176
+ throw new DatabaseError(`Failed to get poll results for event ${eventId}`, error);
177
+ }
178
+ }
179
+ /**
180
+ * Markdown export for the admin Event Entries view (AdminEventEntriesView
181
+ * "Download Report" button). For a poll event, renders the same
182
+ * per-option vote tally as getPollResults — the caller passes
183
+ * event.pollConfig.options since this repository has no access to the
184
+ * events collection to resolve labels itself. For every other event type
185
+ * (survey included — survey's points-based leaderboard is intentional,
186
+ * see getEventPollResults' doc comment in event-actions.ts), lists
187
+ * individual entries: submitter, submission date, review status, points,
188
+ * and form responses — mirroring what the on-screen table + "Responses"
189
+ * panel already show.
190
+ */
191
+ async getExportReport(eventId, event) {
192
+ try {
193
+ const lines = [];
194
+ lines.push(`# Event Entries Report — ${event.title}`);
195
+ lines.push("");
196
+ lines.push(`Generated ${new Date().toISOString()}`);
197
+ lines.push("");
198
+ lines.push("---");
199
+ lines.push("");
200
+ if (event.type === "poll" && event.pollOptions) {
201
+ const tally = await this.getPollResults(eventId);
202
+ const countByOption = new Map(tally.map((t) => [t.optionId, t.count]));
203
+ const total = tally.reduce((sum, t) => sum + t.count, 0);
204
+ lines.push("## Poll Results");
205
+ lines.push("");
206
+ if (total === 0) {
207
+ lines.push("_No votes yet._");
208
+ lines.push("");
209
+ }
210
+ else {
211
+ lines.push(...formatPollResultLines(event.pollOptions, countByOption, total));
212
+ lines.push("");
213
+ lines.push(`${total} total vote${total === 1 ? "" : "s"}.`);
214
+ lines.push("");
215
+ }
216
+ return lines.join("\n");
217
+ }
218
+ const snapshot = await this.getCollection()
219
+ .where(EVENT_ENTRY_FIELDS.EVENT_ID, "==", eventId)
220
+ .get();
221
+ const entries = snapshot.docs
222
+ .map((doc) => this.mapDoc(doc))
223
+ .sort((a, b) => b.submittedAt.getTime() - a.submittedAt.getTime());
224
+ lines.push(`## Entries (${entries.length})`);
225
+ lines.push("");
226
+ if (entries.length === 0) {
227
+ lines.push("_No entries yet._");
228
+ lines.push("");
229
+ return lines.join("\n");
230
+ }
231
+ for (const entry of entries) {
232
+ const who = entry.userDisplayName || entry.userEmail || entry.userId || "Guest";
233
+ lines.push(`### ${who}`);
234
+ lines.push(`- Submitted: ${entry.submittedAt.toISOString()}`);
235
+ lines.push(`- Status: ${entry.reviewStatus}`);
236
+ if (typeof entry.points === "number")
237
+ lines.push(`- Points: ${entry.points}`);
238
+ if (entry.pollVotes && entry.pollVotes.length > 0) {
239
+ lines.push(`- Poll votes: ${entry.pollVotes.join(", ")}`);
240
+ if (entry.pollComment)
241
+ lines.push(`- Comment: ${entry.pollComment}`);
242
+ }
243
+ if (entry.formResponses && Object.keys(entry.formResponses).length > 0) {
244
+ lines.push("- Responses:");
245
+ lines.push(...formatFormResponseLines(entry.formResponses));
246
+ }
247
+ lines.push("");
248
+ }
249
+ return lines.join("\n");
250
+ }
251
+ catch (error) {
252
+ void normalizeError(error);
253
+ throw new DatabaseError(`Failed to get export report for event ${eventId}`, error);
254
+ }
255
+ }
103
256
  async createEntry(input) {
104
257
  try {
105
258
  const now = new Date();