@mohasinac/appkit 4.76.0 → 4.78.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.
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "stampVersion": 1,
3
- "version": "4.76.0",
4
- "head": "5da3ede2a94c94baadd8314d99ff43092dc5abd2",
3
+ "version": "4.78.0",
4
+ "head": "fd8f0ee8109c0e0c16c567de8d8263c2bfa71551",
5
5
  "branch": "main",
6
6
  "dirty": true,
7
- "builtAt": "2026-11-09T10:25:07.042Z"
7
+ "builtAt": "2026-11-09T13:25:12.547Z"
8
8
  }
@@ -1,9 +1,3 @@
1
- /**
2
- * Event Domain Actions (appkit/features/events)
3
- *
4
- * Business logic for event CRUD, status changes, entries, and public access.
5
- * Auth, rate-limiting, and input validation are handled by the calling server action.
6
- */
7
1
  import type { JsonValue } from "../../../schemas/types";
8
2
  import type { EventDocument, EventEntryDocument, EventEntryMedia } from "../schemas";
9
3
  import type { EventStatus } from "../types";
@@ -1,3 +1,4 @@
1
+ import { resolveEntryApproval, checkEntryMedia, resolveEntryLimit, } from "../schemas/firestore.js";
1
2
  /**
2
3
  * Event Domain Actions (appkit/features/events)
3
4
  *
@@ -510,11 +511,83 @@ export async function enterEvent(eventId, input, user, request) {
510
511
  * pool — that is the moderation path, not a standing review queue of every
511
512
  * entrant.
512
513
  */
513
- const autoApprove = event.type === "poll" ||
514
+ /*
515
+ * ⚠ LEGACY FALLBACK ONLY — do not add a type to this expression.
516
+ *
517
+ * This was the whole decision until 2026-11-09, and it is `event.type ===`
518
+ * branching: the exact shape the governing principle calls a defect. It also
519
+ * meant a new type could not choose — `contest` is absent, so contest
520
+ * entries were moderated by accident, and changing that needed a release.
521
+ *
522
+ * It survives ONLY so events already in the database behave exactly as they
523
+ * did. `resolveEntryApproval` consults it last. A NEW type declares its
524
+ * default in `event-types-seed-data.ts`; a single event overrides with
525
+ * `entryApproval` on its own row. Delete this once every type has a default.
526
+ */
527
+ const legacyAutoApprove = event.type === "poll" ||
514
528
  event.type === "feedback" ||
515
529
  eventType === "giveaway" ||
516
530
  (event.type === "survey" &&
517
531
  !event.surveyConfig?.entryReviewRequired);
532
+ /*
533
+ * The event's own setting wins, then the type's default, then the legacy
534
+ * expression. Anything unrecognised resolves to MODERATED — fail closed, so
535
+ * newer config against an older deploy cannot start publishing unreviewed
536
+ * user content.
537
+ */
538
+ /*
539
+ * ⚠ The TYPE-level default is passed as `undefined` for now. The precedence
540
+ * chain is built for three levels — event row, then type default, then the
541
+ * legacy expression — and `event-types-seed-data.ts` is where a type's
542
+ * default belongs, beside the `ruleSets` it already carries.
543
+ *
544
+ * Reading it here needs the type definition loaded at this point, which this
545
+ * action does not currently do. Stated rather than faked: the EVENT-level
546
+ * override the owner asked for works completely; the type-level default is
547
+ * the next step and slots into the middle argument with no other change.
548
+ */
549
+ /*
550
+ * ── THE EVENT'S OWN ENTRY RULES, enforced on the SERVER ──────────────
551
+ *
552
+ * 🛑 Both checks live here rather than only in the route's zod schema,
553
+ * because zod cannot see the EVENT. The schema can bound request size with
554
+ * a fixed ceiling; only this point knows that THIS contest allows 3 photos
555
+ * and 1 video and one entry per person.
556
+ *
557
+ * 🛑 And a client-side form that hides the submit button is not
558
+ * enforcement — the POST is still reachable. This is the write path, so it
559
+ * is where the limits have to hold.
560
+ */
561
+ const mediaProblem = checkEntryMedia(input.media, event.entryMedia);
562
+ if (mediaProblem)
563
+ throw new ValidationError(mediaProblem);
564
+ /*
565
+ * One entry per participant by default (owner, 2026-11-09: "for now block
566
+ * single entry only"), overridable per event via `entryLimit`.
567
+ *
568
+ * ⚠ Checked for signed-in users only. A guest entry has no stable identity
569
+ * to count against — `guestIpHash` exists but an IP is shared by households
570
+ * and whole offices, so counting on it would block real people. Guest
571
+ * participation is gated separately by `allowGuestParticipation`; an event
572
+ * that needs a hard per-person limit should require sign-in, which is what
573
+ * the contest does.
574
+ *
575
+ * ⚠ NOT race-safe against two simultaneous submissions from one account.
576
+ * Making it so needs a transaction around count+create, or a deterministic
577
+ * entry id like `${eventId}:${uid}`. Stated rather than implied: the window
578
+ * is small and the consequence is a duplicate entry, not a lost one.
579
+ */
580
+ if (user?.uid) {
581
+ const limit = resolveEntryLimit(event.entryLimit);
582
+ const already = await eventEntryRepository.countUserEntries(resolvedEventId, user.uid);
583
+ if (already >= limit) {
584
+ throw new ValidationError(limit === 1
585
+ ? "You have already entered this event."
586
+ : `You have reached the limit of ${limit} entries for this event.`);
587
+ }
588
+ }
589
+ const approvalMode = resolveEntryApproval(event, undefined, legacyAutoApprove);
590
+ const autoApprove = approvalMode === "auto";
518
591
  const reviewStatus = autoApprove ? "approved" : "pending";
