@mohasinac/appkit 4.55.0 → 4.57.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.build-stamp.json +4 -4
- package/dist/_internal/server/features/checkout/actions.d.ts +53 -0
- package/dist/_internal/server/features/checkout/actions.js +156 -8
- package/dist/_internal/server/features/orders/actions.d.ts +30 -0
- package/dist/_internal/server/features/orders/actions.js +103 -0
- package/dist/_internal/server/features/orders/adapters.js +7 -0
- package/dist/_internal/server/features/shipping/reconcile.d.ts +28 -0
- package/dist/_internal/server/features/shipping/reconcile.js +93 -0
- package/dist/_internal/shared/actions/action-registry.js +53 -0
- package/dist/_internal/shared/config/schema.d.ts +6 -0
- package/dist/_internal/shared/listing-types/route-gating.d.ts +56 -0
- package/dist/_internal/shared/listing-types/route-gating.js +114 -0
- package/dist/_internal/shared/shipping/quote.d.ts +153 -0
- package/dist/_internal/shared/shipping/quote.js +123 -0
- package/dist/client.d.ts +22 -0
- package/dist/client.js +10 -0
- package/dist/features/about/components/PublicProfileView.js +2 -1
- package/dist/features/account/components/UserOrdersView.js +25 -3
- package/dist/features/admin/components/OwnerQuickActions.d.ts +1 -1
- package/dist/features/admin/components/OwnerQuickActions.js +53 -6
- package/dist/features/auctions/components/AuctionDetailPageView.js +2 -1
- package/dist/features/auth/schemas/firestore.d.ts +15 -0
- package/dist/features/blog/components/BlogPostView.js +2 -1
- package/dist/features/cart/components/CartPriceBreakdown.d.ts +2 -0
- package/dist/features/cart/components/CartPriceBreakdown.js +1 -1
- package/dist/features/categories/components/FranchiseDetailPageView.js +3 -1
- package/dist/features/digital-codes/components/DigitalCodeDetailPageView.js +2 -1
- package/dist/features/faq/components/FAQPageContent.js +17 -4
- package/dist/features/faq/constants/help-guides.d.ts +36 -0
- package/dist/features/faq/constants/help-guides.js +64 -0
- package/dist/features/homepage/components/HeroCarousel.js +7 -3
- package/dist/features/homepage/components/MarketplaceHomepageView.js +16 -1
- package/dist/features/homepage/components/WelcomeSection.d.ts +16 -3
- package/dist/features/homepage/components/WelcomeSection.js +3 -3
- package/dist/features/homepage/lib/section-renderer.d.ts +9 -0
- package/dist/features/homepage/lib/section-renderer.js +24 -6
- package/dist/features/homepage/lib/slide-gating.d.ts +52 -0
- package/dist/features/homepage/lib/slide-gating.js +98 -0
- package/dist/features/live/components/LiveItemDetailPageView.js +2 -1
- package/dist/features/media/MediaAvatar.js +1 -1
- package/dist/features/media/MediaImage.d.ts +18 -1
- package/dist/features/media/MediaImage.js +1 -1
- package/dist/features/orders/components/EmiInstallmentsPanel.d.ts +19 -0
- package/dist/features/orders/components/EmiInstallmentsPanel.js +57 -0
- package/dist/features/orders/components/index.d.ts +1 -0
- package/dist/features/orders/components/index.js +1 -0
- package/dist/features/orders/schemas/firestore.d.ts +38 -0
- package/dist/features/orders/schemas/index.d.ts +2 -2
- package/dist/features/orders/types/index.d.ts +15 -0
- package/dist/features/points/repository/points.repository.d.ts +81 -0
- package/dist/features/points/repository/points.repository.js +155 -0
- package/dist/features/points/schemas/firestore.d.ts +118 -0
- package/dist/features/points/schemas/firestore.js +116 -0
- package/dist/features/pre-orders/components/PreOrderDetailPageView.js +2 -1
- package/dist/features/products/components/PrizeDrawDetailPageView.js +2 -1
- package/dist/features/products/components/ProductDetailPageView.js +3 -1
- package/dist/features/reviews/components/ReviewDetailPageView.js +3 -2
- package/dist/features/seller/actions/seller-actions.d.ts +17 -0
- package/dist/features/seller/actions/seller-actions.js +33 -0
- package/dist/features/stores/components/StoreHeader.js +3 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +8 -0
- package/dist/next/routing/route-map.d.ts +4 -0
- package/dist/next/routing/route-map.js +2 -0
- package/dist/schemas/registry.d.ts +2 -2
- package/dist/seed/_helpers/category-models/chunk0.d.ts +2 -0
- package/dist/seed/_helpers/category-models/chunk0.js +2648 -0
- package/dist/seed/_helpers/category-models/chunk1.d.ts +2 -0
- package/dist/seed/_helpers/category-models/chunk1.js +2318 -0
- package/dist/seed/_helpers/category-models/chunk2.d.ts +2 -0
- package/dist/seed/_helpers/category-models/chunk2.js +2560 -0
- package/dist/seed/_helpers/category-models/chunk3.d.ts +2 -0
- package/dist/seed/_helpers/category-models/chunk3.js +2196 -0
- package/dist/seed/_helpers/category-models/chunk4.d.ts +2 -0
- package/dist/seed/_helpers/category-models/chunk4.js +1732 -0
- package/dist/seed/_helpers/category-models.d.ts +9 -2
- package/dist/seed/_helpers/category-models.js +19 -1368
- package/dist/seed/carousel-slides-seed-data.js +48 -7
- package/dist/server.d.ts +3 -1
- package/dist/server.js +11 -0
- package/dist/styles.css +38 -6
- package/dist/tailwind-utilities.css +1 -1
- package/dist/ui/components/ImageMosaic.d.ts +17 -1
- package/dist/ui/components/ImageMosaic.js +28 -5
- package/dist/ui/components/ImageMosaic.style.css +37 -5
- package/package.json +1 -1
package/dist/client.js
CHANGED
|
@@ -405,6 +405,8 @@ export { couponDraftToPayload } from "./features/seller/schemas/coupon-form.js";
|
|
|
405
405
|
export { UserAccountHubView, UserOrdersView, UserBidsView, OrderDetailView, UserReturnsView, useNotifications } from "./features/account/index.js";
|
|
406
406
|
export { useOrders, useOrder, OrdersList } from "./features/orders/index.js";
|
|
407
407
|
export { OrderStatusTimeline } from "./features/orders/components/OrderStatusTimeline.js";
|
|
408
|
+
// Buyer-facing EMI schedule with a pay-any-instalment action.
|
|
409
|
+
export { EmiInstallmentsPanel } from "./features/orders/components/EmiInstallmentsPanel.js";
|
|
408
410
|
export { CountdownDisplay } from "./ui/components/CountdownDisplay.js";
|
|
409
411
|
export { OrderAddonBadges } from "./features/orders/components/OrderAddonBadges.js";
|
|
410
412
|
// Manual-payment (cash / UPI / EMI) shared constants — pure data + predicates,
|
|
@@ -546,6 +548,14 @@ export { PRODUCT_FIELDS, PRODUCT_STATUS_TRANSITIONS, ORDER_FIELDS, REVIEW_FIELDS
|
|
|
546
548
|
// Checkout lanes — the derived auction > offer > standard partition of the
|
|
547
549
|
// cart, and the priority rule that decides which one may be checked out.
|
|
548
550
|
export { CART_LANE, CART_LANE_PRIORITY, CART_LANE_LABELS, laneOf, activeLane, laneItems, laneCounts, isLaneCheckoutable, laneBlockReason, isLockedLane, canAddNewItems, } from "./_internal/shared/checkout/lanes.js";
|
|
551
|
+
/*
|
|
552
|
+
* Shipping quote model. Public because the CONSUMER owns the route that asks
|
|
553
|
+
* the carrier for rates (`/api/shipping/quote`) and the checkout UI that
|
|
554
|
+
* renders them — the rounding rule has to be reachable from both, and it must
|
|
555
|
+
* be the same rule the server-side reconciliation uses, or the buyer is
|
|
556
|
+
* credited against a figure they were never charged.
|
|
557
|
+
*/
|
|
558
|
+
export { roundShippingUp, toQuoteOptions, reconcileShipping, SHIPPING_ROUND_UP_TO, } from "./_internal/shared/shipping/quote.js";
|
|
549
559
|
/*
|
|
550
560
|
* The cart page needs these to tell a multi-member line from an ordinary one: a
|
|
551
561
|
* group/bundle line's `productId` is a group or category id, not a product id,
|
|
@@ -16,6 +16,7 @@ import { pluginFor } from "../../../_internal/shared/listing-types/_registry.js"
|
|
|
16
16
|
import { normalizeListingType } from "../../products/utils/listing-type.js";
|
|
17
17
|
import { isAdminUser, isSellerUser } from "../../auth/role-predicates.js";
|
|
18
18
|
import { User, Star, ShoppingBag, Package, Trophy, Globe, MapPin, ExternalLink } from "lucide-react";
|
|
19
|
+
import { OwnerQuickActions } from "../../admin/components/OwnerQuickActions.js";
|
|
19
20
|
const __P = {
|
|
20
21
|
p4: "p-[var(--appkit-space-4)]",
|
|
21
22
|
p6: "p-[var(--appkit-space-6)]",
|
|
@@ -120,7 +121,7 @@ export async function PublicProfileView({ userId, tab, }) {
|
|
|
120
121
|
const profileHeroCtx = { displayName, photoURL, memberSince, isSeller, storeSlug, flex, page };
|
|
121
122
|
const statItems = buildProfileStatItems(t, { listingCount, reviewCount, itemsSold, auctionsWon, totalOrders, isSeller });
|
|
122
123
|
const activeTab = tab === "catalogue" ? "catalogue" : "overview";
|
|
123
|
-
return (_jsxs(Div, { className: "-mx-4 md:-mx-6 lg:-mx-8 -mt-6 sm:-mt-8 lg:-mt-10", children: [renderProfileHero(t, profileHeroCtx), _jsxs(Stack, { gap: "2xl", className: `${page.container.md}`, paddingY: "y-3xl", children: [_jsxs(Row, { gap: "md", padding: "t-xs", border: "bottom", children: [_jsx(TextLink, { href: `/profile/${userId}/overview`, variant: activeTab === "overview" ? "default" : "muted", weight: activeTab === "overview" ? "semibold" : "normal", children: "Overview" }), _jsx(TextLink, { href: `/profile/${userId}/catalogue`, variant: activeTab === "catalogue" ? "default" : "muted", weight: activeTab === "catalogue" ? "semibold" : "normal", children: "Catalogue" })] }), activeTab === "catalogue" ? (_jsx(PublicCatalogueView, { ownerSlug: userId })) : (_jsxs(_Fragment, { children: [renderProfileStatsRow(flex, statItems), renderProfileBioSection(pub), renderStoreDescriptionSection(isSeller, storeSlug ?? null, storeDescription ?? null, storeName, t), renderProfileListingsSection(t, products, storeSlug ?? null), renderAuthoredReviewsSection(t, reviewsAuthored, displayName), reviewsReceivedAsBuyer.length > 0 && renderBuyerReceivedReviewsSection(t, reviewsReceivedAsBuyer)] })), _jsx(Row, { justify: "center", padding: "t-xs", children: _jsxs(Link, { href: String(ROUTES.HOME), className: "text-[length:var(--appkit-text-sm)] text-[var(--appkit-color-text-faint)] hover:text-[var(--appkit-color-text-muted)]", children: ["\u2190 ", t("backHome")] }) })] })] }));
|
|
124
|
+
return (_jsxs(Div, { className: "-mx-4 md:-mx-6 lg:-mx-8 -mt-6 sm:-mt-8 lg:-mt-10", children: [renderProfileHero(t, profileHeroCtx), _jsxs(Stack, { gap: "2xl", className: `${page.container.md}`, paddingY: "y-3xl", children: [_jsx(Row, { justify: "end", children: _jsx(OwnerQuickActions, { resource: "USER", placement: "detail", recordLabel: displayName, editHref: String(ROUTES.ADMIN.USER_EDIT(userId)) }) }), _jsxs(Row, { gap: "md", padding: "t-xs", border: "bottom", children: [_jsx(TextLink, { href: `/profile/${userId}/overview`, variant: activeTab === "overview" ? "default" : "muted", weight: activeTab === "overview" ? "semibold" : "normal", children: "Overview" }), _jsx(TextLink, { href: `/profile/${userId}/catalogue`, variant: activeTab === "catalogue" ? "default" : "muted", weight: activeTab === "catalogue" ? "semibold" : "normal", children: "Catalogue" })] }), activeTab === "catalogue" ? (_jsx(PublicCatalogueView, { ownerSlug: userId })) : (_jsxs(_Fragment, { children: [renderProfileStatsRow(flex, statItems), renderProfileBioSection(pub), renderStoreDescriptionSection(isSeller, storeSlug ?? null, storeDescription ?? null, storeName, t), renderProfileListingsSection(t, products, storeSlug ?? null), renderAuthoredReviewsSection(t, reviewsAuthored, displayName), reviewsReceivedAsBuyer.length > 0 && renderBuyerReceivedReviewsSection(t, reviewsReceivedAsBuyer)] })), _jsx(Row, { justify: "center", padding: "t-xs", children: _jsxs(Link, { href: String(ROUTES.HOME), className: "text-[length:var(--appkit-text-sm)] text-[var(--appkit-color-text-faint)] hover:text-[var(--appkit-color-text-muted)]", children: ["\u2190 ", t("backHome")] }) })] })] }));
|
|
124
125
|
}
|
|
125
126
|
function buildProfileStatItems(t, ctx) {
|
|
126
127
|
const { listingCount, reviewCount, itemsSold, auctionsWon, totalOrders, isSeller } = ctx;
|
|
@@ -51,6 +51,27 @@ const ORDER_LANE_TABS = [
|
|
|
51
51
|
{ id: "auction", label: "Auction wins" },
|
|
52
52
|
{ id: "offer", label: "Offer wins" },
|
|
53
53
|
];
|
|
54
|
+
/**
|
|
55
|
+
* Payment-method tabs — a THIRD axis, independent of the lane tabs above and
|
|
56
|
+
* the lifecycle scope beside them.
|
|
57
|
+
*
|
|
58
|
+
* 🛑 EMI is not a lane and not a status, which is why it could not join
|
|
59
|
+
* either existing control: `orderType` records how the order was WON
|
|
60
|
+
* (standard / auction / offer) and the scope records where it IS. How it is
|
|
61
|
+
* being PAID is orthogonal to both — an auction win can be on an EMI plan.
|
|
62
|
+
*
|
|
63
|
+
* Every id is exactly a stored `OrderDocument.paymentMethod` value, and
|
|
64
|
+
* `paymentMethod` is `canFilter: true` in the orders SIEVE_FIELDS — checked
|
|
65
|
+
* before wiring this, because an unallowlisted field does not raise: the
|
|
66
|
+
* clause is silently dropped and the chip returns the ENTIRE order list while
|
|
67
|
+
* looking like a filter (Root Cause #62).
|
|
68
|
+
*/
|
|
69
|
+
const PAYMENT_METHOD_TABS = [
|
|
70
|
+
{ id: "", label: "Any" },
|
|
71
|
+
{ id: "emi", label: "EMI plans" },
|
|
72
|
+
{ id: "cod", label: "Cash on delivery" },
|
|
73
|
+
{ id: "online", label: "Paid online" },
|
|
74
|
+
];
|
|
54
75
|
const STATUS_OPTIONS = [
|
|
55
76
|
{ value: "pending", label: "Pending" },
|
|
56
77
|
{ value: "processing", label: "Processing" },
|
|
@@ -85,7 +106,7 @@ export function UserOrdersView({ onOrderClick }) {
|
|
|
85
106
|
* (CLAUDE.md, Root Cause #35).
|
|
86
107
|
*/
|
|
87
108
|
const searchParams = useSearchParams();
|
|
88
|
-
const hasNarrowingFilter = ["q", "search", "status", "orderType", "filters"].some((k) => Boolean(searchParams.get(k)));
|
|
109
|
+
const hasNarrowingFilter = ["q", "search", "status", "orderType", "paymentMethod", "filters"].some((k) => Boolean(searchParams.get(k)));
|
|
89
110
|
const emptyMessage = hasNarrowingFilter
|
|
90
111
|
? "No orders match your search or filters."
|
|
91
112
|
: "You haven't placed any orders yet.";
|
|
@@ -104,7 +125,7 @@ export function UserOrdersView({ onOrderClick }) {
|
|
|
104
125
|
fields: ["id", "productTitle"],
|
|
105
126
|
},
|
|
106
127
|
emptyLabel: emptyMessage,
|
|
107
|
-
filterKeys: ["status", "orderType"],
|
|
128
|
+
filterKeys: ["status", "orderType", "paymentMethod"],
|
|
108
129
|
defaultSort: sortBy("createdAt", "DESC"),
|
|
109
130
|
queryKey: ["user", "orders", "listing"],
|
|
110
131
|
endpoint: ACCOUNT_ENDPOINTS.ORDERS,
|
|
@@ -115,6 +136,7 @@ export function UserOrdersView({ onOrderClick }) {
|
|
|
115
136
|
buildFilters: (state) => [
|
|
116
137
|
state.status ? sieveFilter("status", SIEVE_OP.EQ, state.status) : null,
|
|
117
138
|
state.orderType ? sieveFilter("orderType", SIEVE_OP.EQ, state.orderType) : null,
|
|
139
|
+
state.paymentMethod ? sieveFilter("paymentMethod", SIEVE_OP.EQ, state.paymentMethod) : null,
|
|
118
140
|
]
|
|
119
141
|
.filter(Boolean)
|
|
120
142
|
.join(",") || undefined,
|
|
@@ -123,7 +145,7 @@ export function UserOrdersView({ onOrderClick }) {
|
|
|
123
145
|
// in flight" without those two questions fighting over one control.
|
|
124
146
|
buildExtraParams: () => orderScope.extraParams,
|
|
125
147
|
renderAboveContent: orderScope.renderAboveContent,
|
|
126
|
-
renderFilterPanel: ({ pendingFilters, setPendingFilters }) => (_jsxs(Stack, { gap: "md", children: [_jsx(FilterChipGroup, { label: "Type", tabs: ORDER_LANE_TABS, value: pendingFilters.orderType ?? "", onChange: (id) => setPendingFilters((prev) => ({ ...prev, orderType: id })) }), _jsx(Div, { children: _jsx(PaginatedSelect, { value: pendingFilters.status || null, onChange: (v) => setPendingFilters((p) => ({ ...p, status: v ?? "" })), options: STATUS_OPTIONS, placeholder: "All statuses", ariaLabel: "Filter by order status" }) })] })),
|
|
148
|
+
renderFilterPanel: ({ pendingFilters, setPendingFilters }) => (_jsxs(Stack, { gap: "md", children: [_jsx(FilterChipGroup, { label: "Type", tabs: ORDER_LANE_TABS, value: pendingFilters.orderType ?? "", onChange: (id) => setPendingFilters((prev) => ({ ...prev, orderType: id })) }), _jsx(FilterChipGroup, { label: "Payment", tabs: PAYMENT_METHOD_TABS, value: pendingFilters.paymentMethod ?? "", onChange: (id) => setPendingFilters((prev) => ({ ...prev, paymentMethod: id })) }), _jsx(Div, { children: _jsx(PaginatedSelect, { value: pendingFilters.status || null, onChange: (v) => setPendingFilters((p) => ({ ...p, status: v ?? "" })), options: STATUS_OPTIONS, placeholder: "All statuses", ariaLabel: "Filter by order status" }) })] })),
|
|
127
149
|
renderCards: (rows, _view, _selection, isLoading) => {
|
|
128
150
|
if (isLoading) {
|
|
129
151
|
return (_jsx(Stack, { gap: "md", children: Array.from({ length: 3 }).map((_, i) => (_jsx(Div, { className: "h-24 animate-pulse border border-[var(--appkit-color-border)]", rounded: "xl" }, i))) }));
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Which registry bucket supplies the labels and the confirm copy. */
|
|
2
|
-
export type OwnerQuickActionsResource = "PRODUCT" | "CATEGORY" | "BRAND" | "BUNDLE" | "GROUP" | "SUBLISTING" | "EVENT" | "BLOG" | "REVIEW";
|
|
2
|
+
export type OwnerQuickActionsResource = "PRODUCT" | "CATEGORY" | "BRAND" | "FRANCHISE" | "BUNDLE" | "GROUP" | "SUBLISTING" | "EVENT" | "BLOG" | "REVIEW" | "STORE" | "USER" | "CAROUSEL" | "SECTION";
|
|
3
3
|
export interface OwnerQuickActionsProps {
|
|
4
4
|
resource: OwnerQuickActionsResource;
|
|
5
5
|
/** Where the pencil goes — that record's dashboard editor. */
|
|
@@ -54,6 +54,45 @@ import { IconButton } from "../../../ui/components/IconButton.js";
|
|
|
54
54
|
import { Row, useToast } from "../../../ui/index.js";
|
|
55
55
|
import { normalizeError } from "../../../errors/normalize.js";
|
|
56
56
|
import { toUserMessage } from "../../../errors/error-display-map.js";
|
|
57
|
+
const RESOURCE_LEAVES = {
|
|
58
|
+
PRODUCT: { bucket: "PRODUCT", edit: "edit", remove: "delete", duplicate: "duplicate" },
|
|
59
|
+
CATEGORY: { bucket: "CATEGORY", edit: "edit", remove: "delete" },
|
|
60
|
+
BRAND: { bucket: "BRAND", edit: "edit", remove: "delete" },
|
|
61
|
+
BUNDLE: { bucket: "BUNDLE", edit: "edit", remove: "delete" },
|
|
62
|
+
GROUP: { bucket: "GROUP", edit: "edit", remove: "delete" },
|
|
63
|
+
SUBLISTING: { bucket: "SUBLISTING", edit: "edit", remove: "delete" },
|
|
64
|
+
EVENT: { bucket: "EVENT", edit: "edit", remove: "delete", duplicate: "duplicate" },
|
|
65
|
+
REVIEW: { bucket: "REVIEW", edit: "edit", remove: "delete" },
|
|
66
|
+
/** 🛑 `edit-post` / `delete-post`, not `edit` / `delete`. See the note above. */
|
|
67
|
+
BLOG: { bucket: "BLOG", edit: "edit-post", remove: "delete-post", duplicate: "duplicate-post" },
|
|
68
|
+
/**
|
|
69
|
+
* A franchise row IS a category (`categoryType: "franchise"`), so it reuses
|
|
70
|
+
* the category leaves rather than duplicating copy that would drift.
|
|
71
|
+
*/
|
|
72
|
+
FRANCHISE: { bucket: "CATEGORY", edit: "edit", remove: "delete" },
|
|
73
|
+
/** Carousel slides have no bucket of their own; the leaves live under ADMIN. */
|
|
74
|
+
CAROUSEL: { bucket: "ADMIN", edit: "edit-carousel", remove: "delete-carousel" },
|
|
75
|
+
/*
|
|
76
|
+
* ── The three PENCIL-ONLY resources ─────────────────────────────────────
|
|
77
|
+
*
|
|
78
|
+
* `remove` is omitted on purpose for all three. Each is a delete whose blast
|
|
79
|
+
* radius is far larger than the page the bin would sit on, and a public
|
|
80
|
+
* surface is the worst possible place to offer it:
|
|
81
|
+
*
|
|
82
|
+
* STORE — deleting a store orphans its products, orders and payouts. The
|
|
83
|
+
* dashboard offers `suspend-store` / `verify-store`, which are the
|
|
84
|
+
* operations an admin actually wants from a store page.
|
|
85
|
+
* USER — deleting a user cascades across orders, reviews, bids and
|
|
86
|
+
* addresses. `ADMIN.ban-user` is the reversible tool, and it has
|
|
87
|
+
* its own flow with a reason.
|
|
88
|
+
* SECTION — homepage section `order` is POSITIONAL, so removing one silently
|
|
89
|
+
* renumbers its neighbours. There is also no per-section public
|
|
90
|
+
* page; the pencil goes to the list that owns them.
|
|
91
|
+
*/
|
|
92
|
+
STORE: { bucket: "ADMIN", edit: "manage-store" },
|
|
93
|
+
USER: { bucket: "ADMIN", edit: "manage-user" },
|
|
94
|
+
SECTION: { bucket: "ADMIN", edit: "edit-section" },
|
|
95
|
+
};
|
|
57
96
|
export function OwnerQuickActions({ resource, editHref, onDelete, onClone, ownerId, storeId, recordLabel, placement = "card", className = "", }) {
|
|
58
97
|
const session = useOptionalSession();
|
|
59
98
|
const isOwnerOrAdmin = useIsOwner(ownerId);
|
|
@@ -61,7 +100,8 @@ export function OwnerQuickActions({ resource, editHref, onDelete, onClone, owner
|
|
|
61
100
|
const { showToast } = useToast();
|
|
62
101
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
63
102
|
const [deleting, setDeleting] = useState(false);
|
|
64
|
-
const
|
|
103
|
+
const leaves = RESOURCE_LEAVES[resource];
|
|
104
|
+
const bucket = ACTIONS[leaves.bucket];
|
|
65
105
|
const handleConfirmedDelete = useCallback(async () => {
|
|
66
106
|
if (!onDelete)
|
|
67
107
|
return;
|
|
@@ -113,10 +153,17 @@ export function OwnerQuickActions({ resource, editHref, onDelete, onClone, owner
|
|
|
113
153
|
*/
|
|
114
154
|
if (!isAdmin && !isOwnerOrAdmin && !ownsStore)
|
|
115
155
|
return null;
|
|
116
|
-
const editDef = bucket.edit;
|
|
117
|
-
const deleteDef = bucket.
|
|
156
|
+
const editDef = bucket[leaves.edit];
|
|
157
|
+
const deleteDef = leaves.remove ? bucket[leaves.remove] : undefined;
|
|
118
158
|
const confirmation = deleteDef?.confirmation;
|
|
119
|
-
const showClone = Boolean(onClone) && isAdmin;
|
|
159
|
+
const showClone = Boolean(onClone) && isAdmin && Boolean(leaves.duplicate);
|
|
160
|
+
/*
|
|
161
|
+
* 🛑 `leaves.remove` gates the bin, not just `onDelete`. A resource whose
|
|
162
|
+
* mapping omits a delete leaf (store, user, homepage section) must render no
|
|
163
|
+
* bin even if a caller passes `onDelete` — the safety decision belongs to the
|
|
164
|
+
* resource, not to each of its call sites, or the next call site re-opens it.
|
|
165
|
+
*/
|
|
166
|
+
const showDelete = Boolean(onDelete) && Boolean(leaves.remove);
|
|
120
167
|
return (_jsxs(_Fragment, { children: [_jsxs(Row, { gap: "xs", align: "center", className: className,
|
|
121
168
|
/*
|
|
122
169
|
* Stops the click reaching an ancestor `<Link>` or the card's own
|
|
@@ -135,8 +182,8 @@ export function OwnerQuickActions({ resource, editHref, onDelete, onClone, owner
|
|
|
135
182
|
*/
|
|
136
183
|
if (typeof window !== "undefined")
|
|
137
184
|
window.location.assign(editHref);
|
|
138
|
-
} }), showClone && (_jsx(IconButton, { "aria-label": (bucket.duplicate?.ariaLabel ?? bucket.duplicate?.label) ||
|
|
139
|
-
"Duplicate", size: "touch", variant: placement === "card" ? "scrim" : "ghost", icon: _jsx(Icon, { name: "duplicate" }), onClick: () => void onClone?.() })),
|
|
185
|
+
} }), showClone && (_jsx(IconButton, { "aria-label": (leaves.duplicate ? bucket[leaves.duplicate]?.ariaLabel ?? bucket[leaves.duplicate]?.label : undefined) ||
|
|
186
|
+
"Duplicate", size: "touch", variant: placement === "card" ? "scrim" : "ghost", icon: _jsx(Icon, { name: "duplicate" }), onClick: () => void onClone?.() })), showDelete && (_jsx(IconButton, { "aria-label": deleteDef?.ariaLabel ?? deleteDef?.label ?? "Delete", size: "touch", variant: placement === "card" ? "scrim" : "danger", icon: _jsx(Icon, { name: "delete" }), onClick: () => setConfirmOpen(true) }))] }), showDelete && (_jsx(ConfirmDeleteModal, { isOpen: confirmOpen, onClose: () => setConfirmOpen(false), onConfirm: handleConfirmedDelete, isDeleting: deleting, title: confirmation?.title ?? "Delete this record?", message: recordLabel && confirmation
|
|
140
187
|
? `${confirmation.body}\n\nDeleting: ${recordLabel}`
|
|
141
188
|
: confirmation?.body, confirmText: confirmation?.confirmLabel ?? deleteDef?.label ?? "Delete", variant: "danger" }))] }));
|
|
142
189
|
}
|
|
@@ -43,6 +43,7 @@ import { getGroupsWithItemsForProduct } from "../../../_internal/server/features
|
|
|
43
43
|
import { SublistingCarouselSection } from "../../products/components/SublistingCarouselSection.js";
|
|
44
44
|
import { isFinalSale } from "../../products/constants/final-sale.js";
|
|
45
45
|
import { OwnerQuickActions } from "../../admin/components/OwnerQuickActions.js";
|
|
46
|
+
import { PurchaseReviewAffordance } from "../../reviews/components/PurchaseReviewAffordance.js";
|
|
46
47
|
function toDescriptionHtml(raw) {
|
|
47
48
|
if (!raw)
|
|
48
49
|
return "";
|
|
@@ -235,6 +236,6 @@ export async function AuctionDetailPageView({ id, initialAuction, onPlaceBid, on
|
|
|
235
236
|
return (_jsx(CollapsibleBidHistory, { productId: String(product.id), initialData: bidsResult, currency: currency }));
|
|
236
237
|
}, renderRelated: () => {
|
|
237
238
|
const related = toRelatedAuctionCards(relatedDocs, product.id, new Date());
|
|
238
|
-
return (_jsxs(Stack, { gap: "xl", children: [related.length > 0 && (_jsx(RelatedProducts, { labels: { title: "Similar Auctions" }, renderGrid: () => (_jsx(MarketplaceAuctionGrid, { auctions: related })) })), _jsx(GroupedListingsCarousel, { groups: groups }), _jsx(RelatedItemsSection, { relatedItems: relatedItems, relatedByBrand: relatedByBrand, relatedByTags: relatedByTags, relatedByStore: relatedByStore, categoryLabel: categoryName || category || undefined, brandLabel: brand || undefined, storeLabel: storeName || undefined })] }));
|
|
239
|
+
return (_jsxs(Stack, { gap: "xl", children: [related.length > 0 && (_jsx(RelatedProducts, { labels: { title: "Similar Auctions" }, renderGrid: () => (_jsx(MarketplaceAuctionGrid, { auctions: related })) })), _jsx(PurchaseReviewAffordance, { productId: String(product.id), storeId: product.storeId ? String(product.storeId) : undefined }), _jsx(GroupedListingsCarousel, { groups: groups }), _jsx(RelatedItemsSection, { relatedItems: relatedItems, relatedByBrand: relatedByBrand, relatedByTags: relatedByTags, relatedByStore: relatedByStore, categoryLabel: categoryName || category || undefined, brandLabel: brand || undefined, storeLabel: storeName || undefined })] }));
|
|
239
240
|
} }), renderAuctionStoreReviews(storeId), onPlaceBid && (_jsx(AuctionBottomActions, { productId: String(product.id), currentBid: currentBid, startingBid: startingBid, tiers: bidIncrementTiers, minBidIncrementOverride: minBidIncrementOverride, currency: currency, isEnded: isEnded, auctionEndDate: endDate, buyNowPrice: buyNowPrice, bidCount: bidCount, tags: tags, onPlaceBid: onPlaceBid, onBuyNow: onBuyNow }))] })] }));
|
|
240
241
|
}
|
|
@@ -84,6 +84,21 @@ export interface UserDocument extends BaseDocument {
|
|
|
84
84
|
storeId?: string;
|
|
85
85
|
storeSlug?: string;
|
|
86
86
|
storeStatus?: "pending" | "approved" | "rejected";
|
|
87
|
+
/**
|
|
88
|
+
* Denormalised sum of this user's `pointsLedger` entries. 1 point = ₹1.
|
|
89
|
+
*
|
|
90
|
+
* 🛑 A MIRROR, and `pointsRepository` is the only thing allowed to write it —
|
|
91
|
+
* always in the same transaction as the ledger entry that moved it. Root
|
|
92
|
+
* Cause #42: a mirror maintained by more than one write path drifts, and this
|
|
93
|
+
* one guards money-shaped value, so drift either invents points or swallows
|
|
94
|
+
* earned ones.
|
|
95
|
+
*
|
|
96
|
+
* 🛑 Absence means "never computed", NOT zero. `getBalance` falls back to
|
|
97
|
+
* summing the ledger rather than answering 0, because every user document
|
|
98
|
+
* written before this field existed has no value here and answering zero
|
|
99
|
+
* would quietly deny people credits they hold.
|
|
100
|
+
*/
|
|
101
|
+
pointsBalance?: number;
|
|
87
102
|
isTester?: boolean;
|
|
88
103
|
canTestAdmin?: boolean;
|
|
89
104
|
publicProfile?: {
|
|
@@ -11,6 +11,7 @@ import { getMediaUrl } from "../../media/types/index.js";
|
|
|
11
11
|
import { getDefaultLocale } from "../../../core/baseline-resolver.js";
|
|
12
12
|
import { normalizeRichTextHtml } from "../../../utils/string.formatter.js";
|
|
13
13
|
import { ROUTES } from "../../../next/routing/route-map.js";
|
|
14
|
+
import { OwnerQuickActions } from "../../admin/components/OwnerQuickActions.js";
|
|
14
15
|
const __P = {
|
|
15
16
|
p8: "p-[var(--appkit-space-8)]",
|
|
16
17
|
};
|
|
@@ -26,7 +27,7 @@ const CATEGORY_BADGE = {
|
|
|
26
27
|
};
|
|
27
28
|
const CLS_FEATURED_BADGE = "bg-warning-surface text-warning dark:bg-warning-surface dark:text-warning px-[var(--appkit-space-2)] py-[var(--appkit-space-0-5)] rounded-full";
|
|
28
29
|
function renderBlogPostHeader(post, date, labels) {
|
|
29
|
-
return (_jsxs(Div, { className: "mb-8", children: [_jsxs(Row, { className: "mb-4", gap: "sm", children: [_jsx(Span, { size: "xs", weight: "medium", className: `inline-block capitalize ${CATEGORY_BADGE[post.category] ?? ""}`, rounded: "full", padding: "pill-xs", children: post.category }), post.isFeatured && _jsx(Span, { size: "xs", weight: "medium", className: CLS_FEATURED_BADGE, children: labels?.featured ?? "Featured" })] }),
|
|
30
|
+
return (_jsxs(Div, { className: "mb-8", children: [_jsxs(Row, { className: "mb-4", gap: "sm", children: [_jsx(Span, { size: "xs", weight: "medium", className: `inline-block capitalize ${CATEGORY_BADGE[post.category] ?? ""}`, rounded: "full", padding: "pill-xs", children: post.category }), post.isFeatured && _jsx(Span, { size: "xs", weight: "medium", className: CLS_FEATURED_BADGE, children: labels?.featured ?? "Featured" })] }), _jsxs(Row, { className: "mb-4", gap: "sm", align: "center", justify: "between", wrap: true, children: [_jsx(Heading, { level: 1, size: "3xl", weight: "bold", children: post.title }), _jsx(OwnerQuickActions, { resource: "BLOG", placement: "detail", editHref: String(ROUTES.ADMIN.BLOG_EDIT(post.id)), recordLabel: post.title })] }), post.excerpt && _jsx(Text, { className: "mb-6", color: "muted", size: "lg", children: post.excerpt }), _jsxs(Row, { color: "muted", textSize: "sm", wrap: true, gap: "md", children: [post.authorName && _jsxs(Span, { children: [labels?.author ?? "By", " ", _jsx(Span, { weight: "medium", color: "muted", children: post.authorName })] }), post.readTimeMinutes != null && _jsxs(Span, { children: [post.readTimeMinutes, " ", labels?.readTime ?? "min read"] }), date && _jsxs(Span, { children: [labels?.publishedOn ?? "Published", " ", date] }), post.views != null && _jsxs(Span, { children: [post.views, " ", labels?.viewsLabel ?? "views"] })] })] }));
|
|
30
31
|
}
|
|
31
32
|
function renderBlogPostRelated(related, title, renderRelatedCard) {
|
|
32
33
|
if (related.length === 0)
|
|
@@ -24,6 +24,7 @@ export interface CartPriceBreakdownStore {
|
|
|
24
24
|
giftWrapFee: number;
|
|
25
25
|
shipmentProtectionFee: number;
|
|
26
26
|
saleProtectionFee: number;
|
|
27
|
+
emiSurcharge: number;
|
|
27
28
|
gstAmount: number;
|
|
28
29
|
couponDiscount: number;
|
|
29
30
|
total: number;
|
|
@@ -40,6 +41,7 @@ export interface CartPriceBreakdownData {
|
|
|
40
41
|
giftWrapFee: number;
|
|
41
42
|
shipmentProtectionFee: number;
|
|
42
43
|
saleProtectionFee: number;
|
|
44
|
+
emiSurcharge: number;
|
|
43
45
|
gstAmount: number;
|
|
44
46
|
couponDiscount: number;
|
|
45
47
|
platformFee: number;
|
|
@@ -21,5 +21,5 @@ export function CartPriceBreakdown({ preview, itemCount, currency = "INR", unava
|
|
|
21
21
|
const subtotalLabel = itemCount !== undefined
|
|
22
22
|
? `Subtotal (${itemCount} item${itemCount !== 1 ? "s" : ""}${storeCount > 1 ? `, ${storeCount} stores` : ""})`
|
|
23
23
|
: "Subtotal";
|
|
24
|
-
return (_jsxs(Stack, { gap: "xs", className: className, children: [_jsx(Line, { label: subtotalLabel, amount: money(preview.subtotal) }), preview.shippingFee > 0 && (_jsx(Line, { label: withStoreCount("Shipping", storeCount), amount: money(preview.shippingFee) })), preview.codHandlingFee > 0 && (_jsx(Line, { label: "COD handling fee", amount: money(preview.codHandlingFee) })), preview.giftWrapFee > 0 && (_jsx(Line, { label: withStoreCount("Gift wrap", countCharged((s) => s.giftWrapFee)), amount: money(preview.giftWrapFee) })), preview.shipmentProtectionFee > 0 && (_jsx(Line, { label: withStoreCount("Shipment protection", countCharged((s) => s.shipmentProtectionFee)), amount: money(preview.shipmentProtectionFee) })), preview.saleProtectionFee > 0 && (_jsx(Line, { label: withStoreCount("Sale protection", countCharged((s) => s.saleProtectionFee)), amount: money(preview.saleProtectionFee) })), preview.platformFee > 0 && _jsx(Line, { label: "Platform fee", amount: money(preview.platformFee) }), preview.gstOnFee > 0 && (_jsx(Line, { label: "GST on platform fee", amount: money(preview.gstOnFee) })), preview.couponDiscount > 0 && (_jsx(Line, { label: "Coupon discount", amount: `−${money(preview.couponDiscount)}`, tone: "success" })), _jsx(Div, { border: "top-subtle", paddingY: "t-sm", children: _jsx(Line, { label: "Total", amount: money(preview.total), tone: "primary", strong: true }) }), preview.gstAmount > 0 && (_jsxs(Text, { size: "xs", color: "muted", children: ["Includes GST of ", money(preview.gstAmount)] })), errorNote ? (_jsx(Text, { size: "xs", color: "error", children: errorNote })) : (isLoading && (_jsx(Text, { size: "xs", color: "muted", children: "Updating\u2026" })))] }));
|
|
24
|
+
return (_jsxs(Stack, { gap: "xs", className: className, children: [_jsx(Line, { label: subtotalLabel, amount: money(preview.subtotal) }), preview.shippingFee > 0 && (_jsx(Line, { label: withStoreCount("Shipping", storeCount), amount: money(preview.shippingFee) })), preview.codHandlingFee > 0 && (_jsx(Line, { label: "COD handling fee", amount: money(preview.codHandlingFee) })), preview.giftWrapFee > 0 && (_jsx(Line, { label: withStoreCount("Gift wrap", countCharged((s) => s.giftWrapFee)), amount: money(preview.giftWrapFee) })), preview.shipmentProtectionFee > 0 && (_jsx(Line, { label: withStoreCount("Shipment protection", countCharged((s) => s.shipmentProtectionFee)), amount: money(preview.shipmentProtectionFee) })), preview.saleProtectionFee > 0 && (_jsx(Line, { label: withStoreCount("Sale protection", countCharged((s) => s.saleProtectionFee)), amount: money(preview.saleProtectionFee) })), preview.emiSurcharge > 0 && (_jsx(Line, { label: "EMI surcharge", amount: money(preview.emiSurcharge) })), preview.platformFee > 0 && _jsx(Line, { label: "Platform fee", amount: money(preview.platformFee) }), preview.gstOnFee > 0 && (_jsx(Line, { label: "GST on platform fee", amount: money(preview.gstOnFee) })), preview.couponDiscount > 0 && (_jsx(Line, { label: "Coupon discount", amount: `−${money(preview.couponDiscount)}`, tone: "success" })), _jsx(Div, { border: "top-subtle", paddingY: "t-sm", children: _jsx(Line, { label: "Total", amount: money(preview.total), tone: "primary", strong: true }) }), preview.gstAmount > 0 && (_jsxs(Text, { size: "xs", color: "muted", children: ["Includes GST of ", money(preview.gstAmount)] })), errorNote ? (_jsx(Text, { size: "xs", color: "error", children: errorNote })) : (isLoading && (_jsx(Text, { size: "xs", color: "muted", children: "Updating\u2026" })))] }));
|
|
25
25
|
}
|
|
@@ -9,6 +9,8 @@ import { Heading, Text } from "../../../ui/components/Typography.js";
|
|
|
9
9
|
import { MediaImage } from "../../media/MediaImage.js";
|
|
10
10
|
import { ProductGrid } from "../../products/components/ProductGrid.js";
|
|
11
11
|
import { Alert } from "../../../ui/components/Alert.js";
|
|
12
|
+
import { OwnerQuickActions } from "../../admin/components/OwnerQuickActions.js";
|
|
13
|
+
import { ROUTES } from "../../../next/routing/route-map.js";
|
|
12
14
|
const PAGE_SIZE = 24;
|
|
13
15
|
export async function FranchiseDetailPageView({ slug, franchise, }) {
|
|
14
16
|
const result = await safeRead(() => listPublicProducts({
|
|
@@ -37,7 +39,7 @@ export async function FranchiseDetailPageView({ slug, franchise, }) {
|
|
|
37
39
|
const products = rows.map((r) => toProductItem(r));
|
|
38
40
|
const total = reportedTotal ?? products.length;
|
|
39
41
|
const cover = franchise.display?.coverImage;
|
|
40
|
-
return (_jsx(Main, { children: _jsx(Section, { padding: "y-2xl", children: _jsxs(Container, { size: "xl", children: [_jsxs(Row, { align: "center", gap: "comfortable", className: "mb-8", children: [cover ? (_jsx(MediaImage, { src: cover, alt: franchise.name ?? slug, size: "thumbnail", rounded: "lg" })) : null, _jsxs(Stack, { gap: "dense", children: [_jsx(Heading, { level: 1, color: "primary", size: "3xl", weight: "semibold", children: franchise.name }), franchise.description ? (_jsx(Text, { color: "muted", className: "max-w-2xl", children: franchise.description })) : null] })] }), franchise.isActive === false ? (_jsxs(Alert, { variant: "info", className: "mb-6", children: ["We are not stocking ", franchise.name, " yet. This page is here so the link works \u2014 check back, or browse what we do carry."] })) : null, readFailed ? (_jsxs(Alert, { variant: "warning", children: ["We could not load ", franchise.name, " listings just now. Please try again."] })) : (_jsxs(_Fragment, { children: [_jsx(Text, { color: "muted", className: "mb-4", children: total === 0
|
|
42
|
+
return (_jsx(Main, { children: _jsx(Section, { padding: "y-2xl", children: _jsxs(Container, { size: "xl", children: [_jsxs(Row, { align: "center", gap: "comfortable", className: "mb-8", children: [cover ? (_jsx(MediaImage, { src: cover, alt: franchise.name ?? slug, size: "thumbnail", rounded: "lg" })) : null, _jsxs(Stack, { gap: "dense", children: [_jsxs(Row, { gap: "sm", align: "center", justify: "between", wrap: true, children: [_jsx(Heading, { level: 1, color: "primary", size: "3xl", weight: "semibold", children: franchise.name }), franchise.id ? (_jsx(OwnerQuickActions, { resource: "FRANCHISE", placement: "detail", recordLabel: franchise.name ?? slug, editHref: String(ROUTES.ADMIN.CATEGORIES_EDIT(franchise.id)) })) : null] }), franchise.description ? (_jsx(Text, { color: "muted", className: "max-w-2xl", children: franchise.description })) : null] })] }), franchise.isActive === false ? (_jsxs(Alert, { variant: "info", className: "mb-6", children: ["We are not stocking ", franchise.name, " yet. This page is here so the link works \u2014 check back, or browse what we do carry."] })) : null, readFailed ? (_jsxs(Alert, { variant: "warning", children: ["We could not load ", franchise.name, " listings just now. Please try again."] })) : (_jsxs(_Fragment, { children: [_jsx(Text, { color: "muted", className: "mb-4", children: total === 0
|
|
41
43
|
? "No listings yet."
|
|
42
44
|
: `${total}${truncated ? "+" : ""} listing${total === 1 ? "" : "s"}` }), _jsx(ProductGrid, { products: products, emptyLabel: "No listings yet." })] }))] }) }) }));
|
|
43
45
|
}
|
|
@@ -23,6 +23,7 @@ import { getGroupsWithItemsForProduct } from "../../../_internal/server/features
|
|
|
23
23
|
import { ReviewsListingPanel } from "../../reviews/components/ReviewsListingPanel.js";
|
|
24
24
|
import { ListingBottomActions } from "../../products/components/ListingBottomActions.js";
|
|
25
25
|
import { productImageUrls } from "../../products/schemas/firestore.js";
|
|
26
|
+
import { PurchaseReviewAffordance } from "../../reviews/components/PurchaseReviewAffordance.js";
|
|
26
27
|
function toDescriptionHtml(raw) {
|
|
27
28
|
if (!raw)
|
|
28
29
|
return "";
|
|
@@ -84,5 +85,5 @@ export async function DigitalCodeDetailPageView({ slug, initialProduct, renderPr
|
|
|
84
85
|
storeId: typeof p.storeId === "string" ? p.storeId : undefined,
|
|
85
86
|
storeName: storeName ?? undefined,
|
|
86
87
|
inStock: codesLeft > 0,
|
|
87
|
-
}) })), 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 })] })) }), _jsx(ListingBottomActions, { listingType: "digital-code", anchorId: "digital-code-buy-bar", price: price, currency: currency, unavailable: codesLeft <= 0 })] })] }));
|
|
88
|
+
}) })), renderRelated: () => (_jsxs(Stack, { gap: "xl", children: [_jsx(PurchaseReviewAffordance, { productId: String(product.id), storeId: product.storeId ? String(product.storeId) : undefined }), _jsx(GroupedListingsCarousel, { groups: groups }), _jsx(RelatedItemsSection, { relatedItems: relatedItems, relatedByBrand: relatedByBrand, relatedByTags: relatedByTags, relatedByStore: relatedByStore, categoryLabel: categoryName || category || undefined, brandLabel: brand, storeLabel: storeName ?? undefined })] })) }), _jsx(ListingBottomActions, { listingType: "digital-code", anchorId: "digital-code-buy-bar", price: price, currency: currency, unavailable: codesLeft <= 0 })] })] }));
|
|
88
89
|
}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { Alert, Div, EmptyState, Heading, Input, SlottedListingView, Text } from "../../../ui/index.js";
|
|
3
|
+
import { Alert, Div, EmptyState, Heading, Input, Row, SlottedListingView, Text, TextLink } from "../../../ui/index.js";
|
|
4
4
|
import { sortBy } from "../../../constants/sort.js";
|
|
5
5
|
import { FAQCategorySidebar } from "./FAQCategorySidebar.js";
|
|
6
6
|
import { FAQSortDropdown } from "./FAQSortDropdown.js";
|
|
7
7
|
import { FAQAccordion } from "./FAQAccordion.js";
|
|
8
8
|
import { ContactCTA } from "./ContactCTA.js";
|
|
9
9
|
import { useFaqList } from "../hooks/useFaqList.js";
|
|
10
|
+
import { helpGuideFor } from "../constants/help-guides.js";
|
|
10
11
|
export function FAQPageContent({ initialCategory = "all", categories, routeHelpers, labels, contact, searchValue, onSearchChange, sortOption, onSortChange, page = 1, pageSize = 100, renderPagination, renderMobileCategoryTabs, renderAccordion, }) {
|
|
11
12
|
const selectedCategory = initialCategory;
|
|
12
13
|
const sorts = sortOption === "helpful"
|
|
@@ -49,9 +50,21 @@ export function FAQPageContent({ initialCategory = "all", categories, routeHelpe
|
|
|
49
50
|
allFaqs: labels.allFaqs,
|
|
50
51
|
stillHaveQuestions: labels.stillHaveQuestions,
|
|
51
52
|
contactSupport: labels.contactSupport,
|
|
52
|
-
} }) }), _jsxs(Div, { className: "col-span-1 lg:col-span-8 xl:col-span-9", children: [_jsx(SlottedListingView, { portal: "public", inlineToolbar: true, className: "space-y-6", renderHeader: () =>
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
} }) }), _jsxs(Div, { className: "col-span-1 lg:col-span-8 xl:col-span-9", children: [_jsx(SlottedListingView, { portal: "public", inlineToolbar: true, className: "space-y-6", renderHeader: () => {
|
|
54
|
+
/*
|
|
55
|
+
* The fuller answer, next to the short ones.
|
|
56
|
+
*
|
|
57
|
+
* Only on a chosen category: on "all" there is no single guide
|
|
58
|
+
* to offer, and a link to the Help Centre index here would
|
|
59
|
+
* duplicate the footer's for no gain. `helpGuideFor` falls back
|
|
60
|
+
* to the index for the two categories that span the product, so
|
|
61
|
+
* a selected category always has somewhere honest to point.
|
|
62
|
+
*/
|
|
63
|
+
const guide = selectedCategory !== "all" ? helpGuideFor(selectedCategory) : null;
|
|
64
|
+
return (_jsxs(Row, { align: "center", justify: "between", gap: "sm", className: "min-w-0", children: [_jsxs(Text, { size: "sm", color: "muted", className: "min-w-0", children: [labels.resultCount(total), selectedCategory !== "all"
|
|
65
|
+
? ` ${labels.inCategory(categories.find((c) => c.key === selectedCategory)?.label ?? selectedCategory)}`
|
|
66
|
+
: ""] }), guide && (_jsxs(TextLink, { href: guide.path, size: "sm", variant: "muted", className: "flex-shrink-0", children: [guide.label, " \u2192"] }))] }));
|
|
67
|
+
}, renderSearch: () => (_jsx(Input, { value: searchValue, onChange: (event) => onSearchChange(event.target.value), placeholder: labels.searchPlaceholder })), renderSort: () => (_jsx(FAQSortDropdown, { selectedSort: sortOption, onSortChange: onSortChange, labels: {
|
|
55
68
|
label: labels.sortLabel,
|
|
56
69
|
helpful: labels.sortHelpful,
|
|
57
70
|
newest: labels.sortNewest,
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { FAQCategory } from "../types/index";
|
|
2
|
+
/**
|
|
3
|
+
* The Help Centre guide that answers a FAQ category at length.
|
|
4
|
+
*
|
|
5
|
+
* A FAQ is a short answer to one question; a help guide is the surrounding
|
|
6
|
+
* explanation. They were written as separate surfaces and never linked —
|
|
7
|
+
* measured 2026-10-20, the FAQ seed contained **zero** `/help/` links across
|
|
8
|
+
* 91 entries — so a reader who wanted the fuller version had to already know
|
|
9
|
+
* the Help Centre existed and find the right page in it.
|
|
10
|
+
*
|
|
11
|
+
* 🛑 `Record<FAQCategory, …>`, deliberately, so adding a tenth category is a
|
|
12
|
+
* COMPILE error rather than a category that silently offers no guide. This is
|
|
13
|
+
* the same decision as `ALL_LISTING_TYPES_MAP` and `EMAIL_ELIGIBLE_TYPES`, and
|
|
14
|
+
* the reason is Root Cause #61: every hand-written enumeration of a union in
|
|
15
|
+
* this codebase has eventually fallen behind it.
|
|
16
|
+
*
|
|
17
|
+
* `null` is a real answer and not a gap. `technical_support` and `general`
|
|
18
|
+
* span the whole product, so pointing them at one guide would be arbitrary —
|
|
19
|
+
* they get the Help Centre index instead, via `HELP_INDEX_PATH`. Writing a
|
|
20
|
+
* guide to point them at later is a content decision, not a missing entry.
|
|
21
|
+
*
|
|
22
|
+
* 🛑 Paths, not `ROUTES`. The Help Centre pages are consumer routes under
|
|
23
|
+
* `src/app/[locale]/help/`, and `ROUTES` is appkit's own map — adding them
|
|
24
|
+
* there would make appkit declare pages it does not own. They are stable,
|
|
25
|
+
* linked from the footer, and covered by `audit-nav-page-wiring`.
|
|
26
|
+
*/
|
|
27
|
+
export declare const HELP_INDEX_PATH: "/help";
|
|
28
|
+
export declare const FAQ_CATEGORY_HELP_GUIDE: Record<FAQCategory, {
|
|
29
|
+
path: string;
|
|
30
|
+
label: string;
|
|
31
|
+
} | null>;
|
|
32
|
+
/** The guide for a category, or the Help Centre index when none is specific. */
|
|
33
|
+
export declare function helpGuideFor(category: FAQCategory): {
|
|
34
|
+
path: string;
|
|
35
|
+
label: string;
|
|
36
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Help Centre guide that answers a FAQ category at length.
|
|
3
|
+
*
|
|
4
|
+
* A FAQ is a short answer to one question; a help guide is the surrounding
|
|
5
|
+
* explanation. They were written as separate surfaces and never linked —
|
|
6
|
+
* measured 2026-10-20, the FAQ seed contained **zero** `/help/` links across
|
|
7
|
+
* 91 entries — so a reader who wanted the fuller version had to already know
|
|
8
|
+
* the Help Centre existed and find the right page in it.
|
|
9
|
+
*
|
|
10
|
+
* 🛑 `Record<FAQCategory, …>`, deliberately, so adding a tenth category is a
|
|
11
|
+
* COMPILE error rather than a category that silently offers no guide. This is
|
|
12
|
+
* the same decision as `ALL_LISTING_TYPES_MAP` and `EMAIL_ELIGIBLE_TYPES`, and
|
|
13
|
+
* the reason is Root Cause #61: every hand-written enumeration of a union in
|
|
14
|
+
* this codebase has eventually fallen behind it.
|
|
15
|
+
*
|
|
16
|
+
* `null` is a real answer and not a gap. `technical_support` and `general`
|
|
17
|
+
* span the whole product, so pointing them at one guide would be arbitrary —
|
|
18
|
+
* they get the Help Centre index instead, via `HELP_INDEX_PATH`. Writing a
|
|
19
|
+
* guide to point them at later is a content decision, not a missing entry.
|
|
20
|
+
*
|
|
21
|
+
* 🛑 Paths, not `ROUTES`. The Help Centre pages are consumer routes under
|
|
22
|
+
* `src/app/[locale]/help/`, and `ROUTES` is appkit's own map — adding them
|
|
23
|
+
* there would make appkit declare pages it does not own. They are stable,
|
|
24
|
+
* linked from the footer, and covered by `audit-nav-page-wiring`.
|
|
25
|
+
*/
|
|
26
|
+
export const HELP_INDEX_PATH = "/help";
|
|
27
|
+
/**
|
|
28
|
+
* The orders guide, named once because THREE categories share it.
|
|
29
|
+
*
|
|
30
|
+
* `orders_payment`, `shipping_delivery` and `returns_refunds` all resolve to
|
|
31
|
+
* the same page — see the per-entry notes below for why — so the path and the
|
|
32
|
+
* label were written out three times each. Extracted so a retitle is one edit
|
|
33
|
+
* rather than three, and so the three cannot drift into disagreeing labels for
|
|
34
|
+
* one destination.
|
|
35
|
+
*/
|
|
36
|
+
const ORDERS_GUIDE = {
|
|
37
|
+
path: "/help/orders",
|
|
38
|
+
label: "Orders, Returns & Support guide",
|
|
39
|
+
};
|
|
40
|
+
export const FAQ_CATEGORY_HELP_GUIDE = {
|
|
41
|
+
orders_payment: ORDERS_GUIDE,
|
|
42
|
+
// Shipping questions are about an order in flight, which is what the orders
|
|
43
|
+
// guide covers end to end. A separate shipping guide does not exist, and a
|
|
44
|
+
// link to one that did not would be worse than this.
|
|
45
|
+
shipping_delivery: ORDERS_GUIDE,
|
|
46
|
+
returns_refunds: ORDERS_GUIDE,
|
|
47
|
+
// "Is my Beyblade original?" — the vocabulary behind most questions about
|
|
48
|
+
// what an item actually is.
|
|
49
|
+
product_information: { path: "/help/authenticity", label: "Is my Beyblade original?" },
|
|
50
|
+
account_security: { path: "/help/account", label: "Account & Safety guide" },
|
|
51
|
+
// Safety, not shopping: the account guide is where reporting and blocking
|
|
52
|
+
// live.
|
|
53
|
+
scam_awareness: { path: "/help/account", label: "Account & Safety guide" },
|
|
54
|
+
fees_and_taxes: { path: "/help/shopping", label: "Shopping & Checkout guide" },
|
|
55
|
+
technical_support: null,
|
|
56
|
+
general: null,
|
|
57
|
+
};
|
|
58
|
+
/** The guide for a category, or the Help Centre index when none is specific. */
|
|
59
|
+
export function helpGuideFor(category) {
|
|
60
|
+
return (FAQ_CATEGORY_HELP_GUIDE[category] ?? {
|
|
61
|
+
path: HELP_INDEX_PATH,
|
|
62
|
+
label: "Help Centre",
|
|
63
|
+
});
|
|
64
|
+
}
|
|
@@ -10,6 +10,8 @@ import { POSITION_FILL, FLEX_CENTER, HERO_PLACEHOLDER_BG } from "../constants/he
|
|
|
10
10
|
/* The one carousel position/navigation control. The dot and arrow class
|
|
11
11
|
strings this file used to import are deleted — see CarouselNav.style.css. */
|
|
12
12
|
import { CarouselNav } from "../../../ui/components/CarouselNav.js";
|
|
13
|
+
import { OwnerQuickActions } from "../../admin/components/OwnerQuickActions.js";
|
|
14
|
+
import { ROUTES } from "../../../next/routing/route-map.js";
|
|
13
15
|
function makeButtonClickHandler(href, openInNewTab, push) {
|
|
14
16
|
return () => {
|
|
15
17
|
if (openInNewTab) {
|
|
@@ -88,7 +90,7 @@ function SlideBackground({ bg, legacy, mobileLegacy, isMobile, priority, }) {
|
|
|
88
90
|
if (effectiveBg.type === "video") {
|
|
89
91
|
return (_jsxs(_Fragment, { children: [_jsx(MediaVideo, { src: src, thumbnailUrl: effectiveBg.thumbnail ?? legacy?.thumbnail, alt: "", autoPlayMuted: true, loop: true, controls: false }), effectiveBg.dimOverlay?.enabled && (_jsx(Div, { className: POSITION_FILL, style: { backgroundColor: `rgba(0,0,0,${(effectiveBg.dimOverlay.opacity ?? 0) / 100})` } }))] }));
|
|
90
92
|
}
|
|
91
|
-
return (_jsxs(_Fragment, { children: [_jsx(MediaImage, { src: src, alt: "", size: "hero", priority: priority, fetchPriority: priority ? "high" : "low" }), effectiveBg.dimOverlay?.enabled && (_jsx(Div, { className: POSITION_FILL, style: { backgroundColor: `rgba(0,0,0,${(effectiveBg.dimOverlay.opacity ?? 0) / 100})` } }))] }));
|
|
93
|
+
return (_jsxs(_Fragment, { children: [_jsx(MediaImage, { src: src, alt: "", size: "hero", objectFit: "cover", priority: priority, fetchPriority: priority ? "high" : "low" }), effectiveBg.dimOverlay?.enabled && (_jsx(Div, { className: POSITION_FILL, style: { backgroundColor: `rgba(0,0,0,${(effectiveBg.dimOverlay.opacity ?? 0) / 100})` } }))] }));
|
|
92
94
|
}
|
|
93
95
|
/** Renders the background of a card (image, video, color, gradient). */
|
|
94
96
|
function CardBackground({ bg }) {
|
|
@@ -102,7 +104,9 @@ function CardBackground({ bg }) {
|
|
|
102
104
|
return (_jsx(Div, { className: POSITION_FILL, style: { background: `linear-gradient(${angle}deg, ${from}, ${to})` } }));
|
|
103
105
|
}
|
|
104
106
|
if (bg.type === "image" && bg.url) {
|
|
105
|
-
|
|
107
|
+
// `cover` for the same reason as the slide backdrop above — this sits
|
|
108
|
+
// behind the card's eyebrow/title/description, not beside them.
|
|
109
|
+
return _jsx(MediaImage, { src: bg.url, alt: "", size: "card", objectFit: "cover" });
|
|
106
110
|
}
|
|
107
111
|
return null;
|
|
108
112
|
}
|
|
@@ -198,7 +202,7 @@ export function HeroCarousel({ initialSlides, push, height, defaultAutoplayDelay
|
|
|
198
202
|
onMouseEnter: pauseOnHover ? () => setIsPaused(true) : undefined, onMouseLeave: pauseOnHover ? () => setIsPaused(false) : undefined, onFocus: () => setIsPaused(true), onBlur: () => setIsPaused(false), tabIndex: 0, children: [_jsx(Div, { "aria-live": "polite", "aria-atomic": "true", className: "sr-only", children: `Slide ${currentSlide + 1} of ${slides.length}` }), _jsx(Div, { className: POSITION_FILL, children: _jsx(Div, { layout: "flex", ref: slidesRef, onScroll: handleSlidesScroll, className: "h-full overflow-x-auto scroll-smooth snap-x snap-mandatory [scrollbar-width:none] [&::-webkit-scrollbar]:hidden", style: { gap: 0 }, children: slides.map((slide, slideIndex) => {
|
|
199
203
|
const slideHeightClass = getSlideHeightClass(slide.settings?.height);
|
|
200
204
|
const hasCards = slide.cards && slide.cards.length > 0;
|
|
201
|
-
return (_jsxs(Div, { className: `snap-start flex-none w-full relative self-stretch bg-neutral-900 ${slideHeightClass}`, children: [_jsx(Div, { className: POSITION_FILL, children: _jsx(SlideBackground, { bg: slide.background, legacy: slide.media, mobileLegacy: slide.mobileMedia, isMobile: isMobile, priority: slideIndex === 0 }) }), slide.overlay && (_jsxs(Div, { layout: "flex-col", align: "start", justify: "center", className: `${POSITION_FILL} text-left md:px-[4rem] lg:px-[8rem]`, padding: "x-lg", children: [slide.overlay.subtitle && (_jsx(Text, { color: "inverse", shadow: "sm", className: "stagger-1 opacity-80 mb-1 md:mb-2 tracking-widest", mdSize: "sm", size: "xs", transform: "uppercase", children: slide.overlay.subtitle })), slide.overlay.title && (
|
|
205
|
+
return (_jsxs(Div, { className: `snap-start flex-none w-full relative self-stretch bg-neutral-900 ${slideHeightClass}`, children: [_jsx(Div, { className: "absolute bottom-2 left-2 z-10", children: _jsx(OwnerQuickActions, { resource: "CAROUSEL", placement: "card", recordLabel: slide.title ?? `Slide ${slideIndex + 1}`, editHref: String(ROUTES.ADMIN.CAROUSEL_EDIT(slide.id)) }) }), _jsx(Div, { className: POSITION_FILL, children: _jsx(SlideBackground, { bg: slide.background, legacy: slide.media, mobileLegacy: slide.mobileMedia, isMobile: isMobile, priority: slideIndex === 0 }) }), slide.overlay && (_jsxs(Div, { layout: "flex-col", align: "start", justify: "center", className: `${POSITION_FILL} text-left md:px-[4rem] lg:px-[8rem]`, padding: "x-lg", children: [slide.overlay.subtitle && (_jsx(Text, { color: "inverse", shadow: "sm", className: "stagger-1 opacity-80 mb-1 md:mb-2 tracking-widest", mdSize: "sm", size: "xs", transform: "uppercase", children: slide.overlay.subtitle })), slide.overlay.title && (
|
|
202
206
|
// level={2}, NOT 1. The welcome section above this
|
|
203
207
|
// carousel owns the page's single <h1>; a carousel that
|
|
204
208
|
// emits one <h1> per slide gives the document as many
|
|
@@ -17,6 +17,7 @@ import { getHomepageFaqsCached } from "../../faq/utils/getHomepageFaqsCached.js"
|
|
|
17
17
|
import { safeRead } from "../../../errors/safe-read.js";
|
|
18
18
|
import { fetchLiveStats } from "../lib/live-stats.js";
|
|
19
19
|
import { renderSection, AnnouncementBar } from "../lib/section-renderer.js";
|
|
20
|
+
import { gateCarouselSlides } from "../lib/slide-gating.js";
|
|
20
21
|
import { SECTION_COPY } from "../constants/section-copy.js";
|
|
21
22
|
import { homepageSectionsRepository } from "../repository/homepage-sections.repository.js";
|
|
22
23
|
import { isListingTypeEnabled, isCategoryTypeEnabled, } from "../../../_internal/shared/listing-types/feature-flags.js";
|
|
@@ -215,6 +216,20 @@ export async function MarketplaceHomepageView({ adSlots, newsletterFormSlot, onB
|
|
|
215
216
|
? eventsResult.items
|
|
216
217
|
: undefined,
|
|
217
218
|
};
|
|
218
|
-
|
|
219
|
+
/*
|
|
220
|
+
* The same "do not advertise what we do not sell" rule the section filter
|
|
221
|
+
* above applies, one level deeper.
|
|
222
|
+
*
|
|
223
|
+
* `enabledSections` is keyed on `SectionType`, so it can drop the auctions
|
|
224
|
+
* RAIL but is structurally blind to a carousel — a carousel is one section
|
|
225
|
+
* of type `carousel` whatever its slides happen to be about. With
|
|
226
|
+
* `auction: false` live, the hero still shipped a "Live auctions" card and a
|
|
227
|
+
* full "Win it at your price" slide, both pointing at `/auctions`.
|
|
228
|
+
*
|
|
229
|
+
* Gated on the LINK rather than the copy: "Win it at your price" names no
|
|
230
|
+
* type, so a keyword scan would miss the worse of the two offenders while
|
|
231
|
+
* eating innocent ones. See `slide-gating.ts`.
|
|
232
|
+
*/
|
|
233
|
+
const carouselSlides = gateCarouselSlides(toCarouselSlides(slides), siteSettings);
|
|
219
234
|
return (_jsx(Main, { children: _jsxs(Div, { className: "relative", children: [showAnnouncement ? (_jsx(TopBannerPortal, { children: _jsx(AnnouncementBar, { message: announcementMessage, link: siteSettings?.announcementBar?.link, onDismiss: onBannerDismiss }) })) : null, orderedSections.map((section) => renderSection(section, adSlots, newsletterFormSlot ?? null, faqItems, carouselSlides, liveStats, sectionData, brand))] }) }));
|
|
220
235
|
}
|
|
@@ -19,8 +19,13 @@ export interface WelcomeSectionProps {
|
|
|
19
19
|
onSecondaryCtaClick?: () => void;
|
|
20
20
|
trustChips?: WelcomeSectionChip[];
|
|
21
21
|
/**
|
|
22
|
-
* Decorative hero imagery
|
|
23
|
-
*
|
|
22
|
+
* Decorative hero imagery for the mosaic's SMALL cells — the large cell is
|
|
23
|
+
* always the brand panel now, so these no longer compete for it.
|
|
24
|
+
*
|
|
25
|
+
* 🛑 Pass REAL images only. A caller sourcing these from
|
|
26
|
+
* `display.coverImage` must drop `SEED_TILE_PREFIX` values first: those are
|
|
27
|
+
* six flat generated SVGs, and a hero built from them is four coloured
|
|
28
|
+
* rectangles claiming to be a product preview.
|
|
24
29
|
*
|
|
25
30
|
* Must be deterministic: `ImageMosaic` is explicit that `Math.random()` here
|
|
26
31
|
* would break SSR/hydration agreement, so the caller passes a stable
|
|
@@ -29,6 +34,14 @@ export interface WelcomeSectionProps {
|
|
|
29
34
|
heroImages?: readonly string[];
|
|
30
35
|
isLoading?: boolean;
|
|
31
36
|
brandLogoText?: string;
|
|
37
|
+
/**
|
|
38
|
+
* Wordmark caption under the hero logo, e.g. "Let It Rip India".
|
|
39
|
+
*
|
|
40
|
+
* Consumer-supplied, never defaulted to a literal here: appkit is a library
|
|
41
|
+
* and a brand string baked into it is the encapsulation break
|
|
42
|
+
* `audit-ssr-in-appkit` Rule 3 exists to prevent. Omitted = no caption.
|
|
43
|
+
*/
|
|
44
|
+
brandCaption?: string;
|
|
32
45
|
className?: string;
|
|
33
46
|
}
|
|
34
|
-
export declare function WelcomeSection({ title, subtitle, pillLabel, showCTA, ctaLabel, ctaHref, onCtaClick, secondaryCtaLabel, secondaryCtaHref, onSecondaryCtaClick, trustChips, heroImages, isLoading, brandLogoText, className, }: WelcomeSectionProps): React.JSX.Element;
|
|
47
|
+
export declare function WelcomeSection({ title, subtitle, pillLabel, showCTA, ctaLabel, ctaHref, onCtaClick, secondaryCtaLabel, secondaryCtaHref, onSecondaryCtaClick, trustChips, heroImages, isLoading, brandLogoText, brandCaption, className, }: WelcomeSectionProps): React.JSX.Element;
|