@mohasinac/appkit 4.9.3 → 4.9.5

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.
Files changed (67) hide show
  1. package/dist/_internal/client/features/layout/DashboardLayoutClient.js +7 -2
  2. package/dist/_internal/client/features/layout/SidebarCollapseToggle.d.ts +5 -1
  3. package/dist/_internal/client/features/layout/SidebarCollapseToggle.js +8 -2
  4. package/dist/_internal/client/features/layout/sidebarPositionClasses.d.ts +20 -0
  5. package/dist/_internal/client/features/layout/sidebarPositionClasses.js +20 -0
  6. package/dist/_internal/client/hand-mode/HandModeProvider.d.ts +21 -0
  7. package/dist/_internal/client/hand-mode/HandModeProvider.js +81 -0
  8. package/dist/_internal/client/hand-mode/index.d.ts +2 -0
  9. package/dist/_internal/client/hand-mode/index.js +1 -0
  10. package/dist/_internal/server/features/checkout/actions.d.ts +43 -0
  11. package/dist/_internal/server/features/checkout/actions.js +172 -81
  12. package/dist/_internal/server/features/checkout/index.d.ts +1 -1
  13. package/dist/_internal/server/features/checkout/index.js +1 -1
  14. package/dist/_internal/server/features/seo/robots.js +3 -2
  15. package/dist/_internal/server/features/seo/sitemap.js +70 -5
  16. package/dist/_internal/shared/config/schema.d.ts +1 -0
  17. package/dist/_internal/shared/listing-types/classified/config.js +1 -1
  18. package/dist/_internal/shared/listing-types/digital-code/config.js +1 -1
  19. package/dist/_internal/shared/listing-types/live/config.js +1 -1
  20. package/dist/client.d.ts +4 -0
  21. package/dist/client.js +9 -0
  22. package/dist/constants/api-endpoints.d.ts +3 -0
  23. package/dist/constants/api-endpoints.js +1 -0
  24. package/dist/features/about/components/AboutView.js +2 -2
  25. package/dist/features/about/components/DeveloperView.js +2 -2
  26. package/dist/features/about/schemas/firestore.d.ts +1 -0
  27. package/dist/features/account/components/UserSidebar.js +8 -2
  28. package/dist/features/account/hooks/useProfile.d.ts +2 -0
  29. package/dist/features/admin/components/AdminSidebar.js +8 -2
  30. package/dist/features/auth/schemas/firestore.d.ts +3 -0
  31. package/dist/features/cart/components/CartDrawer.js +4 -1
  32. package/dist/features/homepage/components/BrandsSection.js +3 -1
  33. package/dist/features/homepage/components/FeaturedBundlesSection.js +12 -1
  34. package/dist/features/homepage/components/GoogleReviewsSection.js +2 -2
  35. package/dist/features/homepage/components/HeroCarousel.js +3 -1
  36. package/dist/features/homepage/components/SectionCarousel.js +5 -2
  37. package/dist/features/homepage/components/SocialFeedSection.js +2 -2
  38. package/dist/features/homepage/components/WhatsAppCommunitySection.js +3 -1
  39. package/dist/features/layout/BackToTop.js +3 -1
  40. package/dist/features/layout/SidebarLayout.js +7 -1
  41. package/dist/features/seller/components/SellerSidebar.js +8 -2
  42. package/dist/features/shell/QuickFormDrawer.js +3 -1
  43. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +31 -0
  44. package/dist/hand-mode/index.d.ts +12 -0
  45. package/dist/hand-mode/index.js +11 -0
  46. package/dist/providers/db-firebase/base.js +17 -1
  47. package/dist/react/contexts/SessionContext.d.ts +5 -0
  48. package/dist/react/contexts/SessionContext.js +2 -0
  49. package/dist/seed/site-settings-seed-data.js +3 -2
  50. package/dist/server-entry.d.ts +1 -1
  51. package/dist/server-entry.js +1 -1
  52. package/dist/styles.css +2 -1
  53. package/dist/tailwind-utilities.css +1 -1
  54. package/dist/ui/components/Drawer.js +7 -4
  55. package/dist/ui/components/FilterDrawer.js +1 -1
  56. package/dist/ui/components/HandModeRow.d.ts +12 -0
  57. package/dist/ui/components/HandModeRow.js +18 -0
  58. package/dist/ui/components/Layout.d.ts +5 -1
  59. package/dist/ui/components/Layout.js +2 -1
  60. package/dist/ui/components/Layout.style.css +1 -0
  61. package/dist/ui/components/ListingFilterDrawer.js +3 -1
  62. package/dist/ui/components/Modal.js +3 -1
  63. package/dist/ui/components/SideDrawer.js +8 -5
  64. package/dist/ui/components/SideModal.js +7 -4
  65. package/dist/ui/index.d.ts +1 -0
  66. package/dist/ui/index.js +1 -0
  67. package/package.json +1 -1
@@ -33,6 +33,7 @@ import { DASHBOARD_DESKTOP_MEDIA_QUERY } from "../../../shared/features/layout/c
33
33
  import { filterNavItems } from "./filterNavItems";
34
34
  import { useSiteSettings } from "../../../../core/hooks/useSiteSettings";
35
35
  import { useTheme } from "../../theme";
36
+ import { useHandMode } from "../../hand-mode";
36
37
  import { BackgroundRenderer, Div, Nav, Span } from "../../../../ui";
37
38
  import { useToast } from "../../../../ui/components/Toast";
38
39
  import { useVisualViewportInset } from "../../../../react/hooks/useVisualViewportInset";
