@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.
- package/dist/_internal/client/features/maintenance/views/AnalysisRunnerView.js +1 -1
- package/dist/_internal/client/features/maintenance/views/CloudLogsListView.js +1 -1
- package/dist/_internal/client/features/maintenance/views/MaintenanceDashboardView.js +1 -1
- package/dist/_internal/client/features/maintenance/views/ServerErrorDetailView.js +1 -1
- package/dist/_internal/client/features/maintenance/views/ServerErrorsListView.js +1 -1
- package/dist/_internal/server/jobs/core/assignSpinPrize.d.ts +14 -3
- package/dist/_internal/server/jobs/core/assignSpinPrize.js +95 -56
- package/dist/_internal/server/jobs/handlers/assignSpinPrize.js +8 -2
- package/dist/_internal/shared/actions/action-registry.js +15 -0
- package/dist/_internal/shared/features/brands/schema.d.ts +4 -4
- package/dist/_internal/shared/features/categories/bundle-schemas.d.ts +4 -4
- package/dist/_internal/shared/features/orders/schema.d.ts +8 -8
- package/dist/_internal/shared/features/reviews/schema.d.ts +2 -2
- package/dist/client.d.ts +36 -0
- package/dist/client.js +30 -0
- package/dist/constants/api-endpoints.d.ts +6 -0
- package/dist/constants/api-endpoints.js +2 -0
- package/dist/constants/field-names.d.ts +3 -0
- package/dist/constants/field-names.js +4 -0
- package/dist/features/about/components/DeveloperView.d.ts +16 -0
- package/dist/features/about/components/DeveloperView.js +29 -0
- package/dist/features/about/index.d.ts +2 -0
- package/dist/features/about/index.js +1 -0
- package/dist/features/account/schemas/index.d.ts +16 -16
- package/dist/features/addresses/schemas/index.d.ts +8 -8
- package/dist/features/admin/components/AdminAddressesView.js +1 -1
- package/dist/features/admin/components/AdminAllEventEntriesView.js +2 -2
- package/dist/features/admin/components/AdminArtView.js +1 -1
- package/dist/features/admin/components/AdminBidsView.js +2 -2
- package/dist/features/admin/components/AdminBlogView.js +2 -2
- package/dist/features/admin/components/AdminBrandsView.js +2 -2
- package/dist/features/admin/components/AdminBundlesView.js +2 -2
- package/dist/features/admin/components/AdminCartsView.js +2 -2
- package/dist/features/admin/components/AdminCategoriesView.js +2 -2
- package/dist/features/admin/components/AdminClassifiedView.js +1 -1
- package/dist/features/admin/components/AdminContactView.js +2 -2
- package/dist/features/admin/components/AdminCouponsView.js +2 -2
- package/dist/features/admin/components/AdminDigitalCodesView.js +1 -1
- package/dist/features/admin/components/AdminFaqsView.js +2 -2
- package/dist/features/admin/components/AdminFeaturesView.js +1 -1
- package/dist/features/admin/components/AdminGroupedListingsView.js +1 -1
- package/dist/features/admin/components/AdminHistoryView.js +1 -1
- package/dist/features/admin/components/AdminLiveView.js +1 -1
- package/dist/features/admin/components/AdminNewsletterView.js +2 -2
- package/dist/features/admin/components/AdminNotificationsView.js +2 -2
- package/dist/features/admin/components/AdminOrdersView.js +2 -2
- package/dist/features/admin/components/AdminPaymentMethodsView.js +1 -1
- package/dist/features/admin/components/AdminPayoutsView.js +2 -2
- package/dist/features/admin/components/AdminPrizeDrawsView.js +2 -2
- package/dist/features/admin/components/AdminProductsView.js +2 -2
- package/dist/features/admin/components/AdminReturnRequestsView.js +1 -1
- package/dist/features/admin/components/AdminReviewsView.js +2 -2
- package/dist/features/admin/components/AdminScammersView.js +2 -2
- package/dist/features/admin/components/AdminSessionsView.js +2 -2
- package/dist/features/admin/components/AdminStickersView.js +1 -1
- package/dist/features/admin/components/AdminStoreAddressesView.js +1 -1
- package/dist/features/admin/components/AdminStoresView.js +2 -2
- package/dist/features/admin/components/AdminSublistingCategoriesView.js +1 -1
- package/dist/features/admin/components/AdminSupportTicketsView.js +2 -2
- package/dist/features/admin/components/AdminTeamView.js +2 -2
- package/dist/features/admin/components/AdminTesterChecklistView.js +2 -2
- package/dist/features/admin/components/AdminUsersView.js +2 -2
- package/dist/features/admin/components/AdminWishlistsView.js +1 -1
- package/dist/features/admin/hooks/useAdminListing.d.ts +1 -1
- package/dist/features/auth/hooks/useAuth.d.ts +1 -1
- package/dist/features/blog/actions/blog-actions.d.ts +14 -14
- package/dist/features/blog/schemas/index.d.ts +14 -14
- package/dist/features/cart/hooks/useAddToCart.d.ts +6 -2
- package/dist/features/cart/hooks/useAddToCart.js +20 -3
- package/dist/features/cart/schemas/index.d.ts +3 -3
- package/dist/features/cart/utils/pending-ops.d.ts +20 -0
- package/dist/features/cart/utils/pending-ops.js +22 -0
- package/dist/features/catalogue/components/AdminCatalogueApprovalsView.js +1 -1
- package/dist/features/categories/components/CategoryBundlesListing.js +1 -1
- package/dist/features/categories/components/CategoryFilters.js +1 -1
- package/dist/features/categories/components/CategoryTree.js +1 -1
- package/dist/features/categories/schemas/index.d.ts +16 -16
- package/dist/features/events/actions/event-actions.d.ts +21 -1
- package/dist/features/events/actions/event-actions.js +85 -3
- package/dist/features/events/components/AdminEventEditorView.js +11 -2
- package/dist/features/events/components/AdminEventEntriesView.js +37 -4
- package/dist/features/events/components/AdminEventsView.js +2 -2
- package/dist/features/events/components/EventLeaderboard.d.ts +12 -2
- package/dist/features/events/components/EventLeaderboard.js +4 -1
- package/dist/features/events/components/EventRafflesSection.js +2 -2
- package/dist/features/events/repository/event-entry.repository.d.ts +39 -0
- package/dist/features/events/repository/event-entry.repository.js +153 -0
- package/dist/features/events/schemas/firestore.d.ts +28 -1
- package/dist/features/events/schemas/firestore.js +9 -0
- package/dist/features/events/schemas/index.d.ts +13 -13
- package/dist/features/events/types/index.d.ts +26 -0
- package/dist/features/faq/actions/faq-actions.d.ts +8 -8
- package/dist/features/faq/schemas/index.d.ts +22 -22
- package/dist/features/grouped/schemas/index.d.ts +2 -2
- package/dist/features/homepage/components/AnnouncementBar.d.ts +9 -1
- package/dist/features/homepage/components/AnnouncementBar.js +6 -3
- package/dist/features/homepage/components/MarketplaceHomepageView.js +2 -2
- package/dist/features/homepage/components/SectionCarousel.js +5 -1
- package/dist/features/homepage/components/WelcomeSection.js +2 -2
- package/dist/features/homepage/schemas/index.d.ts +2 -2
- package/dist/features/layout/BottomNavbar.js +8 -6
- package/dist/features/lottery/schemas/zod.d.ts +10 -10
- package/dist/features/media/MediaVideo.js +11 -3
- package/dist/features/orders/schemas/index.d.ts +52 -52
- package/dist/features/products/components/CompareOverlay.d.ts +9 -0
- package/dist/features/products/components/CompareOverlay.js +28 -3
- package/dist/features/products/components/InteractiveProductCard.js +9 -3
- package/dist/features/products/components/MakeOfferForm.js +1 -1
- package/dist/features/products/components/PlaceBidForm.js +1 -1
- package/dist/features/products/components/PrizeDrawCollage.js +1 -1
- package/dist/features/products/components/PrizeDrawsSection.js +2 -2
- package/dist/features/products/components/ProductDetailActions.js +3 -1
- package/dist/features/products/components/ProductGrid.js +1 -1
- package/dist/features/products/components/ProductInfo.js +1 -1
- package/dist/features/products/components/ProductsIndexListing.js +12 -2
- package/dist/features/products/hooks/useRelatedProducts.js +1 -1
- package/dist/features/products/schemas/index.d.ts +10 -10
- package/dist/features/promotions/schemas/index.d.ts +8 -8
- package/dist/features/reviews/schemas/index.d.ts +12 -12
- package/dist/features/scams/schemas/index.d.ts +6 -6
- package/dist/features/seller/components/CouponInlineSelect.js +1 -1
- package/dist/features/seller/components/SellerArtView.js +2 -2
- package/dist/features/seller/components/SellerAuctionsView.js +2 -2
- package/dist/features/seller/components/SellerBidsView.js +2 -2
- package/dist/features/seller/components/SellerBundlesView.js +1 -1
- package/dist/features/seller/components/SellerClassifiedView.js +2 -2
- package/dist/features/seller/components/SellerCouponsView.js +2 -2
- package/dist/features/seller/components/SellerDigitalCodesView.js +2 -2
- package/dist/features/seller/components/SellerGroupedListingsView.js +1 -1
- package/dist/features/seller/components/SellerLiveView.js +2 -2
- package/dist/features/seller/components/SellerOffersView.js +2 -2
- package/dist/features/seller/components/SellerOrdersView.js +2 -2
- package/dist/features/seller/components/SellerPayoutMethodsView.js +2 -2
- package/dist/features/seller/components/SellerPayoutsView.js +2 -2
- package/dist/features/seller/components/SellerPreOrdersView.js +2 -2
- package/dist/features/seller/components/SellerPrizeDrawsView.js +2 -2
- package/dist/features/seller/components/SellerProductsView.js +2 -2
- package/dist/features/seller/components/SellerShippingConfigsView.js +2 -2
- package/dist/features/seller/components/SellerStickersView.js +2 -2
- package/dist/features/seller/components/SellerStoreCategoriesView.js +1 -1
- package/dist/features/seller/components/SublistingInlineSelect.js +1 -1
- package/dist/features/seller/hooks/useSellerListingData.d.ts +1 -1
- package/dist/features/seller/schemas/index.d.ts +16 -16
- package/dist/features/server-errors/schemas/index.d.ts +4 -4
- package/dist/features/shell/StepForm.d.ts +1 -1
- package/dist/features/shipments/components/AdminShipmentsView.js +2 -2
- package/dist/features/stores/components/StoreProductsView.js +1 -1
- package/dist/features/stores/components/StoresIndexListing.js +1 -1
- package/dist/features/stores/schemas/index.d.ts +30 -30
- package/dist/features/support/schemas/index.d.ts +2 -2
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +68 -1
- package/dist/features/whatsapp-bot/helpers/payment-proof-message.d.ts +14 -0
- package/dist/features/whatsapp-bot/helpers/payment-proof-message.js +17 -0
- package/dist/features/whatsapp-bot/helpers/whatsapp.d.ts +2 -8
- package/dist/features/whatsapp-bot/helpers/whatsapp.js +4 -11
- package/dist/features/wishlist/repository/user-wishlist.repository.d.ts +11 -0
- package/dist/features/wishlist/repository/user-wishlist.repository.js +33 -0
- package/dist/index.d.ts +7 -12
- package/dist/index.js +16 -28
- package/dist/next/components/ErrorView.js +1 -1
- package/dist/next/components/NotFoundView.js +1 -1
- package/dist/next/components/UnauthorizedView.js +1 -1
- package/dist/next/routing/route-map.d.ts +4 -0
- package/dist/next/routing/route-map.js +2 -0
- package/dist/react/contexts/SessionContext.d.ts +1 -1
- package/dist/react/hooks/use-action-dispatch.d.ts +1 -1
- package/dist/react/hooks/useFormStatePreservation.d.ts +1 -1
- package/dist/schemas/registry.d.ts +236 -236
- package/dist/schemas/webhooks/razorpay.d.ts +100 -100
- package/dist/security/index.d.ts +1 -1
- package/dist/security/index.js +1 -1
- package/dist/security/rate-limit.d.ts +10 -0
- package/dist/security/rate-limit.js +13 -1
- package/dist/seed/events-seed-data.js +52 -1
- package/dist/server.d.ts +6 -0
- package/dist/server.js +8 -0
- package/dist/styles.css +25 -1
- package/dist/tailwind-utilities.css +1 -1
- package/dist/ui/components/HorizontalScroller.d.ts +7 -1
- package/dist/ui/components/HorizontalScroller.js +2 -2
- package/dist/ui/components/HorizontalScroller.style.css +24 -0
- package/dist/validation/schemas.d.ts +12 -12
- 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
|
-
|
|
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 &&
|
|
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
|
|
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
|
|
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();
|