@mohasinac/appkit 4.71.2 → 4.72.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 (43) hide show
  1. package/dist/.build-stamp.json +3 -3
  2. package/dist/constants/api-endpoints.d.ts +3 -0
  3. package/dist/constants/api-endpoints.js +6 -0
  4. package/dist/features/classified/components/ClassifiedDetailPageView.js +2 -2
  5. package/dist/features/digital-codes/components/DigitalCodeDetailPageView.js +2 -2
  6. package/dist/features/events/components/EntryVoteButtons.d.ts +19 -0
  7. package/dist/features/events/components/EntryVoteButtons.js +65 -0
  8. package/dist/features/events/repository/entry-votes.repository.d.ts +45 -0
  9. package/dist/features/events/repository/entry-votes.repository.js +154 -0
  10. package/dist/features/events/schemas/entry-votes.d.ts +134 -0
  11. package/dist/features/events/schemas/entry-votes.js +114 -0
  12. package/dist/features/events/schemas/index.d.ts +29 -7
  13. package/dist/features/events/schemas/index.js +29 -2
  14. package/dist/features/inventory/components/AdminMoveLedgerView.d.ts +1 -1
  15. package/dist/features/live/components/LiveItemDetailPageView.js +2 -2
  16. package/dist/features/pre-orders/components/PreOrderDetailPageView.js +3 -3
  17. package/dist/features/products/components/ClearanceDetailPageView.js +2 -2
  18. package/dist/features/products/components/index.d.ts +0 -2
  19. package/dist/features/products/components/index.js +0 -1
  20. package/dist/features/products/schemas/index.d.ts +6 -7
  21. package/dist/features/products/schemas/index.js +20 -2
  22. package/dist/features/promotions/schemas/firestore.d.ts +17 -1
  23. package/dist/features/promotions/schemas/firestore.js +22 -0
  24. package/dist/features/promotions/schemas/index.d.ts +45 -21
  25. package/dist/features/promotions/schemas/index.js +37 -8
  26. package/dist/features/promotions/types/index.d.ts +1 -1
  27. package/dist/features/reviews/schemas/index.d.ts +3 -0
  28. package/dist/features/reviews/schemas/index.js +25 -0
  29. package/dist/index.d.ts +0 -2
  30. package/dist/index.js +0 -3
  31. package/dist/next/routing/portals.d.ts +22 -0
  32. package/dist/next/routing/portals.js +43 -0
  33. package/dist/providers/db-firebase/admin.d.ts +2 -0
  34. package/dist/providers/db-firebase/admin.js +25 -2
  35. package/dist/schemas/registry.d.ts +11 -8
  36. package/dist/styles.css +1 -1
  37. package/dist/tailwind-utilities.css +1 -1
  38. package/dist/ui/components/ListingDetailShell.d.ts +68 -0
  39. package/dist/ui/components/ListingDetailShell.js +32 -0
  40. package/package.json +533 -525
  41. package/scripts/seed-cli.mjs +32 -2
  42. package/dist/features/products/components/PreOrderDetailView.d.ts +0 -18
  43. package/dist/features/products/components/PreOrderDetailView.js +0 -12
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "stampVersion": 1,
3
- "version": "4.71.2",
4
- "head": "2f712f0de777c8205b4dd33037754c70c5d9074d",
3
+ "version": "4.72.0",
4
+ "head": "575e897961a5c80f9a04fbbf12f3f78346e9d654",
5
5
  "branch": "main",
6
6
  "dirty": true,
7
- "builtAt": "2026-11-08T17:02:06.024Z"
7
+ "builtAt": "2026-11-09T00:00:40.787Z"
8
8
  }