@@ -149,7 +150,10 @@ function DashboardBottomNav({ items, activeHref }) {
149
150
  return (_jsxs(Link, { href: item.href, className: `flex flex-1 flex-col items-center justify-center gap-[var(--appkit-space-0-5)] py-[var(--appkit-space-2)] ${isActive ? "text-[var(--appkit-color-primary)]" : "text-[var(--appkit-color-text-muted)]"}`, "aria-current": isActive ? "page" : undefined, children: [item.icon && _jsx(Span, { size: "base", children: item.icon }), _jsx(Span, { size: "xs", className: "max-w-full truncate", children: item.label })] }, item.href));
150
151
  }) }));
151
152
  }
152
- const DEFAULT_CONTENT_PADDING = "px-[var(--appkit-space-5)] py-[var(--appkit-space-8)] lg:pl-14 lg:pr-6 xl:pl-16 xl:pr-10";
153
+ // Asymmetric padding clears the persistent sidebar rail — right-heavy by
154
+ // default (rail docks left), left-heavy in left-hand mode (rail docks right).
155
+ const DEFAULT_CONTENT_PADDING_RIGHT = "px-[var(--appkit-space-5)] py-[var(--appkit-space-8)] lg:pl-14 lg:pr-6 xl:pl-16 xl:pr-10";
156
+ const DEFAULT_CONTENT_PADDING_LEFT = "px-[var(--appkit-space-5)] py-[var(--appkit-space-8)] lg:pr-14 lg:pl-6 xl:pr-16 xl:pl-10";
153
157
  const DEFAULT_CONTENT_MAX_WIDTH = "max-w-screen-2xl";