519
592
  const entry = await eventEntryRepository.createEntry({
520
593
  eventId: resolvedEventId,
@@ -5,6 +5,7 @@ import { useUrlTable } from "../../../react/hooks/useUrlTable.js";
5
5
  import { useEvents } from "../hooks/useEvents.js";
6
6
  import { Div } from "../../../ui/components/Div.js";
7
7
  import { Grid, Row, Stack } from "../../../ui/components/Layout.js";
8
+ import { Button } from "../../../ui/components/Button.js";
8
9
  import { ListingFilterDrawer } from "../../../ui/components/ListingFilterDrawer.js";
9
10
  import { ListingToolbar, applyToggleVisibility } from "../../../ui/components/ListingToolbar.js";
10
11
  import { useFieldVisibility } from "../../shell/useFieldVisibility.js";
@@ -119,6 +120,24 @@ export function EventsIndexListing({ initialData, facetCounts }) {
119
120
  filterParts.push(sieveFilter(EVENT_FIELDS.STARTS_AT, SIEVE_OP.GTE, dateFrom));
120
121
  if (dateTo)
121
122
  filterParts.push(sieveFilter(EVENT_FIELDS.ENDS_AT, SIEVE_OP.LTE, dateTo));
123
+ /*
124
+ * LIVE / FINISHED tabs, split BY DATE WINDOW rather than by `status`
125
+ * (owner, 2026-11-09): an event is finished when its `endsAt` has passed.
126
+ *
127
+ * Why not `status`: the two can disagree. An event whose `endsAt` passed
128
+ * while nobody moved it off `active` would sit under "Live" forever, and a
129
+ * tab that lies about what is running is worse than no tab. The clock
130
+ * cannot forget to be updated.
131
+ *
132
+ * The "Show expired" toggle stays and the two do NOT fight: it widens the
133
+ * STATUS set, this narrows the DATE window. Selecting Finished with "Show
134
+ * expired" off still works, because a finished event may still be `active`.
135
+ */
136
+ const phase = table.get(TABLE_KEYS.TAB);
137
+ if (phase === "live" || phase === "finished") {
138
+ const nowIso = new Date().toISOString();
139
+ filterParts.push(sieveFilter(EVENT_FIELDS.ENDS_AT, phase === "live" ? SIEVE_OP.GTE : SIEVE_OP.LT, nowIso));
140
+ }
122
141
  const params = {
123
142
  q: table.get(TABLE_KEYS.QUERY) || undefined,
124
143
  page: table.getNumber(TABLE_KEYS.PAGE, 1),
@@ -131,7 +150,23 @@ export function EventsIndexListing({ initialData, facetCounts }) {
131
150
  const commitSearch = useCallback(() => {
132
151
  table.set(TABLE_KEYS.QUERY, searchInput.trim());
133
152
  }, [searchInput, table]);
134
- return (_jsxs(Div, { className: "min-h-screen", children: [_jsx(ListingToolbar, { filterCount: activeFilterCount, onFiltersClick: openFilters, searchValue: searchInput, searchPlaceholder: "Search events...", onSearchChange: setSearchInput, onSearchCommit: commitSearch, sortValue: table.get(TABLE_KEYS.SORT) || sortBy(EVENT_FIELDS.STARTS_AT, "ASC"), sortOptions: EVENT_PUBLIC_SORT_OPTIONS, onSortChange: (v) => { table.set(TABLE_KEYS.SORT, v); }, view: view, onViewChange: handleViewToggle, onResetAll: resetAll, hasActiveState: hasActiveState, toggles: applyToggleVisibility([
153
+ return (_jsxs(Div, { className: "min-h-screen", children: [_jsx(Row, { gap: "xs", className: "px-[var(--appkit-space-4)] pt-[var(--appkit-space-3)]", children: [
154
+ { key: "", label: "All" },
155
+ { key: "live", label: "Live" },
156
+ { key: "finished", label: "Finished" },
157
+ ].map((t) => {
158
+ const active = (table.get(TABLE_KEYS.TAB) || "") === t.key;
159
+ return (_jsx(Button, { variant: active ? "primary" : "ghost", size: "sm", "aria-pressed": active, onClick: () => {
160
+ /*
161
+ * Reset to page 1. Without this, switching from a set with 5
162
+ * pages to one with 1 leaves `page=4` in the URL and the grid
163
+ * renders empty — which reads as "no finished events" rather
164
+ * than "you are past the end".
165
+ */
166
+ table.set(TABLE_KEYS.PAGE, "");
167
+ table.set(TABLE_KEYS.TAB, t.key);
168
+ }, children: t.label }, t.key || "all"));
169
+ }) }), _jsx(ListingToolbar, { filterCount: activeFilterCount, onFiltersClick: openFilters, searchValue: searchInput, searchPlaceholder: "Search events...", onSearchChange: setSearchInput, onSearchCommit: commitSearch, sortValue: table.get(TABLE_KEYS.SORT) || sortBy(EVENT_FIELDS.STARTS_AT, "ASC"), sortOptions: EVENT_PUBLIC_SORT_OPTIONS, onSortChange: (v) => { table.set(TABLE_KEYS.SORT, v); }, view: view, onViewChange: handleViewToggle, onResetAll: resetAll, hasActiveState: hasActiveState, toggles: applyToggleVisibility([
135
170
  { label: "Show expired", active: showExpired, onChange: (next) => table.set(TABLE_KEYS.SHOW_EXPIRED, next ? "true" : "") },
136
171
  ], editMode ? {} : toolbarVisibilityFields, toolbarFieldOrder) }), totalPages > 1 && (_jsx(StickyToolbar, { offset: "header+pagination", tone: "translucent", border: true, padding: "toolbar", children: _jsx(Row, { justify: "center", children: _jsx(Pagination, { currentPage: currentPage, totalPages: totalPages, onPageChange: (p) => table.setPage(p) }) }) })), _jsx(Div, { padding: "y-lg", children: isLoading ? (_jsx(Grid, { cols: "cards", gap: "lg", children: Array.from({ length: 6 }).map((_, i) => (_jsxs(Div, { className: `${__O.hidden} animate-pulse`, border: "subtle", rounded: "xl", children: [_jsx(Div, { className: "aspect-video", surface: "subtle" }), _jsxs(Stack, { className: `${__P.p4}`, gap: "sm", children: [_jsx(Div, { className: "h-4 w-3/4", surface: "subtle", rounded: "default" }), _jsx(Div, { className: "h-3 w-full", surface: "subtle", rounded: "default" }), _jsx(Div, { className: "h-3 w-2/3", surface: "subtle", rounded: "default" }), _jsx(Div, { className: "h-8 mt-2", surface: "subtle", rounded: "default" })] })] }, i))) })) : events.length === 0 ? (_jsx(Text, { paddingY: "3xl", color: "muted", size: "sm", align: "start", children: "No events found." })) : view === "list" ? (_jsx(Stack, { className: "divide-y divide-zinc-100 divide-[var(--appkit-color-border-subtle)]", border: "subtle", rounded: "xl", children: events.map((event) => (_jsx(EventCard, { event: event }, event.id))) })) : (_jsx(Grid, { cols: "cards", gap: "lg", children: events.map((event) => (_jsx(EventCard, { event: event }, event.id))) })) }), _jsx(ListingFilterDrawer, { open: filterOpen, onClose: () => setFilterOpen(false), onApply: applyFilters, onClear: clearFilters, activeCount: activeFilterCount, children: _jsx(EventFilters, { table: pendingTable, variant: "public", counts: facetCounts, hideEmpty: Boolean(facetCounts) }) })] }));
137
172
  }
@@ -124,6 +124,68 @@ export interface EventDocument extends BaseDocument {
124
124
  */
125
125
  isPinned?: boolean;
126
126
  /** Test-data flag — set on the shared admin-seeded test event; swept by testDataCleanup. */
127
+ /**
128
+ * Who may publish an entry: nobody (`"moderated"`) or the entrant
129
+ * (`"auto"`). Set ON THE EVENT, per owner 2026-11-09.
130
+ *
131
+ * ## 🛑 This replaces a hardcoded type switch
132
+ *
133
+ * `enterEvent` decided this with:
134
+ *
135
+ * const autoApprove =
136
+ * event.type === "poll" || event.type === "feedback" ||
137
+ * eventType === "giveaway" ||
138
+ * (event.type === "survey" && !surveyConfig?.entryReviewRequired);
139
+ *
140
+ * That is `event.type === …` branching, which the governing principle names
141
+ * as a defect rather than a safeguard: *"a mechanism never asks what TYPE is
142
+ * this — it asks what CAPABILITY does this have"*. It also meant a NEW event
143
+ * type could not choose: `contest` is absent from that list, so contest
144
+ * entries were moderated by accident rather than by decision, and changing
145
+ * it would have required an appkit release.
146
+ *
147
+ * Now it is a value on the row. A moderated photo contest and an
148
+ * auto-approving one are two seed rows, not two code paths.
149
+ *
150
+ * 🛑 OPEN union, deliberately — `(string & {})` keeps autocomplete while
151
+ * allowing a mode this release has never heard of. `resolveEntryApproval`
152
+ * treats anything unknown as MODERATED, which is the fail-closed direction:
153
+ * an unrecognised setting must not publish unreviewed user content.
154
+ *
155
+ * Unset falls back to the type's default, then to the legacy expression, so
156
+ * no existing event changes behaviour. See `resolveEntryApproval`.
157
+ */
158
+ entryApproval?: EntryApprovalMode;
159
+ /**
160
+ * How much media ONE entry may carry. Owner spec 2026-11-09: **up to 3
161
+ * photos and 1 video, 4 items total.**
162
+ *
163
+ * 🛑 ON THE EVENT ROW, not in code, for the same reason `entryApproval` is:
164
+ * *"for now block single entry only but later allow config changes for all
165
+ * on the events"*. A second contest wanting 10 photos is a seed edit.
166
+ *
167
+ * The caps were already enforced — but as literals inside the submission
168
+ * route's zod schema (`.min(1).max(4)` plus two `.refine`s). That works for
169
+ * exactly one contest and cannot vary per event, which is the shape this
170
+ * project keeps having to undo.
171
+ *
172
+ * ⚠ zod cannot express these statically once they are per-event, so the
173
+ * schema keeps an ABSOLUTE ceiling (a sanity bound on request size) and the
174
+ * handler enforces THIS event's numbers after loading it. Both run; the
175
+ * schema is the cheap gate, the handler is the real one.
176
+ */
177
+ entryMedia?: EventEntryMediaLimits;
178
+ /**
179
+ * How many entries ONE participant may submit to this event.
180
+ *
181
+ * Owner, 2026-11-09: *"for now block single entry only"* — so the default
182
+ * is 1, and an event that wants more sets its own number.
183
+ *
184
+ * 🛑 Enforced on the SERVER, not by hiding the form. A client that has
185
+ * already submitted can still POST again; the check belongs where the write
186
+ * happens.
187
+ */
188
+ entryLimit?: number;
127
189
  isTestData?: boolean;
128
190
  /** When isTestData, the cutoff after which testDataCleanup deletes this doc. */
129
191
  testDataExpiresAt?: Date;
@@ -492,4 +554,74 @@ export declare const eventQueryHelpers: {
492
554
  readonly withRaffle: () => readonly ["hasRaffle", "==", true];
493
555
  readonly raffleNotYetDrawn: () => readonly ["raffleWinnerUserId", "==", null];
494
556
  };
557
+ /**
558
+ * How an event decides whether a submitted entry is published immediately.
559
+ *
560
+ * 🛑 OPEN, not a closed enum. The platform ships two modes; a deployment
561
+ * that needs a third ("auto below N entries", "auto for verified buyers")
562
+ * writes its own slug and a resolver rule rather than waiting for a release.
563
+ */
564
+ export type EntryApprovalMode = "auto" | "moderated" | (string & {});
565
+ /**
566
+ * Resolve the approval mode for one entry submission.
567
+ *
568
+ * Order, most specific first:
569
+ * 1. `event.entryApproval` — this event's own decision
570
+ * 2. `typeDefault` — the event TYPE's default, from seed config
571
+ * 3. the legacy expression — see below
572
+ *
573
+ * 🛑 FAILS CLOSED. Anything not recognised as `"auto"` resolves to
574
+ * moderated. An unknown mode arriving from newer config against an older
575
+ * deploy must not start publishing unreviewed user content — degrading to a
576
+ * review queue is recoverable, publishing is not.
577
+ *
578
+ * ⚠ The third fallback is a MIGRATION PATH, not a design. It reproduces the
579
+ * hardcoded type switch this field replaced so that events already in the
580
+ * database keep behaving exactly as they did. It should be deleted once every
581
+ * event type declares its own default in `event-types-seed-data.ts`.
582
+ */
583
+ export declare function resolveEntryApproval(event: {
584
+ entryApproval?: EntryApprovalMode;
585
+ type?: string;
586
+ surveyConfig?: unknown;
587
+ }, typeDefault?: EntryApprovalMode, legacyAutoApprove?: boolean): "auto" | "moderated";
588
+ /**
589
+ * Per-entry media caps. All optional — an unset field falls back to the
590
+ * platform default in `resolveEntryMediaLimits`.
591
+ */
592
+ export interface EventEntryMediaLimits {
593
+ /** Max images in one entry. Platform default: 3. */
594
+ maxImages?: number;
595
+ /** Max videos in one entry. Platform default: 1. */
596
+ maxVideos?: number;
597
+ /** Max items overall. Platform default: 4. */
598
+ maxTotal?: number;
599
+ /** Require at least one image. Platform default: true. */
600
+ requireImage?: boolean;
601
+ }
602
+ /**
603
+ * The platform defaults, stated once. Owner spec 2026-11-09 for the Diwali
604
+ * photo/video contest: 3 photos + 1 video, 4 total.
605
+ *
606
+ * 🛑 `maxTotal` is NOT `maxImages + maxVideos`. It is a separate cap on
607
+ * purpose: 3 + 1 = 4 today, but an event allowing 5 images and 2 videos may
608
+ * still want a 5-item ceiling. Deriving it would silently remove that choice.
609
+ */
610
+ export declare const DEFAULT_ENTRY_MEDIA_LIMITS: Required<EventEntryMediaLimits>;
611
+ /** This event's caps, with every unset field filled from the platform default. */
612
+ export declare function resolveEntryMediaLimits(limits?: EventEntryMediaLimits): Required<EventEntryMediaLimits>;
613
+ /**
614
+ * Check one submission's media against an event's caps.
615
+ *
616
+ * Returns a human-readable reason, or `null` when it passes. A string rather
617
+ * than a boolean because the caller surfaces it to the participant — "At most
618
+ * 3 photos" is actionable; "invalid" is not.
619
+ */
620
+ export declare function checkEntryMedia(media: ReadonlyArray<{
621
+ type: string;
622
+ }> | undefined, limits?: EventEntryMediaLimits): string | null;
623
+ /** How many entries one participant may submit. Platform default: 1. */
624
+ export declare const DEFAULT_ENTRY_LIMIT = 1;
625
+ /** This event's per-participant entry limit. */
626
+ export declare function resolveEntryLimit(entryLimit?: number): number;
495
627
  export {};
@@ -140,3 +140,83 @@ export const eventQueryHelpers = {
140
140
  withRaffle: () => ["hasRaffle", "==", true],
141
141
  raffleNotYetDrawn: () => ["raffleWinnerUserId", "==", null],
142
142
  };
143
+ /**
144
+ * Resolve the approval mode for one entry submission.
145
+ *
146
+ * Order, most specific first:
147
+ * 1. `event.entryApproval` — this event's own decision
148
+ * 2. `typeDefault` — the event TYPE's default, from seed config
149
+ * 3. the legacy expression — see below
150
+ *
151
+ * 🛑 FAILS CLOSED. Anything not recognised as `"auto"` resolves to
152
+ * moderated. An unknown mode arriving from newer config against an older
153
+ * deploy must not start publishing unreviewed user content — degrading to a
154
+ * review queue is recoverable, publishing is not.
155
+ *
156
+ * ⚠ The third fallback is a MIGRATION PATH, not a design. It reproduces the
157
+ * hardcoded type switch this field replaced so that events already in the
158
+ * database keep behaving exactly as they did. It should be deleted once every
159
+ * event type declares its own default in `event-types-seed-data.ts`.
160
+ */
161
+ export function resolveEntryApproval(event, typeDefault, legacyAutoApprove) {
162
+ const chosen = event.entryApproval ?? typeDefault;
163
+ if (chosen !== undefined)
164
+ return chosen === "auto" ? "auto" : "moderated";
165
+ return legacyAutoApprove ? "auto" : "moderated";
166
+ }
167
+ /**
168
+ * The platform defaults, stated once. Owner spec 2026-11-09 for the Diwali
169
+ * photo/video contest: 3 photos + 1 video, 4 total.
170
+ *
171
+ * 🛑 `maxTotal` is NOT `maxImages + maxVideos`. It is a separate cap on
172
+ * purpose: 3 + 1 = 4 today, but an event allowing 5 images and 2 videos may
173
+ * still want a 5-item ceiling. Deriving it would silently remove that choice.
174
+ */
175
+ export const DEFAULT_ENTRY_MEDIA_LIMITS = {
176
+ maxImages: 3,
177
+ maxVideos: 1,
178
+ maxTotal: 4,
179
+ requireImage: true,
180
+ };
181
+ /** This event's caps, with every unset field filled from the platform default. */
182
+ export function resolveEntryMediaLimits(limits) {
183
+ return {
184
+ maxImages: limits?.maxImages ?? DEFAULT_ENTRY_MEDIA_LIMITS.maxImages,
185
+ maxVideos: limits?.maxVideos ?? DEFAULT_ENTRY_MEDIA_LIMITS.maxVideos,
186
+ maxTotal: limits?.maxTotal ?? DEFAULT_ENTRY_MEDIA_LIMITS.maxTotal,
187
+ requireImage: limits?.requireImage ?? DEFAULT_ENTRY_MEDIA_LIMITS.requireImage,
188
+ };
189
+ }
190
+ /**
191
+ * Check one submission's media against an event's caps.
192
+ *
193
+ * Returns a human-readable reason, or `null` when it passes. A string rather
194
+ * than a boolean because the caller surfaces it to the participant — "At most
195
+ * 3 photos" is actionable; "invalid" is not.
196
+ */
197
+ export function checkEntryMedia(media, limits) {
198
+ const l = resolveEntryMediaLimits(limits);
199
+ const items = media ?? [];
200
+ if (!items.length) {
201
+ return l.requireImage ? "Add at least one photo" : null;
202
+ }
203
+ if (items.length > l.maxTotal)
204
+ return `At most ${l.maxTotal} items in total`;
205
+ const images = items.filter((m) => m.type === "image").length;
206
+ const videos = items.filter((m) => m.type === "video").length;
207
+ if (images > l.maxImages)
208
+ return `At most ${l.maxImages} photos`;
209
+ if (videos > l.maxVideos)
210
+ return `At most ${l.maxVideos} video${l.maxVideos === 1 ? "" : "s"}`;
211
+ if (l.requireImage && images < 1)
212
+ return "At least one photo is required";
213
+ return null;
214
+ }
215
+ /** How many entries one participant may submit. Platform default: 1. */
216
+ export const DEFAULT_ENTRY_LIMIT = 1;
217
+ /** This event's per-participant entry limit. */
218
+ export function resolveEntryLimit(entryLimit) {
219
+ return typeof entryLimit === "number" && entryLimit > 0
220
+ ? entryLimit
221
+ : DEFAULT_ENTRY_LIMIT;
222
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mohasinac/appkit",
3
- "version": "4.76.0",
3
+ "version": "4.78.0",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public"