@@ -450,6 +450,7 @@ export declare const EVENT_ENDPOINTS: {
450
450
  readonly LIST: "/api/events";
451
451
  readonly BY_ID: (id: string) => string;
452
452
  readonly ENTRIES: (id: string) => string;
453
+ readonly ENTRY_VOTE: (id: string, entryId: string) => string;
453
454
  readonly LEADERBOARD: (id: string, limit?: number) => string;
454
455
  readonly SPIN: (id: string) => string;
455
456
  /**
@@ -1046,6 +1047,7 @@ export declare const API_ENDPOINTS: {
1046
1047
  readonly LIST: "/api/events";
1047
1048
  readonly BY_ID: (id: string) => string;
1048
1049
  readonly ENTRIES: (id: string) => string;
1050
+ readonly ENTRY_VOTE: (id: string, entryId: string) => string;
1049
1051
  readonly LEADERBOARD: (id: string, limit?: number) => string;
1050
1052
  readonly SPIN: (id: string) => string;
1051
1053
  /**
@@ -1660,6 +1662,7 @@ export declare const API_ROUTES: {
1660
1662
  readonly LIST: "/api/events";
1661
1663
  readonly BY_ID: (id: string) => string;
1662
1664
  readonly ENTRIES: (id: string) => string;
1665
+ readonly ENTRY_VOTE: (id: string, entryId: string) => string;
1663
1666
  readonly LEADERBOARD: (id: string, limit?: number) => string;
1664
1667
  readonly SPIN: (id: string) => string;
1665
1668
  /**
@@ -569,6 +569,12 @@ export const EVENT_ENDPOINTS = {
569
569
  LIST: "/api/events",
570
570
  BY_ID: (id) => `/api/events/${id}`,
571
571
  ENTRIES: (id) => `/api/events/${id}/entries`,
572
+ /*
573
+ * Voting on a contest entry. The response carries COUNTS only — the voter
574
+ * list is admin-only and comes from a different handler, so a public caller
575
+ * cannot reach it by changing a query string.
576
+ */
577
+ ENTRY_VOTE: (id, entryId) => `/api/events/${id}/entries/${entryId}/vote`,
572
578
  LEADERBOARD: (id, limit) => `/api/events/${id}/leaderboard${limit !== undefined ? `?limit=${limit}` : ""}`,
573
579
  // SB9-E user spin assignment
574
580
  SPIN: (id) => `/api/events/${id}/spin`,
@@ -18,7 +18,7 @@ import { Heading, Span, Text } from "../../../ui/components/Typography.js";
18
18
  import { Main, Nav, Section } from "../../../ui/components/Semantic.js";
19
19
  import { RichText } from "../../../ui/rich-text/RichText.js";
20
20
  import { SpecTable } from "../../../ui/components/SpecTable.js";
21
- import { PreOrderDetailView } from "../../products/components/PreOrderDetailView.js";
21
+ import { ListingDetailShell } from "../../../ui/components/ListingDetailShell.js";
22
22
  import { ProductGalleryClient } from "../../products/components/ProductGalleryClient.js";
23
23
  import { ProductTabsShell } from "../../products/components/ProductTabsShell.js";
24
24
  import { toCustomSectionTabs } from "../../products/components/CustomSectionTabContent.js";
@@ -127,7 +127,7 @@ export async function ClassifiedDetailPageView({ slug, initialProduct, renderOff
127
127
  price: price ?? undefined,
128
128
  storeId: typeof p.storeId === "string" ? p.storeId : undefined,
129
129
  storeName: storeName ?? undefined,
130
- } }), _jsxs(Container, { size: "xl", padding: "y-lg", children: [_jsxs(Row, { className: "mb-4", align: "center", justify: "between", gap: "sm", wrap: true, children: [_jsxs(Nav, { "aria-label": "Breadcrumb", layout: "flex-wrap", gap: "2xs", textSize: "xs", color: "muted", children: [_jsx(Link, { href: String(ROUTES.HOME), className: CLS_BREADCRUMB_LINK, children: "Home" }), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.CLASSIFIED), className: CLS_BREADCRUMB_LINK, children: "Classifieds" }), category && (_jsxs(_Fragment, { children: [_jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.CATEGORY_DETAIL(category)), className: CLS_BREADCRUMB_LINK, children: categoryName || category })] })), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Span, { className: "truncate max-w-[200px]", color: "muted", children: title })] }), _jsx(ShareButton, { title: title })] }), _jsx(PreOrderDetailView, { renderGallery: () => (_jsx(SectionVisibilityGate, { surfaceId: "pdp-sections", sectionKey: "gallery", initialVisible: pdpSectionVisibility.gallery !== false, children: _jsx(ProductGalleryClient, { images: images, productName: title }) })), renderInfo: () => (_jsx(SectionVisibilityGate, { surfaceId: "pdp-sections", sectionKey: "info", initialVisible: pdpSectionVisibility.info !== false, children: _jsxs(Stack, { gap: "md", children: [_jsxs(Div, { children: [_jsxs(Row, { gap: "xs", wrap: true, className: "mb-2", children: [_jsx(Span, { size: "xs", weight: "semibold", className: CLS_CLASSIFIED_BADGE, children: "Classified" }), meta?.negotiable && (_jsx(Span, { size: "xs", weight: "medium", className: "inline-block", padding: "pill-sm", rounded: "full", surface: "subtle", color: "muted", children: "Negotiable" })), meta?.acceptsShipping && (_jsx(Span, { size: "xs", weight: "medium", className: "inline-block", padding: "pill-sm", rounded: "full", surface: "subtle", color: "muted", children: "Shipping available" }))] }), _jsx(OwnerQuickActions, { resource: "PRODUCT", placement: "detail", recordLabel: String(p.title ?? p.slug ?? p.id), storeId: p.storeId ? String(p.storeId) : undefined, editHref: String(ROUTES.STORE.PRODUCTS_EDIT(String(p.id))) }), _jsx(Heading, { level: 1, className: "leading-snug", smSize: "2xl", color: "primary", size: "xl", weight: "bold", children: title })] }), price !== null && (_jsx(Text, { size: "2xl", weight: "bold", color: "primary", children: _jsx(GatedPrice, { children: formatCurrency(price, currency) }) })), location && (_jsxs(Row, { color: "muted", textSize: "sm", align: "center", gap: "xs", children: [_jsx(Span, { children: "\uD83D\uDCCD" }), _jsx(Span, { children: location }), meta?.meetupArea?.pincode && _jsxs(Span, { children: ["\u00B7 ", meta.meetupArea.pincode] })] })), descriptionHtml && (_jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm max-w-none dark:prose-invert prose-p:my-0", className: "text-[length:var(--appkit-text-sm)] leading-relaxed text-[var(--appkit-color-text-muted)] line-clamp-4" })), safeSeller && (_jsx(Div, { border: "subtle", rounded: "xl", surface: "muted", padding: "md", children: _jsxs(Row, { justify: "between", align: "center", children: [_jsxs(Div, { children: [_jsx(Text, { className: "text-[10px] tracking-wide mb-0.5", color: "faint", transform: "uppercase", children: "Sold by" }), _jsx(Text, { size: "sm", weight: "semibold", color: "primary", children: safeSeller })] }), storeHref && (_jsx(Link, { href: storeHref, className: "shrink-0 rounded-lg bg-primary/10 dark:bg-primary/20 px-[var(--appkit-space-3)] py-[var(--appkit-space-1-5)] text-[length:var(--appkit-text-xs)] font-semibold text-primary-700 dark:text-primary-300 hover:bg-primary/20 dark:hover:bg-primary/30 transition-colors", children: "Visit Store \u2192" }))] }) }))] }) })), renderTabs: () => (_jsx(SectionVisibilityGate, { surfaceId: "pdp-sections", sectionKey: "tabs", initialVisible: pdpSectionVisibility.tabs !== false, children: _jsx(ProductTabsShell, { descriptionContent: descriptionHtml ? (_jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm sm:prose max-w-none dark:prose-invert", className: "text-[var(--appkit-color-text-muted)]" })) : undefined, specsContent: specs.length > 0 ? (_jsx(SpecTable, { rows: specs })) : undefined, customTabs: toCustomSectionTabs(p.customSections) }) })), renderBuyBar: () => (_jsx(SectionVisibilityGate, { surfaceId: "pdp-sections", sectionKey: "actions", initialVisible: pdpSectionVisibility.actions !== false, children: _jsx(Stack, { id: "classified-contact-bar", className: "p-[var(--appkit-space-5)]", border: "subtle", gap: "md", rounded: "xl", surface: "muted", children: !hasOfferSlotOverride &&
130
+ } }), _jsxs(Container, { size: "xl", padding: "y-lg", children: [_jsxs(Row, { className: "mb-4", align: "center", justify: "between", gap: "sm", wrap: true, children: [_jsxs(Nav, { "aria-label": "Breadcrumb", layout: "flex-wrap", gap: "2xs", textSize: "xs", color: "muted", children: [_jsx(Link, { href: String(ROUTES.HOME), className: CLS_BREADCRUMB_LINK, children: "Home" }), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.CLASSIFIED), className: CLS_BREADCRUMB_LINK, children: "Classifieds" }), category && (_jsxs(_Fragment, { children: [_jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.CATEGORY_DETAIL(category)), className: CLS_BREADCRUMB_LINK, children: categoryName || category })] })), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Span, { className: "truncate max-w-[200px]", color: "muted", children: title })] }), _jsx(ShareButton, { title: title })] }), _jsx(ListingDetailShell, { layout: "grid-2", renderGallery: () => (_jsx(SectionVisibilityGate, { surfaceId: "pdp-sections", sectionKey: "gallery", initialVisible: pdpSectionVisibility.gallery !== false, children: _jsx(ProductGalleryClient, { images: images, productName: title }) })), renderInfo: () => (_jsx(SectionVisibilityGate, { surfaceId: "pdp-sections", sectionKey: "info", initialVisible: pdpSectionVisibility.info !== false, children: _jsxs(Stack, { gap: "md", children: [_jsxs(Div, { children: [_jsxs(Row, { gap: "xs", wrap: true, className: "mb-2", children: [_jsx(Span, { size: "xs", weight: "semibold", className: CLS_CLASSIFIED_BADGE, children: "Classified" }), meta?.negotiable && (_jsx(Span, { size: "xs", weight: "medium", className: "inline-block", padding: "pill-sm", rounded: "full", surface: "subtle", color: "muted", children: "Negotiable" })), meta?.acceptsShipping && (_jsx(Span, { size: "xs", weight: "medium", className: "inline-block", padding: "pill-sm", rounded: "full", surface: "subtle", color: "muted", children: "Shipping available" }))] }), _jsx(OwnerQuickActions, { resource: "PRODUCT", placement: "detail", recordLabel: String(p.title ?? p.slug ?? p.id), storeId: p.storeId ? String(p.storeId) : undefined, editHref: String(ROUTES.STORE.PRODUCTS_EDIT(String(p.id))) }), _jsx(Heading, { level: 1, className: "leading-snug", smSize: "2xl", color: "primary", size: "xl", weight: "bold", children: title })] }), price !== null && (_jsx(Text, { size: "2xl", weight: "bold", color: "primary", children: _jsx(GatedPrice, { children: formatCurrency(price, currency) }) })), location && (_jsxs(Row, { color: "muted", textSize: "sm", align: "center", gap: "xs", children: [_jsx(Span, { children: "\uD83D\uDCCD" }), _jsx(Span, { children: location }), meta?.meetupArea?.pincode && _jsxs(Span, { children: ["\u00B7 ", meta.meetupArea.pincode] })] })), descriptionHtml && (_jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm max-w-none dark:prose-invert prose-p:my-0", className: "text-[length:var(--appkit-text-sm)] leading-relaxed text-[var(--appkit-color-text-muted)] line-clamp-4" })), safeSeller && (_jsx(Div, { border: "subtle", rounded: "xl", surface: "muted", padding: "md", children: _jsxs(Row, { justify: "between", align: "center", children: [_jsxs(Div, { children: [_jsx(Text, { className: "text-[10px] tracking-wide mb-0.5", color: "faint", transform: "uppercase", children: "Sold by" }), _jsx(Text, { size: "sm", weight: "semibold", color: "primary", children: safeSeller })] }), storeHref && (_jsx(Link, { href: storeHref, className: "shrink-0 rounded-lg bg-primary/10 dark:bg-primary/20 px-[var(--appkit-space-3)] py-[var(--appkit-space-1-5)] text-[length:var(--appkit-text-xs)] font-semibold text-primary-700 dark:text-primary-300 hover:bg-primary/20 dark:hover:bg-primary/30 transition-colors", children: "Visit Store \u2192" }))] }) }))] }) })), renderTabs: () => (_jsx(SectionVisibilityGate, { surfaceId: "pdp-sections", sectionKey: "tabs", initialVisible: pdpSectionVisibility.tabs !== false, children: _jsx(ProductTabsShell, { descriptionContent: descriptionHtml ? (_jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm sm:prose max-w-none dark:prose-invert", className: "text-[var(--appkit-color-text-muted)]" })) : undefined, specsContent: specs.length > 0 ? (_jsx(SpecTable, { rows: specs })) : undefined, customTabs: toCustomSectionTabs(p.customSections) }) })), renderAside: () => (_jsx(SectionVisibilityGate, { surfaceId: "pdp-sections", sectionKey: "actions", initialVisible: pdpSectionVisibility.actions !== false, children: _jsx(Stack, { id: "classified-contact-bar", className: "p-[var(--appkit-space-5)]", border: "subtle", gap: "md", rounded: "xl", surface: "muted", children: !hasOfferSlotOverride &&
131
131
  (offerActionNode ?? (
132
132
  /*
133
133
  * Reachable only when the listing has no price at all: a
@@ -18,7 +18,7 @@ import { Heading, Span, Text } from "../../../ui/components/Typography.js";
18
18
  import { Main, Nav, Section } from "../../../ui/components/Semantic.js";
19
19
  import { RichText } from "../../../ui/rich-text/RichText.js";
20
20
  import { SpecTable } from "../../../ui/components/SpecTable.js";
21
- import { PreOrderDetailView } from "../../products/components/PreOrderDetailView.js";
21
+ import { ListingDetailShell } from "../../../ui/components/ListingDetailShell.js";
22
22
  import { ProductGalleryClient } from "../../products/components/ProductGalleryClient.js";
23
23
  import { ProductTabsShell } from "../../products/components/ProductTabsShell.js";
24
24
  import { toCustomSectionTabs } from "../../products/components/CustomSectionTabContent.js";
@@ -101,5 +101,5 @@ export async function DigitalCodeDetailPageView({ slug, initialProduct, renderPr
101
101
  price: price ?? undefined,
102
102
  storeId: typeof p.storeId === "string" ? p.storeId : undefined,
103
103
  storeName: storeName ?? undefined,
104
- } }), _jsxs(Container, { size: "xl", padding: "y-lg", children: [_jsxs(Row, { className: "mb-4", align: "center", justify: "between", gap: "sm", wrap: true, children: [_jsxs(Nav, { "aria-label": "Breadcrumb", layout: "flex-wrap", gap: "2xs", textSize: "xs", color: "muted", children: [_jsx(Link, { href: String(ROUTES.HOME), className: CLS_BREADCRUMB_LINK, children: "Home" }), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.DIGITAL_CODES), className: CLS_BREADCRUMB_LINK, children: "Digital Codes" }), category && (_jsxs(_Fragment, { children: [_jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.CATEGORY_DETAIL(category)), className: CLS_BREADCRUMB_LINK, children: categoryName || category })] })), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Span, { className: "truncate max-w-[200px]", color: "muted", children: title })] }), _jsx(ShareButton, { title: title })] }), _jsx(PreOrderDetailView, { renderGallery: () => (_jsx(SectionVisibilityGate, { surfaceId: "pdp-sections", sectionKey: "gallery", initialVisible: pdpSectionVisibility.gallery !== false, children: _jsx(ProductGalleryClient, { images: images, productName: title }) })), renderInfo: () => (_jsx(SectionVisibilityGate, { surfaceId: "pdp-sections", sectionKey: "info", initialVisible: pdpSectionVisibility.info !== false, children: _jsxs(Stack, { gap: "md", children: [_jsxs(Div, { children: [_jsxs(Row, { gap: "xs", wrap: true, className: "mb-2", children: [_jsx(Span, { size: "xs", weight: "semibold", className: CLS_DIGITALCODE_BADGE, children: "Digital Code" }), codesLeft > 0 ? (_jsxs(Span, { size: "xs", weight: "medium", className: "inline-block", padding: "pill-sm", rounded: "full", surface: "subtle", color: "muted", children: [codesLeft, " available"] })) : (_jsx(Span, { size: "xs", weight: "semibold", className: "inline-block", padding: "pill-sm", rounded: "full", surface: "danger-surface", color: "error", children: "Sold out" }))] }), _jsx(OwnerQuickActions, { resource: "PRODUCT", placement: "detail", recordLabel: String(p.title ?? p.slug ?? p.id), storeId: p.storeId ? String(p.storeId) : undefined, editHref: String(ROUTES.STORE.PRODUCTS_EDIT(String(p.id))) }), _jsx(Heading, { level: 1, className: "leading-snug", smSize: "2xl", color: "primary", size: "xl", weight: "bold", children: title })] }), price !== null && (_jsx(Text, { size: "2xl", weight: "bold", color: "primary", children: _jsx(GatedPrice, { children: formatCurrency(price, currency) }) })), meta?.redemptionInstructions && (_jsx(Text, { size: "xs", color: "muted", children: meta.redemptionInstructions })), descriptionHtml && (_jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm max-w-none dark:prose-invert prose-p:my-0", className: "text-[length:var(--appkit-text-sm)] leading-relaxed text-[var(--appkit-color-text-muted)] line-clamp-4" })), safeSeller && (_jsx(Div, { border: "subtle", rounded: "xl", surface: "muted", padding: "md", children: _jsxs(Row, { justify: "between", align: "center", children: [_jsxs(Div, { children: [_jsx(Text, { className: "text-[10px] tracking-wide mb-0.5", color: "faint", transform: "uppercase", children: "Sold by" }), _jsx(Text, { size: "sm", weight: "semibold", color: "primary", children: safeSeller })] }), storeHref && (_jsx(Link, { href: storeHref, className: "shrink-0 rounded-lg bg-primary/10 dark:bg-primary/20 px-[var(--appkit-space-3)] py-[var(--appkit-space-1-5)] text-[length:var(--appkit-text-xs)] font-semibold text-primary-700 dark:text-primary-300 hover:bg-primary/20 dark:hover:bg-primary/30 transition-colors", children: "Visit Store \u2192" }))] }) }))] }) })), renderTabs: () => (_jsx(SectionVisibilityGate, { surfaceId: "pdp-sections", sectionKey: "tabs", initialVisible: pdpSectionVisibility.tabs !== false, children: _jsx(ProductTabsShell, { descriptionContent: descriptionHtml ? (_jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm sm:prose max-w-none dark:prose-invert", className: "text-[var(--appkit-color-text-muted)]" })) : undefined, specsContent: specs.length > 0 ? (_jsx(SpecTable, { rows: specs })) : undefined, reviewsContent: _jsx(ReviewsListingPanel, { source: { kind: "product", productId: product.id }, stateMode: "local", context: "listing", initialData: initialReviews, emptyLabel: "No reviews yet \u2014 be the first to review this product." }), customTabs: toCustomSectionTabs(p.customSections) }) })), renderBuyBar: () => (_jsx(SectionVisibilityGate, { surfaceId: "pdp-sections", sectionKey: "actions", initialVisible: pdpSectionVisibility.actions !== false, children: _jsx(Div, { id: "digital-code-buy-bar", children: !hasPrimaryActionsSlotOverride && primaryActionsNode }) })), renderRelated: () => (_jsxs(Stack, { gap: "xl", children: [_jsx(PurchaseReviewAffordance, { productId: String(product.id), storeId: product.storeId ? String(product.storeId) : undefined }), _jsx(SectionVisibilityGate, { surfaceId: "pdp-sections", sectionKey: "grouped-listings", initialVisible: pdpSectionVisibility["grouped-listings"] !== false, children: _jsx(GroupedListingsCarousel, { groups: groups }) }), _jsx(SectionVisibilityGate, { surfaceId: "pdp-sections", sectionKey: "related", initialVisible: pdpSectionVisibility.related !== false, children: _jsx(RelatedItemsSection, { relatedItems: relatedItems, relatedByBrand: relatedByBrand, relatedByTags: relatedByTags, relatedByStore: relatedByStore, categoryLabel: categoryName || category || undefined, brandLabel: brand, storeLabel: storeName ?? undefined }) })] })) }), _jsx(Div, { className: "mt-8", children: _jsx(AdminComposableRegion, { pageId: "digital-codes-slug", initialCards: pageRegionCards, functionalSlots: { "pdp-primary-actions": primaryActionsNode } }) }), _jsx(ListingBottomActions, { listingType: "digital-code", anchorId: "digital-code-buy-bar", price: price, currency: currency, unavailable: codesLeft <= 0 })] })] }));
104
+ } }), _jsxs(Container, { size: "xl", padding: "y-lg", children: [_jsxs(Row, { className: "mb-4", align: "center", justify: "between", gap: "sm", wrap: true, children: [_jsxs(Nav, { "aria-label": "Breadcrumb", layout: "flex-wrap", gap: "2xs", textSize: "xs", color: "muted", children: [_jsx(Link, { href: String(ROUTES.HOME), className: CLS_BREADCRUMB_LINK, children: "Home" }), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.DIGITAL_CODES), className: CLS_BREADCRUMB_LINK, children: "Digital Codes" }), category && (_jsxs(_Fragment, { children: [_jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.CATEGORY_DETAIL(category)), className: CLS_BREADCRUMB_LINK, children: categoryName || category })] })), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Span, { className: "truncate max-w-[200px]", color: "muted", children: title })] }), _jsx(ShareButton, { title: title })] }), _jsx(ListingDetailShell, { layout: "grid-2", renderGallery: () => (_jsx(SectionVisibilityGate, { surfaceId: "pdp-sections", sectionKey: "gallery", initialVisible: pdpSectionVisibility.gallery !== false, children: _jsx(ProductGalleryClient, { images: images, productName: title }) })), renderInfo: () => (_jsx(SectionVisibilityGate, { surfaceId: "pdp-sections", sectionKey: "info", initialVisible: pdpSectionVisibility.info !== false, children: _jsxs(Stack, { gap: "md", children: [_jsxs(Div, { children: [_jsxs(Row, { gap: "xs", wrap: true, className: "mb-2", children: [_jsx(Span, { size: "xs", weight: "semibold", className: CLS_DIGITALCODE_BADGE, children: "Digital Code" }), codesLeft > 0 ? (_jsxs(Span, { size: "xs", weight: "medium", className: "inline-block", padding: "pill-sm", rounded: "full", surface: "subtle", color: "muted", children: [codesLeft, " available"] })) : (_jsx(Span, { size: "xs", weight: "semibold", className: "inline-block", padding: "pill-sm", rounded: "full", surface: "danger-surface", color: "error", children: "Sold out" }))] }), _jsx(OwnerQuickActions, { resource: "PRODUCT", placement: "detail", recordLabel: String(p.title ?? p.slug ?? p.id), storeId: p.storeId ? String(p.storeId) : undefined, editHref: String(ROUTES.STORE.PRODUCTS_EDIT(String(p.id))) }), _jsx(Heading, { level: 1, className: "leading-snug", smSize: "2xl", color: "primary", size: "xl", weight: "bold", children: title })] }), price !== null && (_jsx(Text, { size: "2xl", weight: "bold", color: "primary", children: _jsx(GatedPrice, { children: formatCurrency(price, currency) }) })), meta?.redemptionInstructions && (_jsx(Text, { size: "xs", color: "muted", children: meta.redemptionInstructions })), descriptionHtml && (_jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm max-w-none dark:prose-invert prose-p:my-0", className: "text-[length:var(--appkit-text-sm)] leading-relaxed text-[var(--appkit-color-text-muted)] line-clamp-4" })), safeSeller && (_jsx(Div, { border: "subtle", rounded: "xl", surface: "muted", padding: "md", children: _jsxs(Row, { justify: "between", align: "center", children: [_jsxs(Div, { children: [_jsx(Text, { className: "text-[10px] tracking-wide mb-0.5", color: "faint", transform: "uppercase", children: "Sold by" }), _jsx(Text, { size: "sm", weight: "semibold", color: "primary", children: safeSeller })] }), storeHref && (_jsx(Link, { href: storeHref, className: "shrink-0 rounded-lg bg-primary/10 dark:bg-primary/20 px-[var(--appkit-space-3)] py-[var(--appkit-space-1-5)] text-[length:var(--appkit-text-xs)] font-semibold text-primary-700 dark:text-primary-300 hover:bg-primary/20 dark:hover:bg-primary/30 transition-colors", children: "Visit Store \u2192" }))] }) }))] }) })), renderTabs: () => (_jsx(SectionVisibilityGate, { surfaceId: "pdp-sections", sectionKey: "tabs", initialVisible: pdpSectionVisibility.tabs !== false, children: _jsx(ProductTabsShell, { descriptionContent: descriptionHtml ? (_jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm sm:prose max-w-none dark:prose-invert", className: "text-[var(--appkit-color-text-muted)]" })) : undefined, specsContent: specs.length > 0 ? (_jsx(SpecTable, { rows: specs })) : undefined, reviewsContent: _jsx(ReviewsListingPanel, { source: { kind: "product", productId: product.id }, stateMode: "local", context: "listing", initialData: initialReviews, emptyLabel: "No reviews yet \u2014 be the first to review this product." }), customTabs: toCustomSectionTabs(p.customSections) }) })), renderAside: () => (_jsx(SectionVisibilityGate, { surfaceId: "pdp-sections", sectionKey: "actions", initialVisible: pdpSectionVisibility.actions !== false, children: _jsx(Div, { id: "digital-code-buy-bar", children: !hasPrimaryActionsSlotOverride && primaryActionsNode }) })), renderRelated: () => (_jsxs(Stack, { gap: "xl", children: [_jsx(PurchaseReviewAffordance, { productId: String(product.id), storeId: product.storeId ? String(product.storeId) : undefined }), _jsx(SectionVisibilityGate, { surfaceId: "pdp-sections", sectionKey: "grouped-listings", initialVisible: pdpSectionVisibility["grouped-listings"] !== false, children: _jsx(GroupedListingsCarousel, { groups: groups }) }), _jsx(SectionVisibilityGate, { surfaceId: "pdp-sections", sectionKey: "related", initialVisible: pdpSectionVisibility.related !== false, children: _jsx(RelatedItemsSection, { relatedItems: relatedItems, relatedByBrand: relatedByBrand, relatedByTags: relatedByTags, relatedByStore: relatedByStore, categoryLabel: categoryName || category || undefined, brandLabel: brand, storeLabel: storeName ?? undefined }) })] })) }), _jsx(Div, { className: "mt-8", children: _jsx(AdminComposableRegion, { pageId: "digital-codes-slug", initialCards: pageRegionCards, functionalSlots: { "pdp-primary-actions": primaryActionsNode } }) }), _jsx(ListingBottomActions, { listingType: "digital-code", anchorId: "digital-code-buy-bar", price: price, currency: currency, unavailable: codesLeft <= 0 })] })] }));
105
105
  }
@@ -0,0 +1,19 @@
1
+ import type { EntryVoteValue } from "../schemas/entry-votes";
2
+ export interface EntryVoteButtonsProps {
3
+ likes: number;
4
+ dislikes: number;
5
+ /** How the signed-in user has already voted, if at all. */
6
+ myVote: EntryVoteValue | null;
7
+ /** From the rule set. When false, no dislike control is rendered at all. */
8
+ allowsDislike: boolean;
9
+ /** Signed-out visitors see counts and a prompt, never a dead button. */
10
+ canVote: boolean;
11
+ /** Returns the authoritative counts; the component reverts if it throws. */
12
+ onVote: (value: EntryVoteValue) => Promise<{
13
+ likes: number;
14
+ dislikes: number;
15
+ myVote: EntryVoteValue | null;
16
+ }>;
17
+ signInHref?: string;
18
+ }
19
+ export declare function EntryVoteButtons({ likes, dislikes, myVote, allowsDislike, canVote, onVote, signInHref, }: EntryVoteButtonsProps): import("react").JSX.Element;
@@ -0,0 +1,65 @@
1
+ "use client";
2
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
3
+ /**
4
+ * Vote controls for a contest entry.
5
+ *
6
+ * 🛑 WHAT RENDERS IS DECIDED BY CONFIG, NOT BY THIS FILE.
7
+ *
8
+ * `allowsDislike` comes from the event's `entryRanking` rule set. A like-only
9
+ * popularity contest and a net-score vote are the same component with a
10
+ * different rule row — the dislike button is ABSENT, not disabled, when the
11
+ * rule does not use it, so there is nothing to click and nothing in the DOM.
12
+ *
13
+ * 🛑 COUNTS ONLY. This never receives or renders who voted. The voter list is
14
+ * admin-only and comes from a different handler; the public summary type does
15
+ * not carry it, so a leak here would be a compile error.
16
+ *
17
+ * Optimistic, with a revert. A vote is a single click on a page that may be
18
+ * showing forty entries, so waiting for a round trip before the number moves
19
+ * feels broken — but a failed request puts the previous state back rather than
20
+ * leaving a number the server disagrees with.
21
+ */
22
+ import { useCallback, useState } from "react";
23
+ import { Button } from "../../../ui/components/Button.js";
24
+ import { Div } from "../../../ui/components/Div.js";
25
+ import { Span } from "../../../ui/components/Typography.js";
26
+ export function EntryVoteButtons({ likes, dislikes, myVote, allowsDislike, canVote, onVote, signInHref, }) {
27
+ const [state, setState] = useState({ likes, dislikes, myVote });
28
+ const [busy, setBusy] = useState(false);
29
+ const cast = useCallback(async (value) => {
30
+ if (!canVote || busy)
31
+ return;
32
+ const previous = state;
33
+ /*
34
+ * Mirror the server's own rules so the optimistic number matches what
35
+ * the transaction will do: pressing the same button again RETRACTS, and
36
+ * switching sides moves both counters.
37
+ */
38
+ const retracting = state.myVote === value;
39
+ const next = {
40
+ likes: state.likes +
41
+ (value === "like" ? (retracting ? -1 : 1) : 0) +
42
+ (!retracting && state.myVote === "like" ? -1 : 0),
43
+ dislikes: state.dislikes +
44
+ (value === "dislike" ? (retracting ? -1 : 1) : 0) +
45
+ (!retracting && state.myVote === "dislike" ? -1 : 0),
46
+ myVote: retracting ? null : value,
47
+ };
48
+ setState({ ...next, likes: Math.max(0, next.likes), dislikes: Math.max(0, next.dislikes) });
49
+ setBusy(true);
50
+ try {
51
+ // The server's numbers win — it read the row inside a transaction.
52
+ setState(await onVote(value));
53
+ }
54
+ catch {
55
+ setState(previous);
56
+ }
57
+ finally {
58
+ setBusy(false);
59
+ }
60
+ }, [busy, canVote, onVote, state]);
61
+ if (!canVote) {
62
+ return (_jsxs(Div, { className: "flex items-center gap-2", children: [_jsxs(Span, { children: ["\u2665 ", state.likes] }), allowsDislike ? _jsxs(Span, { children: ["\u2661 ", state.dislikes] }) : null, signInHref ? (_jsx(Span, { children: _jsx("a", { href: signInHref, children: "Sign in to vote" }) })) : null] }));
63
+ }
64
+ return (_jsxs(Div, { className: "flex items-center gap-2", children: [_jsxs(Button, { variant: state.myVote === "like" ? "primary" : "secondary", size: "sm", disabled: busy, onClick: () => void cast("like"), "aria-pressed": state.myVote === "like", "aria-label": state.myVote === "like" ? "Remove your like" : "Like this entry", children: ["\u2665 ", state.likes] }), allowsDislike ? (_jsxs(Button, { variant: state.myVote === "dislike" ? "primary" : "secondary", size: "sm", disabled: busy, onClick: () => void cast("dislike"), "aria-pressed": state.myVote === "dislike", "aria-label": state.myVote === "dislike" ? "Remove your dislike" : "Dislike this entry", children: ["\u2661 ", state.dislikes] })) : null] }));
65
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Entry votes — cast, change, retract, and summarise.
3
+ *
4
+ * See `../schemas/entry-votes.ts` for why the document id is the idempotency
5
+ * key, why only `userId` is stored on the row, and why ranking is a config
6
+ * rule rather than arithmetic in this file.
7
+ *
8
+ * @tag domain:events
9
+ * @tag layer:repository
10
+ * @tag access:server-only
11
+ */
12
+ import { BaseRepository } from "../../../providers/db-firebase";
13
+ import { type EntryVoteAudit, type EntryVoteSummary, type EntryVoteValue, type EventEntryVoteDocument } from "../schemas/entry-votes";
14
+ export declare class EventEntryVotesRepository extends BaseRepository<EventEntryVoteDocument> {
15
+ constructor();
16
+ /**
17
+ * Cast, change or retract a vote.
18
+ *
19
+ * 🛑 ONE TRANSACTION, and the counters are adjusted by the DELTA the vote
20
+ * actually caused — never by a bare increment beside a bare set. The three
21
+ * cases differ and each would corrupt the count if treated as one:
22
+ *
23
+ * none → like likes +1
24
+ * like → dislike likes -1 AND dislikes +1 (the case a toggle misses)
25
+ * like → like retraction: likes -1, row deleted
26
+ *
27
+ * Reading the vote row inside the transaction is what makes each adjustment
28
+ * conditional on the membership really changing, so a double-click or a
29
+ * replayed request cannot double-count.
30
+ */
31
+ cast(eventId: string, entryId: string, userId: string, value: EntryVoteValue): Promise<EntryVoteSummary>;
32
+ /** How this user voted. One document read — no query, no index. */
33
+ myVote(entryId: string, userId: string): Promise<EntryVoteValue | null>;
34
+ /**
35
+ * ADMIN ONLY — who voted, and which way.
36
+ *
37
+ * 🛑 The public path never calls this. `EntryVoteAudit` and
38
+ * `EntryVoteSummary` are separate, non-overlapping types precisely so a
39
+ * public handler returning the wrong one is a compile error rather than a
40
+ * leak of who voted. Owner rule: *"only admins see who voted, rest see
41
+ * numbers"*.
42
+ */
43
+ audit(entryId: string, summary: EntryVoteSummary, max?: number): Promise<EntryVoteAudit>;
44
+ }
45
+ export declare const eventEntryVotesRepository: EventEntryVotesRepository;
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Entry votes — cast, change, retract, and summarise.
3
+ *
4
+ * See `../schemas/entry-votes.ts` for why the document id is the idempotency
5
+ * key, why only `userId` is stored on the row, and why ranking is a config
6
+ * rule rather than arithmetic in this file.
7
+ *
8
+ * @tag domain:events
9
+ * @tag layer:repository
10
+ * @tag access:server-only
11
+ */
12
+ import { normalizeError } from "../../../errors/normalize.js";
13
+ import { DatabaseError } from "../../../errors/index.js";
14
+ import { serverLogger } from "../../../monitoring/index.js";
15
+ import { BaseRepository } from "../../../providers/db-firebase/index.js";
16
+ import { EVENT_ENTRY_VOTES_COLLECTION, EVENT_ENTRY_VOTE_FIELDS, eventEntryVoteId, } from "../schemas/entry-votes.js";
17
+ import { EVENT_ENTRIES_COLLECTION } from "../schemas/firestore.js";
18
+ export class EventEntryVotesRepository extends BaseRepository {
19
+ constructor() {
20
+ super(EVENT_ENTRY_VOTES_COLLECTION);
21
+ }
22
+ /**
23
+ * Cast, change or retract a vote.
24
+ *
25
+ * 🛑 ONE TRANSACTION, and the counters are adjusted by the DELTA the vote
26
+ * actually caused — never by a bare increment beside a bare set. The three
27
+ * cases differ and each would corrupt the count if treated as one:
28
+ *
29
+ * none → like likes +1
30
+ * like → dislike likes -1 AND dislikes +1 (the case a toggle misses)
31
+ * like → like retraction: likes -1, row deleted
32
+ *
33
+ * Reading the vote row inside the transaction is what makes each adjustment
34
+ * conditional on the membership really changing, so a double-click or a
35
+ * replayed request cannot double-count.
36
+ */
37
+ async cast(eventId, entryId, userId, value) {
38
+ try {
39
+ const db = this.db;
40
+ const voteRef = this.getCollection().doc(eventEntryVoteId(entryId, userId));
41
+ const entryRef = db.collection(EVENT_ENTRIES_COLLECTION).doc(entryId);
42
+ return await db.runTransaction(async (tx) => {
43
+ const [voteSnap, entrySnap] = await Promise.all([tx.get(voteRef), tx.get(entryRef)]);
44
+ if (!entrySnap.exists) {
45
+ // Voting on a deleted entry would strand a row the cascade cannot
46
+ // find — the cleanup keys on the entry existing.
47
+ throw new DatabaseError(`Entry not found: ${entryId}`);
48
+ }
49
+ const counters = entrySnap.data() ?? {};
50
+ let likes = Number(counters.likeCount ?? 0);
51
+ let dislikes = Number(counters.dislikeCount ?? 0);
52
+ const previous = voteSnap.exists
53
+ ? voteSnap.data().value
54
+ : null;
55
+ let myVote;
56
+ const now = new Date();
57
+ if (previous === value) {
58
+ // Same direction twice is a RETRACTION — the second press of a
59
+ // pressed button. Matches how the like button already behaves.
60
+ tx.delete(voteRef);
61
+ if (value === "like")
62
+ likes -= 1;
63
+ else
64
+ dislikes -= 1;
65
+ myVote = null;
66
+ }
67
+ else {
68
+ if (previous === "like")
69
+ likes -= 1;
70
+ if (previous === "dislike")
71
+ dislikes -= 1;
72
+ if (value === "like")
73
+ likes += 1;
74
+ else
75
+ dislikes += 1;
76
+ tx.set(voteRef, {
77
+ eventId,
78
+ entryId,
79
+ userId,
80
+ value,
81
+ createdAt: voteSnap.exists
82
+ ? (voteSnap.data().createdAt ?? now)
83
+ : now,
84
+ updatedAt: now,
85
+ }, { merge: true });
86
+ myVote = value;
87
+ }
88
+ /*
89
+ * 🛑 Floor at zero, the same call `eventLikes.toggle` makes. A count
90
+ * can only go negative if it has drifted — a row removed by a cascade
91
+ * without the counter moving — and a public "-1" is worse than an
92
+ * understated 0.
93
+ */
94
+ likes = Math.max(0, likes);
95
+ dislikes = Math.max(0, dislikes);
96
+ tx.update(entryRef, { likeCount: likes, dislikeCount: dislikes, updatedAt: now });
97
+ /*
98
+ * `score` is NOT computed here. The ranking rule is config
99
+ * (`entryRanking` rule sets) and `resolveEntryRanking` is the one
100
+ * place it is interpreted — a second formula in a repository is how
101
+ * two screens come to disagree about who is winning.
102
+ */
103
+ return { likes, dislikes, score: likes - dislikes, myVote };
104
+ });
105
+ }
106
+ catch (err) {
107
+ const e = normalizeError(err);
108
+ serverLogger.error("entryVotes.cast failed", { entryId, userId, value, message: e.message });
109
+ throw new DatabaseError(`Failed to record vote: ${e.message}`);
110
+ }
111
+ }
112
+ /** How this user voted. One document read — no query, no index. */
113
+ async myVote(entryId, userId) {
114
+ try {
115
+ const snap = await this.getCollection().doc(eventEntryVoteId(entryId, userId)).get();
116
+ if (!snap.exists)
117
+ return null;
118
+ return snap.data().value;
119
+ }
120
+ catch (err) {
121
+ const e = normalizeError(err);
122
+ serverLogger.error("entryVotes.myVote failed", { entryId, userId, message: e.message });
123
+ throw new DatabaseError(`Failed to read vote: ${e.message}`);
124
+ }
125
+ }
126
+ /**
127
+ * ADMIN ONLY — who voted, and which way.
128
+ *
129
+ * 🛑 The public path never calls this. `EntryVoteAudit` and
130
+ * `EntryVoteSummary` are separate, non-overlapping types precisely so a
131
+ * public handler returning the wrong one is a compile error rather than a
132
+ * leak of who voted. Owner rule: *"only admins see who voted, rest see
133
+ * numbers"*.
134
+ */
135
+ async audit(entryId, summary, max = 500) {
136
+ try {
137
+ const snap = await this.getCollection()
138
+ .where(EVENT_ENTRY_VOTE_FIELDS.ENTRY_ID, "==", entryId)
139
+ .limit(max)
140
+ .get();
141
+ const voters = snap.docs.map((d) => {
142
+ const v = d.data();
143
+ return { userId: v.userId, value: v.value, at: v.updatedAt ?? v.createdAt };
144
+ });
145
+ return { ...summary, voters };
146
+ }
147
+ catch (err) {
148
+ const e = normalizeError(err);
149
+ serverLogger.error("entryVotes.audit failed", { entryId, message: e.message });
150
+ throw new DatabaseError(`Failed to read voters: ${e.message}`);
151
+ }
152
+ }
153
+ }
154
+ export const eventEntryVotesRepository = new EventEntryVotesRepository();
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Entry votes — like/dislike on a CONTEST ENTRY, and who cast them.
3
+ *
4
+ * ## Why a sibling of `likes.ts` rather than a field on the entry
5
+ *
6
+ * Deliberately the same shape as `EventLikeDocument`, for the same reasons its
7
+ * header gives: the document id IS the idempotency key, so a repeat vote
8
+ * overwrites itself, a retraction is a `.delete()`, and "how did I vote" is a
9
+ * single `.doc(id).get()` with no query, no index and no transaction.
10
+ *
11
+ * The rejected alternative is the same one: a `votedBy[]` array on the entry.
12
+ * It caps out at Firestore's 1 MiB document limit — a real ceiling for a
13
+ * popular entry and a silent one — and it has no clean un-vote.
14
+ *
15
+ * ## 🛑 The difference from a like: a vote has a VALUE
16
+ *
17
+ * `likes.ts` stores existence — the row is the like. A vote stores a
18
+ * direction, so the row carries `value` and changing your mind is a `.set()`
19
+ * over the same id rather than a delete-then-create. That keeps "one vote per
20
+ * user per entry" true by construction instead of by a rule somebody enforces.
21
+ *
22
+ * ## 🛑 Who may see WHO voted
23
+ *
24
+ * Owner, 2026-11-08: *"only admins see who voted, rest see numbers"*.
25
+ *
26
+ * So the voter list is an admin projection and the counts are the public one.
27
+ * This file stores only `userId` — never a denormalised name or email — for
28
+ * exactly the reason `likes.ts` states: a public surface reads the counts, and
29
+ * the cheapest way to never leak a field is to not store it. An admin screen
30
+ * joins the user's own public projection at read time.
31
+ *
32
+ * ## 🛑 Ranking is a CONFIG RULE, never a fixed formula
33
+ *
34
+ * This file first shipped with `entryScore = likes - dislikes` hardcoded, which
35
+ * is the exact mistake the rework exists to remove: a rule baked into code that
36
+ * an admin then cannot change. There are several legitimate situations — a
37
+ * like-only popularity contest, a net-score vote, a straight turnout count, a
38
+ * jury pick — and the event, not the source, decides which applies.
39
+ *
40
+ * So the rule is a value on the event and `resolveEntryRanking` is the one
41
+ * place it is interpreted. Adding a fifth rule is a case here plus a config
42
+ * option; it is never a second copy of the arithmetic somewhere else.
43
+ *
44
+ * @tag domain:events
45
+ * @tag layer:schema
46
+ */
47
+ import type { BaseDocument } from "../../../_internal/shared/types/base-document";
48
+ export declare const EVENT_ENTRY_VOTES_COLLECTION: "eventEntryVotes";
49
+ /**
50
+ * The deterministic id — one row per user per entry.
51
+ *
52
+ * 🛑 `__` matches `eventLikeId`, `courierVoteId` and the tester-response id.
53
+ * Firestore ids may not contain `/`, and a single `_` appears inside real
54
+ * uids; a double underscore does not.
55
+ */
56
+ export declare function eventEntryVoteId(entryId: string, userId: string): string;
57
+ /** Which way the vote points. */
58
+ export type EntryVoteValue = "like" | "dislike";
59
+ export interface EventEntryVoteDocument extends BaseDocument {
60
+ /** Denormalised so an admin can sweep one event without joining entries. */
61
+ eventId: string;
62
+ entryId: string;
63
+ /** The Firebase Auth uid. The ONLY identity stored — see the header. */
64
+ userId: string;
65
+ value: EntryVoteValue;
66
+ }
67
+ export declare const EVENT_ENTRY_VOTE_FIELDS: {
68
+ readonly EVENT_ID: "eventId";
69
+ readonly ENTRY_ID: "entryId";
70
+ readonly USER_ID: "userId";
71
+ readonly VALUE: "value";
72
+ readonly CREATED_AT: "createdAt";
73
+ readonly UPDATED_AT: "updatedAt";
74
+ };
75
+ /**
76
+ * What a PUBLIC visitor may see: counts, and their own vote. Never a voter.
77
+ */
78
+ export interface EntryVoteSummary {
79
+ likes: number;
80
+ dislikes: number;
81
+ /** likes − dislikes. The ranking key. */
82
+ score: number;
83
+ /** The viewer's own vote, when signed in. `null` means they have not voted. */
84
+ myVote: EntryVoteValue | null;
85
+ }
86
+ /**
87
+ * What an ADMIN may additionally see.
88
+ *
89
+ * A separate type rather than optional fields on the summary above, so a
90
+ * public handler cannot return this by forgetting to strip something — the
91
+ * shapes do not overlap, and returning the wrong one is a type error rather
92
+ * than a leak.
93
+ */
94
+ export interface EntryVoteAudit extends EntryVoteSummary {
95
+ voters: Array<{
96
+ userId: string;
97
+ value: EntryVoteValue;
98
+ at: Date;
99
+ }>;
100
+ }
101
+ /**
102
+ * How an event ranks its entries. Stored on the event, chosen by an admin.
103
+ *
104
+ * `manual` means a human picks the winner and the scoreboard is informational
105
+ * only — which is a real situation (a jury prize), not an absence of a rule.
106
+ */
107
+ export type EntryRankingRule = "most-likes" | "net-score" | "most-votes" | "earliest" | "manual" | (string & {});
108
+ /** The default when an event does not say. Matches "most liked photo wins". */
109
+ export declare const DEFAULT_ENTRY_RANKING: EntryRankingRule;
110
+ export interface EntryRankingInput {
111
+ likes: number;
112
+ dislikes: number;
113
+ /** Used by `earliest`, and as the tie-break for every other rule. */
114
+ submittedAt: Date;
115
+ }
116
+ /**
117
+ * The ONE place a ranking rule is interpreted.
118
+ *
119
+ * Returns the sort key, descending — so the gallery, the scoreboard, the winner
120
+ * resolution and any export all order entries identically. Two screens
121
+ * disagreeing about who is winning is the failure this prevents.
122
+ *
123
+ * `manual` returns 0 for every entry on purpose: with no computed order, the
124
+ * caller must fall back to the tie-break (earliest first) rather than invent
125
+ * one, and the UI shows a human decision is pending.
126
+ */
127
+ export declare function resolveEntryRanking(rule: EntryRankingRule, entry: EntryRankingInput): number;
128
+ /**
129
+ * The published tie-break: the earlier entry wins.
130
+ *
131
+ * Stated here because it has to be announced BEFORE a contest closes. Deciding
132
+ * a tie afterwards, however fairly, looks like deciding it to suit someone.
133
+ */
134
+ export declare function entryTieBreak(a: EntryRankingInput, b: EntryRankingInput): number;