@mohasinac/appkit 4.9.5 → 4.10.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/_internal/server/features/auctions/actions.js +3 -2
- package/dist/_internal/server/features/auctions/service.d.ts +4 -3
- package/dist/_internal/server/features/auctions/service.js +6 -6
- package/dist/_internal/server/features/bundles/data.d.ts +9 -0
- package/dist/_internal/server/features/bundles/data.js +14 -0
- package/dist/_internal/server/features/bundles/index.d.ts +1 -1
- package/dist/_internal/server/features/bundles/index.js +1 -1
- package/dist/_internal/server/features/catalogue/og.d.ts +20 -0
- package/dist/_internal/server/features/catalogue/og.js +27 -0
- package/dist/_internal/server/features/grouped/data.d.ts +12 -0
- package/dist/_internal/server/features/grouped/data.js +26 -1
- package/dist/_internal/server/features/grouped/index.d.ts +1 -1
- package/dist/_internal/server/features/grouped/index.js +1 -1
- package/dist/_internal/server/features/products/data.d.ts +23 -0
- package/dist/_internal/server/features/products/data.js +159 -0
- package/dist/_internal/server/features/products/index.d.ts +1 -1
- package/dist/_internal/server/features/products/index.js +1 -1
- package/dist/_internal/shared/features/auctions/config.d.ts +20 -0
- package/dist/_internal/shared/features/auctions/config.js +31 -1
- package/dist/_internal/shared/listing-types/art/config.d.ts +1 -1
- package/dist/_internal/shared/listing-types/art/config.js +1 -1
- package/dist/_internal/shared/listing-types/classified/config.d.ts +1 -1
- package/dist/_internal/shared/listing-types/classified/config.js +1 -1
- package/dist/_internal/shared/listing-types/digital-code/config.d.ts +1 -1
- package/dist/_internal/shared/listing-types/digital-code/config.js +1 -1
- package/dist/_internal/shared/listing-types/live/config.d.ts +1 -1
- package/dist/_internal/shared/listing-types/live/config.js +1 -1
- package/dist/_internal/shared/listing-types/prize-draw/config.d.ts +1 -1
- package/dist/_internal/shared/listing-types/prize-draw/config.js +1 -1
- package/dist/_internal/shared/listing-types/stickers/config.d.ts +1 -1
- package/dist/_internal/shared/listing-types/stickers/config.js +1 -1
- package/dist/client.d.ts +0 -6
- package/dist/client.js +1 -4
- package/dist/constants/api-endpoints.d.ts +3 -0
- package/dist/constants/api-endpoints.js +1 -0
- package/dist/features/admin/components/AdminSiteSettingsView.js +12 -4
- package/dist/features/admin/schemas/firestore.d.ts +9 -0
- package/dist/features/admin/schemas/firestore.js +6 -0
- package/dist/features/auctions/actions/bid-actions.js +4 -1
- package/dist/features/auctions/components/AuctionBottomActions.d.ts +2 -1
- package/dist/features/auctions/components/AuctionBottomActions.js +14 -1
- package/dist/features/auctions/components/AuctionDetailPageView.js +21 -9
- package/dist/features/auctions/components/LiveMinIncrement.d.ts +18 -0
- package/dist/features/auctions/components/LiveMinIncrement.js +17 -0
- package/dist/features/auctions/components/PlaceBidFormClient.d.ts +5 -2
- package/dist/features/auctions/components/PlaceBidFormClient.js +7 -3
- package/dist/features/catalogue/components/PublicCatalogueItemDetailView.d.ts +11 -0
- package/dist/features/catalogue/components/PublicCatalogueItemDetailView.js +33 -0
- package/dist/features/catalogue/components/PublicCatalogueView.js +2 -1
- package/dist/features/catalogue/index.d.ts +2 -0
- package/dist/features/catalogue/index.js +1 -0
- package/dist/features/categories/components/BundleDetailView.d.ts +3 -1
- package/dist/features/categories/components/BundleDetailView.js +3 -2
- package/dist/features/classified/components/ClassifiedContactSellerPanel.d.ts +12 -0
- package/dist/features/classified/components/ClassifiedContactSellerPanel.js +49 -0
- package/dist/features/classified/components/ClassifiedDetailPageView.d.ts +7 -0
- package/dist/features/classified/components/ClassifiedDetailPageView.js +69 -0
- package/dist/features/contact/components/ContactPageView.js +8 -2
- package/dist/features/digital-codes/components/DigitalCodeDetailPageView.d.ts +19 -0
- package/dist/features/digital-codes/components/DigitalCodeDetailPageView.js +79 -0
- package/dist/features/grouped/components/GroupedListingsCarousel.d.ts +13 -0
- package/dist/features/grouped/components/GroupedListingsCarousel.js +31 -0
- package/dist/features/grouped/repository/grouped-listings.repository.d.ts +8 -0
- package/dist/features/grouped/repository/grouped-listings.repository.js +16 -0
- package/dist/features/layout/BottomActions.js +4 -3
- package/dist/features/layout/BottomActionsContext.d.ts +10 -0
- package/dist/features/layout/BottomActionsContext.js +5 -1
- package/dist/features/layout/hooks/useBottomActions.d.ts +2 -0
- package/dist/features/layout/hooks/useBottomActions.js +4 -1
- package/dist/features/live/components/LiveItemDetailPageView.d.ts +9 -0
- package/dist/features/live/components/LiveItemDetailPageView.js +74 -0
- package/dist/features/media/MediaVideo.js +17 -1
- package/dist/features/pre-orders/components/PreOrderDetailPageView.js +9 -1
- package/dist/features/products/components/PrizeDrawDetailPageView.js +11 -1
- package/dist/features/products/components/ProductDetailPageView.js +11 -116
- package/dist/features/products/components/RelatedItemsSection.d.ts +16 -0
- package/dist/features/products/components/RelatedItemsSection.js +17 -0
- package/dist/features/seller/components/SellerProductShell.js +1 -1
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +83 -3
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/next/routing/route-map.d.ts +2 -0
- package/dist/next/routing/route-map.js +7 -0
- package/dist/seed/bids-seed-data.js +48 -3
- package/dist/seed/grouped-listings-seed-data.js +167 -0
- package/dist/seed/payouts-seed-data.js +4 -4
- package/dist/seed/products-art-seed-data.js +188 -5
- package/dist/seed/products-auctions-seed-data.js +215 -6
- package/dist/seed/products-classifieds-seed-data.js +255 -8
- package/dist/seed/products-digital-codes-seed-data.js +258 -19
- package/dist/seed/products-live-items-seed-data.js +143 -10
- package/dist/seed/products-preorders-seed-data.js +213 -8
- package/dist/seed/products-prize-draws-seed-data.js +198 -0
- package/dist/seed/products-standard-seed-data.js +141 -0
- package/dist/seed/products-stickers-seed-data.js +178 -5
- package/dist/seed/site-settings-seed-data.js +10 -29
- package/dist/seed/store-extensions-seed-data.js +1 -1
- package/dist/server-entry.d.ts +4 -1
- package/dist/server-entry.js +4 -1
- package/dist/server.d.ts +4 -2
- package/dist/server.js +4 -2
- package/dist/styles.css +29 -1
- package/dist/tailwind-utilities.css +1 -1
- package/dist/ui/components/ImageLightbox.js +37 -3
- package/dist/ui/components/ImageLightbox.style.css +28 -0
- package/dist/utils/media-url.d.ts +9 -0
- package/dist/utils/media-url.js +39 -0
- package/package.json +1 -1
|
@@ -4,12 +4,13 @@ import { useState, useTransition } from "react";
|
|
|
4
4
|
import { useRouter } from "next/navigation";
|
|
5
5
|
import { formatCurrency } from "../../../utils/number.formatter";
|
|
6
6
|
import { isAuthError } from "../../../utils/auth-error";
|
|
7
|
-
import { Button, Div, LoginRequiredModal, Modal, Row, Span, Stack, Text } from "../../../ui";
|
|
7
|
+
import { Button, CountdownDisplay, Div, LoginRequiredModal, Modal, Row, Span, Stack, Text } from "../../../ui";
|
|
8
8
|
import { Form } from "../../../ui/components/Form";
|
|
9
9
|
import { FieldInput } from "../../../ui/forms/FieldInput";
|
|
10
10
|
import { applyZodIssues } from "../../../ui/forms/FormShell";
|
|
11
11
|
import { placeBidSchema } from "../schemas/bid-input";
|
|
12
12
|
import { useLiveAuctionBid } from "../hooks/useLiveAuctionBid";
|
|
13
|
+
import { resolveMinBidIncrement, } from "../../../_internal/shared/features/auctions/config";
|
|
13
14
|
import { normalizeError } from "../../../errors/normalize";
|
|
14
15
|
const __P = {
|
|
15
16
|
p5: "p-[var(--appkit-space-5)]",
|
|
@@ -21,13 +22,16 @@ const BID_ERROR_DISPLAY = {
|
|
|
21
22
|
BID_SELF_BID: "You cannot bid on your own listing.",
|
|
22
23
|
BID_USER_BANNED: "Your account is currently restricted from bidding. Contact support.",
|
|
23
24
|
};
|
|
24
|
-
export function PlaceBidFormClient({ productId, currentBid: ssrCurrentBid, startingBid,
|
|
25
|
+
export function PlaceBidFormClient({ productId, currentBid: ssrCurrentBid, startingBid, tiers, minBidIncrementOverride, currency, isEnded, auctionEndDate, buyNowPrice, bidsHaveStarted = false, bidCount: ssrBidCount, tags = [], onPlaceBid, onBuyNow, }) {
|
|
25
26
|
const router = useRouter();
|
|
26
27
|
// Live-updates while the bid card is open — reflects other bidders'
|
|
27
28
|
// activity on this auction, not just the bidder's own submission.
|
|
28
29
|
const live = useLiveAuctionBid(productId, ssrCurrentBid, ssrBidCount, { enabled: !isEnded });
|
|
29
30
|
const currentBid = live.currentBid;
|
|
30
31
|
const bidCount = live.bidCount;
|
|
32
|
+
// Tiered, floor-raising: resolves fresh whenever the live current bid
|
|
33
|
+
// crosses a tier boundary while this modal is open.
|
|
34
|
+
const minBidIncrement = resolveMinBidIncrement(currentBid, tiers, minBidIncrementOverride);
|
|
31
35
|
const minBid = currentBid + minBidIncrement;
|
|
32
36
|
const [bidAmount, setBidAmount] = useState(String(minBid));
|
|
33
37
|
const [stepMul, setStepMul] = useState(1);
|
|
@@ -110,7 +114,7 @@ export function PlaceBidFormClient({ productId, currentBid: ssrCurrentBid, start
|
|
|
110
114
|
}, children: ["+", formatCurrency(minBidIncrement * n, currency)] }, n))), _jsx(Button, { type: "button", variant: stepMul === "custom" ? "primary" : "ghost", size: "sm", role: "radio", "aria-checked": stepMul === "custom", disabled: isEnded || isPending, onClick: () => setStepMul("custom"), children: "Custom" })] }), _jsx(FieldInput, { name: "bidAmount", type: "number", value: bidAmount, readOnly: stepMul !== "custom", onChange: (value) => {
|
|
111
115
|
setBidAmount(value);
|
|
112
116
|
clearErrors();
|
|
113
|
-
}, placeholder: `At least ${formatCurrency(minBid, currency)}`, min: minBid, "aria-label": "Your bid amount", disabled: isEnded || isPending }), stepMul === "custom" && (_jsxs(Text, { size: "xs", color: "muted", "aria-live": "polite", children: ["Any amount \u2265 ", formatCurrency(minBid, currency), " is accepted."] })), success && (_jsx(Text, { className: "text-success", size: "xs", children: "\u2713 Bid placed successfully!" })), _jsx(Button, { variant: "primary", size: "md", className: "w-full", disabled: isEnded || isPending, isLoading: isPending, type: "button", onClick: () => {
|
|
117
|
+
}, placeholder: `At least ${formatCurrency(minBid, currency)}`, min: minBid, "aria-label": "Your bid amount", disabled: isEnded || isPending }), stepMul === "custom" && (_jsxs(Text, { size: "xs", color: "muted", "aria-live": "polite", children: ["Any amount \u2265 ", formatCurrency(minBid, currency), " is accepted."] })), success && (_jsx(Text, { className: "text-success", size: "xs", children: "\u2713 Bid placed successfully!" })), !isEnded && auctionEndDate && (_jsxs(Text, { align: "center", size: "xs", color: "muted", children: ["Ends in ", _jsx(CountdownDisplay, { targetDate: auctionEndDate, format: "auto", expiredLabel: "Ended" })] })), _jsx(Button, { variant: "primary", size: "md", className: "w-full", disabled: isEnded || isPending, isLoading: isPending, type: "button", onClick: () => {
|
|
114
118
|
clearErrors();
|
|
115
119
|
const parsed = schema.safeParse({ bidAmount });
|
|
116
120
|
if (!parsed.success)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
export interface PublicCatalogueItemDetailViewProps {
|
|
3
|
+
ownerSlug: string;
|
|
4
|
+
itemId: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Public detail page for a single personal-catalogue item — previously had
|
|
8
|
+
* no route or view at all; the public catalogue grid rendered bare, unlinked
|
|
9
|
+
* cards (see Recurrent Root Cause sweep, 2026-08-20 session).
|
|
10
|
+
*/
|
|
11
|
+
export declare function PublicCatalogueItemDetailView({ ownerSlug, itemId }: PublicCatalogueItemDetailViewProps): React.JSX.Element;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { useQuery } from "@tanstack/react-query";
|
|
5
|
+
import { Container, Div, Grid, Heading, Main, Row, Section, Skeleton, Span, Stack, Text } from "../../../ui";
|
|
6
|
+
import { MediaImage } from "../../media/MediaImage";
|
|
7
|
+
import { apiClient } from "../../../http";
|
|
8
|
+
import { ACCOUNT_ENDPOINTS } from "../../../constants/api-endpoints";
|
|
9
|
+
import { formatCurrency } from "../../../utils/number.formatter";
|
|
10
|
+
import { ROUTES } from "../../../next/routing/route-map";
|
|
11
|
+
const __O = {
|
|
12
|
+
hidden: "overflow-hidden",
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Public detail page for a single personal-catalogue item — previously had
|
|
16
|
+
* no route or view at all; the public catalogue grid rendered bare, unlinked
|
|
17
|
+
* cards (see Recurrent Root Cause sweep, 2026-08-20 session).
|
|
18
|
+
*/
|
|
19
|
+
export function PublicCatalogueItemDetailView({ ownerSlug, itemId }) {
|
|
20
|
+
const { data, isLoading, isError } = useQuery({
|
|
21
|
+
queryKey: ["catalogue", "public-item", ownerSlug, itemId],
|
|
22
|
+
queryFn: () => apiClient.get(ACCOUNT_ENDPOINTS.PUBLIC_CATALOGUE_ITEM(ownerSlug, itemId)),
|
|
23
|
+
});
|
|
24
|
+
if (isLoading) {
|
|
25
|
+
return (_jsx(Main, { children: _jsx(Section, { padding: "y-xl", children: _jsx(Container, { size: "md", children: _jsx(Skeleton, { variant: "rectangular", height: 400 }) }) }) }));
|
|
26
|
+
}
|
|
27
|
+
if (isError || !data?.item) {
|
|
28
|
+
return (_jsx(Main, { children: _jsx(Section, { padding: "y-5xl", children: _jsx(Container, { size: "md", children: _jsxs(Stack, { align: "start", gap: "md", className: "text-left", children: [_jsx(Heading, { level: 1, size: "2xl", weight: "semibold", color: "primary", children: "Item Not Found" }), _jsx(Text, { color: "muted", children: "This catalogue item may have been made private or removed." }), _jsx(Link, { href: `${ROUTES.PUBLIC.PROFILE(ownerSlug)}/catalogue`, className: "text-[length:var(--appkit-text-sm)] font-medium text-primary-600 hover:underline", children: "Back to catalogue" })] }) }) }) }));
|
|
29
|
+
}
|
|
30
|
+
const { owner, item, linkedProductHref } = data;
|
|
31
|
+
const images = item.images.length > 0 ? item.images : item.mainImage ? [item.mainImage] : [];
|
|
32
|
+
return (_jsx(Main, { children: _jsxs(Container, { size: "lg", padding: "y-lg", children: [_jsxs(Row, { className: "mb-4", align: "center", gap: "xs", textSize: "xs", color: "muted", children: [_jsx(Link, { href: "/", className: "hover:text-primary-600 transition-colors", children: "Home" }), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: `${ROUTES.PUBLIC.PROFILE(ownerSlug)}/catalogue`, className: "hover:text-primary-600 transition-colors", children: owner.displayName ?? "Catalogue" }), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Span, { className: "truncate max-w-[200px]", color: "muted", children: item.title })] }), _jsxs(Grid, { cols: 2, gap: "lg", children: [images.length > 0 ? (_jsx(Div, { className: `relative aspect-square w-full ${__O.hidden}`, rounded: "xl", surface: "muted", children: _jsx(MediaImage, { src: images[0], alt: item.title, size: "hero", objectFit: "contain" }) })) : (_jsx(Div, { className: "relative aspect-square w-full", rounded: "xl", surface: "muted" })), images.length > 1 && (_jsx(Row, { gap: "sm", wrap: true, className: "col-span-2", children: images.slice(1).map((src, i) => (_jsx(Div, { className: `relative h-20 w-20 ${__O.hidden}`, rounded: "lg", surface: "muted", children: _jsx(MediaImage, { src: src, alt: `${item.title} photo ${i + 2}`, size: "thumbnail", objectFit: "cover" }) }, i))) })), _jsxs(Stack, { gap: "md", children: [_jsx(Heading, { level: 1, size: "2xl", weight: "bold", color: "primary", children: item.title }), typeof item.price === "number" && item.price > 0 && (_jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "2xl", weight: "bold", color: "primary", children: formatCurrency(item.price) }), _jsx(Text, { size: "xs", color: "muted", children: "Estimated value \u2014 not for direct sale from this page." })] })), _jsxs(Row, { gap: "sm", wrap: true, children: [item.condition && (_jsx(Span, { size: "xs", weight: "medium", padding: "pill-sm", rounded: "full", surface: "subtle", color: "muted", children: item.condition })), typeof item.quantity === "number" && (_jsxs(Span, { size: "xs", weight: "medium", padding: "pill-sm", rounded: "full", surface: "subtle", color: "muted", children: ["Qty: ", item.quantity] }))] }), item.description && (_jsx(Text, { color: "muted", className: "whitespace-pre-line", children: item.description })), owner.displayName && (_jsxs(Div, { border: "subtle", rounded: "xl", surface: "muted", padding: "md", children: [_jsx(Text, { className: "text-[10px] tracking-wide mb-0.5", color: "faint", transform: "uppercase", children: "Owned by" }), _jsx(Text, { size: "sm", weight: "semibold", color: "primary", children: owner.displayName })] })), linkedProductHref && (_jsx(Link, { href: linkedProductHref, className: "inline-flex w-fit items-center rounded-lg bg-primary px-[var(--appkit-space-4)] py-[var(--appkit-space-2)] text-[length:var(--appkit-text-sm)] font-semibold text-white transition-colors hover:opacity-90", children: "View live listing \u2192" }))] })] })] }) }));
|
|
33
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import Link from "next/link";
|
|
3
4
|
import { useQuery } from "@tanstack/react-query";
|
|
4
5
|
import { Div, Grid, Heading, Skeleton, Text } from "../../../ui";
|
|
5
6
|
import { MediaImage } from "../../media/MediaImage";
|
|
@@ -18,5 +19,5 @@ export function PublicCatalogueView({ ownerSlug }) {
|
|
|
18
19
|
if (items.length === 0) {
|
|
19
20
|
return _jsx(Text, { variant: "secondary", children: "Nothing public here yet." });
|
|
20
21
|
}
|
|
21
|
-
return (_jsx(Grid, { cols: 4, gap: "md", children: items.map((item) => (_jsxs(Div, { rounded: "xl", border: "default", padding: "md", surface: "card", children: [_jsx(MediaImage, { src: item.mainImage || item.images[0] || "", alt: item.title, size: "card" }), _jsx(Div, { padding: "t-sm", children: _jsx(Heading, { level: 4, size: "sm", children: item.title }) }), typeof item.price === "number" && item.price > 0 && (_jsx(Text, { size: "sm", color: "muted", children: formatCurrency(item.price) }))] }, item.id))) }));
|
|
22
|
+
return (_jsx(Grid, { cols: 4, gap: "md", children: items.map((item) => (_jsx(Link, { href: `/catalogue/${ownerSlug}/${item.id}`, className: "block transition-opacity hover:opacity-90", children: _jsxs(Div, { rounded: "xl", border: "default", padding: "md", surface: "card", children: [_jsx(MediaImage, { src: item.mainImage || item.images[0] || "", alt: item.title, size: "card" }), _jsx(Div, { padding: "t-sm", children: _jsx(Heading, { level: 4, size: "sm", children: item.title }) }), typeof item.price === "number" && item.price > 0 && (_jsx(Text, { size: "sm", color: "muted", children: formatCurrency(item.price) }))] }) }, item.id))) }));
|
|
22
23
|
}
|
|
@@ -9,4 +9,6 @@ export { CatalogueItemEditorView } from "./components/CatalogueItemEditorView";
|
|
|
9
9
|
export type { CatalogueItemEditorViewProps } from "./components/CatalogueItemEditorView";
|
|
10
10
|
export { PublicCatalogueView } from "./components/PublicCatalogueView";
|
|
11
11
|
export type { PublicCatalogueViewProps } from "./components/PublicCatalogueView";
|
|
12
|
+
export { PublicCatalogueItemDetailView } from "./components/PublicCatalogueItemDetailView";
|
|
13
|
+
export type { PublicCatalogueItemDetailViewProps } from "./components/PublicCatalogueItemDetailView";
|
|
12
14
|
export { AdminCatalogueApprovalsView } from "./components/AdminCatalogueApprovalsView";
|
|
@@ -6,4 +6,5 @@ export { approveCatalogueListingAction, rejectCatalogueListingAction, } from "./
|
|
|
6
6
|
export { UserCatalogueView } from "./components/UserCatalogueView";
|
|
7
7
|
export { CatalogueItemEditorView } from "./components/CatalogueItemEditorView";
|
|
8
8
|
export { PublicCatalogueView } from "./components/PublicCatalogueView";
|
|
9
|
+
export { PublicCatalogueItemDetailView } from "./components/PublicCatalogueItemDetailView";
|
|
9
10
|
export { AdminCatalogueApprovalsView } from "./components/AdminCatalogueApprovalsView";
|
|
@@ -18,5 +18,7 @@ export interface BundleDetailViewProps {
|
|
|
18
18
|
onBuyNow?: (input: {
|
|
19
19
|
bundleSlug: string;
|
|
20
20
|
}) => Promise<unknown>;
|
|
21
|
+
/** Other active bundles, excluding this one — renders a "Related Bundles" section when non-empty. */
|
|
22
|
+
relatedBundles?: CategoryDocument[];
|
|
21
23
|
}
|
|
22
|
-
export declare function BundleDetailView({ bundle, members, onBuyNow, }: BundleDetailViewProps): React.JSX.Element;
|
|
24
|
+
export declare function BundleDetailView({ bundle, members, onBuyNow, relatedBundles, }: BundleDetailViewProps): React.JSX.Element;
|
|
@@ -8,6 +8,7 @@ import { BundleCollage } from "./BundleCollage";
|
|
|
8
8
|
import { MediaImage } from "../../media/MediaImage";
|
|
9
9
|
import { ROUTES } from "../../../next/routing/route-map";
|
|
10
10
|
import { computeBundleDiscount } from "../../../_internal/shared/features/categories/bundle-pricing";
|
|
11
|
+
import { CategoryGrid } from "./CategoryGrid";
|
|
11
12
|
const __O = {
|
|
12
13
|
hidden: "overflow-hidden",
|
|
13
14
|
};
|
|
@@ -16,7 +17,7 @@ const STOCK_BADGE_TEXT = {
|
|
|
16
17
|
out_of_stock: BUNDLE_COPY.stockBadge.out_of_stock,
|
|
17
18
|
};
|
|
18
19
|
const PLACEHOLDER_EMOJI = "📦";
|
|
19
|
-
export function BundleDetailView({ bundle, members = [], onBuyNow, }) {
|
|
20
|
+
export function BundleDetailView({ bundle, members = [], onBuyNow, relatedBundles = [], }) {
|
|
20
21
|
const memberCount = members.length || bundle.bundleProductIds?.length || 0;
|
|
21
22
|
const stock = bundle.bundleStockStatus ?? "in_stock";
|
|
22
23
|
const cover = bundle.display?.coverImage;
|
|
@@ -24,5 +25,5 @@ export function BundleDetailView({ bundle, members = [], onBuyNow, }) {
|
|
|
24
25
|
? formatCurrency(bundle.bundlePrice, "INR")
|
|
25
26
|
: BUNDLE_COPY.detail.priceFallback;
|
|
26
27
|
const discount = computeBundleDiscount(bundle.bundlePrice, bundle.bundleOriginalTotal);
|
|
27
|
-
return (
|
|
28
|
+
return (_jsxs(Main, { children: [_jsx(Section, { padding: "y-2xl", children: _jsx(Container, { size: "xl", children: _jsxs(Stack, { gap: "lg", children: [_jsxs(Stack, { gap: "lg", direction: "md-row", align: "start", children: [_jsx(Div, { className: "w-full md:w-2/5", children: members.length > 0 ? (_jsx(BundleCollage, { members: members })) : (_jsx(Div, { className: `aspect-video w-full ${__O.hidden}`, rounded: "2xl", surface: "subtle", children: _jsx(Div, { className: "h-full w-full", children: _jsx(MediaImage, { src: cover, alt: bundle.name, size: "hero", fallback: PLACEHOLDER_EMOJI }) }) })) }), _jsxs(Stack, { gap: "md", className: "flex-1", children: [_jsx(Heading, { level: 1, size: "3xl", weight: "semibold", color: "primary", children: bundle.name }), _jsxs(Row, { gap: "sm", align: "center", wrap: true, children: [_jsx(Text, { size: "lg", weight: "bold", children: priceLabel }), discount && (_jsxs(_Fragment, { children: [_jsx(Text, { size: "sm", color: "muted", className: "line-through", children: formatCurrency(discount.originalTotal, "INR") }), _jsx(Badge, { variant: "success", children: BUNDLE_COPY.detail.discountBadge(discount.percent) })] })), _jsxs(Text, { size: "sm", color: "muted", children: ["\u00B7 ", BUNDLE_COPY.detail.itemCount(memberCount)] }), _jsx(Badge, { variant: BUNDLE_STOCK_VARIANT[stock], children: STOCK_BADGE_TEXT[stock] })] }), discount && (_jsx(Text, { size: "sm", color: "success", weight: "medium", children: BUNDLE_COPY.detail.savingsLabel(formatCurrency(discount.savings, "INR")) })), bundle.description && (_jsxs(Stack, { gap: "xs", children: [_jsx(Heading, { level: 2, className: "text-[var(--appkit-color-text-muted)]", size: "sm", weight: "semibold", children: BUNDLE_COPY.detailDescriptionHeading }), _jsx(Text, { className: "whitespace-pre-line", children: bundle.description })] })), onBuyNow ? (_jsx(BundleBuyNowCta, { bundleSlug: bundle.slug, outOfStock: stock === "out_of_stock", onBuyNow: onBuyNow })) : (_jsxs(Div, { textSize: "sm", className: "border-dashed", border: "strong", surface: "muted", rounded: "xl", padding: "md", "aria-live": "polite", children: [_jsx(Text, { weight: "semibold", className: "mb-1 block", children: BUNDLE_COPY.detail.ctaDisabled }), _jsx(Text, { size: "sm", color: "muted", children: BUNDLE_COPY.detail.ctaHint })] }))] })] }), _jsxs(Stack, { gap: "md", children: [_jsx(Heading, { level: 2, size: "xl", weight: "semibold", color: "primary", children: BUNDLE_COPY.detailItemsHeading }), members.length === 0 ? (_jsx(Text, { color: "muted", children: BUNDLE_COPY.detail.emptyMembers })) : (_jsx(Ul, { gap: "2", className: "grid grid-cols-1 sm:grid-cols-2", children: members.map((p, i) => (_jsxs(Li, { layout: "flex-center", gap: "2", rounded: "lg", border: "subtle", padding: "inlineSm", textSize: "sm", children: [_jsxs(Span, { size: "xs", weight: "semibold", className: "w-5", color: "faint", children: ["#", i + 1] }), _jsx(Link, { href: String(ROUTES.PUBLIC.PRODUCT_DETAIL?.(p.slug ?? p.id) ?? "#"), className: "flex-1 truncate font-medium hover:underline hover:text-primary", children: p.title }), _jsx(Span, { size: "xs", color: "muted", children: formatCurrency(p.price ?? 0, p.currency ?? "INR") })] }, p.id))) }))] })] }) }) }), relatedBundles.length > 0 && (_jsx(Section, { border: "subtle", surface: "default", className: "border-t", padding: "y-lg", children: _jsxs(Container, { size: "xl", children: [_jsx(Heading, { level: 2, className: "mb-4", size: "xl", weight: "semibold", children: "Related Bundles" }), _jsx(CategoryGrid, { categories: relatedBundles, getHref: (c) => String(ROUTES.PUBLIC.BUNDLE_DETAIL(c.slug)) })] }) }))] }));
|
|
28
29
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { startClassifiedConversationAction } from "../../../_internal/server/features/classified/actions";
|
|
3
|
+
export interface ClassifiedContactSellerPanelProps {
|
|
4
|
+
productId: string;
|
|
5
|
+
onContactSeller: typeof startClassifiedConversationAction;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* The only purchase-path CTA on a classified listing — no cart, no checkout.
|
|
9
|
+
* Extracted from the old ClassifiedDetailView so the richer, full-parity
|
|
10
|
+
* ClassifiedDetailPageView can slot this into a `renderBuyBar` prop.
|
|
11
|
+
*/
|
|
12
|
+
export declare function ClassifiedContactSellerPanel({ productId, onContactSeller }: ClassifiedContactSellerPanelProps): React.JSX.Element;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { normalizeError } from "../../../errors/normalize";
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
import Link from "next/link";
|
|
6
|
+
import { Button, Div, Stack, Text } from "../../../ui";
|
|
7
|
+
import { ROUTES } from "../../../next/routing/route-map";
|
|
8
|
+
const __P = {
|
|
9
|
+
p4: "p-[var(--appkit-space-4)]",
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* The only purchase-path CTA on a classified listing — no cart, no checkout.
|
|
13
|
+
* Extracted from the old ClassifiedDetailView so the richer, full-parity
|
|
14
|
+
* ClassifiedDetailPageView can slot this into a `renderBuyBar` prop.
|
|
15
|
+
*/
|
|
16
|
+
export function ClassifiedContactSellerPanel({ productId, onContactSeller }) {
|
|
17
|
+
const [pending, setPending] = useState(false);
|
|
18
|
+
const [conversation, setConversation] = useState(null);
|
|
19
|
+
const [error, setError] = useState(null);
|
|
20
|
+
async function handleContactSeller() {
|
|
21
|
+
setPending(true);
|
|
22
|
+
setError(null);
|
|
23
|
+
try {
|
|
24
|
+
const result = await onContactSeller({ productId });
|
|
25
|
+
if (result && typeof result === "object" && "ok" in result) {
|
|
26
|
+
if (result.ok) {
|
|
27
|
+
setConversation(result.data);
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
setError(result.error);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
setConversation(result);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
catch (e) {
|
|
38
|
+
void normalizeError(e);
|
|
39
|
+
setError(e instanceof Error ? e.message : "Something went wrong");
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
setPending(false);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (conversation) {
|
|
46
|
+
return (_jsxs(Div, { className: `border border-border bg-muted/40 ${__P.p4}`, rounded: "lg", children: [_jsx(Text, { className: "mb-2", weight: "medium", children: "Conversation started!" }), _jsx(Link, { href: ROUTES.USER.MESSAGES, className: "text-primary underline underline-offset-2", children: "Go to your messages \u2192" })] }));
|
|
47
|
+
}
|
|
48
|
+
return (_jsxs(Stack, { gap: "sm", children: [error && (_jsx(Text, { className: "text-destructive", size: "sm", children: error })), _jsx(Button, { type: "button", variant: "primary", size: "lg", isLoading: pending, disabled: pending, onClick: handleContactSeller, className: "w-full", children: "Contact Seller" })] }));
|
|
49
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ProductDocument } from "../../products/schemas/firestore";
|
|
2
|
+
export interface ClassifiedDetailPageViewProps {
|
|
3
|
+
slug: string;
|
|
4
|
+
/** Pre-fetched product document — dedupes with generateMetadata() via React.cache(). */
|
|
5
|
+
initialProduct?: ProductDocument | null;
|
|
6
|
+
}
|
|
7
|
+
export declare function ClassifiedDetailPageView({ slug, initialProduct }: ClassifiedDetailPageViewProps): Promise<import("react").JSX.Element>;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import Link from "next/link";
|
|
3
|
+
import { getClassifiedForDetail } from "../../../_internal/server/features/classified/data";
|
|
4
|
+
import { startClassifiedConversationAction } from "../../../_internal/server/features/classified/actions";
|
|
5
|
+
const CLS_BREADCRUMB_LINK = "hover:text-primary-600 transition-colors";
|
|
6
|
+
const CLS_CLASSIFIED_BADGE = "inline-block rounded-full bg-cyan-100 dark:bg-cyan-900/30 px-[var(--appkit-space-2-5)] py-[var(--appkit-space-0-5)] text-cyan-700 dark:text-cyan-300";
|
|
7
|
+
import { ROUTES } from "../../../next";
|
|
8
|
+
import { getDefaultCurrency } from "../../../core/baseline-resolver";
|
|
9
|
+
import { formatCurrency } from "../../../utils/number.formatter";
|
|
10
|
+
import { normalizeRichTextHtml } from "../../../utils/string.formatter";
|
|
11
|
+
import { safeDisplayName } from "../../../security";
|
|
12
|
+
import { Container, Div, Heading, Main, Nav, RichText, Row, Section, Span, Stack, Text, Dl, Dt, Dd, } from "../../../ui";
|
|
13
|
+
import { PreOrderDetailView } from "../../products/components/PreOrderDetailView";
|
|
14
|
+
import { ProductGalleryClient } from "../../products/components/ProductGalleryClient";
|
|
15
|
+
import { ProductTabsShell } from "../../products/components/ProductTabsShell";
|
|
16
|
+
import { CustomSectionTabContent } from "../../products/components/CustomSectionTabContent";
|
|
17
|
+
import { ShareButton } from "../../products/components/ShareButton";
|
|
18
|
+
import { HistoryTracker } from "../../history/components/HistoryTracker";
|
|
19
|
+
import { RelatedItemsSection } from "../../products/components/RelatedItemsSection";
|
|
20
|
+
import { computeRelatedItems } from "../../../_internal/server/features/products/data";
|
|
21
|
+
import { GroupedListingsCarousel } from "../../grouped/components/GroupedListingsCarousel";
|
|
22
|
+
import { getGroupsWithItemsForProduct } from "../../../_internal/server/features/grouped/data";
|
|
23
|
+
import { ClassifiedContactSellerPanel } from "./ClassifiedContactSellerPanel";
|
|
24
|
+
function toDescriptionHtml(raw) {
|
|
25
|
+
if (!raw)
|
|
26
|
+
return "";
|
|
27
|
+
const s = typeof raw === "string" ? raw : JSON.stringify(raw);
|
|
28
|
+
return normalizeRichTextHtml(s);
|
|
29
|
+
}
|
|
30
|
+
export async function ClassifiedDetailPageView({ slug, initialProduct }) {
|
|
31
|
+
const product = initialProduct !== undefined
|
|
32
|
+
? (initialProduct ?? undefined)
|
|
33
|
+
: await getClassifiedForDetail(slug).catch(() => undefined);
|
|
34
|
+
if (!product) {
|
|
35
|
+
return (_jsx(Main, { children: _jsx(Section, { padding: "y-5xl", children: _jsx(Container, { size: "md", children: _jsxs(Stack, { align: "start", gap: "md", className: "text-left", children: [_jsx(Heading, { level: 1, size: "2xl", weight: "semibold", color: "primary", children: "Classified Listing Not Found" }), _jsx(Text, { color: "muted", children: "The classified listing you are looking for may have been removed." }), _jsx(Link, { href: String(ROUTES.PUBLIC.CLASSIFIED), className: "text-[length:var(--appkit-text-sm)] font-medium text-primary-600 hover:underline", children: "Browse Classifieds" })] }) }) }) }));
|
|
36
|
+
}
|
|
37
|
+
const p = product;
|
|
38
|
+
const currency = p.currency || getDefaultCurrency();
|
|
39
|
+
const title = String(p.title ?? p.name ?? "Classified Listing");
|
|
40
|
+
const price = typeof p.price === "number" ? p.price : null;
|
|
41
|
+
const images = Array.isArray(p.images) ? p.images : typeof p.mainImage === "string" ? [p.mainImage] : [];
|
|
42
|
+
const meta = product.classified;
|
|
43
|
+
const location = meta?.meetupArea ? [meta.meetupArea.locality, meta.meetupArea.city].filter(Boolean).join(", ") : null;
|
|
44
|
+
const category = Array.isArray(p.categorySlugs) && p.categorySlugs.length > 0 ? String(p.categorySlugs[0]) : (typeof p.category === "string" ? p.category : null);
|
|
45
|
+
const categoryName = Array.isArray(p.categoryNames) && p.categoryNames.length > 0 ? String(p.categoryNames[0]) : (typeof p.categoryName === "string" ? p.categoryName : null);
|
|
46
|
+
const brand = typeof p.brand === "string" ? p.brand : undefined;
|
|
47
|
+
const storeName = typeof p.storeName === "string" ? p.storeName : null;
|
|
48
|
+
const safeSeller = storeName ? safeDisplayName(storeName, "") : null;
|
|
49
|
+
const storeSlug = (typeof p.storeSlug === "string" ? p.storeSlug : null) || (typeof p.storeId === "string" ? p.storeId : null);
|
|
50
|
+
const storeHref = storeSlug ? String(ROUTES.PUBLIC.STORE_DETAIL(storeSlug)) : null;
|
|
51
|
+
const specs = Array.isArray(p.specifications) ? p.specifications : [];
|
|
52
|
+
const customSections = Array.isArray(p.customSections) ? p.customSections : [];
|
|
53
|
+
const descriptionHtml = toDescriptionHtml(p.description);
|
|
54
|
+
const [{ relatedItems, relatedByBrand, relatedByTags, relatedByStore }, groups] = await Promise.all([
|
|
55
|
+
computeRelatedItems(product),
|
|
56
|
+
getGroupsWithItemsForProduct(product.id),
|
|
57
|
+
]);
|
|
58
|
+
return (_jsxs(Main, { children: [_jsx(HistoryTracker, { productId: String(p.id ?? p.slug ?? ""), productType: "product", snapshot: {
|
|
59
|
+
title,
|
|
60
|
+
thumb: images[0],
|
|
61
|
+
price: price ?? undefined,
|
|
62
|
+
storeId: typeof p.storeId === "string" ? p.storeId : undefined,
|
|
63
|
+
storeName: storeName ?? undefined,
|
|
64
|
+
} }), _jsxs(Container, { size: "xl", padding: "y-lg", children: [_jsxs(Row, { className: "mb-4", align: "center", justify: "between", gap: "sm", wrap: true, children: [_jsxs(Nav, { "aria-label": "Breadcrumb", layout: "flex-wrap", gap: "2xs", textSize: "xs", color: "muted", children: [_jsx(Link, { href: String(ROUTES.HOME), className: CLS_BREADCRUMB_LINK, children: "Home" }), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.CLASSIFIED), className: CLS_BREADCRUMB_LINK, children: "Classifieds" }), category && (_jsxs(_Fragment, { children: [_jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.CATEGORY_DETAIL(category)), className: CLS_BREADCRUMB_LINK, children: categoryName || category })] })), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Span, { className: "truncate max-w-[200px]", color: "muted", children: title })] }), _jsx(ShareButton, { title: title })] }), _jsx(PreOrderDetailView, { renderGallery: () => _jsx(ProductGalleryClient, { images: images, productName: title }), renderInfo: () => (_jsxs(Stack, { gap: "md", children: [_jsxs(Div, { children: [_jsxs(Row, { gap: "xs", wrap: true, className: "mb-2", children: [_jsx(Span, { size: "xs", weight: "semibold", className: CLS_CLASSIFIED_BADGE, children: "Classified" }), meta?.negotiable && (_jsx(Span, { size: "xs", weight: "medium", className: "inline-block", padding: "pill-sm", rounded: "full", surface: "subtle", color: "muted", children: "Negotiable" })), meta?.acceptsShipping && (_jsx(Span, { size: "xs", weight: "medium", className: "inline-block", padding: "pill-sm", rounded: "full", surface: "subtle", color: "muted", children: "Shipping available" }))] }), _jsx(Heading, { level: 1, className: "leading-snug", smSize: "2xl", color: "primary", size: "xl", weight: "bold", children: title })] }), price !== null && (_jsx(Text, { size: "2xl", weight: "bold", color: "primary", children: formatCurrency(price, currency) })), location && (_jsxs(Row, { color: "muted", textSize: "sm", align: "center", gap: "xs", children: [_jsx(Span, { children: "\uD83D\uDCCD" }), _jsx(Span, { children: location }), meta?.meetupArea?.pincode && _jsxs(Span, { children: ["\u00B7 ", meta.meetupArea.pincode] })] })), meta?.contactMethod && (_jsxs(Text, { size: "xs", color: "muted", children: ["Contact via: ", meta.contactMethod === "both" ? "chat or phone" : meta.contactMethod] })), descriptionHtml && (_jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm max-w-none dark:prose-invert prose-p:my-0", className: "text-[length:var(--appkit-text-sm)] leading-relaxed text-[var(--appkit-color-text-muted)] line-clamp-4" })), safeSeller && (_jsx(Div, { border: "subtle", rounded: "xl", surface: "muted", padding: "md", children: _jsxs(Row, { justify: "between", align: "center", children: [_jsxs(Div, { children: [_jsx(Text, { className: "text-[10px] tracking-wide mb-0.5", color: "faint", transform: "uppercase", children: "Sold by" }), _jsx(Text, { size: "sm", weight: "semibold", color: "primary", children: safeSeller })] }), storeHref && (_jsx(Link, { href: storeHref, className: "shrink-0 rounded-lg bg-primary/10 dark:bg-primary/20 px-[var(--appkit-space-3)] py-[var(--appkit-space-1-5)] text-[length:var(--appkit-text-xs)] font-semibold text-primary-700 dark:text-primary-300 hover:bg-primary/20 dark:hover:bg-primary/30 transition-colors", children: "Visit Store \u2192" }))] }) }))] })), renderTabs: () => (_jsx(ProductTabsShell, { descriptionContent: descriptionHtml ? (_jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm sm:prose max-w-none dark:prose-invert", className: "text-[var(--appkit-color-text-muted)]" })) : undefined, specsContent: specs.length > 0 ? (_jsx(Dl, { divide: "subtle", rounded: "xl", border: "subtle", className: "overflow-hidden", children: specs.map((s, i) => (_jsxs(Row, { gap: "md", oddEven: "zebra", surface: "default", padding: "inline", children: [_jsx(Dt, { className: "w-36 flex-shrink-0", color: "primary", weight: "medium", children: s.name }), _jsxs(Dd, { className: "flex-1", color: "muted", children: [s.value, s.unit ? ` ${s.unit}` : ""] })] }, i))) })) : undefined, customTabs: customSections.map((s) => ({
|
|
65
|
+
id: s.id,
|
|
66
|
+
label: s.title,
|
|
67
|
+
content: _jsx(CustomSectionTabContent, { section: s }),
|
|
68
|
+
})) })), renderBuyBar: () => (_jsx(Stack, { id: "classified-contact-bar", className: "p-[var(--appkit-space-5)]", border: "subtle", gap: "md", rounded: "xl", surface: "muted", children: _jsx(ClassifiedContactSellerPanel, { productId: String(product.id), onContactSeller: startClassifiedConversationAction }) })), renderRelated: () => (_jsxs(Stack, { gap: "xl", children: [_jsx(GroupedListingsCarousel, { groups: groups }), _jsx(RelatedItemsSection, { relatedItems: relatedItems, relatedByBrand: relatedByBrand, relatedByTags: relatedByTags, relatedByStore: relatedByStore, categoryLabel: categoryName || category || undefined, brandLabel: brand, storeLabel: storeName ?? undefined })] })) })] })] }));
|
|
69
|
+
}
|
|
@@ -27,8 +27,14 @@ export function ContactPageView() {
|
|
|
27
27
|
{
|
|
28
28
|
icon: "💬",
|
|
29
29
|
label: "WhatsApp",
|
|
30
|
-
value: "+91
|
|
31
|
-
href: "https://wa.me/
|
|
30
|
+
value: "+91 89196 65811",
|
|
31
|
+
href: "https://wa.me/918919665811",
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
icon: "👥",
|
|
35
|
+
label: "WhatsApp Community",
|
|
36
|
+
value: "Join the group",
|
|
37
|
+
href: "https://chat.whatsapp.com/JOlbdSwhVWYDKujolcBZDr",
|
|
32
38
|
},
|
|
33
39
|
], labels: {
|
|
34
40
|
title: "Support",
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ProductDocument } from "../../products/schemas/firestore";
|
|
2
|
+
export interface DigitalCodeDetailPageViewProps {
|
|
3
|
+
slug: string;
|
|
4
|
+
/** Pre-fetched product document — dedupes with generateMetadata() via React.cache(). */
|
|
5
|
+
initialProduct?: ProductDocument | null;
|
|
6
|
+
/** Render-prop for the buy bar (Add to Cart / Buy Now / Wishlist) — wired by the page shim, same component standard products use. */
|
|
7
|
+
renderPrimaryActions?: (ctx: {
|
|
8
|
+
productId: string;
|
|
9
|
+
productSlug: string;
|
|
10
|
+
productTitle: string;
|
|
11
|
+
productImage?: string;
|
|
12
|
+
price: number | null;
|
|
13
|
+
currency: string;
|
|
14
|
+
storeId?: string;
|
|
15
|
+
storeName?: string;
|
|
16
|
+
inStock: boolean;
|
|
17
|
+
}) => React.ReactNode;
|
|
18
|
+
}
|
|
19
|
+
export declare function DigitalCodeDetailPageView({ slug, initialProduct, renderPrimaryActions }: DigitalCodeDetailPageViewProps): Promise<import("react").JSX.Element>;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import Link from "next/link";
|
|
3
|
+
import { getDigitalCodeForDetail } from "../../../_internal/server/features/digital-code/data";
|
|
4
|
+
const CLS_BREADCRUMB_LINK = "hover:text-primary-600 transition-colors";
|
|
5
|
+
const CLS_DIGITALCODE_BADGE = "inline-block rounded-full bg-violet-100 dark:bg-violet-900/30 px-[var(--appkit-space-2-5)] py-[var(--appkit-space-0-5)] text-violet-700 dark:text-violet-300";
|
|
6
|
+
import { ROUTES } from "../../../next";
|
|
7
|
+
import { getDefaultCurrency } from "../../../core/baseline-resolver";
|
|
8
|
+
import { formatCurrency } from "../../../utils/number.formatter";
|
|
9
|
+
import { normalizeRichTextHtml } from "../../../utils/string.formatter";
|
|
10
|
+
import { safeDisplayName } from "../../../security";
|
|
11
|
+
import { Div, Container, Heading, Main, Nav, RichText, Row, Section, Span, Stack, Text, Dl, Dt, Dd, } from "../../../ui";
|
|
12
|
+
import { PreOrderDetailView } from "../../products/components/PreOrderDetailView";
|
|
13
|
+
import { ProductGalleryClient } from "../../products/components/ProductGalleryClient";
|
|
14
|
+
import { ProductTabsShell } from "../../products/components/ProductTabsShell";
|
|
15
|
+
import { CustomSectionTabContent } from "../../products/components/CustomSectionTabContent";
|
|
16
|
+
import { ShareButton } from "../../products/components/ShareButton";
|
|
17
|
+
import { HistoryTracker } from "../../history/components/HistoryTracker";
|
|
18
|
+
import { RelatedItemsSection } from "../../products/components/RelatedItemsSection";
|
|
19
|
+
import { computeRelatedItems, getReviewItemsForProduct } from "../../../_internal/server/features/products/data";
|
|
20
|
+
import { GroupedListingsCarousel } from "../../grouped/components/GroupedListingsCarousel";
|
|
21
|
+
import { getGroupsWithItemsForProduct } from "../../../_internal/server/features/grouped/data";
|
|
22
|
+
import { ReviewsList } from "../../reviews/components/ReviewsList";
|
|
23
|
+
function toDescriptionHtml(raw) {
|
|
24
|
+
if (!raw)
|
|
25
|
+
return "";
|
|
26
|
+
const s = typeof raw === "string" ? raw : JSON.stringify(raw);
|
|
27
|
+
return normalizeRichTextHtml(s);
|
|
28
|
+
}
|
|
29
|
+
export async function DigitalCodeDetailPageView({ slug, initialProduct, renderPrimaryActions }) {
|
|
30
|
+
const product = initialProduct !== undefined
|
|
31
|
+
? (initialProduct ?? undefined)
|
|
32
|
+
: await getDigitalCodeForDetail(slug).catch(() => undefined);
|
|
33
|
+
if (!product) {
|
|
34
|
+
return (_jsx(Main, { children: _jsx(Section, { padding: "y-5xl", children: _jsx(Container, { size: "md", children: _jsxs(Stack, { align: "start", gap: "md", className: "text-left", children: [_jsx(Heading, { level: 1, size: "2xl", weight: "semibold", color: "primary", children: "Digital Code Listing Not Found" }), _jsx(Text, { color: "muted", children: "The digital code listing you are looking for may have been removed." }), _jsx(Link, { href: String(ROUTES.PUBLIC.DIGITAL_CODES), className: "text-[length:var(--appkit-text-sm)] font-medium text-primary-600 hover:underline", children: "Browse Digital Codes" })] }) }) }) }));
|
|
35
|
+
}
|
|
36
|
+
const p = product;
|
|
37
|
+
const currency = p.currency || getDefaultCurrency();
|
|
38
|
+
const title = String(p.title ?? p.name ?? "Digital Code");
|
|
39
|
+
const price = typeof p.price === "number" ? p.price : null;
|
|
40
|
+
const images = Array.isArray(p.images) ? p.images : typeof p.mainImage === "string" ? [p.mainImage] : [];
|
|
41
|
+
const meta = product.digitalCode;
|
|
42
|
+
const codesLeft = meta?.codesAvailable ?? 0;
|
|
43
|
+
const category = Array.isArray(p.categorySlugs) && p.categorySlugs.length > 0 ? String(p.categorySlugs[0]) : (typeof p.category === "string" ? p.category : null);
|
|
44
|
+
const categoryName = Array.isArray(p.categoryNames) && p.categoryNames.length > 0 ? String(p.categoryNames[0]) : (typeof p.categoryName === "string" ? p.categoryName : null);
|
|
45
|
+
const brand = typeof p.brand === "string" ? p.brand : undefined;
|
|
46
|
+
const storeName = typeof p.storeName === "string" ? p.storeName : null;
|
|
47
|
+
const safeSeller = storeName ? safeDisplayName(storeName, "") : null;
|
|
48
|
+
const storeSlug = (typeof p.storeSlug === "string" ? p.storeSlug : null) || (typeof p.storeId === "string" ? p.storeId : null);
|
|
49
|
+
const storeHref = storeSlug ? String(ROUTES.PUBLIC.STORE_DETAIL(storeSlug)) : null;
|
|
50
|
+
const specs = Array.isArray(p.specifications) ? p.specifications : [];
|
|
51
|
+
const customSections = Array.isArray(p.customSections) ? p.customSections : [];
|
|
52
|
+
const descriptionHtml = toDescriptionHtml(p.description);
|
|
53
|
+
const [{ relatedItems, relatedByBrand, relatedByTags, relatedByStore }, groups, reviews] = await Promise.all([
|
|
54
|
+
computeRelatedItems(product),
|
|
55
|
+
getGroupsWithItemsForProduct(product.id),
|
|
56
|
+
getReviewItemsForProduct(product.id),
|
|
57
|
+
]);
|
|
58
|
+
return (_jsxs(Main, { children: [_jsx(HistoryTracker, { productId: String(p.id ?? p.slug ?? ""), productType: "product", snapshot: {
|
|
59
|
+
title,
|
|
60
|
+
thumb: images[0],
|
|
61
|
+
price: price ?? undefined,
|
|
62
|
+
storeId: typeof p.storeId === "string" ? p.storeId : undefined,
|
|
63
|
+
storeName: storeName ?? undefined,
|
|
64
|
+
} }), _jsxs(Container, { size: "xl", padding: "y-lg", children: [_jsxs(Row, { className: "mb-4", align: "center", justify: "between", gap: "sm", wrap: true, children: [_jsxs(Nav, { "aria-label": "Breadcrumb", layout: "flex-wrap", gap: "2xs", textSize: "xs", color: "muted", children: [_jsx(Link, { href: String(ROUTES.HOME), className: CLS_BREADCRUMB_LINK, children: "Home" }), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.DIGITAL_CODES), className: CLS_BREADCRUMB_LINK, children: "Digital Codes" }), category && (_jsxs(_Fragment, { children: [_jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.CATEGORY_DETAIL(category)), className: CLS_BREADCRUMB_LINK, children: categoryName || category })] })), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Span, { className: "truncate max-w-[200px]", color: "muted", children: title })] }), _jsx(ShareButton, { title: title })] }), _jsx(PreOrderDetailView, { renderGallery: () => _jsx(ProductGalleryClient, { images: images, productName: title }), renderInfo: () => (_jsxs(Stack, { gap: "md", children: [_jsxs(Div, { children: [_jsxs(Row, { gap: "xs", wrap: true, className: "mb-2", children: [_jsx(Span, { size: "xs", weight: "semibold", className: CLS_DIGITALCODE_BADGE, children: "Digital Code" }), codesLeft > 0 ? (_jsxs(Span, { size: "xs", weight: "medium", className: "inline-block", padding: "pill-sm", rounded: "full", surface: "subtle", color: "muted", children: [codesLeft, " available"] })) : (_jsx(Span, { size: "xs", weight: "semibold", className: "inline-block", padding: "pill-sm", rounded: "full", surface: "danger-surface", color: "error", children: "Sold out" }))] }), _jsx(Heading, { level: 1, className: "leading-snug", smSize: "2xl", color: "primary", size: "xl", weight: "bold", children: title })] }), price !== null && (_jsx(Text, { size: "2xl", weight: "bold", color: "primary", children: formatCurrency(price, currency) })), meta?.redemptionInstructions && (_jsx(Text, { size: "xs", color: "muted", children: meta.redemptionInstructions })), descriptionHtml && (_jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm max-w-none dark:prose-invert prose-p:my-0", className: "text-[length:var(--appkit-text-sm)] leading-relaxed text-[var(--appkit-color-text-muted)] line-clamp-4" })), safeSeller && (_jsx(Div, { border: "subtle", rounded: "xl", surface: "muted", padding: "md", children: _jsxs(Row, { justify: "between", align: "center", children: [_jsxs(Div, { children: [_jsx(Text, { className: "text-[10px] tracking-wide mb-0.5", color: "faint", transform: "uppercase", children: "Sold by" }), _jsx(Text, { size: "sm", weight: "semibold", color: "primary", children: safeSeller })] }), storeHref && (_jsx(Link, { href: storeHref, className: "shrink-0 rounded-lg bg-primary/10 dark:bg-primary/20 px-[var(--appkit-space-3)] py-[var(--appkit-space-1-5)] text-[length:var(--appkit-text-xs)] font-semibold text-primary-700 dark:text-primary-300 hover:bg-primary/20 dark:hover:bg-primary/30 transition-colors", children: "Visit Store \u2192" }))] }) }))] })), renderTabs: () => (_jsx(ProductTabsShell, { descriptionContent: descriptionHtml ? (_jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm sm:prose max-w-none dark:prose-invert", className: "text-[var(--appkit-color-text-muted)]" })) : undefined, specsContent: specs.length > 0 ? (_jsx(Dl, { divide: "subtle", rounded: "xl", border: "subtle", className: "overflow-hidden", children: specs.map((s, i) => (_jsxs(Row, { gap: "md", oddEven: "zebra", surface: "default", padding: "inline", children: [_jsx(Dt, { className: "w-36 flex-shrink-0", color: "primary", weight: "medium", children: s.name }), _jsxs(Dd, { className: "flex-1", color: "muted", children: [s.value, s.unit ? ` ${s.unit}` : ""] })] }, i))) })) : undefined, reviewsContent: _jsx(ReviewsList, { reviews: reviews, context: "listing", emptyLabel: "No reviews yet \u2014 be the first to review this product." }), customTabs: customSections.map((s) => ({
|
|
65
|
+
id: s.id,
|
|
66
|
+
label: s.title,
|
|
67
|
+
content: _jsx(CustomSectionTabContent, { section: s }),
|
|
68
|
+
})) })), renderBuyBar: () => renderPrimaryActions?.({
|
|
69
|
+
productId: String(product.id),
|
|
70
|
+
productSlug: String(p.slug ?? product.id),
|
|
71
|
+
productTitle: title,
|
|
72
|
+
productImage: images[0],
|
|
73
|
+
price,
|
|
74
|
+
currency,
|
|
75
|
+
storeId: typeof p.storeId === "string" ? p.storeId : undefined,
|
|
76
|
+
storeName: storeName ?? undefined,
|
|
77
|
+
inStock: codesLeft > 0,
|
|
78
|
+
}), renderRelated: () => (_jsxs(Stack, { gap: "xl", children: [_jsx(GroupedListingsCarousel, { groups: groups }), _jsx(RelatedItemsSection, { relatedItems: relatedItems, relatedByBrand: relatedByBrand, relatedByTags: relatedByTags, relatedByStore: relatedByStore, categoryLabel: categoryName || category || undefined, brandLabel: brand, storeLabel: storeName ?? undefined })] })) })] })] }));
|
|
79
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { GroupedListingWithItems } from "../../../_internal/server/features/grouped/data";
|
|
3
|
+
export interface GroupedListingsCarouselProps {
|
|
4
|
+
groups: GroupedListingWithItems[];
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Public renderer for the admin/seller-authored groupedListings "theme
|
|
8
|
+
* scroller" collection — one carousel per group the current product
|
|
9
|
+
* belongs to. Previously fully built (schema, repository, CRUD UI) with
|
|
10
|
+
* zero public consumers; this is the first place it renders on a
|
|
11
|
+
* storefront page.
|
|
12
|
+
*/
|
|
13
|
+
export declare function GroupedListingsCarousel({ groups }: GroupedListingsCarouselProps): React.JSX.Element | null;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { pluginFor } from "../../../_internal/shared/listing-types/_registry";
|
|
4
|
+
import { SectionCarousel } from "../../homepage/components/SectionCarousel";
|
|
5
|
+
import { ProductCard } from "../../products/components/ProductGrid";
|
|
6
|
+
import { toProductItem } from "../../../_internal/server/features/products/data";
|
|
7
|
+
import { CAROUSEL_PER_VIEW } from "../../homepage/constants/carousel-per-view";
|
|
8
|
+
import { Stack } from "../../../ui";
|
|
9
|
+
const GROUP_THEME_TITLE = {
|
|
10
|
+
related: "Other listings you might like",
|
|
11
|
+
character: "Same character",
|
|
12
|
+
lineage: "Same lineage",
|
|
13
|
+
set: "From the same set",
|
|
14
|
+
generic: "You might also like",
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Public renderer for the admin/seller-authored groupedListings "theme
|
|
18
|
+
* scroller" collection — one carousel per group the current product
|
|
19
|
+
* belongs to. Previously fully built (schema, repository, CRUD UI) with
|
|
20
|
+
* zero public consumers; this is the first place it renders on a
|
|
21
|
+
* storefront page.
|
|
22
|
+
*/
|
|
23
|
+
export function GroupedListingsCarousel({ groups }) {
|
|
24
|
+
const visibleGroups = groups.filter((g) => g.items.length > 0);
|
|
25
|
+
if (visibleGroups.length === 0)
|
|
26
|
+
return null;
|
|
27
|
+
return (_jsx(Stack, { gap: "xl", children: visibleGroups.map((group) => {
|
|
28
|
+
const items = group.items.map((p) => toProductItem(p));
|
|
29
|
+
return (_jsx(SectionCarousel, { title: group.title || GROUP_THEME_TITLE[group.groupTheme], pillLabel: "Grouped Listings", headingVariant: "editorial", items: items, isLoading: false, perView: CAROUSEL_PER_VIEW.standard, keyExtractor: (item) => item.id, renderItem: (item) => (_jsx(ProductCard, { product: item, href: item.slug || item.id ? pluginFor(item.listingType ?? "standard").detailRoute(item.slug || item.id) : undefined })) }, group.id));
|
|
30
|
+
}) }));
|
|
31
|
+
}
|
|
@@ -5,5 +5,13 @@ export declare class GroupedListingsRepository extends BaseRepository<GroupedLis
|
|
|
5
5
|
listByStore(storeId: string): Promise<{
|
|
6
6
|
items: GroupedListingDocument[];
|
|
7
7
|
}>;
|
|
8
|
+
/**
|
|
9
|
+
* Groups a product belongs to, restricted to groups that are actually
|
|
10
|
+
* eligible to render publicly (`isActive` + `visibilityStatus:"visible"` —
|
|
11
|
+
* the latter is recomputed by `onProductStockChange` whenever membership
|
|
12
|
+
* drops below `minActiveMembers`, so this is a read-side trust of that
|
|
13
|
+
* precomputed field, not a fresh recompute).
|
|
14
|
+
*/
|
|
15
|
+
findByProductId(productId: string): Promise<GroupedListingDocument[]>;
|
|
8
16
|
}
|
|
9
17
|
export declare const groupedListingsRepository: GroupedListingsRepository;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { BaseRepository } from "../../../providers/db-firebase";
|
|
2
|
+
import { PRODUCT_FIELDS } from "../../../constants/field-names";
|
|
2
3
|
import { GROUPED_LISTINGS_COLLECTION, } from "../schemas/firestore";
|
|
3
4
|
export class GroupedListingsRepository extends BaseRepository {
|
|
4
5
|
constructor() {
|
|
@@ -8,5 +9,20 @@ export class GroupedListingsRepository extends BaseRepository {
|
|
|
8
9
|
const items = await this.findBy("storeId", storeId);
|
|
9
10
|
return { items };
|
|
10
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* Groups a product belongs to, restricted to groups that are actually
|
|
14
|
+
* eligible to render publicly (`isActive` + `visibilityStatus:"visible"` —
|
|
15
|
+
* the latter is recomputed by `onProductStockChange` whenever membership
|
|
16
|
+
* drops below `minActiveMembers`, so this is a read-side trust of that
|
|
17
|
+
* precomputed field, not a fresh recompute).
|
|
18
|
+
*/
|
|
19
|
+
async findByProductId(productId) {
|
|
20
|
+
const snap = await this.getCollection()
|
|
21
|
+
.where("productIds", "array-contains", productId)
|
|
22
|
+
.where(PRODUCT_FIELDS.IS_ACTIVE, "==", true)
|
|
23
|
+
.where("visibilityStatus", "==", "visible")
|
|
24
|
+
.get();
|
|
25
|
+
return snap.docs.map((doc) => this.mapDoc(doc));
|
|
26
|
+
}
|
|
11
27
|
}
|
|
12
28
|
export const groupedListingsRepository = new GroupedListingsRepository();
|
|
@@ -85,12 +85,13 @@ export default function BottomActions() {
|
|
|
85
85
|
const [selectedActionId, setSelectedActionId] = useState(null);
|
|
86
86
|
const containerRef = useRef(null);
|
|
87
87
|
useClickOutside(containerRef, () => setPickerOpen(false));
|
|
88
|
-
const { actions, bulk, infoLabel } = state;
|
|
88
|
+
const { actions, bulk, infoLabel, secondaryLabel } = state;
|
|
89
89
|
const isBulkMode = !!(bulk && bulk.selectedCount > 0);
|
|
90
90
|
const bulkActions = bulk?.actions ?? [];
|
|
91
91
|
const pageActions = actions;
|
|
92
92
|
const isVisible = (isBulkMode ? bulkActions.length > 0 || !!bulk : pageActions.length > 0) ||
|
|
93
|
-
!!infoLabel
|
|
93
|
+
!!infoLabel ||
|
|
94
|
+
!!secondaryLabel;
|
|
94
95
|
// Keep selectedActionId in sync with available bulk actions
|
|
95
96
|
useEffect(() => {
|
|
96
97
|
if (!isBulkMode) {
|
|
@@ -127,7 +128,7 @@ export default function BottomActions() {
|
|
|
127
128
|
isVisible ? "translate-y-0" : "translate-y-full pointer-events-none",
|
|
128
129
|
]
|
|
129
130
|
.filter(Boolean)
|
|
130
|
-
.join(" "), "data-section": "bottomactions-div-401", children: [isBulkMode && (_jsx(BulkPickerPanel, { bulkActions: bulkActions, selectedActionId: selectedActionId, pickerOpen: pickerOpen, onSelect: (id) => { setSelectedActionId(id); setPickerOpen(false); } })), isBulkMode && (_jsx(Div, { className: "h-[3px] w-full bg-[image:var(--appkit-gradient-brand-tri)]" })), infoLabel && !isBulkMode && (_jsx(Div, { border: "subtle", className: "pt-[var(--appkit-space-2)] pb-[var(--appkit-space-0)] border-b /80", padding: "x-md", children: _jsx(Text, { className: "leading-5 truncate", color: "muted", size: "xs", weight: "semibold", children: infoLabel }) })), _jsx(Row, { className: `${BOTTOM_NAV_HEIGHT}`, gap: "sm", padding: "x-sm", children: isBulkMode && bulk ? (_jsxs(_Fragment, { children: [_jsxs(Button, { rounded: "full", gap: "xs", type: "button", variant: "ghost", onClick: dispatchBulkClear, className: "inline- flex-shrink-0 bg-primary-50 hover:bg-primary-100 active:bg-primary-200 dark:bg-primary-950/30 dark:hover:bg-primary-900/50 text-primary-700 dark:text-primary-300 pl-2 pr-3 h-8 border border-primary-200/70 dark:border-primary-800/50 transition-colors min-h-0", "aria-label": "Clear selection", children: [_jsx(X, { className: "w-3.5 h-3.5 flex-shrink-0", "aria-hidden": "true" }), _jsx(Span, { size: "xs", weight: "semibold", className: "tabular-nums whitespace-nowrap leading-none", children: bulk.noun
|
|
131
|
+
.join(" "), "data-section": "bottomactions-div-401", children: [isBulkMode && (_jsx(BulkPickerPanel, { bulkActions: bulkActions, selectedActionId: selectedActionId, pickerOpen: pickerOpen, onSelect: (id) => { setSelectedActionId(id); setPickerOpen(false); } })), isBulkMode && (_jsx(Div, { className: "h-[3px] w-full bg-[image:var(--appkit-gradient-brand-tri)]" })), secondaryLabel && !isBulkMode && (_jsx(Div, { border: "subtle", className: "pt-[var(--appkit-space-2)] pb-[var(--appkit-space-0)] border-b /80", padding: "x-md", children: _jsx(Text, { className: "leading-5 truncate", color: "muted", size: "xs", weight: "semibold", children: secondaryLabel }) })), infoLabel && !isBulkMode && (_jsx(Div, { border: "subtle", className: "pt-[var(--appkit-space-2)] pb-[var(--appkit-space-0)] border-b /80", padding: "x-md", children: _jsx(Text, { className: "leading-5 truncate", color: "muted", size: "xs", weight: "semibold", children: infoLabel }) })), _jsx(Row, { className: `${BOTTOM_NAV_HEIGHT}`, gap: "sm", padding: "x-sm", children: isBulkMode && bulk ? (_jsxs(_Fragment, { children: [_jsxs(Button, { rounded: "full", gap: "xs", type: "button", variant: "ghost", onClick: dispatchBulkClear, className: "inline- flex-shrink-0 bg-primary-50 hover:bg-primary-100 active:bg-primary-200 dark:bg-primary-950/30 dark:hover:bg-primary-900/50 text-primary-700 dark:text-primary-300 pl-2 pr-3 h-8 border border-primary-200/70 dark:border-primary-800/50 transition-colors min-h-0", "aria-label": "Clear selection", children: [_jsx(X, { className: "w-3.5 h-3.5 flex-shrink-0", "aria-hidden": "true" }), _jsx(Span, { size: "xs", weight: "semibold", className: "tabular-nums whitespace-nowrap leading-none", children: bulk.noun
|
|
131
132
|
? `${bulk.selectedCount} ${bulk.noun}`
|
|
132
133
|
: `${bulk.selectedCount} selected` })] }), bulkActions.length > 0 && (_jsxs(Button, { type: "button", variant: "ghost", onClick: () => setPickerOpen((o) => !o), "aria-haspopup": "listbox", "aria-expanded": pickerOpen, className: [
|
|
133
134
|
"flex-1 min-w-0 h-10 flex items-center gap-[var(--appkit-space-2)] px-[var(--appkit-space-3)] rounded-lg border text-[length:var(--appkit-text-sm)] font-medium transition-colors",
|