@mohasinac/appkit 4.63.5 → 4.64.1
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/categories/adapters.d.ts +1 -1
- package/dist/_internal/server/features/categories/adapters.js +13 -0
- package/dist/_internal/server/features/events/like-actions.d.ts +25 -0
- package/dist/_internal/server/features/events/like-actions.js +67 -0
- package/dist/_internal/server/features/events/likes.d.ts +65 -0
- package/dist/_internal/server/features/events/likes.js +146 -0
- package/dist/_internal/server/features/seo/index.d.ts +3 -0
- package/dist/_internal/server/features/seo/index.js +2 -0
- package/dist/_internal/server/features/seo/product-detail-types.d.ts +1 -0
- package/dist/_internal/server/features/seo/product-detail-types.js +26 -0
- package/dist/_internal/server/features/seo/robots.js +20 -1
- package/dist/_internal/server/features/seo/sitemap.js +125 -27
- package/dist/_internal/server/features/seo/static-params.d.ts +107 -0
- package/dist/_internal/server/features/seo/static-params.js +228 -0
- package/dist/_internal/server/features/seo/taxonomy-links.d.ts +44 -0
- package/dist/_internal/server/features/seo/taxonomy-links.js +85 -0
- package/dist/_internal/server/features/stores/adapters.d.ts +2 -2
- package/dist/_internal/server/features/stores/adapters.js +87 -1
- package/dist/_internal/server/functions/scheduled.d.ts +16 -1
- package/dist/_internal/server/functions/scheduled.js +23 -1
- package/dist/_internal/server/jobs/core/sellerStandingRollup.d.ts +63 -0
- package/dist/_internal/server/jobs/core/sellerStandingRollup.js +412 -0
- package/dist/_internal/server/jobs/handlers/index.d.ts +1 -0
- package/dist/_internal/server/jobs/handlers/index.js +1 -0
- package/dist/_internal/server/jobs/handlers/sellerStandingRollup.d.ts +12 -0
- package/dist/_internal/server/jobs/handlers/sellerStandingRollup.js +14 -0
- package/dist/_internal/shared/actions/action-registry.js +61 -0
- package/dist/_internal/shared/features/seller-standing/config.d.ts +130 -0
- package/dist/_internal/shared/features/seller-standing/config.js +153 -0
- package/dist/constants/api-endpoints.d.ts +24 -0
- package/dist/constants/api-endpoints.js +8 -0
- package/dist/features/about/components/HelpPageView.js +9 -1
- package/dist/features/account/components/BecomeSellerView.d.ts +15 -3
- package/dist/features/account/components/BecomeSellerView.js +2 -2
- package/dist/features/admin/components/AdminStoreEditorView.d.ts +12 -1
- package/dist/features/admin/components/AdminStoreEditorView.js +30 -2
- package/dist/features/admin/components/AdminStoresGuideView.js +1 -1
- package/dist/features/admin/components/AdminStoresView.js +59 -2
- package/dist/features/admin/components/AdminTrustGuideView.js +10 -0
- package/dist/features/admin/schemas/admin-editor-forms.d.ts +10 -0
- package/dist/features/admin/schemas/admin-editor-forms.js +34 -0
- package/dist/features/blog/components/BlogIndexPageView.js +2 -2
- package/dist/features/blog/components/BlogListView.js +2 -1
- package/dist/features/blog/components/BlogPostView.js +2 -1
- package/dist/features/blog/constants/category-badge.d.ts +33 -0
- package/dist/features/blog/constants/category-badge.js +40 -0
- package/dist/features/categories/components/TaxonomyLinkIndex.d.ts +39 -0
- package/dist/features/categories/components/TaxonomyLinkIndex.js +11 -0
- package/dist/features/categories/schemas/firestore.d.ts +25 -0
- package/dist/features/categories/utils/load-category-options.d.ts +33 -0
- package/dist/features/categories/utils/load-category-options.js +50 -0
- package/dist/features/events/components/EventCard.js +4 -12
- package/dist/features/events/components/EventInsights.d.ts +27 -0
- package/dist/features/events/components/EventInsights.js +10 -0
- package/dist/features/events/components/EventLikeButton.d.ts +21 -0
- package/dist/features/events/components/EventLikeButton.js +59 -0
- package/dist/features/events/components/EventLikersRail.d.ts +23 -0
- package/dist/features/events/components/EventLikersRail.js +54 -0
- package/dist/features/events/components/EventTypeBadge.d.ts +26 -0
- package/dist/features/events/components/EventTypeBadge.js +7 -0
- package/dist/features/events/components/EventsListPageView.js +2 -2
- package/dist/features/events/components/SpinWheel.d.ts +41 -0
- package/dist/features/events/components/SpinWheel.js +128 -0
- package/dist/features/events/components/SpinWheelView.js +36 -15
- package/dist/features/events/constants/event-type-meta.d.ts +54 -0
- package/dist/features/events/constants/event-type-meta.js +109 -0
- package/dist/features/events/repository/event-likes.repository.d.ts +57 -0
- package/dist/features/events/repository/event-likes.repository.js +151 -0
- package/dist/features/events/schemas/firestore.d.ts +111 -0
- package/dist/features/events/schemas/likes.d.ts +81 -0
- package/dist/features/events/schemas/likes.js +59 -0
- package/dist/features/events/types/index.d.ts +7 -0
- package/dist/features/events/utils/carousel-slide.d.ts +42 -0
- package/dist/features/events/utils/carousel-slide.js +174 -0
- package/dist/features/events/utils/event-charts.d.ts +65 -0
- package/dist/features/events/utils/event-charts.js +172 -0
- package/dist/features/faq/constants/help-guides.js +9 -0
- package/dist/features/faq/schemas/firestore.js +1 -0
- package/dist/features/faq/types/index.d.ts +19 -0
- package/dist/features/homepage/components/BrandsSection.js +25 -1
- package/dist/features/homepage/components/HeroCarousel.js +47 -10
- package/dist/features/homepage/types/index.d.ts +7 -0
- package/dist/features/stores/components/InteractiveStoreCard.js +2 -1
- package/dist/features/stores/components/SellerStandingPanel.d.ts +31 -0
- package/dist/features/stores/components/SellerStandingPanel.js +25 -0
- package/dist/features/stores/components/SellerTierBadge.d.ts +57 -0
- package/dist/features/stores/components/SellerTierBadge.js +19 -0
- package/dist/features/stores/components/StoreCapabilitiesGuideView.js +32 -2
- package/dist/features/stores/components/StoreCreateForm.d.ts +46 -0
- package/dist/features/stores/components/StoreCreateForm.js +107 -0
- package/dist/features/stores/components/StoreGuideHubView.js +17 -4
- package/dist/features/stores/components/StoreHeader.js +2 -1
- package/dist/features/stores/repository/store.repository.js +23 -0
- package/dist/features/stores/schemas/firestore.d.ts +60 -1
- package/dist/features/stores/schemas/firestore.js +18 -0
- package/dist/features/stores/schemas/index.d.ts +2 -2
- package/dist/features/stores/schemas/store-create.d.ts +47 -0
- package/dist/features/stores/schemas/store-create.js +86 -0
- package/dist/features/stores/types/index.d.ts +42 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +21 -0
- package/dist/next/routing/route-map.d.ts +3 -1
- package/dist/next/routing/route-map.js +13 -0
- package/dist/schemas/registry.d.ts +10 -0
- package/dist/server.d.ts +5 -0
- package/dist/server.js +10 -0
- package/dist/styles.css +36 -1
- package/dist/tailwind-utilities.css +1 -1
- package/dist/ui/components/HorizontalScroller.js +15 -2
- package/dist/ui/components/PageHeroHeading.d.ts +40 -0
- package/dist/ui/components/PageHeroHeading.js +15 -0
- package/dist/ui/components/Typography.d.ts +3 -3
- package/dist/ui/components/Typography.js +59 -31
- package/dist/ui/components/Typography.style.css +35 -0
- package/dist/ui/index.d.ts +2 -0
- package/dist/ui/index.js +1 -0
- package/package.json +1 -1
- package/scripts/seed-cli.mjs +4 -2
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wheel itself — segments, a pointer, and the settle.
|
|
3
|
+
*
|
|
4
|
+
* ## What this replaced
|
|
5
|
+
*
|
|
6
|
+
* A CSS circle with one 🎁 emoji rotating 2160deg on a fixed 3.2s timer. No
|
|
7
|
+
* segments, no pointer, and the prize list was loaded but used ONLY to decide
|
|
8
|
+
* whether to disable the button — so a visitor could not see what they might
|
|
9
|
+
* win. `prefers-reduced-motion` was ignored entirely, while every other
|
|
10
|
+
* primitive in `Motion.tsx` wires it.
|
|
11
|
+
*
|
|
12
|
+
* ## 🛑 SEGMENTS ARE EQUAL-SIZED, AND THAT IS NOT A SHORTCUT
|
|
13
|
+
*
|
|
14
|
+
* `weight` IS the odds. `toClientSpinPrize` is an allow-list emitting
|
|
15
|
+
* `{id, label, isActive}` only, and a tester case asserts
|
|
16
|
+
* `weightsInPublicSource: 0` — the stored shape leaked every wheel's odds into
|
|
17
|
+
* a client prop once already (Root Cause #70). Sizing a segment by its weight
|
|
18
|
+
* would publish the same information geometrically instead of as a number.
|
|
19
|
+
*
|
|
20
|
+
* So the wheel shows WHAT you could win, never how likely it is. That is also
|
|
21
|
+
* the honest framing: these are the prizes.
|
|
22
|
+
*
|
|
23
|
+
* @tag domain:events
|
|
24
|
+
* @tag layer:component
|
|
25
|
+
*/
|
|
26
|
+
import React from "react";
|
|
27
|
+
export interface SpinWheelSegment {
|
|
28
|
+
id: string;
|
|
29
|
+
label: string;
|
|
30
|
+
}
|
|
31
|
+
export interface SpinWheelProps {
|
|
32
|
+
segments: SpinWheelSegment[];
|
|
33
|
+
/** Index of the winning segment once known, else null. */
|
|
34
|
+
winningIndex: number | null;
|
|
35
|
+
/** True from the click until the result is revealed. */
|
|
36
|
+
spinning: boolean;
|
|
37
|
+
/** Fired when the wheel has physically stopped. */
|
|
38
|
+
onSettled?: () => void;
|
|
39
|
+
size?: number;
|
|
40
|
+
}
|
|
41
|
+
export declare function SpinWheel({ segments, winningIndex, spinning, onSettled, size, }: SpinWheelProps): React.JSX.Element;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
/**
|
|
4
|
+
* The wheel itself — segments, a pointer, and the settle.
|
|
5
|
+
*
|
|
6
|
+
* ## What this replaced
|
|
7
|
+
*
|
|
8
|
+
* A CSS circle with one 🎁 emoji rotating 2160deg on a fixed 3.2s timer. No
|
|
9
|
+
* segments, no pointer, and the prize list was loaded but used ONLY to decide
|
|
10
|
+
* whether to disable the button — so a visitor could not see what they might
|
|
11
|
+
* win. `prefers-reduced-motion` was ignored entirely, while every other
|
|
12
|
+
* primitive in `Motion.tsx` wires it.
|
|
13
|
+
*
|
|
14
|
+
* ## 🛑 SEGMENTS ARE EQUAL-SIZED, AND THAT IS NOT A SHORTCUT
|
|
15
|
+
*
|
|
16
|
+
* `weight` IS the odds. `toClientSpinPrize` is an allow-list emitting
|
|
17
|
+
* `{id, label, isActive}` only, and a tester case asserts
|
|
18
|
+
* `weightsInPublicSource: 0` — the stored shape leaked every wheel's odds into
|
|
19
|
+
* a client prop once already (Root Cause #70). Sizing a segment by its weight
|
|
20
|
+
* would publish the same information geometrically instead of as a number.
|
|
21
|
+
*
|
|
22
|
+
* So the wheel shows WHAT you could win, never how likely it is. That is also
|
|
23
|
+
* the honest framing: these are the prizes.
|
|
24
|
+
*
|
|
25
|
+
* @tag domain:events
|
|
26
|
+
* @tag layer:component
|
|
27
|
+
*/
|
|
28
|
+
import { useMemo } from "react";
|
|
29
|
+
import { motion, useReducedMotion } from "motion/react";
|
|
30
|
+
import { Div } from "../../../ui/index.js";
|
|
31
|
+
/** Full turns before landing. Enough to read as a spin, short enough to wait. */
|
|
32
|
+
const TURNS = 4;
|
|
33
|
+
/** Degrees past the target before easing back — the "breath" at the end. */
|
|
34
|
+
const OVERSHOOT_DEG = 9;
|
|
35
|
+
/** Alternating fills. CSS variables, so a theme restyles the wheel with it. */
|
|
36
|
+
const SEGMENT_FILLS = [
|
|
37
|
+
"var(--appkit-color-primary)",
|
|
38
|
+
"var(--appkit-color-primary-700)",
|
|
39
|
+
"var(--appkit-color-secondary)",
|
|
40
|
+
"var(--appkit-color-accent)",
|
|
41
|
+
];
|
|
42
|
+
function polar(cx, cy, r, deg) {
|
|
43
|
+
const rad = ((deg - 90) * Math.PI) / 180;
|
|
44
|
+
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
|
|
45
|
+
}
|
|
46
|
+
/** One pie slice from `startDeg` to `endDeg`, clockwise from 12 o'clock. */
|
|
47
|
+
function segmentPath(cx, cy, r, startDeg, endDeg) {
|
|
48
|
+
const a = polar(cx, cy, r, startDeg);
|
|
49
|
+
const b = polar(cx, cy, r, endDeg);
|
|
50
|
+
const largeArc = endDeg - startDeg > 180 ? 1 : 0;
|
|
51
|
+
return `M ${cx} ${cy} L ${a.x} ${a.y} A ${r} ${r} 0 ${largeArc} 1 ${b.x} ${b.y} Z`;
|
|
52
|
+
}
|
|
53
|
+
export function SpinWheel({ segments, winningIndex, spinning, onSettled, size = 288, }) {
|
|
54
|
+
const reduced = useReducedMotion();
|
|
55
|
+
const n = Math.max(segments.length, 1);
|
|
56
|
+
const sweep = 360 / n;
|
|
57
|
+
const cx = size / 2;
|
|
58
|
+
const cy = size / 2;
|
|
59
|
+
const r = size / 2 - 6;
|
|
60
|
+
/**
|
|
61
|
+
* The rotation that puts the winning segment under the pointer.
|
|
62
|
+
*
|
|
63
|
+
* Segment i spans `[i*sweep, (i+1)*sweep)` clockwise from 12 o'clock, so its
|
|
64
|
+
* centre sits at `(i + 0.5) * sweep`. Rotating the wheel by the NEGATIVE of
|
|
65
|
+
* that brings the centre back to the top, where the pointer is. Whole turns
|
|
66
|
+
* are added on top purely for the look.
|
|
67
|
+
*/
|
|
68
|
+
const target = useMemo(() => {
|
|
69
|
+
if (winningIndex === null)
|
|
70
|
+
return 0;
|
|
71
|
+
return TURNS * 360 - (winningIndex + 0.5) * sweep;
|
|
72
|
+
}, [winningIndex, sweep]);
|
|
73
|
+
/*
|
|
74
|
+
* 🛑 THE ANIMATION IS DRIVEN BY THE RESULT, NOT BY A TIMER.
|
|
75
|
+
*
|
|
76
|
+
* The old flow started a 3.2s CSS animation on click, awaited the server,
|
|
77
|
+
* then revealed the prize after ANOTHER blind 3.2s `setTimeout` — so the
|
|
78
|
+
* real wait was `latency + 3200ms`, the emoji visibly stopped before the
|
|
79
|
+
* prize card appeared, and the wheel never landed anywhere in particular
|
|
80
|
+
* because it always finished at 0deg mod 360.
|
|
81
|
+
*
|
|
82
|
+
* Here `winningIndex` stays null until the server answers; the wheel idles,
|
|
83
|
+
* then animates once to the segment it actually won.
|
|
84
|
+
*
|
|
85
|
+
* The keyframes are the "breath": overshoot a few degrees past the target,
|
|
86
|
+
* hold, then ease back onto it. Explicit keyframes rather than a spring so
|
|
87
|
+
* the overshoot is exact and cannot vary with the distance travelled.
|
|
88
|
+
*/
|
|
89
|
+
const animate = winningIndex === null
|
|
90
|
+
? { rotate: spinning ? TURNS * 360 : 0 }
|
|
91
|
+
: { rotate: [0, target + OVERSHOOT_DEG, target] };
|
|
92
|
+
const transition = winningIndex === null
|
|
93
|
+
? { duration: spinning ? 2.4 : 0, ease: "linear", repeat: spinning ? Infinity : 0 }
|
|
94
|
+
: {
|
|
95
|
+
duration: 2.6,
|
|
96
|
+
// Most of the journey is the decelerating spin; the last 12% is the
|
|
97
|
+
// settle back from the overshoot.
|
|
98
|
+
times: [0, 0.88, 1],
|
|
99
|
+
// Mutable, not `as const` — motion types `ease` as `Easing[]`.
|
|
100
|
+
ease: ["easeOut", "easeInOut"],
|
|
101
|
+
};
|
|
102
|
+
/*
|
|
103
|
+
* The wrapper shrink-wraps the svg, which already carries width/height —
|
|
104
|
+
* it exists only to anchor the absolutely-positioned pointer. Sizing it
|
|
105
|
+
* again from props would be a second source of truth for one number.
|
|
106
|
+
*/
|
|
107
|
+
return (_jsxs(Div, { className: "relative mx-auto inline-block leading-none", children: [_jsx(Div, { className: "absolute left-1/2 -translate-x-1/2 -top-0.5 z-10", "aria-hidden": "true", children: _jsx("svg", { width: "28", height: "22", viewBox: "0 0 28 22", children: _jsx("path", { d: "M14 22 L2 0 L26 0 Z", fill: "var(--appkit-color-text)", stroke: "var(--appkit-color-surface)", strokeWidth: "2" }) }) }), _jsxs(motion.svg, { width: size, height: size, viewBox: `0 0 ${size} ${size}`,
|
|
108
|
+
// 🛑 Reduced motion skips the journey entirely and renders the wheel
|
|
109
|
+
// already landed. The result is identical; only the travel is removed.
|
|
110
|
+
animate: reduced ? { rotate: winningIndex === null ? 0 : target } : animate, transition: reduced ? { duration: 0 } : transition, onAnimationComplete: () => {
|
|
111
|
+
if (winningIndex !== null)
|
|
112
|
+
onSettled?.();
|
|
113
|
+
},
|
|
114
|
+
/*
|
|
115
|
+
* 🛑 NOT `origin-center` — that class generates no rule in this
|
|
116
|
+
* project's Tailwind build, and a class with no rule renders as an
|
|
117
|
+
* unstyled element rather than as an error. It matters here: an SVG
|
|
118
|
+
* element's transform-origin defaults to `0 0`, so without this the
|
|
119
|
+
* wheel ORBITS its top-left corner instead of spinning in place.
|
|
120
|
+
*/
|
|
121
|
+
className: "[transform-origin:50%_50%]", role: "img", "aria-label": `Prize wheel with ${n} segment${n === 1 ? "" : "s"}: ${segments.map((s) => s.label).join(", ")}`, children: [segments.map((seg, i) => {
|
|
122
|
+
const start = i * sweep;
|
|
123
|
+
const end = start + sweep;
|
|
124
|
+
const mid = start + sweep / 2;
|
|
125
|
+
const labelPos = polar(cx, cy, r * 0.64, mid);
|
|
126
|
+
return (_jsxs("g", { children: [_jsx("path", { d: segmentPath(cx, cy, r, start, end), fill: SEGMENT_FILLS[i % SEGMENT_FILLS.length], stroke: "var(--appkit-color-surface)", strokeWidth: "2" }), _jsx("text", { x: labelPos.x, y: labelPos.y, fill: "var(--appkit-color-text-on-primary)", fontSize: n > 6 ? 9 : 11, fontWeight: "600", textAnchor: "middle", dominantBaseline: "middle", transform: `rotate(${mid} ${labelPos.x} ${labelPos.y})`, children: seg.label.length > 18 ? `${seg.label.slice(0, 17)}…` : seg.label })] }, seg.id));
|
|
127
|
+
}), _jsx("circle", { cx: cx, cy: cy, r: 16, fill: "var(--appkit-color-surface)", stroke: "var(--appkit-color-border)", strokeWidth: "2" })] })] }));
|
|
128
|
+
}
|
|
@@ -4,9 +4,9 @@ import { useCallback, useMemo, useState } from "react";
|
|
|
4
4
|
import { useTranslations } from "next-intl";
|
|
5
5
|
import { Button, ClaimCouponButton, Div, Heading, LoginRequiredModal, Row, Span, Stack, Text, useToast } from "../../../ui/index.js";
|
|
6
6
|
import { isAuthError } from "../../../utils/auth-error.js";
|
|
7
|
+
import { SpinWheel } from "./SpinWheel.js";
|
|
7
8
|
import { normalizeError } from "../../../errors/normalize.js";
|
|
8
9
|
import { toUserMessage } from "../../../errors/error-display-map.js";
|
|
9
|
-
const CLS_WHEEL = "relative mx-auto aspect-square w-64 overflow-hidden rounded-full border-4 border-warning bg-[image:var(--appkit-gradient-promotion)]";
|
|
10
10
|
// audit-content-alignment-ok: self-contained circular spin-wheel game widget, all text centered under the wheel by design
|
|
11
11
|
const CLS_PRIZE_BOX = "rounded-xl border border-success bg-success-surface dark:border-success p-[var(--appkit-space-4)] text-center";
|
|
12
12
|
const CLS_PRIZE_TITLE = "mt-1 text-[length:var(--appkit-text-lg)] font-bold text-success dark:text-success";
|
|
@@ -23,7 +23,6 @@ const DEFAULT_LABELS = {
|
|
|
23
23
|
couponHint: "Coupon code:",
|
|
24
24
|
errorFallback: "Spin failed. Please try again.",
|
|
25
25
|
};
|
|
26
|
-
const ANIMATION_MS = 3200;
|
|
27
26
|
export function SpinWheelView({ eventId, prizes, alreadyUsed, initialPrizeId, initialCouponCode, windowStart, windowEnd, onSpin, labels, }) {
|
|
28
27
|
const l = { ...DEFAULT_LABELS, ...(labels ?? {}) };
|
|
29
28
|
const activePrizes = useMemo(() => prizes.filter((p) => p.isActive !== false), [prizes]);
|
|
@@ -48,14 +47,28 @@ export function SpinWheelView({ eventId, prizes, alreadyUsed, initialPrizeId, in
|
|
|
48
47
|
setError(null);
|
|
49
48
|
setSpinning(true);
|
|
50
49
|
try {
|
|
50
|
+
/*
|
|
51
|
+
* 🛑 AWAIT FIRST, THEN LAND. The wheel idles while this resolves and
|
|
52
|
+
* only afterwards animates to the segment it actually won.
|
|
53
|
+
*
|
|
54
|
+
* It used to start a 3.2s CSS animation on click, await the server, and
|
|
55
|
+
* then reveal the prize after ANOTHER blind `setTimeout(3200)` — so the
|
|
56
|
+
* real wait was `latency + 3200ms`, the wheel visibly stopped before the
|
|
57
|
+
* prize appeared, and it never landed anywhere meaningful because the
|
|
58
|
+
* keyframes always finished at 0deg mod 360.
|
|
59
|
+
*
|
|
60
|
+
* `<SpinWheel>` now calls `onSettled` when it has physically stopped,
|
|
61
|
+
* which is what reveals the prize card — so the reveal is tied to the
|
|
62
|
+
* animation actually ending rather than to a duration guessed twice.
|
|
63
|
+
*/
|
|
51
64
|
const result = await onSpin(eventId);
|
|
52
|
-
|
|
53
|
-
setSpinning(false);
|
|
65
|
+
{
|
|
54
66
|
if (result.spinPrizeId) {
|
|
55
67
|
setResultPrizeId(result.spinPrizeId);
|
|
56
68
|
setResultCoupon(result.spinPrizeCouponCode);
|
|
57
69
|
}
|
|
58
70
|
else {
|
|
71
|
+
setSpinning(false);
|
|
59
72
|
/*
|
|
60
73
|
* A 2xx with no prize. The server now refuses with a real status and
|
|
61
74
|
* code for every reason it knows (`reasonToHttp`), so reaching here
|
|
@@ -64,7 +77,7 @@ export function SpinWheelView({ eventId, prizes, alreadyUsed, initialPrizeId, in
|
|
|
64
77
|
*/
|
|
65
78
|
setError(l.errorFallback);
|
|
66
79
|
}
|
|
67
|
-
}
|
|
80
|
+
}
|
|
68
81
|
}
|
|
69
82
|
catch (err) {
|
|
70
83
|
const e = normalizeError(err);
|
|
@@ -107,16 +120,24 @@ export function SpinWheelView({ eventId, prizes, alreadyUsed, initialPrizeId, in
|
|
|
107
120
|
const wonPrize = resultPrizeId
|
|
108
121
|
? prizes.find((p) => p.id === resultPrizeId)
|
|
109
122
|
: undefined;
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
123
|
+
/*
|
|
124
|
+
* Which SEGMENT to land on — an index into `activePrizes`, because that is
|
|
125
|
+
* what the wheel draws.
|
|
126
|
+
*
|
|
127
|
+
* 🛑 `wonPrize` is looked up in the FULL list (a prize deactivated after
|
|
128
|
+
* somebody won it must still render), but the INDEX must come from the
|
|
129
|
+
* drawn list or the wheel would stop on the wrong wedge. `-1` means the
|
|
130
|
+
* won prize is no longer on the wheel, which is a real state: the result
|
|
131
|
+
* card still shows, the wheel simply does not animate to a segment that
|
|
132
|
+
* is not there.
|
|
133
|
+
*/
|
|
134
|
+
const winningIndex = useMemo(() => {
|
|
135
|
+
if (!resultPrizeId)
|
|
136
|
+
return null;
|
|
137
|
+
const i = activePrizes.findIndex((p) => p.id === resultPrizeId);
|
|
138
|
+
return i >= 0 ? i : null;
|
|
139
|
+
}, [resultPrizeId, activePrizes]);
|
|
140
|
+
return (_jsxs(Stack, { gap: "md", children: [_jsx(LoginRequiredModal, { isOpen: showLoginModal, onClose: () => setShowLoginModal(false), message: "You need to be signed in to spin the wheel. Please log in or create an account to continue." }), _jsxs(Heading, { level: 2, size: "xl", weight: "semibold", color: "primary", children: ["\uD83C\uDFA1 ", l.heading] }), _jsx(SpinWheel, { segments: activePrizes.map((p) => ({ id: p.id, label: p.label })), winningIndex: winningIndex, spinning: spinning, onSettled: () => setSpinning(false) }), !wonPrize && activePrizes.length > 0 && (_jsx(Row, { wrap: true, gap: "xs", justify: "center", className: "mt-3", children: activePrizes.map((prize) => (_jsx(Span, { layout: "inline-flex", padding: "pill-sm", rounded: "full", size: "xs", surface: "muted", children: prize.label }, prize.id))) })), wonPrize ? (_jsxs(Div, { className: CLS_PRIZE_BOX, children: [_jsx(Text, { className: "tracking-wide text-success", size: "sm", transform: "uppercase", children: l.wonHeadline }), _jsx(Text, { className: CLS_PRIZE_TITLE, children: wonPrize.label }), resultCoupon ? (_jsxs(_Fragment, { children: [_jsxs(Text, { className: CLS_PRIZE_BODY, children: [l.couponHint, " ", _jsx(Span, { weight: "semibold", className: "font-mono", children: resultCoupon })] }), _jsx(Row, { className: "mt-3", justify: "center", children: _jsx(ClaimCouponButton, { couponCode: resultCoupon, size: "sm", source: "spin" }) })] })) : null] })) : null, error ? (_jsx(Text, { className: CLS_ERROR_TEXT, children: error })) : null, _jsx(Row, { justify: "center", children: _jsx(Button, { variant: "primary", size: "lg", onClick: handleSpin, disabled: disabled, children: spinning ? l.spinningButton : l.spinButton }) }), alreadyUsed ? (
|
|
120
141
|
// audit-content-alignment-ok: self-contained circular spin-wheel game widget, status text centered under the wheel by design
|
|
121
142
|
_jsx(Text, { size: "sm", align: "center", color: "muted", children: l.alreadySpun })) : !inWindow ? (
|
|
122
143
|
// audit-content-alignment-ok: self-contained circular spin-wheel game widget, status text centered under the wheel by design
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE description of what an event type looks like and is called.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this file exists
|
|
5
|
+
*
|
|
6
|
+
* The type had two unrelated visual languages and no shared label:
|
|
7
|
+
*
|
|
8
|
+
* - The DETAIL HERO printed the raw enum under a CSS `capitalize`, so
|
|
9
|
+
* `spin_wheel` rendered as the literal text **"Spin_wheel"** — the
|
|
10
|
+
* underscore was never humanised anywhere.
|
|
11
|
+
* - The CARD used a private `TYPE_ICONS` emoji map inside a `"use client"`
|
|
12
|
+
* component, unexported and therefore unreachable from the hero.
|
|
13
|
+
* - The COLOURS lived in a third place (`_constants.ts`), where `offer` and
|
|
14
|
+
* `survey` shared one class string and `feedback` and `raffle` shared
|
|
15
|
+
* another — so four of the eight types were not visually distinguishable.
|
|
16
|
+
* - Both emoji were `aria-hidden` with NO text companion, so a screen-reader
|
|
17
|
+
* user got no event type from a card at all.
|
|
18
|
+
*
|
|
19
|
+
* 🛑 `Record<EventType, …>` is the point. A ninth event type is a COMPILE
|
|
20
|
+
* ERROR here rather than a silently missing badge — the failure mode Root
|
|
21
|
+
* Cause #61 describes, where ten hand-written enumerations of one union
|
|
22
|
+
* drifted and nine of them never learned about the new member.
|
|
23
|
+
*
|
|
24
|
+
* @tag domain:events
|
|
25
|
+
* @tag layer:constants
|
|
26
|
+
*/
|
|
27
|
+
import type { EventType } from "../types";
|
|
28
|
+
export interface EventTypeMeta {
|
|
29
|
+
/** Human label. Title case, never the raw enum. */
|
|
30
|
+
label: string;
|
|
31
|
+
/** Decorative glyph. ALWAYS paired with `label` for a screen reader. */
|
|
32
|
+
emoji: string;
|
|
33
|
+
/**
|
|
34
|
+
* The chip pairing. 🛑 `bg-{status}-surface` with `text-{status}` — both
|
|
35
|
+
* invert together with the theme, which is what keeps them readable in
|
|
36
|
+
* light and dark. Never pair either with a literal `text-white`: that is
|
|
37
|
+
* invisible in exactly one theme (Root Cause #67).
|
|
38
|
+
*/
|
|
39
|
+
chipClass: string;
|
|
40
|
+
/** One line explaining what this kind of event is, for the hero subhead. */
|
|
41
|
+
blurb: string;
|
|
42
|
+
}
|
|
43
|
+
export declare const EVENT_TYPE_META: Record<EventType, EventTypeMeta>;
|
|
44
|
+
/**
|
|
45
|
+
* Safe lookup for a value that arrived as a plain string — an API response, a
|
|
46
|
+
* URL parameter, a document written before a type existed.
|
|
47
|
+
*
|
|
48
|
+
* Falls back to a NEUTRAL chip with the raw value humanised, rather than to
|
|
49
|
+
* one of the real types: mislabelling a raffle as an offer is worse than
|
|
50
|
+
* admitting we do not recognise it.
|
|
51
|
+
*/
|
|
52
|
+
export declare function eventTypeMeta(type: string | undefined): EventTypeMeta;
|
|
53
|
+
/** `spin_wheel` -> `Spin Wheel`. The thing CSS `capitalize` cannot do. */
|
|
54
|
+
export declare function humaniseEventType(type: string | undefined): string;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE description of what an event type looks like and is called.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this file exists
|
|
5
|
+
*
|
|
6
|
+
* The type had two unrelated visual languages and no shared label:
|
|
7
|
+
*
|
|
8
|
+
* - The DETAIL HERO printed the raw enum under a CSS `capitalize`, so
|
|
9
|
+
* `spin_wheel` rendered as the literal text **"Spin_wheel"** — the
|
|
10
|
+
* underscore was never humanised anywhere.
|
|
11
|
+
* - The CARD used a private `TYPE_ICONS` emoji map inside a `"use client"`
|
|
12
|
+
* component, unexported and therefore unreachable from the hero.
|
|
13
|
+
* - The COLOURS lived in a third place (`_constants.ts`), where `offer` and
|
|
14
|
+
* `survey` shared one class string and `feedback` and `raffle` shared
|
|
15
|
+
* another — so four of the eight types were not visually distinguishable.
|
|
16
|
+
* - Both emoji were `aria-hidden` with NO text companion, so a screen-reader
|
|
17
|
+
* user got no event type from a card at all.
|
|
18
|
+
*
|
|
19
|
+
* 🛑 `Record<EventType, …>` is the point. A ninth event type is a COMPILE
|
|
20
|
+
* ERROR here rather than a silently missing badge — the failure mode Root
|
|
21
|
+
* Cause #61 describes, where ten hand-written enumerations of one union
|
|
22
|
+
* drifted and nine of them never learned about the new member.
|
|
23
|
+
*
|
|
24
|
+
* @tag domain:events
|
|
25
|
+
* @tag layer:constants
|
|
26
|
+
*/
|
|
27
|
+
export const EVENT_TYPE_META = {
|
|
28
|
+
sale: {
|
|
29
|
+
label: "Sale",
|
|
30
|
+
emoji: "🏷️",
|
|
31
|
+
chipClass: "bg-success-surface text-success",
|
|
32
|
+
blurb: "Reduced prices for a limited window.",
|
|
33
|
+
},
|
|
34
|
+
offer: {
|
|
35
|
+
label: "Offer",
|
|
36
|
+
emoji: "🎁",
|
|
37
|
+
chipClass: "bg-primary-surface text-primary",
|
|
38
|
+
blurb: "A code to use at checkout.",
|
|
39
|
+
},
|
|
40
|
+
poll: {
|
|
41
|
+
label: "Poll",
|
|
42
|
+
emoji: "📊",
|
|
43
|
+
chipClass: "bg-info-surface text-info",
|
|
44
|
+
blurb: "Vote — the result decides what we do next.",
|
|
45
|
+
},
|
|
46
|
+
survey: {
|
|
47
|
+
label: "Survey",
|
|
48
|
+
emoji: "📝",
|
|
49
|
+
// Was identical to `offer`. Distinct now: a survey asks, an offer gives.
|
|
50
|
+
chipClass: "bg-secondary-surface text-secondary",
|
|
51
|
+
blurb: "A few questions, and your answers shape the answer.",
|
|
52
|
+
},
|
|
53
|
+
feedback: {
|
|
54
|
+
label: "Feedback",
|
|
55
|
+
emoji: "💬",
|
|
56
|
+
chipClass: "bg-warning-surface text-warning",
|
|
57
|
+
blurb: "Tell us what is working and what is not.",
|
|
58
|
+
},
|
|
59
|
+
raffle: {
|
|
60
|
+
label: "Raffle",
|
|
61
|
+
emoji: "🎟️",
|
|
62
|
+
// Was identical to `feedback`. A prize draw is not a comment box.
|
|
63
|
+
chipClass: "bg-accent-surface text-accent",
|
|
64
|
+
blurb: "Free to enter. One entry, one winner.",
|
|
65
|
+
},
|
|
66
|
+
spin_wheel: {
|
|
67
|
+
// 🛑 THE BUG THIS FILE IS NAMED FOR. `capitalize` on the raw enum
|
|
68
|
+
// produced "Spin_wheel" on every spin event's hero.
|
|
69
|
+
label: "Spin the Wheel",
|
|
70
|
+
emoji: "🎡",
|
|
71
|
+
chipClass: "bg-error-surface text-error",
|
|
72
|
+
blurb: "One spin, one prize, straight to your wallet.",
|
|
73
|
+
},
|
|
74
|
+
lottery: {
|
|
75
|
+
label: "Lottery",
|
|
76
|
+
emoji: "🎰",
|
|
77
|
+
chipClass: "bg-info-surface text-info",
|
|
78
|
+
blurb: "Pick a numbered slot and claim its prize.",
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* Safe lookup for a value that arrived as a plain string — an API response, a
|
|
83
|
+
* URL parameter, a document written before a type existed.
|
|
84
|
+
*
|
|
85
|
+
* Falls back to a NEUTRAL chip with the raw value humanised, rather than to
|
|
86
|
+
* one of the real types: mislabelling a raffle as an offer is worse than
|
|
87
|
+
* admitting we do not recognise it.
|
|
88
|
+
*/
|
|
89
|
+
export function eventTypeMeta(type) {
|
|
90
|
+
const known = type ? EVENT_TYPE_META[type] : undefined;
|
|
91
|
+
if (known)
|
|
92
|
+
return known;
|
|
93
|
+
return {
|
|
94
|
+
label: humaniseEventType(type),
|
|
95
|
+
emoji: "📅",
|
|
96
|
+
chipClass: "bg-surface text-muted",
|
|
97
|
+
blurb: "",
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/** `spin_wheel` -> `Spin Wheel`. The thing CSS `capitalize` cannot do. */
|
|
101
|
+
export function humaniseEventType(type) {
|
|
102
|
+
if (!type)
|
|
103
|
+
return "Event";
|
|
104
|
+
return type
|
|
105
|
+
.split(/[_-]+/)
|
|
106
|
+
.filter(Boolean)
|
|
107
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
108
|
+
.join(" ");
|
|
109
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event likes — toggle, count, and who liked recently.
|
|
3
|
+
*
|
|
4
|
+
* See `../schemas/likes.ts` for why the document id is the idempotency key
|
|
5
|
+
* and why no display name is stored on the row.
|
|
6
|
+
*
|
|
7
|
+
* @tag domain:events
|
|
8
|
+
* @tag layer:repository
|
|
9
|
+
* @tag access:server-only
|
|
10
|
+
*/
|
|
11
|
+
import { BaseRepository } from "../../../providers/db-firebase";
|
|
12
|
+
import { type EventLikeDocument } from "../schemas/likes";
|
|
13
|
+
export interface ToggleLikeResult {
|
|
14
|
+
/** The state AFTER the toggle — what the button should now show. */
|
|
15
|
+
liked: boolean;
|
|
16
|
+
/** The event's like count after the write. Safe to render directly. */
|
|
17
|
+
likeCount: number;
|
|
18
|
+
}
|
|
19
|
+
declare class EventLikesRepository extends BaseRepository<EventLikeDocument> {
|
|
20
|
+
constructor();
|
|
21
|
+
/**
|
|
22
|
+
* Like if not liked, unlike if liked. Returns the resulting state.
|
|
23
|
+
*
|
|
24
|
+
* 🛑 ONE TRANSACTION, ONE READ. The counter lives on the event
|
|
25
|
+
* (`stats.likeCount`) and the membership lives on the like document, so a
|
|
26
|
+
* bare `increment()` beside a bare `set()` would double-count a
|
|
27
|
+
* double-click: both writes would land and the count would say 2 for one
|
|
28
|
+
* person. Reading the like document inside the transaction is what makes
|
|
29
|
+
* the increment conditional on the membership actually changing.
|
|
30
|
+
*
|
|
31
|
+
* It is one read, not two — the event document is never read, because
|
|
32
|
+
* `FieldValue.increment` does not need its current value.
|
|
33
|
+
*/
|
|
34
|
+
toggle(eventId: string, userId: string): Promise<ToggleLikeResult>;
|
|
35
|
+
/** Has this user liked it? One document read, no query, no index. */
|
|
36
|
+
hasLiked(eventId: string, userId: string): Promise<boolean>;
|
|
37
|
+
/**
|
|
38
|
+
* The most recent likers' user ids, newest first.
|
|
39
|
+
*
|
|
40
|
+
* Needs the composite index `(eventId ASC, createdAt DESC)` — declared in
|
|
41
|
+
* `appkit/firebase/base/firestore.indexes.json`. 🛑 Every like row is
|
|
42
|
+
* written with `createdAt`, so the `orderBy` cannot silently exclude rows
|
|
43
|
+
* the way an optional field would (Root Cause #100).
|
|
44
|
+
*/
|
|
45
|
+
recentLikerIds(eventId: string, limit?: number, offset?: number): Promise<string[]>;
|
|
46
|
+
/**
|
|
47
|
+
* Like timestamps for the engagement chart, newest first and BOUNDED.
|
|
48
|
+
*
|
|
49
|
+
* `max` exists because this is the one method that reads more than a
|
|
50
|
+
* handful of rows, and an unbounded read on a popular event is exactly the
|
|
51
|
+
* N-document scan Rule #6 budgets against. 200 rows covers a 14-day chart
|
|
52
|
+
* at any volume worth plotting.
|
|
53
|
+
*/
|
|
54
|
+
recentLikeTimes(eventId: string, max?: number): Promise<Date[]>;
|
|
55
|
+
}
|
|
56
|
+
export declare const eventLikesRepository: EventLikesRepository;
|
|
57
|
+
export {};
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event likes — toggle, count, and who liked recently.
|
|
3
|
+
*
|
|
4
|
+
* See `../schemas/likes.ts` for why the document id is the idempotency key
|
|
5
|
+
* and why no display name is stored on the row.
|
|
6
|
+
*
|
|
7
|
+
* @tag domain:events
|
|
8
|
+
* @tag layer:repository
|
|
9
|
+
* @tag access:server-only
|
|
10
|
+
*/
|
|
11
|
+
import { normalizeError } from "../../../errors/normalize.js";
|
|
12
|
+
import { DatabaseError } from "../../../errors/index.js";
|
|
13
|
+
import { serverLogger } from "../../../monitoring/index.js";
|
|
14
|
+
import { BaseRepository } from "../../../providers/db-firebase/index.js";
|
|
15
|
+
import { EVENT_LIKES_COLLECTION, EVENT_LIKE_FIELDS, eventLikeId, LIKERS_PREVIEW_LIMIT, } from "../schemas/likes.js";
|
|
16
|
+
import { EVENTS_COLLECTION } from "../schemas/firestore.js";
|
|
17
|
+
class EventLikesRepository extends BaseRepository {
|
|
18
|
+
constructor() {
|
|
19
|
+
super(EVENT_LIKES_COLLECTION);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Like if not liked, unlike if liked. Returns the resulting state.
|
|
23
|
+
*
|
|
24
|
+
* 🛑 ONE TRANSACTION, ONE READ. The counter lives on the event
|
|
25
|
+
* (`stats.likeCount`) and the membership lives on the like document, so a
|
|
26
|
+
* bare `increment()` beside a bare `set()` would double-count a
|
|
27
|
+
* double-click: both writes would land and the count would say 2 for one
|
|
28
|
+
* person. Reading the like document inside the transaction is what makes
|
|
29
|
+
* the increment conditional on the membership actually changing.
|
|
30
|
+
*
|
|
31
|
+
* It is one read, not two — the event document is never read, because
|
|
32
|
+
* `FieldValue.increment` does not need its current value.
|
|
33
|
+
*/
|
|
34
|
+
async toggle(eventId, userId) {
|
|
35
|
+
try {
|
|
36
|
+
const db = this.db;
|
|
37
|
+
const likeRef = this.getCollection().doc(eventLikeId(eventId, userId));
|
|
38
|
+
const eventRef = db.collection(EVENTS_COLLECTION).doc(eventId);
|
|
39
|
+
return await db.runTransaction(async (tx) => {
|
|
40
|
+
const [likeSnap, eventSnap] = await Promise.all([tx.get(likeRef), tx.get(eventRef)]);
|
|
41
|
+
if (!eventSnap.exists) {
|
|
42
|
+
// Liking a deleted event would create a row nothing can ever clean
|
|
43
|
+
// up — the cascade keys on the event existing.
|
|
44
|
+
throw new DatabaseError(`Event not found: ${eventId}`);
|
|
45
|
+
}
|
|
46
|
+
const current = Number(eventSnap.data()?.stats?.likeCount ?? 0);
|
|
47
|
+
if (likeSnap.exists) {
|
|
48
|
+
tx.delete(likeRef);
|
|
49
|
+
// 🛑 Floor at zero. A count can only go negative if it drifted —
|
|
50
|
+
// a row deleted by the tester cascade without the counter moving,
|
|
51
|
+
// say — and a public "-1 likes" is worse than an understated 0.
|
|
52
|
+
const next = Math.max(0, current - 1);
|
|
53
|
+
tx.update(eventRef, { "stats.likeCount": next, updatedAt: new Date() });
|
|
54
|
+
return { liked: false, likeCount: next };
|
|
55
|
+
}
|
|
56
|
+
tx.set(likeRef, {
|
|
57
|
+
eventId,
|
|
58
|
+
userId,
|
|
59
|
+
createdAt: new Date(),
|
|
60
|
+
});
|
|
61
|
+
const next = current + 1;
|
|
62
|
+
tx.update(eventRef, { "stats.likeCount": next, updatedAt: new Date() });
|
|
63
|
+
return { liked: true, likeCount: next };
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
const e = normalizeError(err);
|
|
68
|
+
serverLogger.error("eventLikes.toggle failed", { eventId, userId, message: e.message });
|
|
69
|
+
throw new DatabaseError(`Failed to toggle like: ${e.message}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** Has this user liked it? One document read, no query, no index. */
|
|
73
|
+
async hasLiked(eventId, userId) {
|
|
74
|
+
try {
|
|
75
|
+
const snap = await this.getCollection().doc(eventLikeId(eventId, userId)).get();
|
|
76
|
+
return snap.exists;
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
const e = normalizeError(err);
|
|
80
|
+
serverLogger.warn("eventLikes.hasLiked failed", { eventId, userId, message: e.message });
|
|
81
|
+
// Fail CLOSED to "not liked": an unliked heart that turns out to be
|
|
82
|
+
// liked is one wasted tap, and `toggle` is idempotent on the id so the
|
|
83
|
+
// state self-corrects. Failing open would show a filled heart the user
|
|
84
|
+
// cannot un-fill.
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The most recent likers' user ids, newest first.
|
|
90
|
+
*
|
|
91
|
+
* Needs the composite index `(eventId ASC, createdAt DESC)` — declared in
|
|
92
|
+
* `appkit/firebase/base/firestore.indexes.json`. 🛑 Every like row is
|
|
93
|
+
* written with `createdAt`, so the `orderBy` cannot silently exclude rows
|
|
94
|
+
* the way an optional field would (Root Cause #100).
|
|
95
|
+
*/
|
|
96
|
+
async recentLikerIds(eventId, limit = LIKERS_PREVIEW_LIMIT, offset = 0) {
|
|
97
|
+
try {
|
|
98
|
+
const snapshot = await this.getCollection()
|
|
99
|
+
.where(EVENT_LIKE_FIELDS.EVENT_ID, "==", eventId)
|
|
100
|
+
.orderBy(EVENT_LIKE_FIELDS.CREATED_AT, "desc")
|
|
101
|
+
.limit(limit + offset)
|
|
102
|
+
.get();
|
|
103
|
+
return snapshot.docs
|
|
104
|
+
.slice(offset)
|
|
105
|
+
.map((d) => String(d.data().userId))
|
|
106
|
+
.filter(Boolean);
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
const e = normalizeError(err);
|
|
110
|
+
serverLogger.warn("eventLikes.recentLikerIds failed", { eventId, message: e.message });
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Like timestamps for the engagement chart, newest first and BOUNDED.
|
|
116
|
+
*
|
|
117
|
+
* `max` exists because this is the one method that reads more than a
|
|
118
|
+
* handful of rows, and an unbounded read on a popular event is exactly the
|
|
119
|
+
* N-document scan Rule #6 budgets against. 200 rows covers a 14-day chart
|
|
120
|
+
* at any volume worth plotting.
|
|
121
|
+
*/
|
|
122
|
+
async recentLikeTimes(eventId, max = 200) {
|
|
123
|
+
try {
|
|
124
|
+
const snapshot = await this.getCollection()
|
|
125
|
+
.where(EVENT_LIKE_FIELDS.EVENT_ID, "==", eventId)
|
|
126
|
+
.orderBy(EVENT_LIKE_FIELDS.CREATED_AT, "desc")
|
|
127
|
+
.limit(max)
|
|
128
|
+
.get();
|
|
129
|
+
return snapshot.docs
|
|
130
|
+
.map((d) => {
|
|
131
|
+
/*
|
|
132
|
+
* Firestore hands back a Timestamp, the Admin SDK sometimes a Date,
|
|
133
|
+
* and a seeded row a Date. Naming both shapes beats `unknown`: the
|
|
134
|
+
* union is small, closed, and says what the field can actually be.
|
|
135
|
+
*/
|
|
136
|
+
const raw = d.data()
|
|
137
|
+
.createdAt;
|
|
138
|
+
if (raw instanceof Date)
|
|
139
|
+
return raw;
|
|
140
|
+
return typeof raw?.toDate === "function" ? raw.toDate() : null;
|
|
141
|
+
})
|
|
142
|
+
.filter((d) => d !== null);
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
const e = normalizeError(err);
|
|
146
|
+
serverLogger.warn("eventLikes.recentLikeTimes failed", { eventId, message: e.message });
|
|
147
|
+
return [];
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
export const eventLikesRepository = new EventLikesRepository();
|