@mohasinac/appkit 4.55.0 → 4.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/dist/.build-stamp.json +4 -4
  2. package/dist/_internal/server/features/checkout/actions.d.ts +53 -0
  3. package/dist/_internal/server/features/checkout/actions.js +156 -8
  4. package/dist/_internal/server/features/orders/actions.d.ts +30 -0
  5. package/dist/_internal/server/features/orders/actions.js +103 -0
  6. package/dist/_internal/server/features/orders/adapters.js +7 -0
  7. package/dist/_internal/server/features/shipping/reconcile.d.ts +28 -0
  8. package/dist/_internal/server/features/shipping/reconcile.js +93 -0
  9. package/dist/_internal/shared/actions/action-registry.js +53 -0
  10. package/dist/_internal/shared/config/schema.d.ts +6 -0
  11. package/dist/_internal/shared/listing-types/route-gating.d.ts +56 -0
  12. package/dist/_internal/shared/listing-types/route-gating.js +114 -0
  13. package/dist/_internal/shared/shipping/quote.d.ts +153 -0
  14. package/dist/_internal/shared/shipping/quote.js +123 -0
  15. package/dist/client.d.ts +22 -0
  16. package/dist/client.js +10 -0
  17. package/dist/features/about/components/PublicProfileView.js +2 -1
  18. package/dist/features/account/components/UserOrdersView.js +25 -3
  19. package/dist/features/admin/components/OwnerQuickActions.d.ts +1 -1
  20. package/dist/features/admin/components/OwnerQuickActions.js +53 -6
  21. package/dist/features/auctions/components/AuctionDetailPageView.js +2 -1
  22. package/dist/features/auth/schemas/firestore.d.ts +15 -0
  23. package/dist/features/blog/components/BlogPostView.js +2 -1
  24. package/dist/features/cart/components/CartPriceBreakdown.d.ts +2 -0
  25. package/dist/features/cart/components/CartPriceBreakdown.js +1 -1
  26. package/dist/features/categories/components/FranchiseDetailPageView.js +3 -1
  27. package/dist/features/digital-codes/components/DigitalCodeDetailPageView.js +2 -1
  28. package/dist/features/faq/components/FAQPageContent.js +17 -4
  29. package/dist/features/faq/constants/help-guides.d.ts +36 -0
  30. package/dist/features/faq/constants/help-guides.js +64 -0
  31. package/dist/features/homepage/components/HeroCarousel.js +7 -3
  32. package/dist/features/homepage/components/MarketplaceHomepageView.js +16 -1
  33. package/dist/features/homepage/components/WelcomeSection.d.ts +16 -3
  34. package/dist/features/homepage/components/WelcomeSection.js +3 -3
  35. package/dist/features/homepage/lib/section-renderer.d.ts +9 -0
  36. package/dist/features/homepage/lib/section-renderer.js +24 -6
  37. package/dist/features/homepage/lib/slide-gating.d.ts +52 -0
  38. package/dist/features/homepage/lib/slide-gating.js +98 -0
  39. package/dist/features/live/components/LiveItemDetailPageView.js +2 -1
  40. package/dist/features/media/MediaAvatar.js +1 -1
  41. package/dist/features/media/MediaImage.d.ts +18 -1
  42. package/dist/features/media/MediaImage.js +1 -1
  43. package/dist/features/orders/components/EmiInstallmentsPanel.d.ts +19 -0
  44. package/dist/features/orders/components/EmiInstallmentsPanel.js +57 -0
  45. package/dist/features/orders/components/index.d.ts +1 -0
  46. package/dist/features/orders/components/index.js +1 -0
  47. package/dist/features/orders/schemas/firestore.d.ts +38 -0
  48. package/dist/features/orders/schemas/index.d.ts +2 -2
  49. package/dist/features/orders/types/index.d.ts +15 -0
  50. package/dist/features/points/repository/points.repository.d.ts +81 -0
  51. package/dist/features/points/repository/points.repository.js +155 -0
  52. package/dist/features/points/schemas/firestore.d.ts +118 -0
  53. package/dist/features/points/schemas/firestore.js +116 -0
  54. package/dist/features/pre-orders/components/PreOrderDetailPageView.js +2 -1
  55. package/dist/features/products/components/PrizeDrawDetailPageView.js +2 -1
  56. package/dist/features/products/components/ProductDetailPageView.js +3 -1
  57. package/dist/features/reviews/components/ReviewDetailPageView.js +3 -2
  58. package/dist/features/seller/actions/seller-actions.d.ts +17 -0
  59. package/dist/features/seller/actions/seller-actions.js +33 -0
  60. package/dist/features/stores/components/StoreHeader.js +3 -1
  61. package/dist/index.d.ts +1 -0
  62. package/dist/index.js +8 -0
  63. package/dist/next/routing/route-map.d.ts +4 -0
  64. package/dist/next/routing/route-map.js +2 -0
  65. package/dist/schemas/registry.d.ts +2 -2
  66. package/dist/seed/_helpers/category-models/chunk0.d.ts +2 -0
  67. package/dist/seed/_helpers/category-models/chunk0.js +2648 -0
  68. package/dist/seed/_helpers/category-models/chunk1.d.ts +2 -0
  69. package/dist/seed/_helpers/category-models/chunk1.js +2318 -0
  70. package/dist/seed/_helpers/category-models/chunk2.d.ts +2 -0
  71. package/dist/seed/_helpers/category-models/chunk2.js +2560 -0
  72. package/dist/seed/_helpers/category-models/chunk3.d.ts +2 -0
  73. package/dist/seed/_helpers/category-models/chunk3.js +2196 -0
  74. package/dist/seed/_helpers/category-models/chunk4.d.ts +2 -0
  75. package/dist/seed/_helpers/category-models/chunk4.js +1732 -0
  76. package/dist/seed/_helpers/category-models.d.ts +9 -2
  77. package/dist/seed/_helpers/category-models.js +19 -1368
  78. package/dist/seed/carousel-slides-seed-data.js +48 -7
  79. package/dist/server.d.ts +3 -1
  80. package/dist/server.js +11 -0
  81. package/dist/styles.css +38 -6
  82. package/dist/tailwind-utilities.css +1 -1
  83. package/dist/ui/components/ImageMosaic.d.ts +17 -1
  84. package/dist/ui/components/ImageMosaic.js +28 -5
  85. package/dist/ui/components/ImageMosaic.style.css +37 -5
  86. package/package.json +1 -1
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Close the loop on one order's shipping: compare what the buyer was charged
3
+ * with what the seller actually spent, and credit the difference as points.
4
+ *
5
+ * Called from the seller's dispatch path, once the real postage is known.
6
+ */
7
+ import { orderRepository } from "../../../../features/orders/repository/orders.repository.js";
8
+ import { pointsRepository } from "../../../../features/points/repository/points.repository.js";
9
+ import { reconcileShipping, } from "../../../shared/shipping/quote.js";
10
+ import { serverLogger } from "../../../../monitoring/index.js";
11
+ /**
12
+ * 🛑 IDEMPOTENT, and the guard is `order.shippingReconciliation` being unset.
13
+ *
14
+ * Crediting points is an APPEND to a ledger. Appends do not deduplicate
15
+ * themselves, so a dispatch action that fired twice — a double-clicked button,
16
+ * a retried job, a webhook delivered again — would pay the buyer twice, and
17
+ * nothing downstream could distinguish that from two genuine savings. The
18
+ * reconciliation record is written in the same step as the credit precisely so
19
+ * it can be the key.
20
+ *
21
+ * It deliberately does NOT use a transaction spanning the order and the
22
+ * ledger: `pointsRepository.append` already runs its own transaction over the
23
+ * ledger and the balance mirror, and nesting would mean reading the order
24
+ * inside it for no added safety. The residual risk is a crash between the
25
+ * credit and the order write, which leaves a credited buyer and an order that
26
+ * looks unreconciled — recoverable, and strictly better than the alternative
27
+ * ordering, which would mark the order done and then fail to pay.
28
+ */
29
+ export async function reconcileOrderShipping(orderId, actual) {
30
+ const order = await orderRepository.findById(orderId);
31
+ if (!order) {
32
+ return { outcome: null, sellerAbsorbs: 0, buyerPoints: 0, skipped: "no-quote" };
33
+ }
34
+ /*
35
+ * No quote means this order never went through the courier picker — a
36
+ * manually-shipped seller, or an order placed before the feature. There is
37
+ * no charged figure to measure against, so there is nothing to reconcile.
38
+ * Inventing one from the flat `shippingFee` would compare the buyer's spend
39
+ * against a number that was never a courier quote.
40
+ */
41
+ if (!order.selectedShippingQuote) {
42
+ return { outcome: null, sellerAbsorbs: 0, buyerPoints: 0, skipped: "no-quote" };
43
+ }
44
+ if (order.shippingReconciliation) {
45
+ return {
46
+ outcome: null,
47
+ sellerAbsorbs: order.shippingReconciliation.sellerAbsorbs,
48
+ buyerPoints: order.shippingReconciliation.buyerPoints,
49
+ skipped: "already-reconciled",
50
+ };
51
+ }
52
+ const result = reconcileShipping(order.selectedShippingQuote, actual);
53
+ const buyerUid = order.userId;
54
+ if (result.buyerPoints > 0) {
55
+ if (!buyerUid) {
56
+ /*
57
+ * A credit with nobody to credit. Recorded rather than thrown: the
58
+ * parcel has shipped and the seller's dispatch must not fail because we
59
+ * cannot find the buyer — but this is a real data fault and has to be
60
+ * loud enough to find.
61
+ */
62
+ serverLogger.error("shipping reconciliation has points but no buyer", {
63
+ orderId,
64
+ buyerPoints: result.buyerPoints,
65
+ });
66
+ return { outcome: null, sellerAbsorbs: 0, buyerPoints: 0, skipped: "no-buyer" };
67
+ }
68
+ await pointsRepository.append({
69
+ userId: buyerUid,
70
+ kind: "shipping_saving",
71
+ amount: result.buyerPoints,
72
+ orderId,
73
+ /*
74
+ * Names both couriers and both figures, because "53 points" on its own
75
+ * is unexplainable six weeks later — and this string is written once and
76
+ * never re-derived, so it stays true of the moment it describes even if
77
+ * the order is edited afterwards.
78
+ */
79
+ description: `Shipping saving on ${orderId}: charged ₹${order.selectedShippingQuote.chargedRate} ` +
80
+ `for ${order.selectedShippingQuote.courierName}, shipped ₹${actual.actualRate} ` +
81
+ `via ${actual.courierName}.`,
82
+ });
83
+ }
84
+ await orderRepository.update(orderId, {
85
+ actualShipmentCost: actual,
86
+ shippingReconciliation: { ...result, reconciledAt: new Date() },
87
+ });
88
+ return {
89
+ outcome: result.buyerPoints > 0 ? "credited" : result.sellerAbsorbs > 0 ? "seller-absorbed" : "exact",
90
+ sellerAbsorbs: result.sellerAbsorbs,
91
+ buyerPoints: result.buyerPoints,
92
+ };
93
+ }
@@ -1039,6 +1039,21 @@ export const ACTIONS = {
1039
1039
  },
