@mohasinac/appkit 4.56.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 +3 -3
- package/dist/_internal/server/features/checkout/actions.d.ts +31 -0
- package/dist/_internal/server/features/checkout/actions.js +117 -5
- 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/shared/actions/action-registry.js +53 -0
- package/dist/_internal/shared/shipping/quote.js +5 -6
- package/dist/client.d.ts +21 -0
- package/dist/client.js +2 -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/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/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 +3 -1
- package/dist/features/homepage/lib/section-renderer.js +2 -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/index.d.ts +2 -2
- package/dist/features/orders/types/index.d.ts +15 -0
- package/dist/features/points/repository/points.repository.js +5 -5
- package/dist/features/points/schemas/firestore.d.ts +16 -0
- package/dist/features/points/schemas/firestore.js +17 -0
- package/dist/features/reviews/components/ReviewDetailPageView.js +3 -2
- package/dist/features/stores/components/StoreHeader.js +3 -1
- 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/server.d.ts +3 -1
- package/dist/server.js +11 -0
- package/package.json +1 -1
|
@@ -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
|
}
|
|
@@ -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
|
}
|
|
@@ -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) {
|
|
@@ -200,7 +202,7 @@ export function HeroCarousel({ initialSlides, push, height, defaultAutoplayDelay
|
|
|
200
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) => {
|
|
201
203
|
const slideHeightClass = getSlideHeightClass(slide.settings?.height);
|
|
202
204
|
const hasCards = slide.cards && slide.cards.length > 0;
|
|
203
|
-
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 && (
|
|
204
206
|
// level={2}, NOT 1. The welcome section above this
|
|
205
207
|
// carousel owns the page's single <h1>; a carousel that
|
|
206
208
|
// emits one <h1> per slide gives the document as many
|
|
@@ -48,6 +48,7 @@ import { CollectionCardsSection } from "../components/CollectionCardsSection.js"
|
|
|
48
48
|
import { HowItWorksSection } from "../components/HowItWorksSection.js";
|
|
49
49
|
import { TestimonialsCarousel } from "../components/TestimonialsCarousel.js";
|
|
50
50
|
import { PromoGrid } from "../components/PromoGrid.js";
|
|
51
|
+
import { OwnerQuickActions } from "../../admin/components/OwnerQuickActions.js";
|
|
51
52
|
const AD_SLOT_MAP = {
|
|
52
53
|
carousel: "afterHero",
|
|
53
54
|
products: "afterFeaturedProducts",
|
|
@@ -433,6 +434,6 @@ export function renderSection(section, adSlots, newsletterFormSlot, faqItems, sl
|
|
|
433
434
|
if (!sectionElement)
|
|
434
435
|
return null;
|
|
435
436
|
const adSlotKey = AD_SLOT_MAP[section.type];
|
|
436
|
-
return (_jsxs(React.Fragment, { children: [_jsx(HomepageSectionBoundary, { sectionId: section.id, sectionType: section.type, children: sectionElement }), adSlots && adSlotKey !== undefined && adSlotKey in adSlots && adSlots[adSlotKey]] }, section.id));
|
|
437
|
+
return (_jsxs(React.Fragment, { children: [_jsx(OwnerQuickActions, { resource: "SECTION", placement: "detail", className: "justify-end", recordLabel: section.type, editHref: String(ROUTES.ADMIN.SECTIONS) }), _jsx(HomepageSectionBoundary, { sectionId: section.id, sectionType: section.type, children: sectionElement }), adSlots && adSlotKey !== undefined && adSlotKey in adSlots && adSlots[adSlotKey]] }, section.id));
|
|
437
438
|
}
|
|
438
439
|
export { AnnouncementBar, hashBannerMessage };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { EmiInstallment } from "../schemas/firestore";
|
|
3
|
+
export interface EmiInstallmentsPanelProps {
|
|
4
|
+
orderId: string;
|
|
5
|
+
installments: readonly EmiInstallment[];
|
|
6
|
+
/** Sum of unpaid instalments, as recorded on the order. */
|
|
7
|
+
remainingBalance?: number;
|
|
8
|
+
currency?: string;
|
|
9
|
+
/**
|
|
10
|
+
* Submits one instalment. Supplied by the consumer so this component does
|
|
11
|
+
* no fetching of its own — the same slot shape the rest of the orders
|
|
12
|
+
* feature uses.
|
|
13
|
+
*/
|
|
14
|
+
onSubmit: (input: {
|
|
15
|
+
installmentIndex: number;
|
|
16
|
+
transactionId: string;
|
|
17
|
+
}) => Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
export declare function EmiInstallmentsPanel({ orderId, installments, remainingBalance, currency, onSubmit, }: EmiInstallmentsPanelProps): React.JSX.Element;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { Badge, Button, Div, Form, Row, Stack, Text, useToast } from "../../../ui/index.js";
|
|
5
|
+
import { FieldInput } from "../../../ui/forms/index.js";
|
|
6
|
+
import { formatCurrency } from "../../../utils/number.formatter.js";
|
|
7
|
+
import { normalizeError } from "../../../errors/normalize.js";
|
|
8
|
+
function stateOf(i) {
|
|
9
|
+
if (i.status === "paid")
|
|
10
|
+
return "paid";
|
|
11
|
+
// A reference the buyer submitted but nobody has confirmed. The status
|
|
12
|
+
// field cannot express this: an instalment is created "pending" and stays
|
|
13
|
+
// "pending" until it is paid, so the presence of a reference is the signal.
|
|
14
|
+
if (i.transactionId)
|
|
15
|
+
return "awaiting-review";
|
|
16
|
+
return "payable";
|
|
17
|
+
}
|
|
18
|
+
const STATE_LABEL = {
|
|
19
|
+
paid: "Paid",
|
|
20
|
+
"awaiting-review": "Awaiting confirmation",
|
|
21
|
+
payable: "Due",
|
|
22
|
+
};
|
|
23
|
+
export function EmiInstallmentsPanel({ orderId, installments, remainingBalance, currency = "INR", onSubmit, }) {
|
|
24
|
+
const { showToast } = useToast();
|
|
25
|
+
const [openIndex, setOpenIndex] = useState(null);
|
|
26
|
+
const [reference, setReference] = useState("");
|
|
27
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
28
|
+
const money = (value) => formatCurrency(value, currency);
|
|
29
|
+
const ordered = [...installments].sort((a, b) => a.index - b.index);
|
|
30
|
+
const outstanding = ordered.filter((i) => i.status !== "paid").length;
|
|
31
|
+
const submit = async (index) => {
|
|
32
|
+
setIsSubmitting(true);
|
|
33
|
+
try {
|
|
34
|
+
await onSubmit({ installmentIndex: index, transactionId: reference.trim() });
|
|
35
|
+
showToast("Payment reference submitted — the seller will confirm it.", "success");
|
|
36
|
+
setOpenIndex(null);
|
|
37
|
+
setReference("");
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
void normalizeError(err);
|
|
41
|
+
// The action's own message is surfaced by the caller's error mapping;
|
|
42
|
+
// this only has to stop the panel pretending the submission landed.
|
|
43
|
+
showToast("Couldn't submit that payment. Please try again.", "error");
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
setIsSubmitting(false);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
return (_jsx(Div, { surface: "card", border: "default", rounded: "lg", padding: "md", children: _jsxs(Stack, { gap: "sm", children: [_jsxs(Row, { align: "center", justify: "between", gap: "sm", children: [_jsx(Text, { weight: "semibold", children: "Your EMI plan" }), remainingBalance !== undefined && remainingBalance > 0 && (_jsxs(Text, { size: "sm", color: "muted", children: [money(remainingBalance), " outstanding across ", outstanding, " instalment", outstanding === 1 ? "" : "s"] }))] }), _jsx(Text, { size: "xs", color: "muted", children: "You can pay any instalment whenever you like, including ahead of its due date \u2014 paying early settles the plan sooner and does not cost more." }), _jsx(Stack, { gap: "xs", children: ordered.map((inst) => {
|
|
50
|
+
const state = stateOf(inst);
|
|
51
|
+
const due = inst.dueDate ? new Date(inst.dueDate) : null;
|
|
52
|
+
return (_jsxs(Div, { border: "default", rounded: "md", padding: "sm", children: [_jsxs(Row, { align: "center", justify: "between", gap: "sm", className: "min-w-0", children: [_jsxs(Stack, { gap: "xs", className: "min-w-0", children: [_jsxs(Text, { size: "sm", weight: "medium", children: ["Instalment ", inst.index, " \u00B7 ", money(inst.amount)] }), _jsx(Text, { size: "xs", color: "muted", children: due ? `Due ${due.toLocaleDateString()}` : "No due date recorded" })] }), _jsxs(Row, { align: "center", gap: "xs", className: "flex-shrink-0", children: [_jsx(Badge, { variant: state === "paid" ? "success" : state === "awaiting-review" ? "warning" : "default", children: STATE_LABEL[state] }), state === "payable" && (_jsx(Button, { type: "button", size: "sm", variant: "secondary", onClick: () => {
|
|
53
|
+
setOpenIndex(openIndex === inst.index ? null : inst.index);
|
|
54
|
+
setReference("");
|
|
55
|
+
}, children: openIndex === inst.index ? "Cancel" : "Pay now" }))] })] }), openIndex === inst.index && (_jsx(Div, { className: "mt-3", children: _jsx(Form, { onSubmit: (e) => e.preventDefault(), children: () => (_jsxs(Stack, { gap: "sm", children: [_jsxs(Text, { size: "xs", color: "muted", children: ["Transfer ", money(inst.amount), " to the seller, then enter the UTR or reference number so they can confirm it."] }), _jsx(FieldInput, { name: `emi-reference-${orderId}-${inst.index}`, label: "UTR / reference number", required: true, value: reference, onChange: setReference }), _jsx(Button, { type: "submit", size: "sm", isLoading: isSubmitting, disabled: reference.trim().length === 0 || isSubmitting, onClick: () => { void submit(inst.index); }, children: "Submit payment reference" })] })) }) }))] }, inst.index));
|
|
56
|
+
}) })] }) }));
|
|
57
|
+
}
|
|
@@ -14,3 +14,4 @@ export { RefundRequestView } from "./RefundRequestView";
|
|
|
14
14
|
export type { RefundRequestViewProps } from "./RefundRequestView";
|
|
15
15
|
export { YourPurchaseStrip } from "./YourPurchaseStrip";
|
|
16
16
|
export type { YourPurchaseStripProps, PurchaseContext } from "./YourPurchaseStrip";
|
|
17
|
+
export { EmiInstallmentsPanel, type EmiInstallmentsPanelProps, } from "./EmiInstallmentsPanel";
|
|
@@ -7,3 +7,4 @@ export { OrderStatusTimeline } from "./OrderStatusTimeline.js";
|
|
|
7
7
|
export { RefundHistoryTable } from "./RefundHistoryTable.js";
|
|
8
8
|
export { RefundRequestView } from "./RefundRequestView.js";
|
|
9
9
|
export { YourPurchaseStrip } from "./YourPurchaseStrip.js";
|
|
10
|
+
export { EmiInstallmentsPanel, } from "./EmiInstallmentsPanel.js";
|
|
@@ -614,6 +614,7 @@ export declare const orderFirestoreSchema: z.ZodObject<{
|
|
|
614
614
|
} | undefined;
|
|
615
615
|
offerId?: string | undefined;
|
|
616
616
|
orderType?: string | undefined;
|
|
617
|
+
isNonRefundable?: boolean | undefined;
|
|
617
618
|
imageUrls?: string[] | undefined;
|
|
618
619
|
refundFeeDeducted?: number | undefined;
|
|
619
620
|
refundNetAmount?: number | undefined;
|
|
@@ -650,7 +651,6 @@ export declare const orderFirestoreSchema: z.ZodObject<{
|
|
|
650
651
|
}[] | undefined;
|
|
651
652
|
emiRemainingBalance?: number | undefined;
|
|
652
653
|
emiComplete?: boolean | undefined;
|
|
653
|
-
isNonRefundable?: boolean | undefined;
|
|
654
654
|
paymentBatchId?: string | undefined;
|
|
655
655
|
refunds?: {
|
|
656
656
|
type: "full" | "partial";
|
|
@@ -762,6 +762,7 @@ export declare const orderFirestoreSchema: z.ZodObject<{
|
|
|
762
762
|
} | undefined;
|
|
763
763
|
offerId?: string | undefined;
|
|
764
764
|
orderType?: string | undefined;
|
|
765
|
+
isNonRefundable?: boolean | undefined;
|
|
765
766
|
imageUrls?: string[] | undefined;
|
|
766
767
|
refundFeeDeducted?: number | undefined;
|
|
767
768
|
refundNetAmount?: number | undefined;
|
|
@@ -798,7 +799,6 @@ export declare const orderFirestoreSchema: z.ZodObject<{
|
|
|
798
799
|
}[] | undefined;
|
|
799
800
|
emiRemainingBalance?: number | undefined;
|
|
800
801
|
emiComplete?: boolean | undefined;
|
|
801
|
-
isNonRefundable?: boolean | undefined;
|
|
802
802
|
paymentBatchId?: string | undefined;
|
|
803
803
|
refunds?: {
|
|
804
804
|
type: "full" | "partial";
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { EmiInstallment } from "../schemas/firestore";
|
|
1
2
|
import type { ListingType } from "../../products/types/index";
|
|
2
3
|
import type { OrderType } from "../utils/order-splitter";
|
|
3
4
|
import type { AppliedOrderDiscount, OrderSourceContext } from "../schemas/firestore";
|
|
@@ -183,6 +184,20 @@ export interface Order {
|
|
|
183
184
|
* read it from site settings, because `gst` is in `PRIVATE_SITE_FIELDS`
|
|
184
185
|
* after an earlier deny-list projection shipped it publicly (Root Cause #70).
|
|
185
186
|
*/
|
|
187
|
+
/**
|
|
188
|
+
* ── EMI, buyer-facing ─────────────────────────────────────────────────
|
|
189
|
+
*
|
|
190
|
+
* 🛑 Declared here because `EmiInstallmentsPanel` is useless without it,
|
|
191
|
+
* and the absence was Root Cause #57's exact shape: the whole EMI schedule
|
|
192
|
+
* sat on `OrderDocument` and `orderDocumentToOrder` mapped none of it, so
|
|
193
|
+
* every buyer surface would have rendered its "no EMI plan" fallback with
|
|
194
|
+
* a real plan on the record. That reads as "this order isn't on EMI"
|
|
195
|
+
* rather than as a bug, which is why it survives.
|
|
196
|
+
*/
|
|
197
|
+
emiEnabled?: boolean;
|
|
198
|
+
emiInstallments?: EmiInstallment[];
|
|
199
|
+
/** Sum of unpaid instalments. 0 once the plan is settled. */
|
|
200
|
+
emiRemainingBalance?: number;
|
|
186
201
|
supplierGstin?: string;
|
|
187
202
|
supplierLegalName?: string;
|
|
188
203
|
supplierAddress?: string;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { getAdminDb } from "../../../providers/db-firebase/index.js";
|
|
2
|
-
import { POINTS_LEDGER_COLLECTION, isPointsEntryWellFormed, } from "../schemas/firestore.js";
|
|
2
|
+
import { POINTS_FIELDS, POINTS_LEDGER_COLLECTION, isPointsEntryWellFormed, } from "../schemas/firestore.js";
|
|
3
3
|
import { USER_COLLECTION } from "../../auth/schemas/firestore.js";
|
|
4
4
|
/**
|
|
5
5
|
* The points ledger: append-only entries, and a balance that is their sum.
|
|
@@ -51,8 +51,8 @@ export class PointsRepository {
|
|
|
51
51
|
const db = getAdminDb();
|
|
52
52
|
const rows = await db
|
|
53
53
|
.collection(POINTS_LEDGER_COLLECTION)
|
|
54
|
-
.where(
|
|
55
|
-
.select(
|
|
54
|
+
.where(POINTS_FIELDS.USER_ID, "==", userId)
|
|
55
|
+
.select(POINTS_FIELDS.AMOUNT)
|
|
56
56
|
.get();
|
|
57
57
|
const total = rows.docs.reduce((sum, d) => sum + (Number(d.data().amount) || 0), 0);
|
|
58
58
|
return Math.round(total * 100) / 100;
|
|
@@ -145,8 +145,8 @@ export class PointsRepository {
|
|
|
145
145
|
const db = getAdminDb();
|
|
146
146
|
const rows = await db
|
|
147
147
|
.collection(POINTS_LEDGER_COLLECTION)
|
|
148
|
-
.where(
|
|
149
|
-
.orderBy(
|
|
148
|
+
.where(POINTS_FIELDS.USER_ID, "==", userId)
|
|
149
|
+
.orderBy(POINTS_FIELDS.CREATED_AT, "desc")
|
|
150
150
|
.limit(limit)
|
|
151
151
|
.get();
|
|
152
152
|
return rows.docs.map((d) => d.data());
|
|
@@ -81,6 +81,22 @@ export interface PointsLedgerEntry extends BaseDocument {
|
|
|
81
81
|
*/
|
|
82
82
|
export declare function isPointsEntryWellFormed(entry: Pick<PointsLedgerEntry, "kind" | "amount" | "adminReason">): boolean;
|
|
83
83
|
export declare const POINTS_LEDGER_COLLECTION = "pointsLedger";
|
|
84
|
+
/**
|
|
85
|
+
* Firestore field names for this collection.
|
|
86
|
+
*
|
|
87
|
+
* Declared rather than written as string literals at the query site, matching
|
|
88
|
+
* every other collection here: a typo in a raw `where("userID", ...)` is a
|
|
89
|
+
* query that returns nothing and raises nothing, which on a points balance
|
|
90
|
+
* reads as "you have no points" rather than as a bug.
|
|
91
|
+
*/
|
|
92
|
+
export declare const POINTS_FIELDS: {
|
|
93
|
+
readonly ID: "id";
|
|
94
|
+
readonly USER_ID: "userId";
|
|
95
|
+
readonly KIND: "kind";
|
|
96
|
+
readonly AMOUNT: "amount";
|
|
97
|
+
readonly ORDER_ID: "orderId";
|
|
98
|
+
readonly CREATED_AT: "createdAt";
|
|
99
|
+
};
|
|
84
100
|
/**
|
|
85
101
|
* 🛑 Points cannot pay for the whole order, and this is the cap that enforces
|
|
86
102
|
* it.
|
|
@@ -71,6 +71,22 @@ export function isPointsEntryWellFormed(entry) {
|
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
73
|
export const POINTS_LEDGER_COLLECTION = "pointsLedger";
|
|
74
|
+
/**
|
|
75
|
+
* Firestore field names for this collection.
|
|
76
|
+
*
|
|
77
|
+
* Declared rather than written as string literals at the query site, matching
|
|
78
|
+
* every other collection here: a typo in a raw `where("userID", ...)` is a
|
|
79
|
+
* query that returns nothing and raises nothing, which on a points balance
|
|
80
|
+
* reads as "you have no points" rather than as a bug.
|
|
81
|
+
*/
|
|
82
|
+
export const POINTS_FIELDS = {
|
|
83
|
+
ID: "id",
|
|
84
|
+
USER_ID: "userId",
|
|
85
|
+
KIND: "kind",
|
|
86
|
+
AMOUNT: "amount",
|
|
87
|
+
ORDER_ID: "orderId",
|
|
88
|
+
CREATED_AT: "createdAt",
|
|
89
|
+
};
|
|
74
90
|
/**
|
|
75
91
|
* 🛑 Points cannot pay for the whole order, and this is the cap that enforces
|
|
76
92
|
* it.
|
|
@@ -94,6 +110,7 @@ export function maxRedeemablePoints(orderTotal, balance) {
|
|
|
94
110
|
return 0;
|
|
95
111
|
if (!Number.isFinite(balance) || balance <= 0)
|
|
96
112
|
return 0;
|
|
113
|
+
// audit-money-units-ok: a percentage of an order total, not a paise/rupee conversion
|
|
97
114
|
const cap = Math.floor((orderTotal * POINTS_MAX_REDEMPTION_PERCENT) / 100);
|
|
98
115
|
return Math.max(0, Math.min(Math.floor(balance), cap));
|
|
99
116
|
}
|
|
@@ -2,10 +2,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import Link from "next/link.js";
|
|
3
3
|
import { reviewRepository } from "../../../repositories/index.js";
|
|
4
4
|
import { safeRead } from "../../../errors/safe-read.js";
|
|
5
|
-
import { Container, Div, Grid, Heading, Main, Nav, Section, Span, Text } from "../../../ui/index.js";
|
|
5
|
+
import { Container, Div, Grid, Heading, Main, Nav, Row, Section, Span, Text } from "../../../ui/index.js";
|
|
6
6
|
import { ROUTES } from "../../../next/routing/route-map.js";
|
|
7
7
|
import { ReviewDetailShell } from "./ReviewDetailShell.js";
|
|
8
8
|
import { ReviewCard } from "./ReviewsList.js";
|
|
9
|
+
import { OwnerQuickActions } from "../../admin/components/OwnerQuickActions.js";
|
|
9
10
|
export async function ReviewDetailPageView({ id }) {
|
|
10
11
|
// The review IS this page's subject — a read failure must surface rather than
|
|
11
12
|
// become the same `null` a removed review produces, which the "Review not
|
|
@@ -42,5 +43,5 @@ export async function ReviewDetailPageView({ id }) {
|
|
|
42
43
|
]);
|
|
43
44
|
const sameProductReviews = sameProductDocs.filter((r) => r.id !== review.id).slice(0, 6);
|
|
44
45
|
const sameStoreReviews = sameStoreDocs.filter((r) => r.id !== review.id).slice(0, 6);
|
|
45
|
-
return (_jsxs(Main, { children: [_jsx(Div, { paddingY: "y-xs-tall", className: "border-b border-neutral-100 bg-neutral-50", padding: "x-md", children: _jsxs(Nav, { layout: "flex", gap: "2xs", textSize: "xs", color: "faint", className: "mx-auto max-w-3xl", "aria-label": "Breadcrumb", children: [_jsx(Link, { href: String(ROUTES.HOME), className: "hover:text-primary transition-colors", children: "Home" }), _jsx(Span, { children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.REVIEWS), className: "hover:text-primary transition-colors", children: "Reviews" }), _jsx(Span, { children: "/" }), _jsx(Span, { className: "text-neutral-700 truncate max-w-[200px]", children: review.title ?? review.id })] }) }), _jsx(ReviewDetailShell, { review: review, storeHref: storeSlug ? String(ROUTES.PUBLIC.STORE_DETAIL(storeSlug)) : null }), sameProductReviews.length > 0 && (_jsx(Section, { padding: "y-lg", border: "subtle", className: "border-t", children: _jsxs(Container, { size: "sm", children: [_jsxs(Heading, { level: 2, className: "mb-4", size: "lg", weight: "semibold", children: ["More reviews for ", review.productTitle ?? "this product"] }), _jsx(Grid, { cols: "cardsWide", gap: "md", children: sameProductReviews.map((r) => (_jsx(ReviewCard, { review: r, context: "listing" }, r.id))) })] }) })), sameStoreReviews.length > 0 && (_jsx(Section, { padding: "y-lg", border: "subtle", className: "border-t", children: _jsxs(Container, { size: "sm", children: [_jsxs(Heading, { level: 2, className: "mb-4", size: "lg", weight: "semibold", children: ["More reviews for ", review.storeName ?? "this store"] }), _jsx(Grid, { cols: "cardsWide", gap: "md", children: sameStoreReviews.map((r) => (_jsx(ReviewCard, { review: r, context: "store" }, r.id))) })] }) }))] }));
|
|
46
|
+
return (_jsxs(Main, { children: [_jsx(Div, { paddingY: "y-xs-tall", className: "border-b border-neutral-100 bg-neutral-50", padding: "x-md", children: _jsxs(Nav, { layout: "flex", gap: "2xs", textSize: "xs", color: "faint", className: "mx-auto max-w-3xl", "aria-label": "Breadcrumb", children: [_jsx(Link, { href: String(ROUTES.HOME), className: "hover:text-primary transition-colors", children: "Home" }), _jsx(Span, { children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.REVIEWS), className: "hover:text-primary transition-colors", children: "Reviews" }), _jsx(Span, { children: "/" }), _jsx(Span, { className: "text-neutral-700 truncate max-w-[200px]", children: review.title ?? review.id })] }) }), _jsx(Row, { justify: "end", className: "mb-2", children: _jsx(OwnerQuickActions, { resource: "REVIEW", placement: "detail", ownerId: review.userId, recordLabel: review.title ?? review.id, editHref: String(ROUTES.USER.REVIEW_EDIT(review.id)) }) }), _jsx(ReviewDetailShell, { review: review, storeHref: storeSlug ? String(ROUTES.PUBLIC.STORE_DETAIL(storeSlug)) : null }), sameProductReviews.length > 0 && (_jsx(Section, { padding: "y-lg", border: "subtle", className: "border-t", children: _jsxs(Container, { size: "sm", children: [_jsxs(Heading, { level: 2, className: "mb-4", size: "lg", weight: "semibold", children: ["More reviews for ", review.productTitle ?? "this product"] }), _jsx(Grid, { cols: "cardsWide", gap: "md", children: sameProductReviews.map((r) => (_jsx(ReviewCard, { review: r, context: "listing" }, r.id))) })] }) })), sameStoreReviews.length > 0 && (_jsx(Section, { padding: "y-lg", border: "subtle", className: "border-t", children: _jsxs(Container, { size: "sm", children: [_jsxs(Heading, { level: 2, className: "mb-4", size: "lg", weight: "semibold", children: ["More reviews for ", review.storeName ?? "this store"] }), _jsx(Grid, { cols: "cardsWide", gap: "md", children: sameStoreReviews.map((r) => (_jsx(ReviewCard, { review: r, context: "store" }, r.id))) })] }) }))] }));
|
|
46
47
|
}
|