@mohasinac/appkit 4.47.0 → 4.48.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 +4 -4
- package/dist/_internal/server/features/checkout/expand-order-items.js +3 -0
- package/dist/_internal/server/features/messages/actions.d.ts +68 -0
- package/dist/_internal/server/features/messages/actions.js +249 -0
- package/dist/_internal/server/features/parts/buildable.d.ts +47 -0
- package/dist/_internal/server/features/parts/buildable.js +159 -0
- package/dist/_internal/server/features/products/list-public.d.ts +17 -0
- package/dist/_internal/server/features/products/list-public.js +92 -5
- package/dist/_internal/server/features/products/og.d.ts +7 -0
- package/dist/_internal/server/features/products/og.js +38 -0
- package/dist/_internal/server/features/site-settings/adapters.d.ts +1 -1
- package/dist/_internal/server/features/site-settings/adapters.js +6 -0
- package/dist/_internal/server/jobs/core/cleanupRtdbEvents.js +28 -0
- package/dist/_internal/server/jobs/core/notificationPrune.js +87 -3
- package/dist/_internal/server/jobs/core/priceIndexRollup.js +36 -1
- package/dist/_internal/shared/actions/action-registry.js +8 -0
- package/dist/_internal/shared/features/notifications/action-url.js +8 -0
- package/dist/_internal/shared/features/notifications/email-eligibility.js +3 -0
- package/dist/_internal/shared/features/site-settings/types.d.ts +4 -0
- package/dist/client.d.ts +10 -0
- package/dist/client.js +8 -0
- package/dist/constants/api-endpoints.d.ts +48 -0
- package/dist/constants/api-endpoints.js +30 -0
- package/dist/constants/notification-types.d.ts +1 -1
- package/dist/features/about/components/GstExplainerView.d.ts +28 -0
- package/dist/features/about/components/GstExplainerView.js +59 -0
- package/dist/features/about/components/GuidesHubView.d.ts +28 -0
- package/dist/features/about/components/GuidesHubView.js +58 -0
- package/dist/features/about/components/PublicProfileView.js +5 -0
- package/dist/features/about/components/SpotAFakeView.d.ts +29 -0
- package/dist/features/about/components/SpotAFakeView.js +49 -0
- package/dist/features/admin/actions/notification-actions.js +4 -0
- package/dist/features/admin/schemas/firestore.d.ts +13 -3
- package/dist/features/admin/schemas/firestore.js +8 -0
- package/dist/features/blog/repository/blog.repository.js +21 -7
- package/dist/features/categories/api/route.js +41 -1
- package/dist/features/categories/components/BreadcrumbTrail.d.ts +39 -17
- package/dist/features/categories/components/BreadcrumbTrail.js +46 -50
- package/dist/features/categories/components/CategoryDetailPageView.js +92 -38
- package/dist/features/categories/components/ContextualTrail.d.ts +62 -0
- package/dist/features/categories/components/ContextualTrail.js +81 -0
- package/dist/features/categories/components/LastCategoryBeacon.d.ts +18 -0
- package/dist/features/categories/components/LastCategoryBeacon.js +33 -0
- package/dist/features/categories/repository/categories.repository.d.ts +21 -0
- package/dist/features/categories/repository/categories.repository.js +36 -3
- package/dist/features/categories/repository/price-index.repository.d.ts +25 -0
- package/dist/features/email/notification-templates.js +4 -0
- package/dist/features/events/repository/events.repository.js +10 -3
- package/dist/features/faq/constants/read-caps.d.ts +29 -0
- package/dist/features/faq/constants/read-caps.js +29 -0
- package/dist/features/faq/repository/faqs.repository.d.ts +0 -2
- package/dist/features/faq/repository/faqs.repository.js +19 -16
- package/dist/features/homepage/components/AdSlot.js +10 -4
- package/dist/features/homepage/hooks/useActiveAd.d.ts +18 -1
- package/dist/features/homepage/hooks/useActiveAd.js +42 -2
- package/dist/features/homepage/lib/section-renderer.js +12 -2
- package/dist/features/layout/AppLayoutShell.d.ts +4 -1
- package/dist/features/layout/AppLayoutShell.js +2 -2
- package/dist/features/layout/TitleBarLayout.d.ts +4 -1
- package/dist/features/layout/TitleBarLayout.js +8 -4
- package/dist/features/media/AvatarUpload.js +8 -1
- package/dist/features/media/MediaAvatar.js +1 -1
- package/dist/features/media/MediaImage.js +3 -1
- package/dist/features/media/MediaLightbox.js +4 -1
- package/dist/features/media/MediaPickerModal.js +6 -1
- package/dist/features/media/MediaVideo.js +3 -1
- package/dist/features/messages/components/ConversationThreadView.d.ts +3 -0
- package/dist/features/messages/components/ConversationThreadView.js +75 -0
- package/dist/features/messages/components/ConversationsInboxView.d.ts +1 -0
- package/dist/features/messages/components/ConversationsInboxView.js +39 -0
- package/dist/features/messages/components/MessageSellerButton.d.ts +17 -0
- package/dist/features/messages/components/MessageSellerButton.js +33 -0
- package/dist/features/messages/hooks/useConversationChannel.d.ts +24 -0
- package/dist/features/messages/hooks/useConversationChannel.js +89 -0
- package/dist/features/messages/hooks/useMessages.d.ts +347 -0
- package/dist/features/messages/hooks/useMessages.js +113 -0
- package/dist/features/messages/repository/conversations.repository.d.ts +44 -0
- package/dist/features/messages/repository/conversations.repository.js +173 -0
- package/dist/features/messages/repository/messages.repository.d.ts +42 -0
- package/dist/features/messages/repository/messages.repository.js +142 -0
- package/dist/features/messages/schemas/firestore.d.ts +99 -0
- package/dist/features/messages/schemas/firestore.js +46 -0
- package/dist/features/orders/repository/orders.repository.js +21 -7
- package/dist/features/orders/schemas/firestore.d.ts +11 -0
- package/dist/features/products/components/CityCollectiblesView.d.ts +31 -0
- package/dist/features/products/components/CityCollectiblesView.js +82 -0
- package/dist/features/products/components/MarketPriceAnchor.d.ts +45 -0
- package/dist/features/products/components/MarketPriceAnchor.js +62 -0
- package/dist/features/products/components/ProductDetailPageView.js +71 -5
- package/dist/features/products/constants/meetup-cities.d.ts +47 -0
- package/dist/features/products/constants/meetup-cities.js +49 -0
- package/dist/features/products/repository/category-assignment.d.ts +82 -11
- package/dist/features/products/repository/category-assignment.js +93 -12
- package/dist/features/products/repository/products.repository.d.ts +12 -1
- package/dist/features/products/repository/products.repository.js +66 -34
- package/dist/features/products/schemas/firestore.d.ts +24 -2
- package/dist/features/products/schemas/firestore.js +13 -1
- package/dist/features/products/schemas/index.d.ts +2 -2
- package/dist/features/promotions/repository/coupons.repository.js +11 -4
- package/dist/features/reviews/repository/reviews.repository.js +11 -4
- package/dist/features/scams/repository/scammer.repository.js +21 -7
- package/dist/features/seller/components/SellerProductShell.d.ts +1 -1
- package/dist/features/seller/components/SellerProductShell.js +2 -2
- package/dist/features/seller/repository/offer.repository.js +21 -6
- package/dist/features/seller/schemas/product-form.d.ts +3 -3
- package/dist/features/seller/schemas/product-form.js +1 -1
- package/dist/features/server-errors/repository/server-errors.repository.js +11 -0
- package/dist/features/server-errors/schemas/firestore.d.ts +17 -0
- package/dist/features/server-errors/schemas/firestore.js +2 -0
- package/dist/features/store-extensions/repository/store-extensions.repositories.d.ts +17 -1
- package/dist/features/store-extensions/repository/store-extensions.repositories.js +31 -0
- package/dist/features/store-extensions/schemas/firestore.d.ts +1 -1
- package/dist/features/store-extensions/schemas/firestore.js +4 -0
- package/dist/features/stores/repository/store.repository.js +11 -3
- package/dist/features/tester/seed-data/authored/community-support__messages.d.ts +2 -0
- package/dist/features/tester/seed-data/authored/community-support__messages.js +200 -0
- package/dist/features/tester/seed-data/authored/index.js +2 -0
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +64 -1
- package/dist/index.d.ts +8 -2
- package/dist/index.js +1 -1
- package/dist/next/routing/route-map.d.ts +14 -0
- package/dist/next/routing/route-map.js +26 -0
- package/dist/providers/db-firebase/rtdb-paths.d.ts +8 -0
- package/dist/providers/db-firebase/rtdb-paths.js +8 -0
- package/dist/repositories/index.d.ts +1 -0
- package/dist/repositories/index.js +4 -0
- package/dist/schemas/registry.d.ts +3 -3
- package/dist/security/index.d.ts +1 -1
- package/dist/security/index.js +1 -1
- package/dist/security/pii-schemas.d.ts +17 -0
- package/dist/security/pii-schemas.js +15 -7
- package/dist/seed/_helpers/category-content-generated.d.ts +3 -3
- package/dist/seed/_helpers/category-content-generated.js +23 -7
- package/dist/seed/_helpers/category-models.d.ts +1 -1
- package/dist/seed/_helpers/category-models.js +25 -1
- package/dist/seed/_helpers/category-names.d.ts +54 -0
- package/dist/seed/_helpers/category-names.js +118 -0
- package/dist/seed/_helpers/character-features.d.ts +1 -1
- package/dist/seed/_helpers/character-features.js +5 -4
- package/dist/seed/_helpers/final-sale-fixtures.js +1 -1
- package/dist/seed/categories-seed-data.js +55 -1
- package/dist/seed/grouped-listings-seed-data.js +2 -2
- package/dist/seed/products-art-seed-data.js +2 -1
- package/dist/seed/products-auctions-seed-data.js +42 -22
- package/dist/seed/products-classifieds-seed-data.js +21 -3
- package/dist/seed/products-digital-codes-seed-data.js +2 -1
- package/dist/seed/products-live-items-seed-data.js +2 -1
- package/dist/seed/products-preorders-seed-data.js +26 -11
- package/dist/seed/products-prize-draws-seed-data.js +8 -1
- package/dist/seed/products-standard-seed-data.js +26 -25
- package/dist/seed/products-stickers-seed-data.js +12 -11
- package/dist/server.d.ts +8 -0
- package/dist/server.js +15 -0
- package/dist/styles.css +113 -1
- package/dist/tailwind-utilities.css +1 -1
- package/dist/tokens/themes/sunset.js +17 -0
- package/dist/tokens/tokens.css +5 -0
- package/dist/ui/components/Breadcrumb.d.ts +8 -1
- package/dist/ui/components/Breadcrumb.js +2 -2
- package/dist/ui/components/Breadcrumb.style.css +37 -0
- package/dist/ui/components/Details.d.ts +11 -1
- package/dist/ui/components/Details.js +2 -2
- package/dist/ui/components/ImageMosaic.d.ts +43 -0
- package/dist/ui/components/ImageMosaic.js +46 -0
- package/dist/ui/components/ImageMosaic.style.css +69 -0
- package/dist/ui/components/SplitTile.d.ts +34 -0
- package/dist/ui/components/SplitTile.js +28 -0
- package/dist/ui/components/index.style.css +1 -0
- package/dist/ui/index.d.ts +4 -0
- package/dist/ui/index.js +4 -0
- package/dist/utils/search-txt-query.d.ts +77 -0
- package/dist/utils/search-txt-query.js +50 -28
- package/package.json +1 -1
|
@@ -381,6 +381,35 @@ export const AD_ENDPOINTS = {
|
|
|
381
381
|
ACTIVE_BY_SLOT: (slotId) => `/api/ads?slot=${encodeURIComponent(slotId)}`,
|
|
382
382
|
};
|
|
383
383
|
// ---------------------------------------------------------------------------
|
|
384
|
+
// Messages — D9 chat
|
|
385
|
+
// ---------------------------------------------------------------------------
|
|
386
|
+
export const MESSAGE_ENDPOINTS = {
|
|
387
|
+
LIST: (opts) => {
|
|
388
|
+
const params = new URLSearchParams();
|
|
389
|
+
if (opts?.limit !== undefined)
|
|
390
|
+
params.set("limit", String(opts.limit));
|
|
391
|
+
if (opts?.cursor)
|
|
392
|
+
params.set("cursor", opts.cursor);
|
|
393
|
+
const qs = params.toString();
|
|
394
|
+
return `/api/messages${qs ? `?${qs}` : ""}`;
|
|
395
|
+
},
|
|
396
|
+
OPEN: "/api/messages",
|
|
397
|
+
OPEN_WITH_SELLER: "/api/messages/with-seller",
|
|
398
|
+
THREAD: (conversationId, opts) => {
|
|
399
|
+
const params = new URLSearchParams();
|
|
400
|
+
if (opts?.limit !== undefined)
|
|
401
|
+
params.set("limit", String(opts.limit));
|
|
402
|
+
if (opts?.cursor)
|
|
403
|
+
params.set("cursor", opts.cursor);
|
|
404
|
+
const qs = params.toString();
|
|
405
|
+
return `/api/messages/${conversationId}${qs ? `?${qs}` : ""}`;
|
|
406
|
+
},
|
|
407
|
+
READ: (conversationId) => `/api/messages/${conversationId}/read`,
|
|
408
|
+
BLOCK: (conversationId) => `/api/messages/${conversationId}/block`,
|
|
409
|
+
TOKEN: (conversationId) => `/api/messages/${conversationId}/token`,
|
|
410
|
+
UNREAD_COUNT: "/api/messages/unread-count",
|
|
411
|
+
};
|
|
412
|
+
// ---------------------------------------------------------------------------
|
|
384
413
|
// Events
|
|
385
414
|
// ---------------------------------------------------------------------------
|
|
386
415
|
export const EVENT_ENDPOINTS = {
|
|
@@ -680,6 +709,7 @@ export const API_ENDPOINTS = {
|
|
|
680
709
|
CORPORATE: CORPORATE_ENDPOINTS,
|
|
681
710
|
CONTACT: CONTACT_ENDPOINTS,
|
|
682
711
|
ADS: AD_ENDPOINTS,
|
|
712
|
+
MESSAGES: MESSAGE_ENDPOINTS,
|
|
683
713
|
EVENTS: EVENT_ENDPOINTS,
|
|
684
714
|
LOTTERY: LOTTERY_ENDPOINTS,
|
|
685
715
|
FAQS: FAQ_ENDPOINTS,
|
|
@@ -29,7 +29,7 @@ import type { NotificationType } from "../features/admin/schemas/firestore";
|
|
|
29
29
|
export { NOTIFICATION_TYPE_VALUES };
|
|
30
30
|
export type { NotificationType };
|
|
31
31
|
/** Alias kept for the two existing importers. */
|
|
32
|
-
export declare const NOTIFICATION_TYPES: readonly ["order_placed", "order_confirmed", "order_shipped", "order_delivered", "order_cancelled", "bid_placed", "bid_outbid", "bid_won", "bid_lost", "auction_ended", "review_approved", "review_replied", "product_available", "promotion", "system", "welcome", "account_action", "support_ticket_update", "scam_report_update", "offer_received", "offer_responded", "offer_expired", "offer_counter_accepted", "refund_initiated", "prize_won", "prize_reveal_expired", "emi_installment_due_soon", "emi_installment_overdue", "payment_review", "catalogue_images_stale"];
|
|
32
|
+
export declare const NOTIFICATION_TYPES: readonly ["order_placed", "order_confirmed", "order_shipped", "order_delivered", "order_cancelled", "bid_placed", "bid_outbid", "bid_won", "bid_lost", "auction_ended", "review_approved", "review_replied", "product_available", "promotion", "system", "welcome", "account_action", "support_ticket_update", "scam_report_update", "offer_received", "offer_responded", "offer_expired", "offer_counter_accepted", "refund_initiated", "prize_won", "prize_reveal_expired", "emi_installment_due_soon", "emi_installment_overdue", "payment_review", "catalogue_images_stale", "message_received"];
|
|
33
33
|
/**
|
|
34
34
|
* `order_shipped` → `Order shipped`.
|
|
35
35
|
*
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface GstExplainerViewProps {
|
|
2
|
+
}
|
|
3
|
+
/**
|
|
4
|
+
* D6b — how GST works on LetItRip, for buyers and for sellers.
|
|
5
|
+
*
|
|
6
|
+
* ## 🛑 Why this page is a differentiator rather than table stakes
|
|
7
|
+
*
|
|
8
|
+
* The round-2 crawl checked seventeen Indian collectibles sites. Exactly ONE
|
|
9
|
+
* (`otakuisland.in`) states tax-inclusive pricing to the buyer in words — *"All
|
|
10
|
+
* prices include GST and there are no hidden charges."* The other sixteen say
|
|
11
|
+
* nothing at all, which means a buyer cannot tell whether the number on the card
|
|
12
|
+
* is what they will pay. Saying it plainly, with the rate bands and who bears
|
|
13
|
+
* what, is the cheapest trust signal available here.
|
|
14
|
+
*
|
|
15
|
+
* ## 🛑 What this page deliberately does NOT claim
|
|
16
|
+
*
|
|
17
|
+
* It does not say "your invoice carries GST today". Charging GST requires a
|
|
18
|
+
* registered GSTIN on `siteSettings.gst`, and `validateGstEnablement` runs a
|
|
19
|
+
* real checksum and refuses an invented one — so until that is entered, orders
|
|
20
|
+
* compute tax at 0 and the Tax row does not render. A page asserting otherwise
|
|
21
|
+
* would be a false statement about a compliance matter, which is the one place
|
|
22
|
+
* this codebase cannot afford confident prose. Everything below is written as
|
|
23
|
+
* "when GST applies", which is true either way.
|
|
24
|
+
*
|
|
25
|
+
* Every figure traces to the model written down in the plan's C0b/C0c and to
|
|
26
|
+
* `taxCodes` — nothing here is an invented rate.
|
|
27
|
+
*/
|
|
28
|
+
export declare function GstExplainerView({}?: GstExplainerViewProps): Promise<import("react").JSX.Element>;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { ROUTES } from "../../../constants/index.js";
|
|
3
|
+
import { PAGE_CONTAINER } from "../../../_internal/shared/styles/page.js";
|
|
4
|
+
import { THEMED_BG_PRIMARY, THEMED_BG_SECONDARY } from "../../../_internal/shared/styles/themed.js";
|
|
5
|
+
import { Alert, Caption, Div, Heading, Row, Section, Stack, Table, Tbody, Td, Text, TextLink, Th, Thead, Tr, } from "../../../ui/index.js";
|
|
6
|
+
const __P = {
|
|
7
|
+
p5: "p-[var(--appkit-space-5)]",
|
|
8
|
+
};
|
|
9
|
+
const __O = {
|
|
10
|
+
xAuto: "overflow-x-auto",
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* D6b — how GST works on LetItRip, for buyers and for sellers.
|
|
14
|
+
*
|
|
15
|
+
* ## 🛑 Why this page is a differentiator rather than table stakes
|
|
16
|
+
*
|
|
17
|
+
* The round-2 crawl checked seventeen Indian collectibles sites. Exactly ONE
|
|
18
|
+
* (`otakuisland.in`) states tax-inclusive pricing to the buyer in words — *"All
|
|
19
|
+
* prices include GST and there are no hidden charges."* The other sixteen say
|
|
20
|
+
* nothing at all, which means a buyer cannot tell whether the number on the card
|
|
21
|
+
* is what they will pay. Saying it plainly, with the rate bands and who bears
|
|
22
|
+
* what, is the cheapest trust signal available here.
|
|
23
|
+
*
|
|
24
|
+
* ## 🛑 What this page deliberately does NOT claim
|
|
25
|
+
*
|
|
26
|
+
* It does not say "your invoice carries GST today". Charging GST requires a
|
|
27
|
+
* registered GSTIN on `siteSettings.gst`, and `validateGstEnablement` runs a
|
|
28
|
+
* real checksum and refuses an invented one — so until that is entered, orders
|
|
29
|
+
* compute tax at 0 and the Tax row does not render. A page asserting otherwise
|
|
30
|
+
* would be a false statement about a compliance matter, which is the one place
|
|
31
|
+
* this codebase cannot afford confident prose. Everything below is written as
|
|
32
|
+
* "when GST applies", which is true either way.
|
|
33
|
+
*
|
|
34
|
+
* Every figure traces to the model written down in the plan's C0b/C0c and to
|
|
35
|
+
* `taxCodes` — nothing here is an invented rate.
|
|
36
|
+
*/
|
|
37
|
+
export async function GstExplainerView({} = {}) {
|
|
38
|
+
const page = { container: PAGE_CONTAINER };
|
|
39
|
+
const themed = { bgPrimary: THEMED_BG_PRIMARY, bgSecondary: THEMED_BG_SECONDARY };
|
|
40
|
+
const { getTranslations } = await import("next-intl/server");
|
|
41
|
+
const t = await getTranslations("gstGuide");
|
|
42
|
+
/*
|
|
43
|
+
* The rate bands, which are a property of the HSN code a category carries —
|
|
44
|
+
* not of the seller and not of the buyer's state. Stated as bands rather than
|
|
45
|
+
* as one number because the catalogue genuinely spans three.
|
|
46
|
+
*/
|
|
47
|
+
const RATE_ROWS = [
|
|
48
|
+
{ band: t("band1Label"), rate: "0%", examples: t("band1Examples"), hsn: "—" },
|
|
49
|
+
{ band: t("band2Label"), rate: "5%", examples: t("band2Examples"), hsn: "9503 0020" },
|
|
50
|
+
{ band: t("band3Label"), rate: "18%", examples: t("band3Examples"), hsn: "9503 0010" },
|
|
51
|
+
];
|
|
52
|
+
const WHO_PAYS = [
|
|
53
|
+
{ line: t("whoPays1Item"), who: t("whoPays1Who"), note: t("whoPays1Note") },
|
|
54
|
+
{ line: t("whoPays2Item"), who: t("whoPays2Who"), note: t("whoPays2Note") },
|
|
55
|
+
{ line: t("whoPays3Item"), who: t("whoPays3Who"), note: t("whoPays3Note") },
|
|
56
|
+
{ line: t("whoPays4Item"), who: t("whoPays4Who"), note: t("whoPays4Note") },
|
|
57
|
+
];
|
|
58
|
+
return (_jsxs(Div, { children: [_jsx(Section, { className: themed.bgPrimary, padding: "y-2xl", children: _jsxs(Div, { className: page.container.lg, children: [_jsx(Heading, { level: 1, size: "3xl", mdSize: "4xl", weight: "bold", className: "mb-3", children: t("title") }), _jsx(Text, { size: "lg", color: "muted", className: "max-w-3xl", children: t("intro") })] }) }), _jsx(Section, { padding: "y-xl", children: _jsxs(Div, { className: page.container.lg, children: [_jsxs(Alert, { variant: "info", className: "mb-8", children: [_jsx(Text, { weight: "semibold", children: t("inclusiveTitle") }), _jsx(Text, { size: "sm", children: t("inclusiveText") })] }), _jsx(Heading, { level: 2, size: "xl", weight: "semibold", className: "mb-4", children: t("ratesTitle") }), _jsx(Text, { color: "muted", className: "mb-4 max-w-3xl", children: t("ratesIntro") }), _jsx(Div, { className: __O.xAuto, children: _jsxs(Table, { size: "sm", children: [_jsx(Thead, { children: _jsxs(Tr, { border: "default", children: [_jsx(Th, { children: t("colBand") }), _jsx(Th, { children: t("colRate") }), _jsx(Th, { children: t("colExamples") }), _jsx(Th, { children: t("colHsn") })] }) }), _jsx(Tbody, { children: RATE_ROWS.map((r) => (_jsxs(Tr, { border: "subtle", children: [_jsx(Td, { padding: "sm", children: r.band }), _jsx(Td, { padding: "sm", children: _jsx(Text, { weight: "semibold", children: r.rate }) }), _jsx(Td, { padding: "sm", color: "muted", children: r.examples }), _jsx(Td, { padding: "sm", color: "muted", children: r.hsn })] }, r.band))) })] }) }), _jsx(Caption, { className: "mt-2", children: t("ratesCaption") })] }) }), _jsx(Section, { className: themed.bgSecondary, padding: "y-xl", children: _jsxs(Div, { className: page.container.lg, children: [_jsx(Heading, { level: 2, size: "xl", weight: "semibold", className: "mb-4", children: t("whoPaysTitle") }), _jsx(Div, { className: __O.xAuto, children: _jsxs(Table, { size: "sm", children: [_jsx(Thead, { children: _jsxs(Tr, { border: "default", children: [_jsx(Th, { children: t("colLine") }), _jsx(Th, { children: t("colWho") }), _jsx(Th, { children: t("colNote") })] }) }), _jsx(Tbody, { children: WHO_PAYS.map((r) => (_jsxs(Tr, { border: "subtle", children: [_jsx(Td, { padding: "sm", children: r.line }), _jsx(Td, { padding: "sm", children: _jsx(Text, { weight: "semibold", children: r.who }) }), _jsx(Td, { padding: "sm", color: "muted", children: r.note })] }, r.line))) })] }) })] }) }), _jsx(Section, { padding: "y-xl", children: _jsxs(Div, { className: page.container.lg, children: [_jsxs(Stack, { gap: "lg", children: [_jsxs(Div, { className: __P.p5, surface: "card", rounded: "xl", border: "subtle", children: [_jsx(Heading, { level: 3, size: "lg", weight: "semibold", className: "mb-2", children: t("splitTitle") }), _jsx(Text, { color: "muted", children: t("splitText") })] }), _jsxs(Div, { className: __P.p5, surface: "card", rounded: "xl", border: "subtle", children: [_jsx(Heading, { level: 3, size: "lg", weight: "semibold", className: "mb-2", children: t("invoiceTitle") }), _jsx(Text, { color: "muted", children: t("invoiceText") })] }), _jsxs(Div, { className: __P.p5, surface: "card", rounded: "xl", border: "subtle", children: [_jsx(Heading, { level: 3, size: "lg", weight: "semibold", className: "mb-2", children: t("sellerTitle") }), _jsx(Text, { color: "muted", children: t("sellerText") })] })] }), _jsxs(Row, { gap: "md", wrap: true, className: "mt-8", children: [_jsx(TextLink, { href: String(ROUTES.PUBLIC.FEES), children: t("linkFees") }), _jsx(TextLink, { href: String(ROUTES.PUBLIC.HOW_PAYOUTS_WORK), children: t("linkPayouts") }), _jsx(TextLink, { href: String(ROUTES.PUBLIC.FAQS), children: t("linkFaqs") })] })] }) })] }));
|
|
59
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* D6 — the content hub: every explainer this site owns, in one place.
|
|
3
|
+
*
|
|
4
|
+
* ## 🛑 Why an index page is worth its own route here
|
|
5
|
+
*
|
|
6
|
+
* There are **fourteen** explainers (`/how-auctions-work` … `/fees`, `/gst`,
|
|
7
|
+
* `/spot-a-fake`, the two seller guides) and the only way to reach most of them
|
|
8
|
+
* is the footer. A footer link is reachable but it is not a *destination*: it
|
|
9
|
+
* cannot rank, it cannot be linked to from a blog post as "our guides", and it
|
|
10
|
+
* gives a crawler no page that establishes the topical cluster these fourteen
|
|
11
|
+
* form. worldhobbyshop's measured SEO edge is category bodies plus internal
|
|
12
|
+
* linking depth, and this is the cheapest internal-linking structure available
|
|
13
|
+
* — one page that links fourteen, each of which links back.
|
|
14
|
+
*
|
|
15
|
+
* ## 🛑 The list is hand-curated and that is deliberate
|
|
16
|
+
*
|
|
17
|
+
* It would be tidier to derive it from `FOOTER_LINK_GROUPS`, and wrong: that
|
|
18
|
+
* array mixes browse surfaces, legal pages and dashboards with the explainers,
|
|
19
|
+
* so deriving would need a filter which is itself a second hand-maintained
|
|
20
|
+
* list — one keyed on nothing. A guide either belongs in a learning index or it
|
|
21
|
+
* does not, and that is an editorial judgement, not a derivation.
|
|
22
|
+
*
|
|
23
|
+
* Every href is a `ROUTES` constant, so `audit-nav-page-wiring` fails the build
|
|
24
|
+
* if any of them stops resolving to a real page.
|
|
25
|
+
*/
|
|
26
|
+
export interface GuidesHubViewProps {
|
|
27
|
+
}
|
|
28
|
+
export declare function GuidesHubView({}?: GuidesHubViewProps): Promise<import("react").JSX.Element>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { ROUTES } from "../../../constants/index.js";
|
|
3
|
+
import { PAGE_CONTAINER } from "../../../_internal/shared/styles/page.js";
|
|
4
|
+
import { THEMED_BG_PRIMARY } from "../../../_internal/shared/styles/themed.js";
|
|
5
|
+
import { Div, Grid, Heading, Section, Text, TextLink } from "../../../ui/index.js";
|
|
6
|
+
const __P = {
|
|
7
|
+
p5: "p-[var(--appkit-space-5)]",
|
|
8
|
+
};
|
|
9
|
+
export async function GuidesHubView({} = {}) {
|
|
10
|
+
const page = { container: PAGE_CONTAINER };
|
|
11
|
+
const { getTranslations } = await import("next-intl/server");
|
|
12
|
+
const t = await getTranslations("guidesHub");
|
|
13
|
+
const GROUPS = [
|
|
14
|
+
{
|
|
15
|
+
heading: t("groupBuyingTitle"),
|
|
16
|
+
blurb: t("groupBuyingBlurb"),
|
|
17
|
+
items: [
|
|
18
|
+
{ href: String(ROUTES.PUBLIC.HOW_AUCTIONS_WORK), label: t("howAuctions"), text: t("howAuctionsText") },
|
|
19
|
+
{ href: String(ROUTES.PUBLIC.HOW_OFFERS_WORK), label: t("howOffers"), text: t("howOffersText") },
|
|
20
|
+
{ href: String(ROUTES.PUBLIC.HOW_PRE_ORDERS_WORK), label: t("howPreOrders"), text: t("howPreOrdersText") },
|
|
21
|
+
{ href: String(ROUTES.PUBLIC.HOW_CHECKOUT_WORKS), label: t("howCheckout"), text: t("howCheckoutText") },
|
|
22
|
+
{ href: String(ROUTES.PUBLIC.HOW_EMI_WORKS), label: t("howEmi"), text: t("howEmiText") },
|
|
23
|
+
{ href: String(ROUTES.PUBLIC.HOW_ORDERS_WORK), label: t("howOrders"), text: t("howOrdersText") },
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
heading: t("groupMoneyTitle"),
|
|
28
|
+
blurb: t("groupMoneyBlurb"),
|
|
29
|
+
items: [
|
|
30
|
+
{ href: String(ROUTES.PUBLIC.FEES), label: t("fees"), text: t("feesText") },
|
|
31
|
+
{ href: String(ROUTES.PUBLIC.GST), label: t("gst"), text: t("gstText") },
|
|
32
|
+
{ href: String(ROUTES.PUBLIC.HOW_PAYOUTS_WORK), label: t("howPayouts"), text: t("howPayoutsText") },
|
|
33
|
+
{ href: String(ROUTES.PUBLIC.SHIPPING_POLICY), label: t("shipping"), text: t("shippingText") },
|
|
34
|
+
{ href: String(ROUTES.PUBLIC.REFUND_POLICY), label: t("refunds"), text: t("refundsText") },
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
heading: t("groupTrustTitle"),
|
|
39
|
+
blurb: t("groupTrustBlurb"),
|
|
40
|
+
items: [
|
|
41
|
+
{ href: String(ROUTES.PUBLIC.SPOT_A_FAKE), label: t("spotAFake"), text: t("spotAFakeText") },
|
|
42
|
+
{ href: String(ROUTES.PUBLIC.SCAMS), label: t("scams"), text: t("scamsText") },
|
|
43
|
+
{ href: String(ROUTES.PUBLIC.HOW_REVIEWS_WORK), label: t("howReviews"), text: t("howReviewsText") },
|
|
44
|
+
{ href: String(ROUTES.PUBLIC.SECURITY), label: t("security"), text: t("securityText") },
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
heading: t("groupSellingTitle"),
|
|
49
|
+
blurb: t("groupSellingBlurb"),
|
|
50
|
+
items: [
|
|
51
|
+
{ href: String(ROUTES.PUBLIC.SELLER_GUIDE), label: t("sellerGuide"), text: t("sellerGuideText") },
|
|
52
|
+
{ href: String(ROUTES.PUBLIC.SELLER_GUIDE_BUNDLES), label: t("bundleGuide"), text: t("bundleGuideText") },
|
|
53
|
+
{ href: String(ROUTES.PUBLIC.SELLER_GUIDE_PRIZE_DRAWS), label: t("prizeDrawGuide"), text: t("prizeDrawGuideText") },
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
];
|
|
57
|
+
return (_jsxs(Div, { children: [_jsx(Section, { className: THEMED_BG_PRIMARY, padding: "y-2xl", children: _jsxs(Div, { className: page.container.lg, children: [_jsx(Heading, { level: 1, size: "3xl", mdSize: "4xl", weight: "bold", className: "mb-3", children: t("title") }), _jsx(Text, { size: "lg", color: "muted", className: "max-w-3xl", children: t("intro") })] }) }), GROUPS.map((g) => (_jsx(Section, { padding: "y-xl", children: _jsxs(Div, { className: page.container.lg, children: [_jsx(Heading, { level: 2, size: "xl", weight: "semibold", className: "mb-1", children: g.heading }), _jsx(Text, { color: "muted", className: "mb-4", children: g.blurb }), _jsx(Grid, { cols: "cards", gap: "md", children: g.items.map((i) => (_jsxs(Div, { className: __P.p5, surface: "card", rounded: "xl", border: "subtle", children: [_jsx(TextLink, { href: i.href, weight: "semibold", children: i.label }), _jsx(Text, { size: "sm", color: "muted", className: "mt-1", children: i.text })] }, i.href))) })] }) }, g.heading)))] }));
|
|
58
|
+
}
|
|
@@ -30,6 +30,11 @@ function toProductItem(p) {
|
|
|
30
30
|
slug: p.slug,
|
|
31
31
|
description: p.description,
|
|
32
32
|
price: p.price,
|
|
33
|
+
/* The strike-through "was" price. The SHARED `toProductItem` already
|
|
34
|
+
* carried it and this local copy did not, so a discounted listing lost
|
|
35
|
+
* its saving on a public profile — the exact drift the comment below
|
|
36
|
+
* warns about, demonstrated once. */
|
|
37
|
+
originalPrice: p.originalPrice,
|
|
33
38
|
currency: p.currency,
|
|
34
39
|
mainImage: p.mainImage,
|
|
35
40
|
images: p.images,
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export interface SpotAFakeViewProps {
|
|
2
|
+
}
|
|
3
|
+
/**
|
|
4
|
+
* D6 — the spot-a-fake guide.
|
|
5
|
+
*
|
|
6
|
+
* ## 🛑 The framing is the finding, and it is not the obvious one
|
|
7
|
+
*
|
|
8
|
+
* The crawl's counterfeit intelligence (plan section 3.7) measured something
|
|
9
|
+
* counter-intuitive: the largest seller of copies in this market makes **ZERO
|
|
10
|
+
* authenticity claims**. It sells itself as an "aftermarket brand" at a
|
|
11
|
+
* fraction of the real price and leaves the inference to the buyer. So a guide
|
|
12
|
+
* written as "here is how they pretend to be real" would be describing a
|
|
13
|
+
* deception that is not happening, and would teach the wrong signals.
|
|
14
|
+
*
|
|
15
|
+
* What actually distinguishes them is silence plus price, then code, then
|
|
16
|
+
* weight — which is why this page is ordered that way and why the authenticity
|
|
17
|
+
* section explains that **"unknown" is a legitimate answer**. A guide that
|
|
18
|
+
* treated every non-original as a fraud would push honest second-hand sellers
|
|
19
|
+
* into overclaiming, which makes the catalogue less truthful, not more.
|
|
20
|
+
*
|
|
21
|
+
* ## 🛑 Every signal here is one the SITE can back up
|
|
22
|
+
*
|
|
23
|
+
* The price band comes from the realised-price index, the printed codes come
|
|
24
|
+
* from the canonical corpus, and the part weights come from the part glossary —
|
|
25
|
+
* 963 slots carrying their weight as fitted. A guide that said "check the
|
|
26
|
+
* weight" without somewhere to check it against would be advice shaped like
|
|
27
|
+
* help, so each signal links to the surface that answers it.
|
|
28
|
+
*/
|
|
29
|
+
export declare function SpotAFakeView({}?: SpotAFakeViewProps): Promise<import("react").JSX.Element>;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { ROUTES } from "../../../constants/index.js";
|
|
3
|
+
import { PAGE_CONTAINER } from "../../../_internal/shared/styles/page.js";
|
|
4
|
+
import { THEMED_BG_PRIMARY, THEMED_BG_SECONDARY } from "../../../_internal/shared/styles/themed.js";
|
|
5
|
+
import { Alert, Div, Heading, Row, Section, Stack, Text, TextLink, } from "../../../ui/index.js";
|
|
6
|
+
const __P = {
|
|
7
|
+
p5: "p-[var(--appkit-space-5)]",
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* D6 — the spot-a-fake guide.
|
|
11
|
+
*
|
|
12
|
+
* ## 🛑 The framing is the finding, and it is not the obvious one
|
|
13
|
+
*
|
|
14
|
+
* The crawl's counterfeit intelligence (plan section 3.7) measured something
|
|
15
|
+
* counter-intuitive: the largest seller of copies in this market makes **ZERO
|
|
16
|
+
* authenticity claims**. It sells itself as an "aftermarket brand" at a
|
|
17
|
+
* fraction of the real price and leaves the inference to the buyer. So a guide
|
|
18
|
+
* written as "here is how they pretend to be real" would be describing a
|
|
19
|
+
* deception that is not happening, and would teach the wrong signals.
|
|
20
|
+
*
|
|
21
|
+
* What actually distinguishes them is silence plus price, then code, then
|
|
22
|
+
* weight — which is why this page is ordered that way and why the authenticity
|
|
23
|
+
* section explains that **"unknown" is a legitimate answer**. A guide that
|
|
24
|
+
* treated every non-original as a fraud would push honest second-hand sellers
|
|
25
|
+
* into overclaiming, which makes the catalogue less truthful, not more.
|
|
26
|
+
*
|
|
27
|
+
* ## 🛑 Every signal here is one the SITE can back up
|
|
28
|
+
*
|
|
29
|
+
* The price band comes from the realised-price index, the printed codes come
|
|
30
|
+
* from the canonical corpus, and the part weights come from the part glossary —
|
|
31
|
+
* 963 slots carrying their weight as fitted. A guide that said "check the
|
|
32
|
+
* weight" without somewhere to check it against would be advice shaped like
|
|
33
|
+
* help, so each signal links to the surface that answers it.
|
|
34
|
+
*/
|
|
35
|
+
export async function SpotAFakeView({} = {}) {
|
|
36
|
+
const page = { container: PAGE_CONTAINER };
|
|
37
|
+
const themed = { bgPrimary: THEMED_BG_PRIMARY, bgSecondary: THEMED_BG_SECONDARY };
|
|
38
|
+
const { getTranslations } = await import("next-intl/server");
|
|
39
|
+
const t = await getTranslations("spotAFake");
|
|
40
|
+
const SIGNALS = [
|
|
41
|
+
{ n: 1, title: t("signal1Title"), text: t("signal1Text"), href: String(ROUTES.PUBLIC.TOOL_PRICE_INDEX) },
|
|
42
|
+
{ n: 2, title: t("signal2Title"), text: t("signal2Text"), href: null },
|
|
43
|
+
{ n: 3, title: t("signal3Title"), text: t("signal3Text"), href: String(ROUTES.PUBLIC.PARTS) },
|
|
44
|
+
{ n: 4, title: t("signal4Title"), text: t("signal4Text"), href: null },
|
|
45
|
+
{ n: 5, title: t("signal5Title"), text: t("signal5Text"), href: null },
|
|
46
|
+
{ n: 6, title: t("signal6Title"), text: t("signal6Text"), href: null },
|
|
47
|
+
];
|
|
48
|
+
return (_jsxs(Div, { children: [_jsx(Section, { className: themed.bgPrimary, padding: "y-2xl", children: _jsxs(Div, { className: page.container.lg, children: [_jsx(Heading, { level: 1, size: "3xl", mdSize: "4xl", weight: "bold", className: "mb-3", children: t("title") }), _jsx(Text, { size: "lg", color: "muted", className: "max-w-3xl", children: t("intro") })] }) }), _jsx(Section, { padding: "y-xl", children: _jsxs(Div, { className: page.container.lg, children: [_jsxs(Alert, { variant: "warning", className: "mb-8", children: [_jsx(Text, { weight: "semibold", children: t("whyTitle") }), _jsx(Text, { size: "sm", children: t("whyText") })] }), _jsx(Heading, { level: 2, size: "xl", weight: "semibold", className: "mb-4", children: t("signalsTitle") }), _jsx(Stack, { gap: "md", children: SIGNALS.map((s) => (_jsxs(Div, { className: __P.p5, surface: "card", rounded: "xl", border: "subtle", children: [_jsx(Heading, { level: 3, size: "lg", weight: "semibold", className: "mb-1", children: `${s.n}. ${s.title}` }), _jsx(Text, { color: "muted", children: s.text }), s.href && (_jsx(Div, { padding: "t-xs", children: _jsx(TextLink, { href: s.href, size: "sm", children: s.n === 1 ? t("linkTools") : t("linkParts") }) }))] }, s.n))) })] }) }), _jsx(Section, { className: themed.bgSecondary, padding: "y-xl", children: _jsx(Div, { className: page.container.lg, children: _jsxs(Stack, { gap: "lg", children: [_jsxs(Div, { className: __P.p5, surface: "card", rounded: "xl", border: "subtle", children: [_jsx(Heading, { level: 3, size: "lg", weight: "semibold", className: "mb-2", children: t("authenticityTitle") }), _jsx(Text, { color: "muted", children: t("authenticityText") })] }), _jsxs(Div, { className: __P.p5, surface: "card", rounded: "xl", border: "subtle", children: [_jsx(Heading, { level: 3, size: "lg", weight: "semibold", className: "mb-2", children: t("reportTitle") }), _jsx(Text, { color: "muted", children: t("reportText") }), _jsxs(Row, { gap: "md", wrap: true, className: "mt-3", children: [_jsx(TextLink, { href: String(ROUTES.PUBLIC.REPORT_ENTITY), children: t("linkReport") }), _jsx(TextLink, { href: String(ROUTES.PUBLIC.SCAMS), children: t("linkScams") })] })] })] }) }) })] }));
|
|
49
|
+
}
|
|
@@ -246,6 +246,10 @@ export async function sendNotification(input) {
|
|
|
246
246
|
*/
|
|
247
247
|
support_ticket_update: "system",
|
|
248
248
|
scam_report_update: "system",
|
|
249
|
+
// D9 — same reasoning: a tenth key would have no stored value on any
|
|
250
|
+
// existing user, and a chat message is a reply to a thread the user is
|
|
251
|
+
// already a participant in, not something to silence independently.
|
|
252
|
+
message_received: "system",
|
|
249
253
|
};
|
|
250
254
|
const typeKey = typeToPrefsKey[type];
|
|
251
255
|
// If the user has explicitly disabled this notification type, skip all external channels.
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import type { AboutContentDocument } from "../../about/schemas/firestore";
|
|
6
6
|
export declare const ADMIN_CHECKOUT_BYPASS_FLAG_KEY: "adminCheckoutBypass";
|
|
7
|
-
export declare const NOTIFICATION_TYPE_VALUES: readonly ["order_placed", "order_confirmed", "order_shipped", "order_delivered", "order_cancelled", "bid_placed", "bid_outbid", "bid_won", "bid_lost", "auction_ended", "review_approved", "review_replied", "product_available", "promotion", "system", "welcome", "account_action", "support_ticket_update", "scam_report_update", "offer_received", "offer_responded", "offer_expired", "offer_counter_accepted", "refund_initiated", "prize_won", "prize_reveal_expired", "emi_installment_due_soon", "emi_installment_overdue", "payment_review", "catalogue_images_stale"];
|
|
7
|
+
export declare const NOTIFICATION_TYPE_VALUES: readonly ["order_placed", "order_confirmed", "order_shipped", "order_delivered", "order_cancelled", "bid_placed", "bid_outbid", "bid_won", "bid_lost", "auction_ended", "review_approved", "review_replied", "product_available", "promotion", "system", "welcome", "account_action", "support_ticket_update", "scam_report_update", "offer_received", "offer_responded", "offer_expired", "offer_counter_accepted", "refund_initiated", "prize_won", "prize_reveal_expired", "emi_installment_due_soon", "emi_installment_overdue", "payment_review", "catalogue_images_stale", "message_received"];
|
|
8
8
|
export type NotificationType = (typeof NOTIFICATION_TYPE_VALUES)[number];
|
|
9
9
|
/**
|
|
10
10
|
* What a notification points AT. Drives the shared `actionUrl` resolver, so a
|
|
@@ -14,7 +14,7 @@ export type NotificationType = (typeof NOTIFICATION_TYPE_VALUES)[number];
|
|
|
14
14
|
* Runtime array for the same reason as the type union above — and
|
|
15
15
|
* `catalogueItem` was being sent with an `as never` cast rather than added.
|
|
16
16
|
*/
|
|
17
|
-
export declare const NOTIFICATION_RELATED_TYPE_VALUES: readonly ["order", "product", "bid", "review", "blog", "user", "offer", "support_ticket", "scammer", "catalogueItem", "payout", "store"];
|
|
17
|
+
export declare const NOTIFICATION_RELATED_TYPE_VALUES: readonly ["order", "product", "bid", "review", "blog", "user", "offer", "support_ticket", "scammer", "catalogueItem", "payout", "store", "conversation"];
|
|
18
18
|
export type NotificationRelatedType = (typeof NOTIFICATION_RELATED_TYPE_VALUES)[number];
|
|
19
19
|
import type { BaseDocument } from "../../../_internal/shared/types/base-document";
|
|
20
20
|
export type NotificationPriority = "low" | "normal" | "high";
|
|
@@ -77,7 +77,7 @@ export type NotificationUpdateInput = Partial<Pick<NotificationDocument, "isRead
|
|
|
77
77
|
export declare const notificationQueryHelpers: {
|
|
78
78
|
readonly byUser: (userId: string) => readonly ["userId", "==", string];
|
|
79
79
|
readonly unread: (userId: string) => readonly [readonly ["userId", "==", string], readonly ["isRead", "==", false]];
|
|
80
|
-
readonly byType: (type: NotificationType) => readonly ["type", "==", "welcome" | "promotion" | "order_placed" | "order_confirmed" | "order_shipped" | "order_delivered" | "order_cancelled" | "bid_placed" | "bid_outbid" | "bid_won" | "bid_lost" | "auction_ended" | "review_approved" | "review_replied" | "product_available" | "system" | "account_action" | "support_ticket_update" | "scam_report_update" | "offer_received" | "offer_responded" | "offer_expired" | "offer_counter_accepted" | "refund_initiated" | "prize_won" | "prize_reveal_expired" | "emi_installment_due_soon" | "emi_installment_overdue" | "payment_review" | "catalogue_images_stale"];
|
|
80
|
+
readonly byType: (type: NotificationType) => readonly ["type", "==", "welcome" | "promotion" | "order_placed" | "order_confirmed" | "order_shipped" | "order_delivered" | "order_cancelled" | "bid_placed" | "bid_outbid" | "bid_won" | "bid_lost" | "auction_ended" | "review_approved" | "review_replied" | "product_available" | "system" | "account_action" | "support_ticket_update" | "scam_report_update" | "offer_received" | "offer_responded" | "offer_expired" | "offer_counter_accepted" | "refund_initiated" | "prize_won" | "prize_reveal_expired" | "emi_installment_due_soon" | "emi_installment_overdue" | "payment_review" | "catalogue_images_stale" | "message_received"];
|
|
81
81
|
};
|
|
82
82
|
export interface TrustBarItem {
|
|
83
83
|
icon: string;
|
|
@@ -515,6 +515,16 @@ export interface SiteSettingsDocument extends BaseDocument {
|
|
|
515
515
|
sms: number;
|
|
516
516
|
};
|
|
517
517
|
};
|
|
518
|
+
/**
|
|
519
|
+
* D9 — chat. Off by default, and `undefined` reads as OFF (never a
|
|
520
|
+
* default-true `??`) — a real setting with a real cost: every conversation
|
|
521
|
+
* is a Firestore subcollection plus an RTDB ping channel, and the feature
|
|
522
|
+
* was deleted once already for shipping broken and earning low usage it
|
|
523
|
+
* never had a chance to prove.
|
|
524
|
+
*/
|
|
525
|
+
chat?: {
|
|
526
|
+
enabled: boolean;
|
|
527
|
+
};
|
|
518
528
|
emailSettings: {
|
|
519
529
|
fromName: string;
|
|
520
530
|
fromEmail: string;
|
|
@@ -84,6 +84,12 @@ export const NOTIFICATION_TYPE_VALUES = [
|
|
|
84
84
|
// it stayed off this union: the cast silenced the one signal that would
|
|
85
85
|
// have said so, and with it every other field's type-check on that call.
|
|
86
86
|
"catalogue_images_stale",
|
|
87
|
+
/**
|
|
88
|
+
* D9 — a new chat message. Bell-only — `EMAIL_ELIGIBLE_TYPES` is `false`,
|
|
89
|
+
* following `support_ticket_update`'s recorded reasoning: the conversation
|
|
90
|
+
* lives in the UI, not the inbox.
|
|
91
|
+
*/
|
|
92
|
+
"message_received",
|
|
87
93
|
];
|
|
88
94
|
/**
|
|
89
95
|
* What a notification points AT. Drives the shared `actionUrl` resolver, so a
|
|
@@ -106,6 +112,8 @@ export const NOTIFICATION_RELATED_TYPE_VALUES = [
|
|
|
106
112
|
"catalogueItem",
|
|
107
113
|
"payout",
|
|
108
114
|
"store",
|
|
115
|
+
// D9 — the chat thread the message belongs to.
|
|
116
|
+
"conversation",
|
|
109
117
|
];
|
|
110
118
|
export const NOTIFICATIONS_COLLECTION = "notifications";
|
|
111
119
|
export const NOTIFICATIONS_INDEXED_FIELDS = [
|
|
@@ -5,7 +5,7 @@ import { BLOG_POST_FIELDS, BLOG_POSTS_COLLECTION, } from "../schemas/index.js";
|
|
|
5
5
|
import { createBlogPostId } from "../schemas/index.js";
|
|
6
6
|
import { increment } from "../../../contracts/field-ops.js";
|
|
7
7
|
import { buildBlogSearchTxt } from "../../../utils/search-txt-builders.js";
|
|
8
|
-
import { planSearchTxt,
|
|
8
|
+
import { planSearchTxt, beginSearchWindow, emptySearchResult, } from "../../../utils/search-txt-query.js";
|
|
9
9
|
class BlogRepository extends BaseRepository {
|
|
10
10
|
constructor() {
|
|
11
11
|
super(BLOG_POSTS_COLLECTION);
|
|
@@ -172,12 +172,19 @@ class BlogRepository extends BaseRepository {
|
|
|
172
172
|
if (plan.head) {
|
|
173
173
|
baseQuery = baseQuery.where(BLOG_POST_FIELDS.SEARCH_TXT, "array-contains", plan.head);
|
|
174
174
|
}
|
|
175
|
-
|
|
175
|
+
/*
|
|
176
|
+
* One bounded window, one raised `maxPageSize`, one page slice — see
|
|
177
|
+
* `beginSearchWindow`. Hand-writing the three is how the other ten
|
|
178
|
+
* repositories each ended up with two of them, which reads as a working
|
|
179
|
+
* multi-word search and silently cannot reach page 2.
|
|
180
|
+
*/
|
|
181
|
+
const win = beginSearchWindow(model, plan);
|
|
182
|
+
const result = await this.sieveQuery(win.model, BlogRepository.SIEVE_FIELDS, {
|
|
176
183
|
baseQuery,
|
|
177
184
|
defaultPageSize: 12,
|
|
178
|
-
maxPageSize: 50,
|
|
185
|
+
maxPageSize: win.maxPageSize(50),
|
|
179
186
|
});
|
|
180
|
-
return
|
|
187
|
+
return win.finish(result);
|
|
181
188
|
}
|
|
182
189
|
/** Derived on every write path via `applyWriteHooks`. */
|
|
183
190
|
buildSearchTxtFor(data) {
|
|
@@ -191,12 +198,19 @@ class BlogRepository extends BaseRepository {
|
|
|
191
198
|
if (plan.head) {
|
|
192
199
|
baseQuery = baseQuery.where(BLOG_POST_FIELDS.SEARCH_TXT, "array-contains", plan.head);
|
|
193
200
|
}
|
|
194
|
-
|
|
201
|
+
/*
|
|
202
|
+
* One bounded window, one raised `maxPageSize`, one page slice — see
|
|
203
|
+
* `beginSearchWindow`. Hand-writing the three is how the other ten
|
|
204
|
+
* repositories each ended up with two of them, which reads as a working
|
|
205
|
+
* multi-word search and silently cannot reach page 2.
|
|
206
|
+
*/
|
|
207
|
+
const win = beginSearchWindow(model, plan);
|
|
208
|
+
const result = await this.sieveQuery(win.model, BlogRepository.SIEVE_FIELDS, {
|
|
195
209
|
baseQuery,
|
|
196
210
|
defaultPageSize: 50,
|
|
197
|
-
maxPageSize: 200,
|
|
211
|
+
maxPageSize: win.maxPageSize(200),
|
|
198
212
|
});
|
|
199
|
-
return
|
|
213
|
+
return win.finish(result);
|
|
200
214
|
}
|
|
201
215
|
}
|
|
202
216
|
BlogRepository.SIEVE_FIELDS = {
|
|
@@ -18,11 +18,50 @@ import { mediaUrlSchema } from "../../../validation/schemas.js";
|
|
|
18
18
|
import { getProviders } from "../../../contracts/index.js";
|
|
19
19
|
import { createRouteHandler } from "../../../next/api/routeHandler.js";
|
|
20
20
|
import { toCategoryListItem } from "../../../_internal/server/features/categories/adapters.js";
|
|
21
|
+
import { categoriesRepository } from "../repository/categories.repository.js";
|
|
21
22
|
import { isListingCategory } from "../constants/listing-categories.js";
|
|
22
23
|
import { planSearchTxt } from "../../../utils/search-txt-query.js";
|
|
23
24
|
import { matchesAllSearchTerms } from "../../../utils/search-txt.js";
|
|
24
25
|
import { normalizeError } from "../../../errors/normalize.js";
|
|
25
26
|
const CACHE_CONTROL_PUBLIC = "public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400";
|
|
27
|
+
/**
|
|
28
|
+
* Overlay the LIVE rolled-up count onto a projected page of categories.
|
|
29
|
+
*
|
|
30
|
+
* 🛑 Why this route and not the grid component: `/categories` renders entirely
|
|
31
|
+
* client-side from this endpoint (its page.tsx does no Firestore read at all,
|
|
32
|
+
* deliberately, so the route can stay static), so this is the only server-side
|
|
33
|
+
* place the numbers it shows pass through. `metrics.totalProductCount` is
|
|
34
|
+
* written by the nightly reconciler ALONE, so every multi-category surface was
|
|
35
|
+
* up to a day behind the category's own page, which counts live.
|
|
36
|
+
*
|
|
37
|
+
* 🛑 An id with no counts document keeps its `metrics` value. It does NOT
|
|
38
|
+
* become 0 — a category whose counter has never been written would otherwise
|
|
39
|
+
* render a confident zero, which is the regression Root Cause #72 names and
|
|
40
|
+
* would make this change worse than leaving it stale.
|
|
41
|
+
*
|
|
42
|
+
* Bounded by construction: the caller has already paginated, and
|
|
43
|
+
* `getLiveCategoryCounts` hard-caps the batch at 200 ids in ONE `getAll`.
|
|
44
|
+
*/
|
|
45
|
+
async function withLiveTotals(items) {
|
|
46
|
+
if (items.length === 0)
|
|
47
|
+
return items;
|
|
48
|
+
const live = await categoriesRepository.liveTotalsFor(items.map((i) => i.id));
|
|
49
|
+
if (live.size === 0)
|
|
50
|
+
return items;
|
|
51
|
+
return items.map((item) => {
|
|
52
|
+
const total = live.get(item.id);
|
|
53
|
+
/*
|
|
54
|
+
* `metrics` is optional on `CategoryItem`, and an item without it is left
|
|
55
|
+
* exactly as it is rather than gaining a half-built one — a metrics object
|
|
56
|
+
* carrying only `totalProductCount` would read as "0 products, N in
|
|
57
|
+
* total", which is a worse answer than the absent object the renderer
|
|
58
|
+
* already handles.
|
|
59
|
+
*/
|
|
60
|
+
if (total === undefined || !item.metrics)
|
|
61
|
+
return item;
|
|
62
|
+
return { ...item, metrics: { ...item.metrics, totalProductCount: total } };
|
|
63
|
+
});
|
|
64
|
+
}
|
|
26
65
|
function buildTreeFromFlat(items, rootId) {
|
|
27
66
|
const byId = new Map(items.map((i) => [i.id, i]));
|
|
28
67
|
// Roots: tier === 0 or no parentIds
|
|
@@ -251,6 +290,7 @@ export async function GET(request) {
|
|
|
251
290
|
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
|
252
291
|
.map(toCategoryListItem);
|
|
253
292
|
}
|
|
293
|
+
items = await withLiveTotals(items);
|
|
254
294
|
const totalPages = total === 0 ? 0 : Math.max(1, Math.ceil(total / perPage));
|
|
255
295
|
/*
|
|
256
296
|
* Shape switches on `?page=`, deliberately, and only there.
|
|
@@ -287,7 +327,7 @@ export async function GET(request) {
|
|
|
287
327
|
// Project BEFORE nesting — `CategoryTreeNode extends CategoryItem`, so the
|
|
288
328
|
// children carry the public shape too rather than being raw documents
|
|
289
329
|
// reachable one level down.
|
|
290
|
-
const allItems = allResult.data.map(toCategoryListItem);
|
|
330
|
+
const allItems = await withLiveTotals(allResult.data.map(toCategoryListItem));
|
|
291
331
|
if (tree === "true" || !flat) {
|
|
292
332
|
const treeNodes = buildTreeFromFlat(allItems, rootId);
|
|
293
333
|
const res = NextResponse.json({ success: true, data: treeNodes });
|
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import type { CategoryItem } from "../types";
|
|
3
|
+
import { type BreadcrumbCrumb } from "../utils/breadcrumb-chain";
|
|
3
4
|
export interface BreadcrumbTrailProps {
|
|
4
5
|
/**
|
|
5
|
-
* The
|
|
6
|
-
*
|
|
6
|
+
* The chain to render, root-first, with the final crumb carrying no `url`.
|
|
7
|
+
*
|
|
8
|
+
* 🛑 PREFER THIS over `category`. It is the same array the page hands to
|
|
9
|
+
* `breadcrumbJsonLd`, which is what makes the visible trail, the markup and
|
|
10
|
+
* `categoryNames` agree by construction rather than by discipline — D4b's
|
|
11
|
+
* rule. Before this prop existed the category page and the product page each
|
|
12
|
+
* hand-rolled their own `<Nav>`, and the product one rendered three crumbs
|
|
13
|
+
* (`Home / Products / Category`) while its JSON-LD carried all eight.
|
|
14
|
+
*/
|
|
15
|
+
crumbs?: readonly BreadcrumbCrumb[];
|
|
16
|
+
/**
|
|
17
|
+
* Legacy input: a category whose `ancestors` array becomes the chain.
|
|
18
|
+
* Kept because it is the published prop shape; `crumbs` wins when both are
|
|
19
|
+
* passed.
|
|
7
20
|
*/
|
|
8
|
-
category
|
|
21
|
+
category?: CategoryItem;
|
|
9
22
|
/**
|
|
10
23
|
* Label for the implicit root item prepended to the trail.
|
|
11
24
|
* Default: "All Categories"
|
|
@@ -31,10 +44,10 @@ export interface BreadcrumbTrailProps {
|
|
|
31
44
|
/**
|
|
32
45
|
* I5b — collapse the middle of a long trail behind a dropdown.
|
|
33
46
|
*
|
|
34
|
-
* 🛑 Needed independently of multi-assignment: a tier-4 part leaf is
|
|
35
|
-
* crumbs (`
|
|
36
|
-
* Performance Tips > Rubber Flat
|
|
37
|
-
* that depth and never addressed how it wraps.
|
|
47
|
+
* 🛑 Needed independently of multi-assignment: a tier-4 part leaf is EIGHT
|
|
48
|
+
* crumbs (`Home > Categories > Spinning Tops > Beyblade Metal > Metal Parts >
|
|
49
|
+
* Performance Tips > Rubber Flat > <title>`) and 390px cannot hold it. D4b
|
|
50
|
+
* measured that depth and never addressed how it wraps.
|
|
38
51
|
*
|
|
39
52
|
* Defaults ON, because the only trails long enough to collapse are the ones
|
|
40
53
|
* that already overflow.
|
|
@@ -51,21 +64,30 @@ export interface BreadcrumbTrailProps {
|
|
|
51
64
|
ids?: readonly string[] | null;
|
|
52
65
|
names?: readonly string[] | null;
|
|
53
66
|
};
|
|
67
|
+
/**
|
|
68
|
+
* `"on-media"` when the trail sits over a cover photo — the category hero
|
|
69
|
+
* does. Forwarded to the `Breadcrumb` primitive, which owns the ink.
|
|
70
|
+
*/
|
|
71
|
+
tone?: "default" | "on-media";
|
|
54
72
|
}
|
|
55
73
|
/**
|
|
56
|
-
* BreadcrumbTrail —
|
|
74
|
+
* BreadcrumbTrail — one row of crumbs, with the middle and the alternates
|
|
75
|
+
* collapsed behind real `<details>` dropdowns.
|
|
76
|
+
*
|
|
77
|
+
* 🛑 BOTH DROPDOWNS CONTAIN REAL `<a>` ELEMENTS IN THE SERVER-RENDERED DOM.
|
|
78
|
+
* `<details>`/`<summary>` is native, needs no JavaScript, works on a keyboard,
|
|
79
|
+
* and — the reason it was chosen over a hover menu — a crawler parsing the HTML
|
|
80
|
+
* sees every link whether or not anything ever opens it. Links injected on
|
|
81
|
+
* open would trade the SEO this exists for against tidiness, which is the
|
|
82
|
+
* opposite of *"better for both crawler and users"*.
|
|
57
83
|
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
84
|
+
* 🛑 Collapsing is PRESENTATION ONLY. The JSON-LD is emitted by the page from
|
|
85
|
+
* the full chain and must stay that way: a crawler never receives a truncated
|
|
86
|
+
* hierarchy because a phone could not show it.
|
|
61
87
|
*
|
|
62
88
|
* @example
|
|
63
89
|
* ```tsx
|
|
64
|
-
* <BreadcrumbTrail
|
|
65
|
-
* category={category}
|
|
66
|
-
* rootHref="/categories"
|
|
67
|
-
* basePath="/categories"
|
|
68
|
-
* />
|
|
90
|
+
* <BreadcrumbTrail crumbs={withTrailHead(categoryCrumbs(category), HEAD)} />
|
|
69
91
|
* ```
|
|
70
92
|
*/
|
|
71
|
-
export declare function BreadcrumbTrail({ category, rootLabel, rootHref, basePath, showCurrent, className, collapse, alternates, }: BreadcrumbTrailProps): React.JSX.Element;
|
|
93
|
+
export declare function BreadcrumbTrail({ crumbs, category, rootLabel, rootHref, basePath, showCurrent, className, collapse, alternates, tone, }: BreadcrumbTrailProps): React.JSX.Element | null;
|