1040
1040
  },
1041
1041
  BLOG: {
1042
+ /*
1043
+ * Clone, admin-only, backed by POST /api/admin/blog/[id]/duplicate. A
1044
+ * templated post is the real use case: same structure and category, new
1045
+ * subject. The copy starts as a DRAFT with a freshly derived slug — blog is
1046
+ * a pure-slug resource, so reusing the source slug would overwrite it.
1047
+ */
1048
+ "duplicate-post": {
1049
+ iconKey: "duplicate",
1050
+ id: "blog.duplicate-post",
1051
+ label: "Duplicate",
1052
+ ariaLabel: "Duplicate this post as a draft",
1053
+ description: "Copy this post into a new draft, without its views or featured flag.",
1054
+ kind: "secondary",
1055
+ permissions: ["admin"],
1056
+ },
1042
1057
  "create-post": {
1043
1058
  iconKey: "create",
1044
1059
  id: "blog.create-post",
@@ -1113,6 +1128,23 @@ export const ACTIONS = {
1113
1128
  },
1114
1129
  },
1115
1130
  EVENT: {
1131
+ /*
1132
+ * Clone, admin-only, backed by POST /api/admin/events/[id]/duplicate. A
1133
+ * recurring sale is the real use case: same copy, same prizes, new dates.
1134
+ *
1135
+ * 🛑 The endpoint rebuilds every lottery slot from its prize definition and
1136
+ * resets the booking half — a spread would attribute a real buyer’s
1137
+ * purchased slot to a brand-new event.
1138
+ */
1139
+ duplicate: {
1140
+ iconKey: "duplicate",
1141
+ id: "event.duplicate",
1142
+ label: "Duplicate",
1143
+ ariaLabel: "Duplicate this event as a draft",
1144
+ description: "Copy this event into a new draft, without its entries, winner or slot bookings.",
1145
+ kind: "secondary",
1146
+ permissions: ["admin"],
1147
+ },
1116
1148
  /*
1117
1149
  * This bucket had only register / cancel-registration / draw-raffle-winner
1118
1150
  * — no edit, no delete — so the owner affordance on an event card or detail
@@ -2208,6 +2240,27 @@ export const ACTIONS = {
2208
2240
  kind: "ghost",
2209
2241
  permissions: ["admin"],
2210
2242
  },
2243
+ /*
2244
+ * Homepage sections have no bucket of their own, so their edit leaf lives
2245
+ * here beside the carousel pair for the same reason — both are homepage
2246
+ * chrome an admin edits from a list rather than from a detail page.
2247
+ *
2248
+ * 🛑 There is deliberately NO `delete-section` counterpart. Section `order`
2249
+ * is POSITIONAL, so removing one silently renumbers its neighbours — not
2250
+ * something to offer from the public homepage, where the only confirmation
2251
+ * a person gets is a dialog. `OwnerQuickActions` enforces the absence by
2252
+ * omitting `remove` from the SECTION mapping, so no call site can pass a
2253
+ * delete handler and have it render.
2254
+ */
2255
+ "edit-section": {
2256
+ iconKey: "edit",
2257
+ id: "admin.edit-section",
2258
+ label: "Edit Section",
2259
+ ariaLabel: "Edit this homepage section",
2260
+ description: "Open the homepage sections list, which owns this section's config.",
2261
+ kind: "ghost",
2262
+ permissions: ["admin"],
2263
+ },
2211
2264
  "delete-carousel": {
2212
2265
  iconKey: "delete",
2213
2266
  id: "admin.delete-carousel",
@@ -89,6 +89,12 @@ export interface AppkitBrandConfig {
89
89
  name: string;
90
90
  /** Short / abbreviated name, e.g. "LT" */
91
91
  shortName?: string;
92
+ /**
93
+ * Standalone lockup line under the homepage hero logo, e.g. "Let It Rip
94
+ * India". Not `name`: that one is a sentence ingredient ("Why Buyers Trust
95
+ * {name}") and this one stands alone, so they are spelled differently.
96
+ */
97
+ heroCaption?: string;
92
98
  /** One-line brand description used in meta tags and footers */
93
99
  description?: string;
94
100
  /** Tagline shown in footer / about sections */
@@ -0,0 +1,56 @@
1
+ /**
2
+ * "Does this href lead somewhere the site has switched off?"
3
+ *
4
+ * ## Why this exists
5
+ *
6
+ * `listings.listingTypes` already hides a disabled type everywhere the type is
7
+ * named — the nav, the `/products` chips, the admin tabs, the homepage
8
+ * SECTIONS (`HOMEPAGE_SECTION_LISTING_TYPE` in `MarketplaceHomepageView`). But
9
+ * a lot of this site's merchandising is free-text content that names a
10
+ * destination by URL and never mentions a listing type at all: a carousel
11
+ * slide's overlay button, a card's CTA, a promo-grid tile.
12
+ *
13
+ * Measured 2026-10-20 with `auction: false` live: the homepage hero carousel
14
+ * still shipped a "Live auctions / Place a bid before the timer runs out"
15
+ * CARD linking to `/auctions`, and a whole `slide-beyblade-auction` slide
16
+ * whose overlay read "Win it at your price". Both are authored content, so no
17
+ * type-keyed gate could see them — the only thing that identifies them is
18
+ * where they point.
19
+ *
20
+ * ## The rule, and the one case that makes it non-obvious
21
+ *
22
+ * A href is disabled when EVERY listing type that claims its route is
23
+ * disabled. Not "any" — `art` and `stickers` share `/art`, so a site that
24
+ * sells art but not stickers must keep `/art` reachable. Treating one disabled
25
+ * claimant as enough would silently delete a working destination.
26
+ *
27
+ * ## What it deliberately does NOT match
28
+ *
29
+ * Editorial pages that merely have a type's name in the path — `/help/auctions`,
30
+ * `/how-auctions-work`, `/seller-guide/prize-draws`. None of them starts with
31
+ * `/auctions`, so prefix matching excludes them for free, and that is correct:
32
+ * an explainer about auctions is still honest reading when auctions are off.
33
+ * A substring test would have eaten all three.
34
+ *
35
+ * Store tabs (`/stores/{slug}/auctions`) are also out of scope — those are
36
+ * resolved by `resolveStoreTabs`, which owns its own visibility, and a second
37
+ * owner is how a mirror drifts (Root Cause #42).
38
+ */
39
+ import type { ListingType } from "../../../features/products/types/index";
40
+ interface ListingFlagSnapshot {
41
+ listings?: {
42
+ listingTypes?: Partial<Record<ListingType, boolean>>;
43
+ };
44
+ }
45
+ /**
46
+ * `true` when `href` points at a listing type's browse page (or any page
47
+ * beneath it) and every type that browses there is disabled.
48
+ *
49
+ * Fails OPEN on anything it cannot classify: an unknown path, an external URL
50
+ * or a missing settings document all return `false`. A settings read that
51
+ * errored must not strip working destinations out of the homepage — the same
52
+ * reasoning as the suggestions endpoint's TTL cache and `src/proxy.ts`'s
53
+ * disabled-routes gate.
54
+ */
55
+ export declare function isDisabledListingHref(href: string | undefined | null, settings: ListingFlagSnapshot | null | undefined): boolean;
56
+ export {};
@@ -0,0 +1,114 @@
1
+ /**
2
+ * "Does this href lead somewhere the site has switched off?"
3
+ *
4
+ * ## Why this exists
5
+ *
6
+ * `listings.listingTypes` already hides a disabled type everywhere the type is
7
+ * named — the nav, the `/products` chips, the admin tabs, the homepage
8
+ * SECTIONS (`HOMEPAGE_SECTION_LISTING_TYPE` in `MarketplaceHomepageView`). But
9
+ * a lot of this site's merchandising is free-text content that names a
10
+ * destination by URL and never mentions a listing type at all: a carousel
11
+ * slide's overlay button, a card's CTA, a promo-grid tile.
12
+ *
13
+ * Measured 2026-10-20 with `auction: false` live: the homepage hero carousel
14
+ * still shipped a "Live auctions / Place a bid before the timer runs out"
15
+ * CARD linking to `/auctions`, and a whole `slide-beyblade-auction` slide
16
+ * whose overlay read "Win it at your price". Both are authored content, so no
17
+ * type-keyed gate could see them — the only thing that identifies them is
18
+ * where they point.
19
+ *
20
+ * ## The rule, and the one case that makes it non-obvious
21
+ *
22
+ * A href is disabled when EVERY listing type that claims its route is
23
+ * disabled. Not "any" — `art` and `stickers` share `/art`, so a site that
24
+ * sells art but not stickers must keep `/art` reachable. Treating one disabled
25
+ * claimant as enough would silently delete a working destination.
26
+ *
27
+ * ## What it deliberately does NOT match
28
+ *
29
+ * Editorial pages that merely have a type's name in the path — `/help/auctions`,
30
+ * `/how-auctions-work`, `/seller-guide/prize-draws`. None of them starts with
31
+ * `/auctions`, so prefix matching excludes them for free, and that is correct:
32
+ * an explainer about auctions is still honest reading when auctions are off.
33
+ * A substring test would have eaten all three.
34
+ *
35
+ * Store tabs (`/stores/{slug}/auctions`) are also out of scope — those are
36
+ * resolved by `resolveStoreTabs`, which owns its own visibility, and a second
37
+ * owner is how a mirror drifts (Root Cause #42).
38
+ */
39
+ import { LISTING_TYPE_REGISTRY } from "./_registry.js";
40
+ import { ALL_LISTING_TYPES, isListingTypeEnabled } from "./feature-flags.js";
41
+ /**
42
+ * browse route → every listing type that browses there.
43
+ *
44
+ * Built from the registry rather than hand-listed, so a tenth type joins it by
45
+ * existing. `art` and `stickers` both resolve to `/art`, which is the entire
46
+ * reason the value is an array.
47
+ */
48
+ const TYPES_BY_BROWSE_ROUTE = (() => {
49
+ const map = new Map();
50
+ for (const type of ALL_LISTING_TYPES) {
51
+ const route = normalisePath(LISTING_TYPE_REGISTRY[type].browseRoute);
52
+ if (!route)
53
+ continue;
54
+ const existing = map.get(route);
55
+ if (existing)
56
+ existing.push(type);
57
+ else
58
+ map.set(route, [type]);
59
+ }
60
+ return map;
61
+ })();
62
+ /**
63
+ * Reduce an authored href to a comparable path.
64
+ *
65
+ * Returns `""` for anything that is not an in-app path — an absolute URL to
66
+ * another origin, a `mailto:`, an anchor. Those can never be a listing browse
67
+ * route, and a caller must not filter them out.
68
+ *
69
+ * 🛑 The locale prefix is stripped. `ROUTES` values are unprefixed
70
+ * (`/auctions`) and `next-intl` adds `/en` at navigation time, but an admin
71
+ * pasting a URL out of the address bar stores the prefixed form — so a
72
+ * comparison against the raw value matches the seed and misses the hand-typed
73
+ * one, which is the half most likely to be wrong.
74
+ */
75
+ function normalisePath(href) {
76
+ if (typeof href !== "string")
77
+ return "";
78
+ const trimmed = href.trim();
79
+ if (!trimmed || !trimmed.startsWith("/"))
80
+ return "";
81
+ // Drop query + hash: `/auctions?sort=ending` is still /auctions.
82
+ const path = trimmed.split(/[?#]/)[0] ?? "";
83
+ // `/en/auctions` → `/auctions`. Two-letter segment only, so a real route
84
+ // whose first segment is short (there are none today, but `/ar` would be a
85
+ // plausible future one) is the only thing this could over-match — and it
86
+ // would have to collide with a locale code to do it.
87
+ const delocalised = path.replace(/^\/[a-z]{2}(?=\/)/, "");
88
+ // Trailing slash off, but never reduce "/" to "".
89
+ return delocalised.length > 1 ? delocalised.replace(/\/+$/, "") : delocalised;
90
+ }
91
+ /**
92
+ * `true` when `href` points at a listing type's browse page (or any page
93
+ * beneath it) and every type that browses there is disabled.
94
+ *
95
+ * Fails OPEN on anything it cannot classify: an unknown path, an external URL
96
+ * or a missing settings document all return `false`. A settings read that
97
+ * errored must not strip working destinations out of the homepage — the same
98
+ * reasoning as the suggestions endpoint's TTL cache and `src/proxy.ts`'s
99
+ * disabled-routes gate.
100
+ */
101
+ export function isDisabledListingHref(href, settings) {
102
+ const path = normalisePath(href);
103
+ if (!path)
104
+ return false;
105
+ for (const [route, types] of TYPES_BY_BROWSE_ROUTE) {
106
+ // The browse page itself, or a detail page under it. `startsWith(route)`
107
+ // alone would match `/live` against `/livestream`, so the boundary is
108
+ // explicit.
109
+ if (path !== route && !path.startsWith(`${route}/`))
110
+ continue;
111
+ return types.every((type) => !isListingTypeEnabled(type, settings));
112
+ }
113
+ return false;
114
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * What the buyer is quoted for shipping, what the seller actually spent, and
3
+ * who wears the difference.
4
+ *
5
+ * Pure and synchronous — no I/O, no Firestore, no provider. Every surface that
6
+ * touches a shipping figure resolves through here so the quote shown at
7
+ * checkout, the amount charged, the order record and the reconciliation can
8
+ * never disagree (Root Cause #75: a rule reachable by copy-paste WILL be
9
+ * copied, and the copy never learns about the branch added later).
10
+ *
11
+ * ## The model
12
+ *
13
+ * 1. Shiprocket returns per-courier rates for the buyer's pincode.
14
+ * 2. Each rate is ROUNDED UP to the nearest ₹50 and that is what the buyer
15
+ * sees and is charged. The raw rate is never shown and never billed.
16
+ * 3. The seller ships. If their courier is dearer than the charged figure they
17
+ * absorb the difference; if it is cheaper the buyer is credited the
18
+ * difference in points, 1 point = ₹1.
19
+ *
20
+ * ## 🛑 Reconciliation measures against the CHARGED figure, not the raw rate
21
+ *
22
+ * This is the decision that makes the whole thing coherent, and it is worth
23
+ * stating because the obvious alternative is defensible and wrong.
24
+ *
25
+ * Measuring against the raw rate would let the platform keep the round-up
26
+ * (₹70 raw → ₹100 charged → a silent ₹30 margin on every parcel) while still
27
+ * calling the ₹50 step a "rounding". Measuring against the charged figure
28
+ * means the buyer is made whole for every rupee they handed over that did not
29
+ * become postage: ₹100 charged, shipped at ₹47, 53 points back. The round-up
30
+ * stops being a margin and becomes a BUFFER that absorbs ordinary courier
31
+ * variance before the seller has to.
32
+ *
33
+ * It also makes the rule explainable in one sentence to both sides — "you pay
34
+ * the rounded quote and get back whatever we did not spend", "you keep the
35
+ * quote and cover any overage" — and a rule a seller can predict is a rule
36
+ * they do not dispute.
37
+ */
38
+ /**
39
+ * Shipping is always billed in whole ₹50 steps.
40
+ *
41
+ * 🛑 Not a formatting choice. A courier rate is a volatile per-parcel number
42
+ * (fuel, zone, weight slab, surcharge) and quoting it to the rupee invites a
43
+ * dispute every time the actual differs by ₹3. A coarse step makes the quote
44
+ * stable, makes it look like a price rather than a machine reading, and buys
45
+ * the headroom that keeps most orders off the reconciliation path entirely.
46
+ */
47
+ export declare const SHIPPING_ROUND_UP_TO = 50;
48
+ /**
49
+ * Round a raw carrier rate up to the next ₹50.
50
+ *
51
+ * `Math.ceil`, so ₹70 → ₹100 and ₹100 → ₹100 — a rate exactly on a step is NOT
52
+ * pushed to the next one. ₹0 stays ₹0: free shipping must survive the rounding
53
+ * or every free-shipping order silently acquires a ₹50 charge.
54
+ *
55
+ * Negative input is clamped to 0 rather than rounded "up" toward zero, which
56
+ * would otherwise turn a bad provider response into a credit.
57
+ */
58
+ export declare function roundShippingUp(rawRate: number): number;
59
+ /** One courier the buyer may pick, as offered at checkout. */
60
+ export interface ShippingQuoteOption {
61
+ /** Stable id from the carrier, so a pick survives a re-quote. */
62
+ courierId: string;
63
+ /** Display name — "Ekart", "Delhivery", "India Post". */
64
+ courierName: string;
65
+ /**
66
+ * The carrier's own rate, kept for the audit trail and NEVER rendered.
67
+ *
68
+ * 🛑 Showing both numbers would invite "why am I paying ₹100 for a ₹70
69
+ * courier" — a question whose honest answer (the difference comes back to
70
+ * you as points if it is not spent) is not something a buyer should have to
71
+ * reconstruct from two figures on a checkout page.
72
+ */
73
+ rawRate: number;
74
+ /** What the buyer sees and is charged. `roundShippingUp(rawRate)`. */
75
+ chargedRate: number;
76
+ estimatedDays: number;
77
+ }
78
+ /**
79
+ * The option the buyer committed to, frozen onto the order.
80
+ *
81
+ * 🛑 Stored, never recomputed. Carrier rates move hourly; re-quoting at
82
+ * reconciliation time would compare the seller's actual spend against a price
83
+ * nobody was ever charged, and the buyer's points would change depending on
84
+ * when the job happened to run. Same reasoning as `OrderSourceContext` being
85
+ * written once at creation.
86
+ */
87
+ export interface SelectedShippingQuote {
88
+ courierId: string;
89
+ courierName: string;
90
+ rawRate: number;
91
+ /** The amount actually added to the order total. */
92
+ chargedRate: number;
93
+ estimatedDays: number;
94
+ /** When the quote was taken, so a stale pick is recognisable. */
95
+ quotedAt: Date;
96
+ }
97
+ /** What the seller really shipped with, recorded when they dispatch. */
98
+ export interface ActualShipmentCost {
99
+ courierId: string;
100
+ courierName: string;
101
+ /** The seller's real postage, to the rupee. Not rounded. */
102
+ actualRate: number;
103
+ }
104
+ /**
105
+ * The outcome of comparing what was charged with what was spent.
106
+ *
107
+ * Exactly one of `sellerAbsorbs` / `buyerPoints` is non-zero, and both are
108
+ * non-negative — a signed "difference" field would make every consumer decide
109
+ * what its sign meant, and the two directions have completely different
110
+ * consequences (one is a cost to a seller, one is a credit to a buyer).
111
+ */
112
+ export interface ShippingReconciliation {
113
+ /** Rupees the seller eats because their courier cost more than quoted. */
114
+ sellerAbsorbs: number;
115
+ /** Points credited to the buyer, 1 point = ₹1. */
116
+ buyerPoints: number;
117
+ /** True when the seller shipped with a courier the buyer did not choose. */
118
+ courierChanged: boolean;
119
+ }
120
+ /**
121
+ * Compare the charged quote against the real spend.
122
+ *
123
+ * 🛑 Works on the CHARGED figure (see the header). The worked examples from the
124
+ * spec, with the ₹70 Ekart quote billed at ₹100:
125
+ *
126
+ * shipped Delhivery ₹100 → sellerAbsorbs 0, buyerPoints 0
127
+ * shipped Delhivery ₹120 → sellerAbsorbs 20, buyerPoints 0
128
+ * shipped India Post ₹47 → sellerAbsorbs 0, buyerPoints 53
129
+ *
130
+ * Points are the EXACT rupee difference and are deliberately not rounded to a
131
+ * ₹50 step. The round-up exists to make a *quote* stable; applying it to a
132
+ * credit would either invent money (rounding up) or quietly keep some of the
133
+ * buyer's (rounding down).
134
+ */
135
+ export declare function reconcileShipping(quote: Pick<SelectedShippingQuote, "chargedRate" | "courierId">, actual: ActualShipmentCost): ShippingReconciliation;
136
+ /**
137
+ * Turn carrier serviceability results into offerable options.
138
+ *
139
+ * 🛑 Drops any courier whose rate is not a usable number instead of charging
140
+ * ₹0 for it. `resolveShippingCost` already carries this lesson in its
141
+ * `shippingUnavailable` flag: a zero there "would ship every parcel free at
142
+ * our expense, silently, until somebody noticed". A malformed row from a
143
+ * provider is missing data, never free postage.
144
+ *
145
+ * Sorted cheapest-first, then fastest — the buyer is choosing, so the default
146
+ * reading order should be the one most of them want.
147
+ */
148
+ export declare function toQuoteOptions(couriers: readonly {
149
+ courierId?: string;
150
+ name: string;
151
+ rate: number;
152
+ estimatedDays: number;
153
+ }[]): ShippingQuoteOption[];
@@ -0,0 +1,123 @@
1
+ /**
2
+ * What the buyer is quoted for shipping, what the seller actually spent, and
3
+ * who wears the difference.
4
+ *
5
+ * Pure and synchronous — no I/O, no Firestore, no provider. Every surface that
6
+ * touches a shipping figure resolves through here so the quote shown at
7
+ * checkout, the amount charged, the order record and the reconciliation can
8
+ * never disagree (Root Cause #75: a rule reachable by copy-paste WILL be
9
+ * copied, and the copy never learns about the branch added later).
10
+ *
11
+ * ## The model
12
+ *
13
+ * 1. Shiprocket returns per-courier rates for the buyer's pincode.
14
+ * 2. Each rate is ROUNDED UP to the nearest ₹50 and that is what the buyer
15
+ * sees and is charged. The raw rate is never shown and never billed.
16
+ * 3. The seller ships. If their courier is dearer than the charged figure they
17
+ * absorb the difference; if it is cheaper the buyer is credited the
18
+ * difference in points, 1 point = ₹1.
19
+ *
20
+ * ## 🛑 Reconciliation measures against the CHARGED figure, not the raw rate
21
+ *
22
+ * This is the decision that makes the whole thing coherent, and it is worth
23
+ * stating because the obvious alternative is defensible and wrong.
24
+ *
25
+ * Measuring against the raw rate would let the platform keep the round-up
26
+ * (₹70 raw → ₹100 charged → a silent ₹30 margin on every parcel) while still
27
+ * calling the ₹50 step a "rounding". Measuring against the charged figure
28
+ * means the buyer is made whole for every rupee they handed over that did not
29
+ * become postage: ₹100 charged, shipped at ₹47, 53 points back. The round-up
30
+ * stops being a margin and becomes a BUFFER that absorbs ordinary courier
31
+ * variance before the seller has to.
32
+ *
33
+ * It also makes the rule explainable in one sentence to both sides — "you pay
34
+ * the rounded quote and get back whatever we did not spend", "you keep the
35
+ * quote and cover any overage" — and a rule a seller can predict is a rule
36
+ * they do not dispute.
37
+ */
38
+ /**
39
+ * Shipping is always billed in whole ₹50 steps.
40
+ *
41
+ * 🛑 Not a formatting choice. A courier rate is a volatile per-parcel number
42
+ * (fuel, zone, weight slab, surcharge) and quoting it to the rupee invites a
43
+ * dispute every time the actual differs by ₹3. A coarse step makes the quote
44
+ * stable, makes it look like a price rather than a machine reading, and buys
45
+ * the headroom that keeps most orders off the reconciliation path entirely.
46
+ */
47
+ export const SHIPPING_ROUND_UP_TO = 50;
48
+ /**
49
+ * Round a raw carrier rate up to the next ₹50.
50
+ *
51
+ * `Math.ceil`, so ₹70 → ₹100 and ₹100 → ₹100 — a rate exactly on a step is NOT
52
+ * pushed to the next one. ₹0 stays ₹0: free shipping must survive the rounding
53
+ * or every free-shipping order silently acquires a ₹50 charge.
54
+ *
55
+ * Negative input is clamped to 0 rather than rounded "up" toward zero, which
56
+ * would otherwise turn a bad provider response into a credit.
57
+ */
58
+ export function roundShippingUp(rawRate) {
59
+ if (!Number.isFinite(rawRate) || rawRate <= 0)
60
+ return 0;
61
+ return Math.ceil(rawRate / SHIPPING_ROUND_UP_TO) * SHIPPING_ROUND_UP_TO;
62
+ }
63
+ /**
64
+ * Compare the charged quote against the real spend.
65
+ *
66
+ * 🛑 Works on the CHARGED figure (see the header). The worked examples from the
67
+ * spec, with the ₹70 Ekart quote billed at ₹100:
68
+ *
69
+ * shipped Delhivery ₹100 → sellerAbsorbs 0, buyerPoints 0
70
+ * shipped Delhivery ₹120 → sellerAbsorbs 20, buyerPoints 0
71
+ * shipped India Post ₹47 → sellerAbsorbs 0, buyerPoints 53
72
+ *
73
+ * Points are the EXACT rupee difference and are deliberately not rounded to a
74
+ * ₹50 step. The round-up exists to make a *quote* stable; applying it to a
75
+ * credit would either invent money (rounding up) or quietly keep some of the
76
+ * buyer's (rounding down).
77
+ */
78
+ export function reconcileShipping(quote, actual) {
79
+ const charged = Number.isFinite(quote.chargedRate) ? Math.max(0, quote.chargedRate) : 0;
80
+ const spent = Number.isFinite(actual.actualRate) ? Math.max(0, actual.actualRate) : 0;
81
+ const delta = roundMoney(charged - spent);
82
+ return {
83
+ sellerAbsorbs: delta < 0 ? Math.abs(delta) : 0,
84
+ buyerPoints: delta > 0 ? delta : 0,
85
+ courierChanged: quote.courierId !== actual.courierId,
86
+ };
87
+ }
88
+ /**
89
+ * Two decimal places.
90
+ *
91
+ * Money is stored as decimal rupees (Root Cause #31), so a subtraction can
92
+ * leave a float artefact — `100 - 47.1` is `52.900000000000006`, and that would
93
+ * become a points credit with eleven decimal places on a user's balance.
94
+ */
95
+ function roundMoney(value) {
96
+ return Math.round(value * 100) / 100;
97
+ }
98
+ /**
99
+ * Turn carrier serviceability results into offerable options.
100
+ *
101
+ * 🛑 Drops any courier whose rate is not a usable number instead of charging
102
+ * ₹0 for it. `resolveShippingCost` already carries this lesson in its
103
+ * `shippingUnavailable` flag: a zero there "would ship every parcel free at
104
+ * our expense, silently, until somebody noticed". A malformed row from a
105
+ * provider is missing data, never free postage.
106
+ *
107
+ * Sorted cheapest-first, then fastest — the buyer is choosing, so the default
108
+ * reading order should be the one most of them want.
109
+ */
110
+ export function toQuoteOptions(couriers) {
111
+ return couriers
112
+ .filter((c) => Number.isFinite(c.rate) && c.rate > 0 && Boolean(c.name))
113
+ .map((c) => ({
114
+ // Providers do not all return an id. Falling back to the name keeps the
115
+ // pick round-trippable; it is only ever compared against itself.
116
+ courierId: c.courierId ?? c.name,
117
+ courierName: c.name,
118
+ rawRate: c.rate,
119
+ chargedRate: roundShippingUp(c.rate),
120
+ estimatedDays: c.estimatedDays,
121
+ }))
122
+ .sort((a, b) => a.chargedRate - b.chargedRate || a.estimatedDays - b.estimatedDays);
123
+ }
package/dist/client.d.ts CHANGED
@@ -387,6 +387,8 @@ export type { UserAccountHubViewProps, UserAccountHubViewLabels, UserOrdersViewP
387
387
  export { useOrders, useOrder, OrdersList } from "./features/orders/index";
388
388
  export { OrderStatusTimeline } from "./features/orders/components/OrderStatusTimeline";
389
389
  export type { OrderStatusTimelineProps } from "./features/orders/components/OrderStatusTimeline";
390
+ export { EmiInstallmentsPanel } from "./features/orders/components/EmiInstallmentsPanel";
391
+ export type { EmiInstallmentsPanelProps } from "./features/orders/components/EmiInstallmentsPanel";
390
392
  export { CountdownDisplay } from "./ui/components/CountdownDisplay";
391
393
  export type { CountdownDisplayProps } from "./ui/components/CountdownDisplay";
392
394
  export { OrderAddonBadges } from "./features/orders/components/OrderAddonBadges";
@@ -534,6 +536,7 @@ export { SELLER_LISTING_TABS, type SellerListingTabId, } from "./features/produc
534
536
  export { ALL_TAB, EMPTY_TAB, ADMIN_PRODUCT_STATUS_TABS, ADMIN_PRODUCT_LISTING_TYPE_TABS, ADMIN_BLOG_STATUS_TABS, ADMIN_USER_STATUS_TABS, ADMIN_USER_ROLE_TABS, ADMIN_STORE_STATUS_TABS, ADMIN_PAYOUT_STATUS_TABS, ADMIN_ORDER_STATUS_TABS, ADMIN_REVIEW_STATUS_TABS, ADMIN_REVIEW_RATING_TABS, ADMIN_BID_STATUS_TABS, ADMIN_CONTACT_STATUS_TABS, ADMIN_NEWSLETTER_STATUS_TABS, ADMIN_EVENT_ENTRY_STATUS_TABS, ADMIN_EVENT_STATUS_TABS, ADMIN_CART_OWNERSHIP_TABS, ADMIN_COUPON_TYPE_TABS, SELLER_PRODUCT_STATUS_TABS, SELLER_AUCTION_STATUS_TABS, SELLER_ORDER_STATUS_TABS, SELLER_OFFER_STATUS_TABS, SELLER_BID_STATUS_TABS, } from "./features/admin/constants/filter-tabs";
535
537
  export { PRODUCT_FIELDS, PRODUCT_STATUS_TRANSITIONS, ORDER_FIELDS, REVIEW_FIELDS, BID_FIELDS, AD_FIELDS, EVENT_FIELDS, EVENT_ENTRY_FIELDS, PAYOUT_FIELDS, STORE_FIELDS, CATEGORY_FIELDS, BLOG_FIELDS, USER_FIELDS, ADDRESS_FIELDS, BRAND_FIELDS, CART_FIELDS, WISHLIST_FIELDS, HISTORY_FIELDS, NOTIFICATION_FIELDS, SESSION_FIELDS, COUPON_USAGE_FIELDS, SCAMMER_FIELDS, SUPPORT_TICKET_FIELDS, CAROUSEL_FIELDS, COUPON_FIELDS, FAQ_FIELDS, HOMEPAGE_SECTION_FIELDS, SITE_SETTINGS_FIELDS, COMMON_FIELDS, OAUTH_STATE_VALUES, SCHEMA_DEFAULTS, } from "./constants/field-names";
536
538
  export { CART_LANE, CART_LANE_PRIORITY, CART_LANE_LABELS, laneOf, activeLane, laneItems, laneCounts, isLaneCheckoutable, laneBlockReason, isLockedLane, canAddNewItems, type CartLane, type LaneAssignable, } from "./_internal/shared/checkout/lanes";
539
+ export { roundShippingUp, toQuoteOptions, reconcileShipping, SHIPPING_ROUND_UP_TO, type ShippingQuoteOption, type SelectedShippingQuote, type ActualShipmentCost, type ShippingReconciliation, } from "./_internal/shared/shipping/quote";
537
540
  export { getCartLineMembers, isMultiMemberLine, } from "./_internal/shared/checkout/line-members";
538
541
  export { newsletterSubscribeSchema, quickCreateTaxonomySchema, payoutMarkPaidSchema, } from "./features/admin/schemas/small-forms";
539
542
  export { raffleEntrySchema } from "./features/contact/schemas/inquiry-forms";
@@ -583,3 +586,22 @@ export { PriceIndexToolView } from "./features/price-index/components/PriceIndex
583
586
  export type { PriceIndexToolViewProps, PriceIndexToolResponse, PriceIndexResultRow, PriceIndexBandRow, PriceIndexConditionRow, } from "./features/price-index/components/PriceIndexToolView";
584
587
  export { COURIER_RATING_MIN_CONFIDENT_SAMPLES, courierRatingDisplay, isValidCourierVote, courierVoteId, rollUpCourierVotes, } from "./_internal/shared/features/shipping/courier-rating";
585
588
  export type { CourierRatingPair, CourierRatingDisplay, CourierRatingVote, CourierStats, } from "./_internal/shared/features/shipping/courier-rating";
589
+ /**
590
+ * The `/api/checkout/pricing-preview` RESPONSE CONTRACT.
591
+ *
592
+ * 🛑 `export type`, from a `_internal/server` module, deliberately. A type-only
593
+ * re-export is fully erased at compile time — `audit-client-server-only-leak`
594
+ * states that explicitly — so this pulls no server chain into the client
595
+ * bundle. It is the one exception worth making for this module.
596
+ *
597
+ * The consumer had been keeping a HAND-WRITTEN copy of both shapes in
598
+ * `src/lib/api/payment-client.ts`, under a comment reading "mirrors
599
+ * `CheckoutPricingPreviewStore` on the server". It drifted exactly as Root
600
+ * Cause #42 predicts: the server gained `emiSurcharge`, the mirror did not, and
601
+ * the preview the cart rendered could not be assigned to the breakdown
602
+ * component that displays it. Four `tsc` errors across the cart and checkout
603
+ * clients, all from one missing field in a second definition of one shape.
604
+ *
605
+ * A response contract has exactly one owner — the handler that produces it.
606
+ */
607
+ export type { CheckoutPricingPreview, CheckoutPricingPreviewStore, } from "./_internal/server/features/checkout/actions";