@mohasinac/appkit 4.63.4 → 4.64.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/categories/adapters.d.ts +1 -1
- package/dist/_internal/server/features/categories/adapters.js +13 -0
- package/dist/_internal/server/features/checkout/actions.d.ts +2 -0
- package/dist/_internal/server/features/checkout/actions.js +22 -5
- 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/orders/adapters.js +3 -1
- 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/site-settings/adapters.js +20 -2
- package/dist/_internal/server/features/stores/adapters.d.ts +2 -2
- package/dist/_internal/server/features/stores/adapters.js +88 -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/analyticsAlertEvaluate.js +5 -2
- package/dist/_internal/server/jobs/core/jobRunners.js +5 -0
- package/dist/_internal/server/jobs/core/onOrderCreate.js +104 -10
- package/dist/_internal/server/jobs/core/onOrderStatusChange.d.ts +2 -0
- package/dist/_internal/server/jobs/core/onOrderStatusChange.js +1 -0
- package/dist/_internal/server/jobs/core/onScamReportCreate.js +1 -0
- package/dist/_internal/server/jobs/core/onScamReportRejected.js +1 -0
- package/dist/_internal/server/jobs/core/onScamReportVerified.js +1 -0
- package/dist/_internal/server/jobs/core/paymentWindowTimeout.js +1 -0
- package/dist/_internal/server/jobs/core/pendingOrderTimeout.js +1 -0
- 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/core/whatsappNotify.d.ts +35 -0
- package/dist/_internal/server/jobs/core/whatsappNotify.js +140 -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/server/notifications/channel-health.d.ts +8 -6
- package/dist/_internal/server/notifications/channel-health.js +2 -2
- package/dist/_internal/server/notifications/send-guard.d.ts +1 -1
- package/dist/_internal/server/notifications/send-guard.js +4 -2
- package/dist/_internal/shared/actions/action-registry.js +89 -0
- package/dist/_internal/shared/checkout/order-math.d.ts +1 -1
- package/dist/_internal/shared/checkout/order-math.js +1 -1
- package/dist/_internal/shared/features/checkout/config.d.ts +2 -0
- package/dist/_internal/shared/features/checkout/config.js +2 -0
- package/dist/_internal/shared/features/messaging/config.d.ts +5 -7
- package/dist/_internal/shared/features/messaging/config.js +4 -1
- 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/_internal/shared/features/site-settings/fees.d.ts +2 -0
- package/dist/_internal/shared/features/site-settings/fees.js +2 -0
- package/dist/_internal/shared/features/site-settings/types.d.ts +27 -2
- package/dist/_internal/shared/fees/calculator.d.ts +8 -0
- package/dist/_internal/shared/fees/calculator.js +7 -0
- package/dist/cli/index.js +2 -0
- package/dist/client.d.ts +2 -2
- package/dist/client.js +1 -1
- package/dist/constants/api-endpoints.d.ts +39 -0
- package/dist/constants/api-endpoints.js +16 -0
- package/dist/constants/notification-types.d.ts +1 -1
- package/dist/constants/notification-types.js +1 -1
- package/dist/core/integration-keys.d.ts +5 -0
- package/dist/core/integration-keys.js +10 -0
- package/dist/features/about/components/FeesView.js +6 -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/account/components/BuyerShoppingGuideView.js +1 -1
- package/dist/features/account/components/NotificationPreferencesPanel.js +4 -3
- package/dist/features/account/types/index.d.ts +2 -0
- package/dist/features/admin/actions/notification-actions.d.ts +17 -2
- package/dist/features/admin/actions/notification-actions.js +154 -9
- package/dist/features/admin/components/AdminOrderEditorView.d.ts +1 -1
- package/dist/features/admin/components/AdminOrdersView.js +2 -0
- package/dist/features/admin/components/AdminSiteSettingsView.js +66 -6
- package/dist/features/admin/components/AdminStoreEditorView.d.ts +12 -1
- package/dist/features/admin/components/AdminStoreEditorView.js +31 -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/constants/site-settings-tabs.d.ts +12 -7
- package/dist/features/admin/constants/site-settings-tabs.js +12 -6
- package/dist/features/admin/repository/site-settings.repository.js +1 -1
- 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/admin/schemas/admin-user-form.d.ts +61 -0
- package/dist/features/admin/schemas/admin-user-form.js +44 -3
- package/dist/features/admin/schemas/firestore.d.ts +73 -0
- package/dist/features/admin/schemas/firestore.js +8 -3
- package/dist/features/auth/checkout-value-otp.d.ts +1 -1
- package/dist/features/auth/checkout-value-otp.js +1 -1
- package/dist/features/auth/permissions/constants.d.ts +2 -2
- package/dist/features/auth/permissions/constants.js +2 -1
- 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/cart/components/CartPriceBreakdown.d.ts +2 -0
- package/dist/features/cart/components/CartPriceBreakdown.js +1 -1
- package/dist/features/cart/components/StoreAddonsPicker.d.ts +5 -2
- package/dist/features/cart/components/StoreAddonsPicker.js +5 -4
- package/dist/features/cart/repository/cart.repository.js +2 -1
- package/dist/features/cart/schemas/firestore.d.ts +2 -1
- 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/checkout/actions/checkout-value-otp-actions.d.ts +5 -10
- package/dist/features/checkout/actions/checkout-value-otp-actions.js +88 -2
- 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/lib/section-renderer.js +1 -1
- package/dist/features/homepage/types/index.d.ts +7 -0
- package/dist/features/orders/actions/refund-actions.js +1 -0
- package/dist/features/orders/components/OrderAddonBadges.d.ts +3 -1
- package/dist/features/orders/components/OrderAddonBadges.js +4 -3
- package/dist/features/orders/repository/orders.repository.d.ts +8 -0
- package/dist/features/orders/repository/orders.repository.js +2 -0
- package/dist/features/orders/schemas/firestore.d.ts +4 -0
- package/dist/features/orders/types/index.d.ts +5 -2
- package/dist/features/search/action-index/settings-entries.js +9 -0
- package/dist/features/store-extensions/repository/store-extensions.repositories.d.ts +6 -1
- package/dist/features/store-extensions/repository/store-extensions.repositories.js +10 -1
- package/dist/features/store-extensions/schemas/firestore.d.ts +17 -1
- package/dist/features/store-extensions/schemas/firestore.js +10 -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 +33 -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 +18 -5
- package/dist/features/stores/components/StoreHeader.js +2 -1
- package/dist/features/stores/components/StoreSettingsGuideView.js +6 -2
- package/dist/features/stores/repository/store.repository.d.ts +23 -1
- package/dist/features/stores/repository/store.repository.js +52 -1
- package/dist/features/stores/schemas/firestore.d.ts +76 -7
- package/dist/features/stores/schemas/firestore.js +21 -6
- package/dist/features/stores/schemas/index.d.ts +88 -2
- package/dist/features/stores/schemas/index.js +14 -0
- 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/features/whatsapp-bot/api/send-status/route.d.ts +12 -0
- package/dist/features/whatsapp-bot/api/send-status/route.js +38 -0
- package/dist/features/whatsapp-bot/api/webhook/route.d.ts +12 -0
- package/dist/features/whatsapp-bot/api/webhook/route.js +43 -0
- package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.d.ts +57 -0
- package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.js +76 -0
- package/dist/features/whatsapp-bot/components/WhatsAppChatButton.d.ts +9 -0
- package/dist/features/whatsapp-bot/components/WhatsAppChatButton.js +7 -0
- package/dist/features/whatsapp-bot/components/index.d.ts +3 -0
- package/dist/features/whatsapp-bot/components/index.js +2 -0
- package/dist/features/whatsapp-bot/helpers/whatsapp.d.ts +52 -0
- package/dist/features/whatsapp-bot/helpers/whatsapp.js +288 -0
- package/dist/features/whatsapp-bot/index.d.ts +3 -0
- package/dist/features/whatsapp-bot/index.js +3 -0
- package/dist/features/whatsapp-bot/manifest.d.ts +2 -0
- package/dist/features/whatsapp-bot/manifest.js +13 -0
- package/dist/features/whatsapp-bot/messages/en.json +7 -0
- package/dist/features/whatsapp-bot/server.d.ts +7 -0
- package/dist/features/whatsapp-bot/server.js +7 -0
- package/dist/features/whatsapp-bot/types/index.d.ts +126 -0
- package/dist/features/whatsapp-bot/types/index.js +1 -0
- package/dist/index.d.ts +23 -6
- package/dist/index.js +38 -12
- package/dist/next/routing/route-map.d.ts +6 -1
- package/dist/next/routing/route-map.js +14 -0
- package/dist/providers/db-firebase/base.repository.d.ts +1 -1
- package/dist/providers/db-firebase/base.repository.js +1 -1
- package/dist/repositories/index.d.ts +1 -1
- package/dist/repositories/index.js +1 -1
- package/dist/schemas/registry.d.ts +115 -0
- package/dist/schemas/registry.js +2 -1
- package/dist/security/pii-encrypt.js +1 -1
- package/dist/server.d.ts +15 -2
- package/dist/server.js +32 -2
- 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/NavigationLoader.d.ts +14 -1
- package/dist/ui/components/NavigationLoader.js +79 -30
- 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
- package/dist/features/orders/utils/order-announcement-message.d.ts +0 -18
- package/dist/features/orders/utils/order-announcement-message.js +0 -16
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `loadOptions` adapters for `<PaginatedSelect>` over the PUBLIC taxonomy
|
|
3
|
+
* endpoints.
|
|
4
|
+
*
|
|
5
|
+
* 🛑 Extracted 2026-10-24 because there were already THREE independent copies
|
|
6
|
+
* of this function — `AdminCategoryEditorView`, `AdminProductEditorView` and
|
|
7
|
+
* `CatalogueItemEditorView` — which is the Duplication Framework's Rule of
|
|
8
|
+
* Three trigger, and a fourth was about to be written for the store-creation
|
|
9
|
+
* form. The body below is lifted from the catalogue copy, which is the only
|
|
10
|
+
* one that got both details right (see the two 🛑 notes).
|
|
11
|
+
*
|
|
12
|
+
* The three existing copies are deliberately NOT migrated here: each lives in
|
|
13
|
+
* an editor another session may hold open, and a shared helper with one honest
|
|
14
|
+
* consumer is a better starting point than a four-way edit. They remain as the
|
|
15
|
+
* migration backlog.
|
|
16
|
+
*/
|
|
17
|
+
export interface TaxonomyOptionPage {
|
|
18
|
+
items: {
|
|
19
|
+
value: string;
|
|
20
|
+
label: string;
|
|
21
|
+
}[];
|
|
22
|
+
hasMore: boolean;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* 🛑 `listingOnly=true` is load-bearing. Without it the picker offers brand
|
|
26
|
+
* and bundle rows as categories — measured against production: "Takara-Tomy",
|
|
27
|
+
* "Original Collector's Set", "Metal Fusion Duo". They share the `categories`
|
|
28
|
+
* collection, and a plain listing category OMITS `categoryType` entirely, so
|
|
29
|
+
* the test cannot be expressed as a query clause; the route applies it in
|
|
30
|
+
* memory.
|
|
31
|
+
*/
|
|
32
|
+
export declare const loadCategoryOptions: (q: string, p: number) => Promise<TaxonomyOptionPage>;
|
|
33
|
+
export declare const loadBrandOptions: (q: string, p: number) => Promise<TaxonomyOptionPage>;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `loadOptions` adapters for `<PaginatedSelect>` over the PUBLIC taxonomy
|
|
3
|
+
* endpoints.
|
|
4
|
+
*
|
|
5
|
+
* 🛑 Extracted 2026-10-24 because there were already THREE independent copies
|
|
6
|
+
* of this function — `AdminCategoryEditorView`, `AdminProductEditorView` and
|
|
7
|
+
* `CatalogueItemEditorView` — which is the Duplication Framework's Rule of
|
|
8
|
+
* Three trigger, and a fourth was about to be written for the store-creation
|
|
9
|
+
* form. The body below is lifted from the catalogue copy, which is the only
|
|
10
|
+
* one that got both details right (see the two 🛑 notes).
|
|
11
|
+
*
|
|
12
|
+
* The three existing copies are deliberately NOT migrated here: each lives in
|
|
13
|
+
* an editor another session may hold open, and a shared helper with one honest
|
|
14
|
+
* consumer is a better starting point than a four-way edit. They remain as the
|
|
15
|
+
* migration backlog.
|
|
16
|
+
*/
|
|
17
|
+
import { apiClient } from "../../../http/index.js";
|
|
18
|
+
import { CATEGORY_ENDPOINTS, BRAND_ENDPOINTS } from "../../../constants/api-endpoints.js";
|
|
19
|
+
/**
|
|
20
|
+
* 🛑 PUBLIC endpoints only. A seller-facing picker that reached
|
|
21
|
+
* `/api/admin/categories` would 403 for the very users it exists for, and the
|
|
22
|
+
* failure renders as an empty dropdown rather than an error.
|
|
23
|
+
*/
|
|
24
|
+
async function loadOptionsFrom(endpoint, query, page,
|
|
25
|
+
/**
|
|
26
|
+
* Extra query params, passed as an object rather than baked into `endpoint`:
|
|
27
|
+
* this builds its own `?`, so a pre-existing query string would produce
|
|
28
|
+
* `...?listingOnly=true?page=1`.
|
|
29
|
+
*/
|
|
30
|
+
extra) {
|
|
31
|
+
const params = new URLSearchParams({ page: String(page), pageSize: "25", ...extra });
|
|
32
|
+
if (query)
|
|
33
|
+
params.set("q", query);
|
|
34
|
+
const res = await apiClient.get(`${endpoint}?${params.toString()}`);
|
|
35
|
+
const data = res ?? {};
|
|
36
|
+
return {
|
|
37
|
+
items: (data.items ?? []).map((c) => ({ value: c.id, label: c.name })),
|
|
38
|
+
hasMore: data.hasMore ?? false,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* 🛑 `listingOnly=true` is load-bearing. Without it the picker offers brand
|
|
43
|
+
* and bundle rows as categories — measured against production: "Takara-Tomy",
|
|
44
|
+
* "Original Collector's Set", "Metal Fusion Duo". They share the `categories`
|
|
45
|
+
* collection, and a plain listing category OMITS `categoryType` entirely, so
|
|
46
|
+
* the test cannot be expressed as a query clause; the route applies it in
|
|
47
|
+
* memory.
|
|
48
|
+
*/
|
|
49
|
+
export const loadCategoryOptions = (q, p) => loadOptionsFrom(CATEGORY_ENDPOINTS.LIST, q, p, { listingOnly: "true" });
|
|
50
|
+
export const loadBrandOptions = (q, p) => loadOptionsFrom(BRAND_ENDPOINTS.LIST, q, p);
|
|
@@ -6,17 +6,12 @@
|
|
|
6
6
|
* purpose from `checkout-actions.ts`'s third-party shipping consent OTP —
|
|
7
7
|
* same crypto primitives, separate Firestore namespace and copy.
|
|
8
8
|
*/
|
|
9
|
+
export type CheckoutValueOtpChannel = "email" | "whatsapp";
|
|
9
10
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* than deleted so the parameter stays self-documenting and a second channel
|
|
15
|
-
* has an obvious seam.
|
|
16
|
-
*/
|
|
17
|
-
export type CheckoutValueOtpChannel = "email";
|
|
18
|
-
/**
|
|
19
|
-
* Sends a checkout-value OTP by email.
|
|
11
|
+
* Sends a checkout-value OTP either by email (default) or WhatsApp
|
|
12
|
+
* (opt-in — the buyer must explicitly choose it in the UI). WhatsApp phone
|
|
13
|
+
* is always resolved server-side from the selected shipping address —
|
|
14
|
+
* never trust a client-supplied phone number.
|
|
20
15
|
*
|
|
21
16
|
* If the chosen channel (and, for email, its skip-worthy fallback) is
|
|
22
17
|
* currently circuit-broken or unconfigured, the OTP gate is treated as
|
|
@@ -9,12 +9,17 @@
|
|
|
9
9
|
import { timingSafeEqual } from "crypto";
|
|
10
10
|
import { ValidationError } from "../../../errors/index.js";
|
|
11
11
|
import { normalizeError } from "../../../errors/normalize.js";
|
|
12
|
+
import { safeRead } from "../../../errors/safe-read.js";
|
|
12
13
|
import { serverLogger } from "../../../monitoring/index.js";
|
|
13
14
|
import { sendEmail } from "../../contact/email.js";
|
|
14
15
|
import { getAdminDb } from "../../../providers/db-firebase/index.js";
|
|
15
16
|
import { resolveDate } from "../../../utils/index.js";
|
|
17
|
+
import { addressesRepository } from "../../addresses/repository/addresses.repository.js";
|
|
18
|
+
import { siteSettingsRepository } from "../../admin/repository/site-settings.repository.js";
|
|
19
|
+
import { sendWhatsAppBusinessMessage } from "../../whatsapp-bot/helpers/whatsapp.js";
|
|
16
20
|
import { isChannelHealthy, withChannelRetry } from "../../../_internal/server/notifications/channel-health.js";
|
|
17
|
-
import {
|
|
21
|
+
import { guardSend, logSuppressedSend } from "../../../_internal/server/notifications/send-guard.js";
|
|
22
|
+
import { CHECKOUT_VALUE_OTP_EXPIRY_MS, CHECKOUT_VALUE_OTP_MAX_ATTEMPTS, hashOtp, generateOtpCode, checkoutValueOtpRef, enforceCheckoutValueOtpRateLimit, buildCheckoutValueOtpMessage, } from "../../auth/checkout-value-otp.js";
|
|
18
23
|
function maskEmail(userEmail) {
|
|
19
24
|
const [local, domain] = userEmail.split("@");
|
|
20
25
|
return domain
|
|
@@ -22,7 +27,10 @@ function maskEmail(userEmail) {
|
|
|
22
27
|
: "***";
|
|
23
28
|
}
|
|
24
29
|
/**
|
|
25
|
-
* Sends a checkout-value OTP by email
|
|
30
|
+
* Sends a checkout-value OTP either by email (default) or WhatsApp
|
|
31
|
+
* (opt-in — the buyer must explicitly choose it in the UI). WhatsApp phone
|
|
32
|
+
* is always resolved server-side from the selected shipping address —
|
|
33
|
+
* never trust a client-supplied phone number.
|
|
26
34
|
*
|
|
27
35
|
* If the chosen channel (and, for email, its skip-worthy fallback) is
|
|
28
36
|
* currently circuit-broken or unconfigured, the OTP gate is treated as
|
|
@@ -37,6 +45,84 @@ export async function sendCheckoutValueOtp(userId, userEmail, channel = "email",
|
|
|
37
45
|
const codeHash = hashOtp(code);
|
|
38
46
|
const expiresAt = new Date(Date.now() + CHECKOUT_VALUE_OTP_EXPIRY_MS);
|
|
39
47
|
const siteName = process.env.NEXT_PUBLIC_SITE_NAME || "LetItRip";
|
|
48
|
+
if (channel === "whatsapp") {
|
|
49
|
+
if (!addressId) {
|
|
50
|
+
throw new ValidationError("Select a delivery address before sending the code via WhatsApp.");
|
|
51
|
+
}
|
|
52
|
+
// A failed settings read makes the WhatsApp channel look deliberately
|
|
53
|
+
// switched off ("send the code by email instead") — a defensible fallback,
|
|
54
|
+
// but one an operator has to be able to see, since it is indistinguishable
|
|
55
|
+
// from an admin having disabled it.
|
|
56
|
+
const settings = await safeRead(() => siteSettingsRepository.getSingleton(), {
|
|
57
|
+
route: "checkout/value-otp",
|
|
58
|
+
key: "checkoutValueOtp.siteSettings",
|
|
59
|
+
fallback: null,
|
|
60
|
+
});
|
|
61
|
+
const creds = settings?.credentials ?? {};
|
|
62
|
+
const whatsappOtpEnabled = settings?.notificationChannels?.whatsapp?.otpEnabled === true;
|
|
63
|
+
const phoneNumberId = creds.whatsappPhoneNumberId?.trim() ?? "";
|
|
64
|
+
const accessToken = creds.whatsappCloudApiToken?.trim() ?? "";
|
|
65
|
+
const credsReady = phoneNumberId && accessToken && !phoneNumberId.includes("PLACEHOLDER") && !accessToken.includes("PLACEHOLDER");
|
|
66
|
+
if (!whatsappOtpEnabled || !credsReady) {
|
|
67
|
+
throw new ValidationError("WhatsApp verification isn't available right now — send the code by email instead.");
|
|
68
|
+
}
|
|
69
|
+
const address = await addressesRepository.findById(addressId);
|
|
70
|
+
/*
|
|
71
|
+
* REVEALED: this phone receives the high-value checkout OTP, so it has to
|
|
72
|
+
* be the real number. `addressesRepository` is ciphertext-by-default, and
|
|
73
|
+
* `.trim()` on the stored value is what the nominal brand caught — the
|
|
74
|
+
* old intersection form let `Encrypted<string>` call string methods.
|
|
75
|
+
*/
|
|
76
|
+
const addressPlain = address ? addressesRepository.revealAddress(address) : null;
|
|
77
|
+
const phone = addressPlain?.phone?.trim();
|
|
78
|
+
if (!phone) {
|
|
79
|
+
throw new ValidationError("The selected address has no phone number on file — send the code by email instead.");
|
|
80
|
+
}
|
|
81
|
+
if (!(await isChannelHealthy("whatsapp"))) {
|
|
82
|
+
serverLogger.warn("sendCheckoutValueOtp: WhatsApp channel unhealthy — skipping OTP gate", { uid: userId, channel });
|
|
83
|
+
return { skipped: true };
|
|
84
|
+
}
|
|
85
|
+
await checkoutValueOtpRef(db, userId).set({
|
|
86
|
+
codeHash,
|
|
87
|
+
expiresAt,
|
|
88
|
+
attempts: 0,
|
|
89
|
+
verified: false,
|
|
90
|
+
createdAt: new Date(),
|
|
91
|
+
});
|
|
92
|
+
const message = buildCheckoutValueOtpMessage(code, siteName);
|
|
93
|
+
// Same guard as the email branch below, same reasoning: transactional, so
|
|
94
|
+
// the kill switch does not apply (the buyer cannot check out without the
|
|
95
|
+
// code), but the daily ceiling does.
|
|
96
|
+
const waGuard = {
|
|
97
|
+
channel: "whatsapp",
|
|
98
|
+
feature: "checkout_value_otp",
|
|
99
|
+
audience: "user",
|
|
100
|
+
transactional: true,
|
|
101
|
+
};
|
|
102
|
+
const waDecision = await guardSend(waGuard);
|
|
103
|
+
if (!waDecision.allow) {
|
|
104
|
+
logSuppressedSend(waGuard, waDecision);
|
|
105
|
+
throw new ValidationError("We couldn't send the verification code via WhatsApp — please try again, or send it by email instead.");
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
const sent = await withChannelRetry("whatsapp", async () => {
|
|
109
|
+
const ok = await sendWhatsAppBusinessMessage({ toPhone: phone, message, phoneNumberId, accessToken });
|
|
110
|
+
if (!ok)
|
|
111
|
+
throw new Error("WhatsApp Cloud API returned a non-OK response");
|
|
112
|
+
return ok;
|
|
113
|
+
});
|
|
114
|
+
if (!sent)
|
|
115
|
+
throw new Error("WhatsApp send failed");
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
void normalizeError(err);
|
|
119
|
+
serverLogger.error("Failed to send checkout value OTP via WhatsApp", { uid: userId, err });
|
|
120
|
+
throw new ValidationError("We couldn't send the verification code via WhatsApp — please try again, or send it by email instead.");
|
|
121
|
+
}
|
|
122
|
+
const maskedPhone = phone.length > 4 ? `${"*".repeat(phone.length - 4)}${phone.slice(-4)}` : "***";
|
|
123
|
+
serverLogger.info(`Checkout value OTP sent via WhatsApp: uid=${userId}`);
|
|
124
|
+
return { maskedPhone };
|
|
125
|
+
}
|
|
40
126
|
if (!(await isChannelHealthy("email"))) {
|
|
41
127
|
serverLogger.warn("sendCheckoutValueOtp: email channel unhealthy — skipping OTP gate", { uid: userId, channel });
|
|
42
128
|
return { skipped: true };
|
|
@@ -6,6 +6,8 @@ import { MediaImage } from "../../media/MediaImage.js";
|
|
|
6
6
|
import { normalizeRichTextHtml } from "../../../utils/string.formatter.js";
|
|
7
7
|
import { EVENT_FIELDS } from "../schemas/index.js";
|
|
8
8
|
import { EventStatusBadge } from "./EventStatusBadge.js";
|
|
9
|
+
import { EventTypeBadge } from "./EventTypeBadge.js";
|
|
10
|
+
import { eventTypeMeta } from "../constants/event-type-meta.js";
|
|
9
11
|
import { ROUTES } from "../../../next/routing/route-map.js";
|
|
10
12
|
import { useLongPress } from "../../../react/hooks/useLongPress.js";
|
|
11
13
|
import { RecordOwnerQuickActions } from "../../admin/components/RecordOwnerQuickActions.js";
|
|
@@ -15,16 +17,6 @@ const __P = {
|
|
|
15
17
|
const __O = {
|
|
16
18
|
hidden: "overflow-hidden",
|
|
17
19
|
};
|
|
18
|
-
const TYPE_ICONS = {
|
|
19
|
-
sale: "🏷️",
|
|
20
|
-
offer: "🎁",
|
|
21
|
-
poll: "📊",
|
|
22
|
-
survey: "📝",
|
|
23
|
-
feedback: "💬",
|
|
24
|
-
raffle: "🎟️",
|
|
25
|
-
spin_wheel: "🎡",
|
|
26
|
-
lottery: "🎰",
|
|
27
|
-
};
|
|
28
20
|
export function EventCard({ event, labels = {}, onParticipate, className = "", selectable = false, isSelected = false, onSelect, renderOwnerActions, }) {
|
|
29
21
|
const longPress = useLongPress(() => onSelect?.(event.id, !isSelected));
|
|
30
22
|
const safeTitle = event.title?.trim() || "Untitled event";
|
|
@@ -33,8 +25,8 @@ export function EventCard({ event, labels = {}, onParticipate, className = "", s
|
|
|
33
25
|
const msLeft = endsAt.getTime() - now.getTime();
|
|
34
26
|
const daysLeft = Math.max(0, Math.ceil(msLeft / (1000 * 60 * 60 * 24)));
|
|
35
27
|
const detailHref = String(ROUTES.PUBLIC.EVENT_DETAIL(event.slug ?? event.id));
|
|
36
|
-
const card = (_jsxs(Stack, { as: "article", border: "default", rounded: "xl", shadow: "hover-md", gap: "none", className: `group relative h-full overflow-hidden bg-[var(--appkit-color-surface)] ${isSelected ? "border-primary outline outline-2 outline-primary" : " "} ${className}`, onMouseDown: onSelect && !isSelected ? longPress.onMouseDown : undefined, onMouseUp: onSelect && !isSelected ? longPress.onMouseUp : undefined, onMouseLeave: onSelect && !isSelected ? longPress.onMouseLeave : undefined, onTouchStart: onSelect && !isSelected ? longPress.onTouchStart : undefined, onTouchEnd: onSelect && !isSelected ? longPress.onTouchEnd : undefined, onTouchCancel: onSelect && !isSelected ? longPress.onTouchCancel : undefined, children: [onSelect && (_jsx(BaseListingCard.Checkbox, { selected: isSelected, onSelect: (e) => { e.preventDefault(); onSelect(event.id, !isSelected); }, label: isSelected ? "Deselect event" : "Select event", position: "top-2 left-2", className: selectable || isSelected ? "opacity-100" : "opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity" })), _jsx(Link, { href: detailHref, className: "block flex-shrink-0", children: event.coverImageUrl || event.coverImage?.url ? (_jsx(Div, { className: `relative aspect-video ${__O.hidden}`, children: _jsx(MediaImage, { src: event.coverImageUrl || event.coverImage?.url || "", alt: safeTitle, size: "card", className: "transition-transform duration-300 group-hover:scale-105" }) })) : (_jsx(Row, { surface: "muted", className: "aspect-video", align: "center", justify: "center", children: _jsx(Span, { className: "opacity-40", size: "5xl", "aria-hidden": "true", children:
|
|
37
|
-
daysLeft > 0 && _jsxs(Span, { children: ["\u23F1 ", daysLeft, "d remaining"] }), _jsxs(Span, { children: ["\
|
|
28
|
+
const card = (_jsxs(Stack, { as: "article", border: "default", rounded: "xl", shadow: "hover-md", gap: "none", className: `group relative h-full overflow-hidden bg-[var(--appkit-color-surface)] ${isSelected ? "border-primary outline outline-2 outline-primary" : " "} ${className}`, onMouseDown: onSelect && !isSelected ? longPress.onMouseDown : undefined, onMouseUp: onSelect && !isSelected ? longPress.onMouseUp : undefined, onMouseLeave: onSelect && !isSelected ? longPress.onMouseLeave : undefined, onTouchStart: onSelect && !isSelected ? longPress.onTouchStart : undefined, onTouchEnd: onSelect && !isSelected ? longPress.onTouchEnd : undefined, onTouchCancel: onSelect && !isSelected ? longPress.onTouchCancel : undefined, children: [onSelect && (_jsx(BaseListingCard.Checkbox, { selected: isSelected, onSelect: (e) => { e.preventDefault(); onSelect(event.id, !isSelected); }, label: isSelected ? "Deselect event" : "Select event", position: "top-2 left-2", className: selectable || isSelected ? "opacity-100" : "opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity" })), _jsx(Link, { href: detailHref, className: "block flex-shrink-0", children: event.coverImageUrl || event.coverImage?.url ? (_jsx(Div, { className: `relative aspect-video ${__O.hidden}`, children: _jsx(MediaImage, { src: event.coverImageUrl || event.coverImage?.url || "", alt: safeTitle, size: "card", className: "transition-transform duration-300 group-hover:scale-105" }) })) : (_jsx(Row, { surface: "muted", className: "aspect-video", align: "center", justify: "center", children: _jsx(Span, { className: "opacity-40", size: "5xl", "aria-hidden": "true", children: eventTypeMeta(event.type).emoji }) })) }), _jsxs(Stack, { className: `flex-1 ${__P.p4}`, children: [_jsxs(Row, { className: "mb-2", align: "start", justify: "between", gap: "sm", children: [_jsx(EventTypeBadge, { type: event.type }), _jsx(EventStatusBadge, { status: event.status })] }), _jsx(Link, { href: detailHref, className: "block", children: _jsx(Heading, { level: 3, className: "text-[var(--appkit-color-text)] leading-snug mb-1 group-hover:text-primary transition-colors", size: "base", weight: "semibold", children: safeTitle }) }), _jsx(RichText, { html: normalizeRichTextHtml(event.description ?? ""), proseClass: "prose prose-sm max-w-none dark:prose-invert prose-p:my-0", className: "mb-3 line-clamp-3 text-[length:var(--appkit-text-sm)] text-[var(--appkit-color-text-muted)]" }), _jsxs(Row, { color: "muted", textSize: "xs", className: "mb-3 mt-auto", align: "center", justify: "between", children: [event.status === EVENT_FIELDS.STATUS_VALUES.ACTIVE &&
|
|
29
|
+
daysLeft > 0 && _jsxs(Span, { children: ["\u23F1 ", daysLeft, "d remaining"] }), _jsxs(Span, { children: ["\u2665 ", event.stats.likeCount ?? 0, " ", (event.stats.likeCount ?? 0) === 1 ? "like" : "likes"] })] }), event.status === EVENT_FIELDS.STATUS_VALUES.ACTIVE && onParticipate ? (_jsx(Button, { rounded: "lg", type: "button", onClick: () => onParticipate(event), className: "w-full bg-primary py-[var(--appkit-space-2)] text-[length:var(--appkit-text-sm)] font-medium text-white transition-colors hover:bg-primary-600", children: labels.participate ?? "Participate" })) : (_jsxs(TextLink, { rounded: "lg", paddingX: "sm", paddingY: "xs", href: detailHref, layout: "inline-flex", align: "center", justify: "center", gap: "xs", className: "w-full border border-[var(--appkit-color-border)] transition-colors hover:bg-[var(--appkit-color-bg)]", color: "primary", size: "sm", weight: "medium", children: [labels.viewDetails ?? "View details", " \u2192"] }))] })] }));
|
|
38
30
|
/*
|
|
39
31
|
* 🛑 Default, not opt-in. Nothing in either repo ever passed
|
|
40
32
|
* `renderOwnerActions`, so this toolbar was unreachable on every grid that
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The charts on an event page — whichever ones have real data.
|
|
3
|
+
*
|
|
4
|
+
* 🛑 THE WHOLE SECTION DISAPPEARS WHEN NOTHING QUALIFIES, and each chart
|
|
5
|
+
* independently returns `null` from its own builder rather than rendering an
|
|
6
|
+
* empty frame. A chart says "we measured this"; an axis pair with a flat line
|
|
7
|
+
* says it too, and says something false. An absent chart says "not yet",
|
|
8
|
+
* which is both true and the common case for a new event.
|
|
9
|
+
*
|
|
10
|
+
* `<Chart>` declares its own `"use client"` internally and loads recharts via
|
|
11
|
+
* `dynamic(..., { ssr: false })`, so this file needs no client boundary and a
|
|
12
|
+
* Server Component can mount it directly.
|
|
13
|
+
*
|
|
14
|
+
* @tag domain:events
|
|
15
|
+
* @tag layer:component
|
|
16
|
+
*/
|
|
17
|
+
import React from "react";
|
|
18
|
+
import type { ChartSpec } from "../../../ui/components/chart-spec";
|
|
19
|
+
import type { SaleProgress } from "../utils/event-charts";
|
|
20
|
+
export interface EventInsightsProps {
|
|
21
|
+
pollChart?: ChartSpec | null;
|
|
22
|
+
savingsChart?: ChartSpec | null;
|
|
23
|
+
likeTrendChart?: ChartSpec | null;
|
|
24
|
+
saleProgress?: SaleProgress | null;
|
|
25
|
+
heading?: string;
|
|
26
|
+
}
|
|
27
|
+
export declare function EventInsights({ pollChart, savingsChart, likeTrendChart, saleProgress, heading, }: EventInsightsProps): React.JSX.Element | null;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { Chart } from "../../../ui/components/Chart.js";
|
|
3
|
+
import { Grid, Heading, Progress, Row, Stack, Text } from "../../../ui/index.js";
|
|
4
|
+
import { CountdownDisplay } from "../../../ui/components/CountdownDisplay.js";
|
|
5
|
+
export function EventInsights({ pollChart, savingsChart, likeTrendChart, saleProgress, heading = "By the numbers", }) {
|
|
6
|
+
const charts = [savingsChart, pollChart, likeTrendChart].filter((c) => Boolean(c));
|
|
7
|
+
if (charts.length === 0 && !saleProgress)
|
|
8
|
+
return null;
|
|
9
|
+
return (_jsxs(Stack, { gap: "md", children: [_jsx(Heading, { level: 2, size: "lg", weight: "semibold", color: "primary", children: heading }), saleProgress && (_jsxs(Stack, { gap: "sm", surface: "card", padding: "md", rounded: "xl", border: "default", children: [_jsxs(Row, { align: "center", justify: "between", gap: "sm", wrap: true, children: [_jsx(Text, { size: "sm", weight: "medium", children: saleProgress.endsAt ? "Time remaining" : "This has ended" }), saleProgress.endsAt && (_jsx(Text, { size: "sm", color: "muted", children: _jsx(CountdownDisplay, { targetDate: saleProgress.endsAt, format: "auto" }) }))] }), _jsx(Progress, { value: saleProgress.elapsedPercent, variant: saleProgress.elapsedPercent > 85 ? "warning" : "primary", size: "sm", label: "Sale window elapsed" }), saleProgress.claimedPercent !== null && (_jsxs(_Fragment, { children: [_jsxs(Row, { align: "center", justify: "between", gap: "sm", wrap: true, children: [_jsx(Text, { size: "sm", weight: "medium", children: "Claimed" }), _jsx(Text, { size: "sm", color: "muted", children: saleProgress.claimedLabel })] }), _jsx(Progress, { value: saleProgress.claimedPercent, variant: saleProgress.claimedPercent > 85 ? "error" : "success", size: "sm", label: "Redemptions against the cap" })] }))] })), charts.length > 0 && (_jsx(Grid, { cols: "cardsWide", gap: "lg", children: charts.map((spec) => (_jsx(Chart, { spec: spec, height: 260 }, spec.title ?? spec.caption))) }))] }));
|
|
10
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The like toggle. Optimistic, signed-in only, and safe to double-click.
|
|
3
|
+
*
|
|
4
|
+
* @tag domain:events
|
|
5
|
+
* @tag layer:component
|
|
6
|
+
*/
|
|
7
|
+
import React from "react";
|
|
8
|
+
export interface EventLikeButtonProps {
|
|
9
|
+
eventId: string;
|
|
10
|
+
/** Server-rendered starting state. */
|
|
11
|
+
initialLiked: boolean;
|
|
12
|
+
initialCount: number;
|
|
13
|
+
/** POSTs the toggle and resolves with the authoritative new state. */
|
|
14
|
+
onToggle: (eventId: string) => Promise<{
|
|
15
|
+
liked: boolean;
|
|
16
|
+
likeCount: number;
|
|
17
|
+
}>;
|
|
18
|
+
size?: "sm" | "md";
|
|
19
|
+
className?: string;
|
|
20
|
+
}
|
|
21
|
+
export declare function EventLikeButton({ eventId, initialLiked, initialCount, onToggle, size, className, }: EventLikeButtonProps): React.JSX.Element;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
/**
|
|
4
|
+
* The like toggle. Optimistic, signed-in only, and safe to double-click.
|
|
5
|
+
*
|
|
6
|
+
* @tag domain:events
|
|
7
|
+
* @tag layer:component
|
|
8
|
+
*/
|
|
9
|
+
import { useCallback, useState, useTransition } from "react";
|
|
10
|
+
import { Button, Row, Span, Icon } from "../../../ui/index.js";
|
|
11
|
+
import { PressScale } from "../../../ui/components/Motion.js";
|
|
12
|
+
import { useOptionalSession } from "../../../react/contexts/SessionContext.js";
|
|
13
|
+
import { LoginRequiredModal } from "../../../ui/components/LoginRequiredModal.js";
|
|
14
|
+
import { normalizeError } from "../../../errors/normalize.js";
|
|
15
|
+
export function EventLikeButton({ eventId, initialLiked, initialCount, onToggle, size = "md", className = "", }) {
|
|
16
|
+
const session = useOptionalSession();
|
|
17
|
+
const [liked, setLiked] = useState(initialLiked);
|
|
18
|
+
const [count, setCount] = useState(initialCount);
|
|
19
|
+
const [showLogin, setShowLogin] = useState(false);
|
|
20
|
+
const [pending, startTransition] = useTransition();
|
|
21
|
+
const handleClick = useCallback(() => {
|
|
22
|
+
/*
|
|
23
|
+
* 🛑 THREE STATES, NOT TWO. `SessionProvider` mounts `initialUser={null}`
|
|
24
|
+
* because the root locale layout must stay static (Root Cause #82), so
|
|
25
|
+
* `loading` is true on every hard load FOR SIGNED-IN USERS TOO. Testing
|
|
26
|
+
* only `!user` would open the sign-in modal at a buyer who is already
|
|
27
|
+
* signed in, on every first click after a page load — the exact defect
|
|
28
|
+
* Root Cause #79 records for the wishlist heart.
|
|
29
|
+
*/
|
|
30
|
+
if (session?.loading)
|
|
31
|
+
return;
|
|
32
|
+
if (!session?.user?.uid) {
|
|
33
|
+
setShowLogin(true);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
// Optimistic: flip now, reconcile from the server response. A like is
|
|
37
|
+
// cheap to be briefly wrong about and the latency is what people notice.
|
|
38
|
+
const nextLiked = !liked;
|
|
39
|
+
setLiked(nextLiked);
|
|
40
|
+
setCount((c) => Math.max(0, c + (nextLiked ? 1 : -1)));
|
|
41
|
+
startTransition(() => {
|
|
42
|
+
void onToggle(eventId)
|
|
43
|
+
.then((res) => {
|
|
44
|
+
// The server is authoritative — it holds the real count, which may
|
|
45
|
+
// have moved for other reasons between paint and click.
|
|
46
|
+
setLiked(res.liked);
|
|
47
|
+
setCount(res.likeCount);
|
|
48
|
+
})
|
|
49
|
+
.catch((err) => {
|
|
50
|
+
void normalizeError(err);
|
|
51
|
+
// Roll back rather than leaving a lie on screen.
|
|
52
|
+
setLiked(liked);
|
|
53
|
+
setCount(initialCount);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
}, [session, liked, initialCount, eventId, onToggle]);
|
|
57
|
+
const label = liked ? "Liked" : "Like";
|
|
58
|
+
return (_jsxs(_Fragment, { children: [_jsx(PressScale, { children: _jsx(Button, { type: "button", variant: liked ? "primary" : "outline", size: size, onClick: handleClick, isLoading: pending, "aria-pressed": liked, "aria-label": `${label} this event — ${count} ${count === 1 ? "person likes" : "people like"} it`, className: className, children: _jsxs(Row, { align: "center", gap: "xs", children: [_jsx(Icon, { name: "wishlist", filled: liked }), _jsx(Span, { weight: "medium", children: label }), _jsx(Span, { numeric: true, children: count })] }) }) }), _jsx(LoginRequiredModal, { isOpen: showLogin, onClose: () => setShowLogin(false), message: "Sign in to like this event and see who else has." })] }));
|
|
59
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Liked by" — the last ten faces, then a way to see the rest.
|
|
3
|
+
*
|
|
4
|
+
* @tag domain:events
|
|
5
|
+
* @tag layer:component
|
|
6
|
+
*/
|
|
7
|
+
import React from "react";
|
|
8
|
+
import type { PublicLiker } from "../schemas/likes";
|
|
9
|
+
export interface EventLikersRailProps {
|
|
10
|
+
/** The most recent likers, server-rendered. */
|
|
11
|
+
likers: PublicLiker[];
|
|
12
|
+
/** The event's total like count — usually larger than `likers.length`. */
|
|
13
|
+
totalCount: number;
|
|
14
|
+
/** True when more exist past the preview. */
|
|
15
|
+
hasMore: boolean;
|
|
16
|
+
/** Fetches one more page for the modal. */
|
|
17
|
+
loadMore?: (offset: number) => Promise<{
|
|
18
|
+
likers: PublicLiker[];
|
|
19
|
+
hasMore: boolean;
|
|
20
|
+
}>;
|
|
21
|
+
className?: string;
|
|
22
|
+
}
|
|
23
|
+
export declare function EventLikersRail({ likers, totalCount, hasMore, loadMore, className, }: EventLikersRailProps): React.JSX.Element | null;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
/**
|
|
4
|
+
* "Liked by" — the last ten faces, then a way to see the rest.
|
|
5
|
+
*
|
|
6
|
+
* @tag domain:events
|
|
7
|
+
* @tag layer:component
|
|
8
|
+
*/
|
|
9
|
+
import { useCallback, useState } from "react";
|
|
10
|
+
import { AvatarGroup, Button, Modal, Row, Stack, Text, Span, Avatar } from "../../../ui/index.js";
|
|
11
|
+
import { LIKERS_PAGE_SIZE } from "../schemas/likes.js";
|
|
12
|
+
import { normalizeError } from "../../../errors/normalize.js";
|
|
13
|
+
/** "Aarav, Riya and 7 others" — names first, count only for the remainder. */
|
|
14
|
+
function summarise(likers, total) {
|
|
15
|
+
if (likers.length === 0)
|
|
16
|
+
return "";
|
|
17
|
+
const shown = likers.slice(0, 2).map((l) => l.displayName);
|
|
18
|
+
const others = Math.max(0, total - shown.length);
|
|
19
|
+
if (others === 0)
|
|
20
|
+
return shown.join(" and ");
|
|
21
|
+
return `${shown.join(", ")} and ${others} other${others === 1 ? "" : "s"}`;
|
|
22
|
+
}
|
|
23
|
+
export function EventLikersRail({ likers, totalCount, hasMore, loadMore, className = "", }) {
|
|
24
|
+
const [open, setOpen] = useState(false);
|
|
25
|
+
const [all, setAll] = useState(likers);
|
|
26
|
+
const [more, setMore] = useState(hasMore);
|
|
27
|
+
const [loading, setLoading] = useState(false);
|
|
28
|
+
const handleLoadMore = useCallback(() => {
|
|
29
|
+
if (!loadMore || loading)
|
|
30
|
+
return;
|
|
31
|
+
setLoading(true);
|
|
32
|
+
void loadMore(all.length)
|
|
33
|
+
.then((page) => {
|
|
34
|
+
setAll((prev) => [...prev, ...page.likers]);
|
|
35
|
+
setMore(page.hasMore);
|
|
36
|
+
})
|
|
37
|
+
.catch((err) => {
|
|
38
|
+
void normalizeError(err);
|
|
39
|
+
// Stop offering a button that is not working, rather than letting it
|
|
40
|
+
// sit there doing nothing on every press.
|
|
41
|
+
setMore(false);
|
|
42
|
+
})
|
|
43
|
+
.finally(() => setLoading(false));
|
|
44
|
+
}, [loadMore, loading, all.length]);
|
|
45
|
+
/*
|
|
46
|
+
* Nothing to show. Deliberately renders null rather than "Be the first to
|
|
47
|
+
* like this" — an empty social proof row advertises that nobody has, which
|
|
48
|
+
* is worse for the event than saying nothing at all. The like BUTTON is
|
|
49
|
+
* still there; this is only the rail beside it.
|
|
50
|
+
*/
|
|
51
|
+
if (likers.length === 0)
|
|
52
|
+
return null;
|
|
53
|
+
return (_jsxs(_Fragment, { children: [_jsxs(Row, { align: "center", gap: "sm", className: className, children: [_jsx(AvatarGroup, { size: "sm", max: likers.length, avatars: likers.map((l) => ({ src: l.photoURL, name: l.displayName, alt: l.displayName })) }), _jsxs(Text, { size: "sm", color: "muted", children: ["Liked by ", _jsx(Span, { weight: "medium", children: summarise(likers, totalCount) })] }), (hasMore || totalCount > likers.length) && (_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => setOpen(true), children: "View all" }))] }), _jsx(Modal, { isOpen: open, onClose: () => setOpen(false), title: `Liked by ${totalCount}`, children: _jsxs(Stack, { gap: "sm", children: [all.map((l) => (_jsxs(Row, { align: "center", gap: "sm", children: [_jsx(Avatar, { size: "sm", src: l.photoURL, name: l.displayName, alt: l.displayName }), _jsx(Text, { size: "sm", children: l.displayName })] }, l.uid))), more && loadMore && (_jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: handleLoadMore, isLoading: loading, children: ["Show ", LIKERS_PAGE_SIZE, " more"] }))] }) })] }));
|
|
54
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The event-type chip. Emoji plus a real label, always both.
|
|
3
|
+
*
|
|
4
|
+
* 🛑 THE LABEL IS NOT OPTIONAL, and that is the accessibility half of this
|
|
5
|
+
* component. `EventCard` rendered the type as a bare `aria-hidden` emoji with
|
|
6
|
+
* no text companion anywhere on the card, so a screen-reader user got no
|
|
7
|
+
* event type at all; the detail hero rendered the raw enum. Neither surface
|
|
8
|
+
* could reach the other's map. This is the shared one.
|
|
9
|
+
*
|
|
10
|
+
* @tag domain:events
|
|
11
|
+
* @tag layer:component
|
|
12
|
+
*/
|
|
13
|
+
import React from "react";
|
|
14
|
+
export interface EventTypeBadgeProps {
|
|
15
|
+
type: string | undefined;
|
|
16
|
+
/**
|
|
17
|
+
* Hide the text and keep only the glyph — for a dense card corner where the
|
|
18
|
+
* label is already rendered nearby.
|
|
19
|
+
*
|
|
20
|
+
* 🛑 Even then an `aria-label` carries the real label, so the type is never
|
|
21
|
+
* lost to a screen reader. A silent emoji is not a badge.
|
|
22
|
+
*/
|
|
23
|
+
compact?: boolean;
|
|
24
|
+
className?: string;
|
|
25
|
+
}
|
|
26
|
+
export declare function EventTypeBadge({ type, compact, className }: EventTypeBadgeProps): React.JSX.Element;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Span } from "../../../ui/index.js";
|
|
3
|
+
import { eventTypeMeta } from "../constants/event-type-meta.js";
|
|
4
|
+
export function EventTypeBadge({ type, compact = false, className = "" }) {
|
|
5
|
+
const meta = eventTypeMeta(type);
|
|
6
|
+
return (_jsxs(Span, { layout: "inline-flex", align: "center", gap: "xs", padding: "pill-sm", rounded: "full", size: "xs", weight: "semibold", className: `${meta.chipClass} ${className}`, "aria-label": compact ? meta.label : undefined, children: [_jsx(Span, { "aria-hidden": "true", children: meta.emoji }), !compact && meta.label] }));
|
|
7
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { eventRepository } from "../../../repositories/index.js";
|
|
3
3
|
import { AdSlot } from "../../homepage/components/AdSlot.js";
|
|
4
|
-
import { Container,
|
|
4
|
+
import { Container, Main, Section, PageHeroHeading } from "../../../ui/index.js";
|
|
5
5
|
import { EventsIndexListing } from "./EventsIndexListing.js";
|
|
6
6
|
import { EVENT_FIELDS } from "../../../constants/field-names.js";
|
|
7
7
|
import { sieveFilter, sieveMultiEq, sieveAnd, SIEVE_OP } from "../../../utils/sieve-builder.js";
|
|
@@ -72,5 +72,5 @@ export async function EventsListPageView({ searchParams = {} }) {
|
|
|
72
72
|
const publicResult = result
|
|
73
73
|
? { ...result, items: sortPinnedFirst(hidePublicTestData(result.items)) }
|
|
74
74
|
: result;
|
|
75
|
-
return (_jsx(Main, { children: _jsx(Section, { padding: "y-2xl", children: _jsxs(Container, { size: "xl", children: [_jsx(
|
|
75
|
+
return (_jsx(Main, { children: _jsx(Section, { padding: "y-2xl", children: _jsxs(Container, { size: "xl", children: [_jsx(PageHeroHeading, { className: "mb-8", pill: "Events & Offers", title: "Events", description: "Sales, spin wheels, raffles and polls \u2014 everything running right now, and what is opening next.", meta: publicResult?.total ? `${publicResult.total} running` : undefined }), _jsx(AdSlot, { id: "listing-sidebar-top", className: "mb-6" }), _jsx(EventsIndexListing, { initialData: publicResult, facetCounts: facetCounts }), _jsx(AdSlot, { id: "listing-sidebar-bottom", className: "mt-8" })] }) }) }));
|
|
76
76
|
}
|
|
@@ -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;
|