@mohasinac/appkit 4.71.2 → 4.73.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/.build-stamp.json +3 -3
- package/dist/_internal/server/features/seo/sitemap.d.ts +58 -1
- package/dist/_internal/server/features/seo/sitemap.js +89 -18
- package/dist/constants/api-endpoints.d.ts +3 -0
- package/dist/constants/api-endpoints.js +6 -0
- package/dist/features/classified/components/ClassifiedDetailPageView.js +2 -2
- package/dist/features/digital-codes/components/DigitalCodeDetailPageView.js +2 -2
- package/dist/features/events/components/EntryVoteButtons.d.ts +19 -0
- package/dist/features/events/components/EntryVoteButtons.js +65 -0
- package/dist/features/events/repository/entry-votes.repository.d.ts +45 -0
- package/dist/features/events/repository/entry-votes.repository.js +154 -0
- package/dist/features/events/schemas/entry-votes.d.ts +134 -0
- package/dist/features/events/schemas/entry-votes.js +114 -0
- package/dist/features/events/schemas/index.d.ts +29 -7
- package/dist/features/events/schemas/index.js +29 -2
- package/dist/features/inventory/components/AdminMoveLedgerView.d.ts +1 -1
- package/dist/features/live/components/LiveItemDetailPageView.js +2 -2
- package/dist/features/pre-orders/components/PreOrderDetailPageView.js +3 -3
- package/dist/features/products/components/ClearanceDetailPageView.js +2 -2
- package/dist/features/products/components/index.d.ts +0 -2
- package/dist/features/products/components/index.js +0 -1
- package/dist/features/products/schemas/index.d.ts +6 -7
- package/dist/features/products/schemas/index.js +20 -2
- package/dist/features/promotions/schemas/firestore.d.ts +17 -1
- package/dist/features/promotions/schemas/firestore.js +22 -0
- package/dist/features/promotions/schemas/index.d.ts +45 -21
- package/dist/features/promotions/schemas/index.js +37 -8
- package/dist/features/promotions/types/index.d.ts +1 -1
- package/dist/features/reviews/schemas/index.d.ts +3 -0
- package/dist/features/reviews/schemas/index.js +25 -0
- package/dist/index.d.ts +0 -2
- package/dist/index.js +0 -3
- package/dist/next/routing/portals.d.ts +22 -0
- package/dist/next/routing/portals.js +43 -0
- package/dist/providers/db-firebase/admin.d.ts +2 -0
- package/dist/providers/db-firebase/admin.js +25 -2
- package/dist/schemas/registry.d.ts +11 -8
- package/dist/server.d.ts +2 -1
- package/dist/server.js +1 -0
- package/dist/styles.css +1 -1
- package/dist/tailwind-utilities.css +1 -1
- package/dist/ui/components/ListingDetailShell.d.ts +68 -0
- package/dist/ui/components/ListingDetailShell.js +32 -0
- package/package.json +533 -525
- package/scripts/seed-cli.mjs +32 -2
- package/dist/features/products/components/PreOrderDetailView.d.ts +0 -18
- package/dist/features/products/components/PreOrderDetailView.js +0 -12
package/dist/.build-stamp.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"stampVersion": 1,
|
|
3
|
-
"version": "4.
|
|
4
|
-
"head": "
|
|
3
|
+
"version": "4.73.0",
|
|
4
|
+
"head": "be53458929a89f0a763476ca3082d1bfe65b3bcb",
|
|
5
5
|
"branch": "main",
|
|
6
6
|
"dirty": true,
|
|
7
|
-
"builtAt": "2026-11-
|
|
7
|
+
"builtAt": "2026-11-09T00:57:51.740Z"
|
|
8
8
|
}
|
|
@@ -1,5 +1,62 @@
|
|
|
1
1
|
import type { MetadataRoute } from "next";
|
|
2
|
+
/**
|
|
3
|
+
* Every section this builder can emit. The names are the same keys the
|
|
4
|
+
* per-section count digest logs and `scripts/deploy.mjs` asserts against, so
|
|
5
|
+
* there is one vocabulary for "which part of the sitemap".
|
|
6
|
+
*/
|
|
7
|
+
export declare const SITEMAP_SECTIONS: readonly ["static", "category", "brand", "franchise", "bundle", "group", "blog", "product", "collectorDrop", "preOrder", "clearance", "classified", "digitalCode", "live", "event", "store", "scammer"];
|
|
8
|
+
export type SitemapSection = (typeof SITEMAP_SECTIONS)[number];
|
|
2
9
|
export interface SitemapOptions {
|
|
3
10
|
baseUrl: string;
|
|
11
|
+
/**
|
|
12
|
+
* Emit ONLY these sections. Omit for all of them, which is what the main
|
|
13
|
+
* shop wants.
|
|
14
|
+
*
|
|
15
|
+
* ## 🛑 Why this exists, and why it is not a nice-to-have
|
|
16
|
+
*
|
|
17
|
+
* The blog and events surfaces now live on their own deployments
|
|
18
|
+
* (`blogs.`/`events.`). Each needs a sitemap carrying **its own** URLs on
|
|
19
|
+
* **its own** host, and the shop needs to stop advertising them — because the
|
|
20
|
+
* moment the shop 301s `/blog/*` to the subdomain, every `/blog` URL left in
|
|
21
|
+
* the shop's sitemap becomes a redirecting entry.
|
|
22
|
+
*
|
|
23
|
+
* That is not a hypothetical. This file's own note at the `disabledRoutes`
|
|
24
|
+
* filter records the measured version: on 2026-10-27 the production sitemap
|
|
25
|
+
* advertised **17 `/blog` URLs and one `/reviews` URL** that already 404'd at
|
|
26
|
+
* the edge — 18 crawl errors on pages we had explicitly asked Google to
|
|
27
|
+
* fetch. And `seo.server.ts` in the consumer records the worse outcome, when
|
|
28
|
+
* half the URL surface followed one host and half another: *every URL in the
|
|
29
|
+
* sitemap 307-redirected and Google dropped the site.*
|
|
30
|
+
*
|
|
31
|
+
* So a host must be able to say "my sitemap is these sections and nothing
|
|
32
|
+
* else", and the two halves must be derived from one list rather than two
|
|
33
|
+
* hand-maintained ones. Hence an allowlist here instead of a second sitemap
|
|
34
|
+
* builder in each extracted app.
|
|
35
|
+
*
|
|
36
|
+
* 🛑 A section NOT selected is not fetched at all — the point is to avoid the
|
|
37
|
+
* Firestore reads too, not merely to filter the result. A subdomain querying
|
|
38
|
+
* the entire product catalogue to throw it away would make every sitemap
|
|
39
|
+
* request pay for sixteen collections it never emits.
|
|
40
|
+
*
|
|
41
|
+
* 🛑 `static` is a section like any other, so an allowlist that omits it
|
|
42
|
+
* emits no static pages. That is deliberate: the shop's static pages
|
|
43
|
+
* (`/about`, `/contact`, the browse routes) belong to the shop, and a
|
|
44
|
+
* subdomain advertising them under its own host would be inventing URLs that
|
|
45
|
+
* do not exist there.
|
|
46
|
+
*/
|
|
47
|
+
sections?: readonly SitemapSection[];
|
|
48
|
+
/**
|
|
49
|
+
* Emit everything EXCEPT these. Mutually exclusive with `sections` in
|
|
50
|
+
* practice; if both are given, `sections` is applied first and this narrows
|
|
51
|
+
* it further.
|
|
52
|
+
*
|
|
53
|
+
* This is the shop's half of the split — `excludeSections: ["blog", "event"]`
|
|
54
|
+
* — and it is spelled as an exclusion rather than a 15-name allowlist so that
|
|
55
|
+
* a NEW section added to this file is carried by the shop automatically. An
|
|
56
|
+
* allowlist there would silently drop the next surface somebody adds, which
|
|
57
|
+
* is the same "measured as empty → looks intentional" failure the digest
|
|
58
|
+
* below exists to catch.
|
|
59
|
+
*/
|
|
60
|
+
excludeSections?: readonly SitemapSection[];
|
|
4
61
|
}
|
|
5
|
-
export declare function buildSitemap({ baseUrl }: SitemapOptions): Promise<MetadataRoute.Sitemap>;
|
|
62
|
+
export declare function buildSitemap({ baseUrl, sections: only, excludeSections, }: SitemapOptions): Promise<MetadataRoute.Sitemap>;
|
|
@@ -67,6 +67,30 @@ function sitemapSectionFailed(label, err) {
|
|
|
67
67
|
serverLogger.error(`sitemap: section "${label}" failed and returned 0 URLs`, { section: label, error: err });
|
|
68
68
|
return [];
|
|
69
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* Every section this builder can emit. The names are the same keys the
|
|
72
|
+
* per-section count digest logs and `scripts/deploy.mjs` asserts against, so
|
|
73
|
+
* there is one vocabulary for "which part of the sitemap".
|
|
74
|
+
*/
|
|
75
|
+
export const SITEMAP_SECTIONS = [
|
|
76
|
+
"static",
|
|
77
|
+
"category",
|
|
78
|
+
"brand",
|
|
79
|
+
"franchise",
|
|
80
|
+
"bundle",
|
|
81
|
+
"group",
|
|
82
|
+
"blog",
|
|
83
|
+
"product",
|
|
84
|
+
"collectorDrop",
|
|
85
|
+
"preOrder",
|
|
86
|
+
"clearance",
|
|
87
|
+
"classified",
|
|
88
|
+
"digitalCode",
|
|
89
|
+
"live",
|
|
90
|
+
"event",
|
|
91
|
+
"store",
|
|
92
|
+
"scammer",
|
|
93
|
+
];
|
|
70
94
|
/**
|
|
71
95
|
* Read every document a query matches, in pages.
|
|
72
96
|
*
|
|
@@ -512,26 +536,39 @@ async function fetchScammerUrls(baseUrl) {
|
|
|
512
536
|
return sitemapSectionFailed("scammer", err);
|
|
513
537
|
}
|
|
514
538
|
}
|
|
515
|
-
export async function buildSitemap({ baseUrl }) {
|
|
539
|
+
export async function buildSitemap({ baseUrl, sections: only, excludeSections, }) {
|
|
540
|
+
/*
|
|
541
|
+
* Which sections this host emits. See `SitemapOptions.sections` for why a
|
|
542
|
+
* host must be able to answer this at all.
|
|
543
|
+
*
|
|
544
|
+
* 🛑 `wants()` gates the FETCH, not the output. An unselected section issues
|
|
545
|
+
* no Firestore query — a subdomain must not pay for sixteen collections it
|
|
546
|
+
* throws away on every sitemap request.
|
|
547
|
+
*/
|
|
548
|
+
const excluded = new Set(excludeSections ?? []);
|
|
549
|
+
const allowed = only ? new Set(only) : null;
|
|
550
|
+
const wants = (s) => !excluded.has(s) && (allowed === null || allowed.has(s));
|
|
551
|
+
/** `[]` for a section this host does not emit, without running its query. */
|
|
552
|
+
const ifWanted = (s, run) => wants(s) ? run() : Promise.resolve([]);
|
|
516
553
|
const [productUrls, categoryUrls, brandUrls, franchiseUrls, bundleUrls, groupedListingUrls, eventUrls, blogUrls, collectorDropUrls, preOrderUrls, clearanceUrls, classifiedUrls, digitalCodeUrls, liveUrls, storeUrls, scammerUrls,] = await Promise.all([
|
|
517
|
-
fetchProductUrls(baseUrl),
|
|
518
|
-
fetchCategoryUrls(baseUrl),
|
|
519
|
-
fetchBrandUrls(baseUrl),
|
|
520
|
-
fetchFranchiseUrls(baseUrl),
|
|
521
|
-
fetchBundleUrls(baseUrl),
|
|
522
|
-
fetchGroupedListingUrls(baseUrl),
|
|
523
|
-
fetchEventUrls(baseUrl),
|
|
524
|
-
fetchBlogPostUrls(baseUrl),
|
|
525
|
-
fetchCollectorDropUrls(baseUrl),
|
|
526
|
-
fetchPreOrderUrls(baseUrl),
|
|
527
|
-
fetchClearanceUrls(baseUrl),
|
|
528
|
-
fetchClassifiedUrls(baseUrl),
|
|
529
|
-
fetchDigitalCodeUrls(baseUrl),
|
|
530
|
-
fetchLiveUrls(baseUrl),
|
|
531
|
-
fetchStoreUrls(baseUrl),
|
|
532
|
-
fetchScammerUrls(baseUrl),
|
|
554
|
+
ifWanted("product", () => fetchProductUrls(baseUrl)),
|
|
555
|
+
ifWanted("category", () => fetchCategoryUrls(baseUrl)),
|
|
556
|
+
ifWanted("brand", () => fetchBrandUrls(baseUrl)),
|
|
557
|
+
ifWanted("franchise", () => fetchFranchiseUrls(baseUrl)),
|
|
558
|
+
ifWanted("bundle", () => fetchBundleUrls(baseUrl)),
|
|
559
|
+
ifWanted("group", () => fetchGroupedListingUrls(baseUrl)),
|
|
560
|
+
ifWanted("event", () => fetchEventUrls(baseUrl)),
|
|
561
|
+
ifWanted("blog", () => fetchBlogPostUrls(baseUrl)),
|
|
562
|
+
ifWanted("collectorDrop", () => fetchCollectorDropUrls(baseUrl)),
|
|
563
|
+
ifWanted("preOrder", () => fetchPreOrderUrls(baseUrl)),
|
|
564
|
+
ifWanted("clearance", () => fetchClearanceUrls(baseUrl)),
|
|
565
|
+
ifWanted("classified", () => fetchClassifiedUrls(baseUrl)),
|
|
566
|
+
ifWanted("digitalCode", () => fetchDigitalCodeUrls(baseUrl)),
|
|
567
|
+
ifWanted("live", () => fetchLiveUrls(baseUrl)),
|
|
568
|
+
ifWanted("store", () => fetchStoreUrls(baseUrl)),
|
|
569
|
+
ifWanted("scammer", () => fetchScammerUrls(baseUrl)),
|
|
533
570
|
]);
|
|
534
|
-
const staticUrls = staticPages(baseUrl);
|
|
571
|
+
const staticUrls = wants("static") ? staticPages(baseUrl) : [];
|
|
535
572
|
// Per-section count digest. A section that drops to zero is otherwise
|
|
536
573
|
// indistinguishable from a site that genuinely has none of that thing — which
|
|
537
574
|
// is exactly how `category: 0` survived unnoticed while every other section
|
|
@@ -715,6 +752,28 @@ export async function buildSitemap({ baseUrl }) {
|
|
|
715
752
|
if (page && closed.has(page))
|
|
716
753
|
sections[name] = 0;
|
|
717
754
|
}
|
|
755
|
+
/*
|
|
756
|
+
* 🛑 A section this host does not EMIT is dropped from the digest entirely,
|
|
757
|
+
* not reported as zero.
|
|
758
|
+
*
|
|
759
|
+
* The comment above this block is the reason, and it was written about the
|
|
760
|
+
* `closed` case: *"a warning that fires every build for a reason everyone
|
|
761
|
+
* knows about is how the next real zero gets ignored. 'Closed on purpose'
|
|
762
|
+
* and 'broken' must not look the same in the log."* Section scoping is the
|
|
763
|
+
* same hazard one axis over — without this, the blogs deployment would warn
|
|
764
|
+
* about fifteen empty sections on every single sitemap request, and the shop
|
|
765
|
+
* would warn about `blog` and `event` forever. Two permanent false alarms is
|
|
766
|
+
* how the alarm stops being read.
|
|
767
|
+
*
|
|
768
|
+
* Deleting the key rather than zeroing it also keeps the digest honest for
|
|
769
|
+
* `scripts/deploy.mjs`, which asserts these counts against the deployed
|
|
770
|
+
* sitemap: a section that is absent from the response should be absent here
|
|
771
|
+
* too, not present-and-zero.
|
|
772
|
+
*/
|
|
773
|
+
for (const name of Object.keys(sections)) {
|
|
774
|
+
if (!wants(name))
|
|
775
|
+
delete sections[name];
|
|
776
|
+
}
|
|
718
777
|
const empty = Object.entries(sections)
|
|
719
778
|
.filter(([name, n]) => n === 0 && !closed.has(sectionPage[name]))
|
|
720
779
|
.map(([name]) => name);
|
|
@@ -724,6 +783,18 @@ export async function buildSitemap({ baseUrl }) {
|
|
|
724
783
|
closed: [...closed],
|
|
725
784
|
});
|
|
726
785
|
}
|
|
786
|
+
/*
|
|
787
|
+
* Logged so a host's scope is visible in production, not just in its source.
|
|
788
|
+
* "Why is /blog missing from the sitemap" is otherwise answerable only by
|
|
789
|
+
* reading the caller.
|
|
790
|
+
*/
|
|
791
|
+
if (only || excludeSections?.length) {
|
|
792
|
+
serverLogger.info("sitemap: section scope applied", {
|
|
793
|
+
...(only ? { only: [...only] } : {}),
|
|
794
|
+
...(excludeSections?.length ? { excluded: [...excludeSections] } : {}),
|
|
795
|
+
emitting: Object.keys(sections),
|
|
796
|
+
});
|
|
797
|
+
}
|
|
727
798
|
if (empty.length > 0) {
|
|
728
799
|
serverLogger.warn(`sitemap: ${empty.length} section(s) produced 0 URLs`, { empty, sections, total });
|
|
729
800
|
}
|
|
@@ -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 {
|
|
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(
|
|
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 {
|
|
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(
|
|
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();
|