154
158
  export function DashboardLayoutClient({ variant, groups, permissions, activeHref: explicitActiveHref, responsive: _responsive, crossNav, className, contentPadding, contentSurface, contentMaxWidth, lightBackground, darkBackground, children, }) {
155
159
  const pathname = usePathname();
@@ -178,6 +182,7 @@ export function DashboardLayoutClient({ variant, groups, permissions, activeHref
178
182
  const { data: settings } = useSiteSettings();
179
183
  const navConfig = settings?.navConfig;
180
184
  const { theme } = useTheme();
185
+ const { hand } = useHandMode();
181
186
  // Explicit props win; otherwise fall back to the same siteSettings.background
182
187
  // the public shell reads, so admin/store/user dashboards match it by default.
183
188
  const resolvedLightBackground = lightBackground ?? settings?.background?.light;
@@ -193,7 +198,7 @@ export function DashboardLayoutClient({ variant, groups, permissions, activeHref
193
198
  return (_jsxs(_Fragment, { children: [hasBackground && (_jsx(BackgroundRenderer, { mode: theme === "dark" ? "dark" : "light", lightMode: resolvedLightBackground ?? { type: "color", value: "" }, darkMode: resolvedDarkBackground ?? { type: "color", value: "" } })), variant === "admin" && (_jsx(AdminSidebar, { variant: "sidebar", desktopOpen: desktopOpen, mobileOpen: mobileOpen, activePath: activeHref, groups: adminGroups, onCloseMobile: close, onToggle: toggle, className: className, renderFooter: renderCrossNavFooter })), variant === "store" && (_jsx(StoreSidebar, { variant: "sidebar", desktopOpen: desktopOpen, mobileOpen: mobileOpen, activeHref: activeHref, items: [], groups: filteredGroups, onCloseMobile: close, onToggle: toggle, className: className, renderFooter: renderCrossNavFooter })), variant === "user" && (_jsx(UserSidebar, { variant: "sidebar", desktopOpen: desktopOpen, mobileOpen: mobileOpen, items: filteredGroups.flatMap((g) => g.items), groups: filteredGroups, onCloseMobile: close, onToggle: toggle, className: className, renderFooter: renderCrossNavFooter })), _jsx(Div, { className: [
194
199
  "w-full flex-1 flex flex-col min-h-[calc(100dvh-var(--header-height,3.5rem))]",
195
200
  "pb-[var(--appkit-space-16)] lg:pb-[0]", // clears the fixed mobile bottom-nav
196
- contentPadding ?? DEFAULT_CONTENT_PADDING,
201
+ contentPadding ?? (hand === "left" ? DEFAULT_CONTENT_PADDING_LEFT : DEFAULT_CONTENT_PADDING_RIGHT),
197
202
  contentSurface,
198
203
  ].filter(Boolean).join(" "), children: _jsx(Div, { className: ["w-full flex-1 mx-auto", contentMaxWidth ?? DEFAULT_CONTENT_MAX_WIDTH].filter(Boolean).join(" "), children: children }) }), _jsx(DashboardBottomNav, { items: filteredGroups.flatMap((g) => g.items), activeHref: activeHref })] }));
199
204
  }
@@ -15,5 +15,9 @@ export interface SidebarCollapseToggleProps {
15
15
  /** aria-label override; defaults to "Collapse sidebar" / "Expand sidebar". */
16
16
  expandedLabel?: string;
17
17
  collapsedLabel?: string;
18
+ /** Which edge the parent sidebar panel is docked to. Defaults to "right"
19
+ * (the handle protrudes to the panel's outer/right edge). "left" mirrors
20
+ * the rounded corner and chevron direction for left-hand mode. */
21
+ edge?: "left" | "right";
18
22
  }
19
- export declare function SidebarCollapseToggle({ expanded, onToggle, expandedLabel, collapsedLabel, }: SidebarCollapseToggleProps): React.JSX.Element;
23
+ export declare function SidebarCollapseToggle({ expanded, onToggle, expandedLabel, collapsedLabel, edge, }: SidebarCollapseToggleProps): React.JSX.Element;
@@ -7,6 +7,12 @@ const HANDLE_STYLE = {
7
7
  const CHEVRON_STYLE = {
8
8
  color: "var(--appkit-color-text-on-primary)",
9
9
  };
10
- export function SidebarCollapseToggle({ expanded, onToggle, expandedLabel = "Collapse sidebar", collapsedLabel = "Expand sidebar", }) {
11
- return (_jsx(IconButton, { type: "button", onClick: onToggle, "aria-label": expanded ? expandedLabel : collapsedLabel, "aria-expanded": expanded, variant: "ghost", style: HANDLE_STYLE, className: "w-9 shrink-0 rounded-r-[1.25rem] shadow-lg transition-all duration-200 hover:shadow-xl hover:brightness-110 active:scale-[0.96]", icon: _jsx("svg", { className: `w-4 h-4 drop-shadow-sm transition-transform duration-300 ${expanded ? "" : "rotate-180"}`, style: CHEVRON_STYLE, fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2.5, d: "M15 19l-7-7 7-7" }) }) }));
10
+ export function SidebarCollapseToggle({ expanded, onToggle, expandedLabel = "Collapse sidebar", collapsedLabel = "Expand sidebar", edge = "right", }) {
11
+ // The panel's free (open) edge is opposite its dock side: a left-docked
12
+ // panel (edge="left") protrudes its handle to the right, and vice versa.
13
+ const roundedClass = edge === "left" ? "rounded-r-[1.25rem]" : "rounded-l-[1.25rem]";
14
+ // Chevron rotation direction mirrors along with the dock side so it keeps
15
+ // pointing "into" the collapsed panel regardless of which edge it's on.
16
+ const chevronRotated = edge === "left" ? !expanded : expanded;
17
+ return (_jsx(IconButton, { type: "button", onClick: onToggle, "aria-label": expanded ? expandedLabel : collapsedLabel, "aria-expanded": expanded, variant: "ghost", style: HANDLE_STYLE, className: `w-9 shrink-0 ${roundedClass} shadow-lg transition-all duration-200 hover:shadow-xl hover:brightness-110 active:scale-[0.96]`, icon: _jsx("svg", { className: `w-4 h-4 drop-shadow-sm transition-transform duration-300 ${chevronRotated ? "rotate-180" : ""}`, style: CHEVRON_STYLE, fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2.5, d: "M15 19l-7-7 7-7" }) }) }));
12
18
  }
@@ -0,0 +1,20 @@
1
+ import type { HandMode } from "../../hand-mode";
2
+ /** Classes for the desktop persistent sidebar rail (Admin/Seller/User
3
+ * `variant="sidebar"`) — flips which edge it docks to, its border side, and
4
+ * its collapsed-state translate direction, based on the hand-mode
5
+ * preference. Kept as a single shared helper so the flip logic isn't
6
+ * hand-forked identically across all three sidebar files. */
7
+ export interface SidebarRailClasses {
8
+ edgeClass: string;
9
+ borderClass: string;
10
+ collapsedTranslateClass: string;
11
+ }
12
+ export declare function getSidebarRailClasses(hand: HandMode): SidebarRailClasses;
13
+ /** Classes for the mobile overlay drawer variant (Admin/Seller/User
14
+ * `variant="overlay"` → `DrawerPanel`) — same edge/border flip, no
15
+ * translate state since the panel is conditionally mounted, not animated. */
16
+ export interface SidebarOverlayClasses {
17
+ edgeClass: string;
18
+ borderClass: string;
19
+ }
20
+ export declare function getSidebarOverlayClasses(hand: HandMode): SidebarOverlayClasses;
@@ -0,0 +1,20 @@
1
+ export function getSidebarRailClasses(hand) {
2
+ if (hand === "left") {
3
+ return {
4
+ edgeClass: "left-0",
5
+ borderClass: "border-r",
6
+ collapsedTranslateClass: "-translate-x-[calc(100%-1.25rem)]",
7
+ };
8
+ }
9
+ return {
10
+ edgeClass: "right-0",
11
+ borderClass: "border-l",
12
+ collapsedTranslateClass: "translate-x-[calc(100%-1.25rem)]",
13
+ };
14
+ }
15
+ export function getSidebarOverlayClasses(hand) {
16
+ if (hand === "left") {
17
+ return { edgeClass: "left-0", borderClass: "border-r" };
18
+ }
19
+ return { edgeClass: "right-0", borderClass: "border-l" };
20
+ }
@@ -0,0 +1,21 @@
1
+ import { type ReactNode } from "react";
2
+ export type HandMode = "left" | "right";
3
+ export interface HandModeContextValue {
4
+ /** Which side CTA panels/drawers/sidebars dock to and where close buttons sit. */
5
+ hand: HandMode;
6
+ /** Persist a new hand-mode preference and re-render the flipped layout. */
7
+ setHand: (next: HandMode) => void;
8
+ /** Flip preference between right ↔ left. */
9
+ toggleHand: () => void;
10
+ }
11
+ export interface HandModeProviderProps {
12
+ /**
13
+ * Seed value from the signed-in user's server-persisted preference
14
+ * (`user.uiPreferences.handMode`), used only when no value is already
15
+ * cached in localStorage on this device.
16
+ */
17
+ initialHand?: HandMode;
18
+ children: ReactNode;
19
+ }
20
+ export declare function HandModeProvider({ initialHand, children }: HandModeProviderProps): import("react").JSX.Element;
21
+ export declare function useHandMode(): HandModeContextValue;
@@ -0,0 +1,81 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { normalizeError } from "../../../errors/normalize";
4
+ import { createContext, useCallback, useContext, useEffect, useMemo, useState, } from "react";
5
+ // Brand-neutral localStorage namespace, matching the `appkit:theme-mode` /
6
+ // `appkit:theme-id` convention. audit-ssr-in-appkit blocks hardcoded brand
7
+ // strings inside `_internal/`.
8
+ const HAND_STORAGE_KEY = "appkit:hand-mode";
9
+ // Default (non-null) context value — unlike `ThemeContext` (which throws
10
+ // outside its provider because it's only ever consumed at the app-shell
11
+ // level), `useHandMode()` is called from base UI primitives (SideDrawer,
12
+ // Modal, Drawer, BackToTop, ...) that ship as part of the public design
13
+ // system and must degrade gracefully to the "right" default for any
14
+ // consumer that hasn't mounted `<HandModeProvider>`.
15
+ const DEFAULT_CONTEXT_VALUE = {
16
+ hand: "right",
17
+ setHand: () => { },
18
+ toggleHand: () => { },
19
+ };
20
+ const HandModeContext = createContext(DEFAULT_CONTEXT_VALUE);
21
+ function readHandPreference() {
22
+ if (typeof window === "undefined")
23
+ return "right";
24
+ try {
25
+ const stored = window.localStorage.getItem(HAND_STORAGE_KEY);
26
+ if (stored === "left" || stored === "right")
27
+ return stored;
28
+ }
29
+ catch (_err) {
30
+ void normalizeError(_err); /* localStorage unavailable in privacy/iframe context */
31
+ }
32
+ return "right";
33
+ }
34
+ /** Apply the hand-mode preference to `<html>` via a `data-hand` attribute.
35
+ * Deliberately NOT `dir="rtl"/"ltr"` — that would trigger the browser's
36
+ * bidi text-direction and native-control-mirroring behavior repo-wide, which
37
+ * is not what this "reachability" preference means. `data-hand` is a plain
38
+ * presentational attribute, same category as `data-theme`. */
39
+ function applyHand(hand) {
40
+ if (typeof document === "undefined")
41
+ return;
42
+ document.documentElement.setAttribute("data-hand", hand);
43
+ }
44
+ export function HandModeProvider({ initialHand, children }) {
45
+ const [hand, setHandState] = useState(() => {
46
+ if (typeof window === "undefined")
47
+ return initialHand ?? "right";
48
+ const stored = readHandPreference();
49
+ // readHandPreference() falls back to "right" when nothing is cached —
50
+ // in that case, prefer the server-seeded value over the hardcoded default.
51
+ try {
52
+ if (window.localStorage.getItem(HAND_STORAGE_KEY) === null) {
53
+ return initialHand ?? stored;
54
+ }
55
+ }
56
+ catch (_err) {
57
+ void normalizeError(_err);
58
+ }
59
+ return stored;
60
+ });
61
+ useEffect(() => {
62
+ applyHand(hand);
63
+ }, [hand]);
64
+ const setHand = useCallback((next) => {
65
+ setHandState(next);
66
+ try {
67
+ window.localStorage.setItem(HAND_STORAGE_KEY, next);
68
+ }
69
+ catch (_err) {
70
+ void normalizeError(_err); /* localStorage unavailable; preference applies for this session */
71
+ }
72
+ }, []);
73
+ const toggleHand = useCallback(() => {
74
+ setHand(hand === "left" ? "right" : "left");
75
+ }, [hand, setHand]);
76
+ const value = useMemo(() => ({ hand, setHand, toggleHand }), [hand, setHand, toggleHand]);
77
+ return _jsx(HandModeContext.Provider, { value: value, children: children });
78
+ }
79
+ export function useHandMode() {
80
+ return useContext(HandModeContext);
81
+ }
@@ -0,0 +1,2 @@
1
+ export { HandModeProvider, useHandMode } from "./HandModeProvider";
2
+ export type { HandMode, HandModeContextValue, HandModeProviderProps, } from "./HandModeProvider";
@@ -0,0 +1 @@
1
+ export { HandModeProvider, useHandMode } from "./HandModeProvider";
@@ -32,6 +32,18 @@ export interface CreateCheckoutOrderInput {
32
32
  /** Buyer opted into shipment protection at checkout. Defaults false (unchecked). Only actually charged/honored when siteSettings.commissions.shipmentProtectionFeeEnabled is also true. */
33
33
  shipmentProtectionAddon?: boolean;
34
34
  }
35
+ /**
36
+ * Exported so `/api/payment/create-order` (pre-charge amount) and the
37
+ * read-only `previewCheckoutPricing` (Order Summary display) can compute the
38
+ * exact same per-seller shipping fee that order creation charges/records —
39
+ * a single source of truth instead of a third re-implementation.
40
+ */
41
+ export declare function resolveShippingCost(storeId: string | undefined): Promise<{
42
+ shippingFee: number;
43
+ storeOwnerId: string | undefined;
44
+ storeEmiEnabled: boolean;
45
+ storeState: string | undefined;
46
+ }>;
35
47
  /**
36
48
  * Place order(s) from the user's cart in a single Firestore transaction.
37
49
  *
@@ -39,6 +51,37 @@ export interface CreateCheckoutOrderInput {
39
51
  * auth + rate-limiting before calling this and supply the resolved user fields.
40
52
  */
41
53
  export declare function createCheckoutOrderAction(input: CreateCheckoutOrderInput): Promise<CheckoutOrderResult>;
54
+ export interface CheckoutPricingPreviewInput {
55
+ userId: string;
56
+ /** Omitted for digital-only carts — GST can't be computed without a buyer state. */
57
+ addressId?: string;
58
+ paymentMethod: CheckoutPaymentMethod;
59
+ excludedProductIds?: string[];
60
+ whatsappNotifyAddon?: boolean;
61
+ giftWrapAddon?: boolean;
62
+ shipmentProtectionAddon?: boolean;
63
+ }
64
+ export interface CheckoutPricingPreview {
65
+ subtotal: number;
66
+ shippingFee: number;
67
+ codHandlingFee: number;
68
+ whatsappNotifyFee: number;
69
+ giftWrapFee: number;
70
+ shipmentProtectionFee: number;
71
+ gstAmount: number;
72
+ couponDiscount: number;
73
+ total: number;
74
+ }
75
+ /**
76
+ * Read-only "what will I actually be charged" preview for the checkout
77
+ * Order Summary. Reuses the exact same building blocks
78
+ * createCheckoutOrderAction / verifyAndPlaceRazorpayOrderAction use when an
79
+ * order is actually placed — resolveShippingCost, the compute*Fee helpers,
80
+ * computeGroupCouponDiscount, and the GST calc — so the number shown to the
81
+ * buyer can never diverge from what gets charged/recorded. Never decrements
82
+ * stock, creates an order, or writes coupon usage.
83
+ */
84
+ export declare function previewCheckoutPricing(input: CheckoutPricingPreviewInput): Promise<CheckoutPricingPreview>;
42
85
  /**
43
86
  * Mark an order as paid after a payment provider verifies the transaction.
44
87
  * Consumers call this from /api/payment/verify or a payment webhook.
@@ -11,7 +11,7 @@ import { roundRupees } from "../../../../utils/number.formatter";
11
11
  import { ApiError, ValidationError, NotFoundError, ERROR_MESSAGES } from "../../../../errors";
12
12
  import { ORDER_FIELDS } from "../../../../constants/field-names";
13
13
  import { serverLogger } from "../../../../monitoring";
14
- import { unitOfWork, siteSettingsRepository, userRepository, storeRepository, couponsRepository, claimedCouponsRepository, addressesRepository, } from "../../../../repositories";
14
+ import { unitOfWork, siteSettingsRepository, userRepository, storeRepository, couponsRepository, claimedCouponsRepository, addressesRepository, productRepository, } from "../../../../repositories";
15
15
  import { calculateGst } from "../../../shared/fees/calculator";
16
16
  import { computePreOrderDepositAmount } from "../../../shared/checkout/order-math";
17
17
  import { failedCheckoutRepository } from "../../../../features/checkout/repository/failed-checkout.repository";
@@ -151,7 +151,13 @@ function accumulateCouponUsage(accumulator, couponCode, couponId, discountAmount
151
151
  entry.totalDiscount += discountAmount;
152
152
  accumulator.set(couponCode, entry);
153
153
  }
154
- async function resolveShippingCost(storeId) {
154
+ /**
155
+ * Exported so `/api/payment/create-order` (pre-charge amount) and the
156
+ * read-only `previewCheckoutPricing` (Order Summary display) can compute the
157
+ * exact same per-seller shipping fee that order creation charges/records —
158
+ * a single source of truth instead of a third re-implementation.
159
+ */
160
+ export async function resolveShippingCost(storeId) {
155
161
  if (!storeId)
156
162
  return { shippingFee: 0, storeOwnerId: undefined, storeEmiEnabled: false, storeState: undefined };
157
163
  const store = await storeRepository.findById(storeId);
@@ -172,6 +178,55 @@ async function resolveStoreState(storeId) {
172
178
  const pickup = addresses.find((a) => a.isDefault) ?? addresses[0];
173
179
  return pickup?.state;
174
180
  }
181
+ /**
182
+ * Prorates every buyer-applied coupon against one seller-group's total. Pure
183
+ * — no side effects (usage-accumulator bookkeeping is layered on by callers
184
+ * that actually place an order). Shared by createOrderForGroup,
185
+ * createRazorpayGroupOrder, and the read-only previewCheckoutPricing so the
186
+ * three can never drift on how a coupon splits across sellers (was three
187
+ * near-identical copies before this extraction — Duplication Decision
188
+ * Framework's Rule of Three).
189
+ */
190
+ function computeGroupCouponDiscount(appliedCoupons, groupLines, groupTotal, cartSubtotal, storeId) {
191
+ let couponDiscount = 0;
192
+ const appliedDiscounts = [];
193
+ for (const coupon of appliedCoupons) {
194
+ let couponGroupDiscount = 0;
195
+ const isSellerScoped = coupon.scope === "seller" && coupon.storeId;
196
+ if (isSellerScoped) {
197
+ if (coupon.storeId !== storeId)
198
+ continue;
199
+ if (coupon.applicableItemIds?.length) {
200
+ const eligibleTotal = groupLines
201
+ .filter((l) => coupon.applicableItemIds.includes(l.itemId))
202
+ .reduce((s, l) => s + l.lineTotal, 0);
203
+ couponGroupDiscount =
204
+ eligibleTotal > 0
205
+ ? Math.min(roundRupees((eligibleTotal / groupTotal) * coupon.discountAmount), eligibleTotal)
206
+ : 0;
207
+ }
208
+ else {
209
+ couponGroupDiscount = Math.min(coupon.discountAmount, groupTotal);
210
+ }
211
+ }
212
+ else if (cartSubtotal > 0) {
213
+ couponGroupDiscount = Math.min(roundRupees((groupTotal / cartSubtotal) * coupon.discountAmount), groupTotal);
214
+ }
215
+ if (couponGroupDiscount > 0) {
216
+ couponDiscount += couponGroupDiscount;
217
+ appliedDiscounts.push({
218
+ code: coupon.code,
219
+ couponId: coupon.couponId,
220
+ type: "coupon",
221
+ discountAmount: couponGroupDiscount,
222
+ scope: coupon.scope,
223
+ storeId: coupon.storeId,
224
+ });
225
+ }
226
+ }
227
+ couponDiscount = Math.min(couponDiscount, groupTotal);
228
+ return { couponDiscount, appliedDiscounts };
229
+ }
175
230
  function buildStockUpdatePayload(product, qtyDelta) {
176
231
  const lt = (product.listingType ?? "standard");
177
232
  const rule = getListingRule(lt);
@@ -313,50 +368,15 @@ async function createOrderForGroup(group, orderType, ctx) {
313
368
  const whatsappNotifyFee = computeWhatsAppNotifyFee(whatsappNotifyAddon, commissions);
314
369
  const giftWrapFee = computeGiftWrapFee(giftWrapAddon, commissions);
315
370
  const shipmentProtectionFee = computeShipmentProtectionFee(groupTotal, shipmentProtectionAddon, commissions);
316
- let couponDiscount = 0;
317
- const appliedDiscounts = [];
318
371
  const groupCouponCodes = new Set();
319
- for (const coupon of appliedCoupons) {
320
- let couponGroupDiscount = 0;
321
- const isSellerScoped = coupon.scope === "seller" && coupon.storeId;
322
- if (isSellerScoped) {
323
- if (coupon.storeId !== firstItem.storeId)
324
- continue;
325
- if (coupon.applicableItemIds?.length) {
326
- const eligibleTotal = group
327
- .filter(({ item }) => coupon.applicableItemIds.includes(item.itemId))
328
- .reduce((s, { item }) => s + item.price * item.quantity, 0);
329
- couponGroupDiscount =
330
- eligibleTotal > 0
331
- ? Math.min(roundRupees((eligibleTotal / groupTotal) * coupon.discountAmount), eligibleTotal)
332
- : 0;
333
- }
334
- else {
335
- couponGroupDiscount = Math.min(coupon.discountAmount, groupTotal);
336
- }
337
- }
338
- else {
339
- if (cartSubtotal > 0) {
340
- couponGroupDiscount = Math.min(roundRupees((groupTotal / cartSubtotal) * coupon.discountAmount), groupTotal);
341
- }
342
- }
343
- if (couponGroupDiscount > 0) {
344
- couponDiscount += couponGroupDiscount;
345
- appliedDiscounts.push({
346
- code: coupon.code,
347
- couponId: coupon.couponId,
348
- type: "coupon",
349
- discountAmount: couponGroupDiscount,
350
- scope: coupon.scope,
351
- storeId: coupon.storeId,
352
- });
353
- if (coupon.couponId) {
354
- accumulateCouponUsage(couponUsageAccumulator, coupon.code, coupon.couponId, couponGroupDiscount);
355
- groupCouponCodes.add(coupon.code);
356
- }
372
+ const groupLines = group.map(({ item }) => ({ itemId: item.itemId, lineTotal: item.price * item.quantity }));
373
+ const { couponDiscount, appliedDiscounts } = computeGroupCouponDiscount(appliedCoupons, groupLines, groupTotal, cartSubtotal, firstItem.storeId);
374
+ for (const discount of appliedDiscounts) {
375
+ if (discount.couponId) {
376
+ accumulateCouponUsage(couponUsageAccumulator, discount.code, discount.couponId, discount.discountAmount);
377
+ groupCouponCodes.add(discount.code);
357
378
  }
358
379
  }
359
- couponDiscount = Math.min(couponDiscount, groupTotal);
360
380
  const orderTotal = Math.max(0, groupTotal - couponDiscount) + shippingFee + codHandlingFee + whatsappNotifyFee + giftWrapFee + shipmentProtectionFee + (emiSchedule?.surchargeAmount ?? 0) + (gstBreakdown?.gstAmount ?? 0);
361
381
  const imageUrls = [
362
382
  ...new Set(group
@@ -733,6 +753,105 @@ export async function createCheckoutOrderAction(input) {
733
753
  ...(unavailable.length > 0 ? { unavailableItems: unavailable } : {}),
734
754
  };
735
755
  }
756
+ const EMPTY_PRICING_PREVIEW = {
757
+ subtotal: 0,
758
+ shippingFee: 0,
759
+ codHandlingFee: 0,
760
+ whatsappNotifyFee: 0,
761
+ giftWrapFee: 0,
762
+ shipmentProtectionFee: 0,
763
+ gstAmount: 0,
764
+ couponDiscount: 0,
765
+ total: 0,
766
+ };
767
+ /**
768
+ * Read-only "what will I actually be charged" preview for the checkout
769
+ * Order Summary. Reuses the exact same building blocks
770
+ * createCheckoutOrderAction / verifyAndPlaceRazorpayOrderAction use when an
771
+ * order is actually placed — resolveShippingCost, the compute*Fee helpers,
772
+ * computeGroupCouponDiscount, and the GST calc — so the number shown to the
773
+ * buyer can never diverge from what gets charged/recorded. Never decrements
774
+ * stock, creates an order, or writes coupon usage.
775
+ */
776
+ export async function previewCheckoutPricing(input) {
777
+ const { userId: uid, addressId, paymentMethod, excludedProductIds = [], whatsappNotifyAddon = false, giftWrapAddon = false, shipmentProtectionAddon = false, } = input;
778
+ const siteSettings = await siteSettingsRepository.getSingleton();
779
+ const commissions = siteSettings?.commissions ?? CHECKOUT_DEFAULT_COMMISSIONS;
780
+ const cart = await unitOfWork.carts.getOrCreate(uid);
781
+ const excludedSet = new Set(excludedProductIds);
782
+ const selectedSet = cart.selectedItemIds?.length ? new Set(cart.selectedItemIds) : null;
783
+ const cartItems = (cart.items ?? []).filter((item) => !excludedSet.has(item.productId) && (!selectedSet || selectedSet.has(item.itemId)));
784
+ if (cartItems.length === 0)
785
+ return EMPTY_PRICING_PREVIEW;
786
+ // Best-effort product lookup — a preview never throws on a since-unpublished
787
+ // product, it just falls back to the item's cart-snapshot price for that
788
+ // line (same fallback cartRepository.getSubtotal() implicitly relies on).
789
+ const uniqueProductIds = [...new Set(cartItems.map((item) => item.productId))];
790
+ const productDocs = await Promise.all(uniqueProductIds.map((pid) => productRepository.findById(pid)));
791
+ const productById = new Map(uniqueProductIds.map((pid, i) => [pid, productDocs[i]]));
792
+ let resolvedAddress = null;
793
+ if (addressId) {
794
+ const addressDoc = await unitOfWork.addresses.findById(addressId);
795
+ resolvedAddress =
796
+ addressDoc && addressDoc.ownerType === "user" && addressDoc.ownerId === uid ? addressDoc : null;
797
+ }
798
+ const unitPriceFor = (item, product) => item.bundleCategorySlug && item.bundleProductIds?.length ? item.price : (product?.price ?? item.price);
799
+ const checks = cartItems.map((item) => ({ item, product: productById.get(item.productId) ?? null }));
800
+ const orderGroups = splitCartIntoOrderGroups(checks);
801
+ const cartSubtotal = orderGroups.reduce((s, { items: g }) => s + g.reduce((gs, { item, product }) => gs + unitPriceFor(item, product) * item.quantity, 0), 0);
802
+ const appliedCoupons = cart.appliedCoupons ?? [];
803
+ let shippingFee = 0;
804
+ let codHandlingFee = 0;
805
+ let whatsappNotifyFeeTotal = 0;
806
+ let giftWrapFeeTotal = 0;
807
+ let shipmentProtectionFeeTotal = 0;
808
+ let gstAmount = 0;
809
+ let couponDiscount = 0;
810
+ for (const { items: group } of orderGroups) {
811
+ const firstItem = group[0].item;
812
+ const groupTotal = group.reduce((sum, { item, product }) => sum + unitPriceFor(item, product) * item.quantity, 0);
813
+ const { shippingFee: groupShippingFee, storeState } = await resolveShippingCost(firstItem.storeId);
814
+ shippingFee += groupShippingFee;
815
+ // Mirrors createOrderForGroup exactly — the COD handling fee is only
816
+ // ever charged for literal "cod" (pay the courier), never upi_manual or
817
+ // cash (both pre-paid via UPI/bank transfer before dispatch).
818
+ if (paymentMethod === "cod")
819
+ codHandlingFee += computeCodHandlingFee(groupTotal, commissions);
820
+ whatsappNotifyFeeTotal += computeWhatsAppNotifyFee(whatsappNotifyAddon, commissions);
821
+ giftWrapFeeTotal += computeGiftWrapFee(giftWrapAddon, commissions);
822
+ shipmentProtectionFeeTotal += computeShipmentProtectionFee(groupTotal, shipmentProtectionAddon, commissions);
823
+ if (siteSettings?.gst?.enabled && resolvedAddress?.state) {
824
+ const intraState = !!storeState && storeState === resolvedAddress.state;
825
+ for (const { item, product } of group) {
826
+ const lineTotal = unitPriceFor(item, product) * item.quantity;
827
+ const rate = product?.gstRate ?? 0;
828
+ if (rate > 0)
829
+ gstAmount += calculateGst(rate, intraState, lineTotal).gstAmount;
830
+ }
831
+ }
832
+ const groupLines = group.map(({ item, product }) => ({ itemId: item.itemId, lineTotal: unitPriceFor(item, product) * item.quantity }));
833
+ const { couponDiscount: groupCouponDiscount } = computeGroupCouponDiscount(appliedCoupons, groupLines, groupTotal, cartSubtotal, firstItem.storeId);
834
+ couponDiscount += groupCouponDiscount;
835
+ }
836
+ const total = Math.max(0, cartSubtotal - couponDiscount) +
837
+ shippingFee +
838
+ codHandlingFee +
839
+ whatsappNotifyFeeTotal +
840
+ giftWrapFeeTotal +
841
+ shipmentProtectionFeeTotal +
842
+ gstAmount;
843
+ return {
844
+ subtotal: cartSubtotal,
845
+ shippingFee,
846
+ codHandlingFee,
847
+ whatsappNotifyFee: whatsappNotifyFeeTotal,
848
+ giftWrapFee: giftWrapFeeTotal,
849
+ shipmentProtectionFee: shipmentProtectionFeeTotal,
850
+ gstAmount,
851
+ couponDiscount,
852
+ total,
853
+ };
854
+ }
736
855
  /**
737
856
  * Mark an order as paid after a payment provider verifies the transaction.
738
857
  * Consumers call this from /api/payment/verify or a payment webhook.
@@ -864,43 +983,8 @@ async function createRazorpayGroupOrder(group, orderType, ctx) {
864
983
  // of re-implementing it inline — was two sequential findById calls per
865
984
  // seller group here, duplicated from resolveShippingCost.
866
985
  const { shippingFee, storeOwnerId } = await resolveShippingCost(firstItem.storeId);
867
- let couponDiscount = 0;
868
- const appliedDiscounts = [];
869
- for (const coupon of appliedCoupons) {
870
- let couponGroupDiscount = 0;
871
- const isSellerScoped = coupon.scope === "seller" && coupon.storeId;
872
- if (isSellerScoped) {
873
- if (coupon.storeId !== firstItem.storeId)
874
- continue;
875
- if (coupon.applicableItemIds?.length) {
876
- const eligibleTotal = group
877
- .filter(({ item }) => coupon.applicableItemIds.includes(item.itemId))
878
- .reduce((s, { item, product }) => s + unitPriceFor(item, product) * item.quantity, 0);
879
- couponGroupDiscount =
880
- eligibleTotal > 0
881
- ? Math.min(roundRupees((eligibleTotal / groupTotal) * coupon.discountAmount), eligibleTotal)
882
- : 0;
883
- }
884
- else {
885
- couponGroupDiscount = Math.min(coupon.discountAmount, groupTotal);
886
- }
887
- }
888
- else if (cartSubtotal > 0) {
889
- couponGroupDiscount = Math.min(roundRupees((groupTotal / cartSubtotal) * coupon.discountAmount), groupTotal);
890
- }
891
- if (couponGroupDiscount > 0) {
892
- couponDiscount += couponGroupDiscount;
893
- appliedDiscounts.push({
894
- code: coupon.code,
895
- couponId: coupon.couponId,
896
- type: "coupon",
897
- discountAmount: couponGroupDiscount,
898
- scope: coupon.scope,
899
- storeId: coupon.storeId,
900
- });
901
- }
902
- }
903
- couponDiscount = Math.min(couponDiscount, groupTotal);
986
+ const groupLines = group.map(({ item, product }) => ({ itemId: item.itemId, lineTotal: unitPriceFor(item, product) * item.quantity }));
987
+ const { couponDiscount, appliedDiscounts } = computeGroupCouponDiscount(appliedCoupons, groupLines, groupTotal, cartSubtotal, firstItem.storeId);
904
988
  const rawPlatformFee = roundRupees(groupTotal * (platformFeePercent / 100));
905
989
  const gstOnFee = roundRupees(rawPlatformFee * (gstPercent / 100));
906
990
  const platformFee = rawPlatformFee + gstOnFee;
@@ -1165,7 +1249,14 @@ export async function verifyAndPlaceRazorpayOrderAction(input) {
1165
1249
  }, 0);
1166
1250
  const expectedPlatformFee = roundRupees(cartSubtotalRs * (platformFeePercent / 100));
1167
1251
  const expectedGstOnFee = roundRupees(expectedPlatformFee * (gstPercent / 100));
1168
- const expectedPaymentAmountRs = cartSubtotalRs + expectedPlatformFee + expectedGstOnFee;
1252
+ // Same per-seller-group shipping sum /api/payment/create-order charged
1253
+ // upfront (and the same resolveShippingCost createRazorpayGroupOrder
1254
+ // below will use to build each order's recorded totalPrice) — keeps this
1255
+ // floor check from passing an amount that's short by the shipping fee.
1256
+ const expectedShippingGroups = splitCartIntoOrderGroups(bucketedPaid.available);
1257
+ const expectedShippingFees = await Promise.all(expectedShippingGroups.map((g) => resolveShippingCost(g.items[0].item.storeId)));
1258
+ const expectedShippingFee = expectedShippingFees.reduce((sum, r) => sum + r.shippingFee, 0);
1259
+ const expectedPaymentAmountRs = cartSubtotalRs + expectedPlatformFee + expectedGstOnFee + expectedShippingFee;
1169
1260
  const rzpOrderRecord = await fetchRazorpayOrder(razorpay_order_id);
1170
1261
  const paidAmountRs = paiseToRupees(rzpOrderRecord.amount);
1171
1262
  if (paidAmountRs < expectedPaymentAmountRs - 1) {
@@ -1,3 +1,3 @@
1
- export { createCheckoutOrderAction, attachPaymentAction, verifyAndPlaceRazorpayOrderAction, type CreateCheckoutOrderInput, type VerifyAndPlaceRazorpayOrderInput, } from "./actions";
1
+ export { createCheckoutOrderAction, attachPaymentAction, verifyAndPlaceRazorpayOrderAction, previewCheckoutPricing, resolveShippingCost, type CreateCheckoutOrderInput, type VerifyAndPlaceRazorpayOrderInput, type CheckoutPricingPreviewInput, type CheckoutPricingPreview, } from "./actions";
2
2
  export { formatShippingAddress, type CheckoutOrderResult, } from "./data";
3
3
  export { CHECKOUT_DEFAULT_COMMISSIONS, CHECKOUT_PAYMENT_METHODS, type CheckoutPaymentMethod, } from "../../../shared/features/checkout/config";
@@ -1,3 +1,3 @@
1
- export { createCheckoutOrderAction, attachPaymentAction, verifyAndPlaceRazorpayOrderAction, } from "./actions";
1
+ export { createCheckoutOrderAction, attachPaymentAction, verifyAndPlaceRazorpayOrderAction, previewCheckoutPricing, resolveShippingCost, } from "./actions";
2
2
  export { formatShippingAddress, } from "./data";
3
3
  export { CHECKOUT_DEFAULT_COMMISSIONS, CHECKOUT_PAYMENT_METHODS, } from "../../../shared/features/checkout/config";
@@ -7,12 +7,13 @@ export function buildRobots({ siteUrl }) {
7
7
  disallow: [
8
8
  "/admin/",
9
9
  "/api/", // audit-hardcoded-api-routes-ok: robots.txt disallow prefix, not an endpoint reference
10
- "/seller/",
10
+ "/store/",
11
11
  "/user/",
12
12
  "/auth/",
13
13
  "/checkout/",
14
14
  "/cart/",
15
- "/demo/",
15
+ "/wishlist",
16
+ "/preview/",
16
17
  "/track/",
17
18
  "/unauthorized/",
18
19
  "/_next/",