@mohasinac/appkit 4.55.0 โ 4.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.build-stamp.json +4 -4
- package/dist/_internal/server/features/checkout/actions.d.ts +22 -0
- package/dist/_internal/server/features/checkout/actions.js +39 -3
- package/dist/_internal/server/features/shipping/reconcile.d.ts +28 -0
- package/dist/_internal/server/features/shipping/reconcile.js +93 -0
- package/dist/_internal/shared/config/schema.d.ts +6 -0
- package/dist/_internal/shared/listing-types/route-gating.d.ts +56 -0
- package/dist/_internal/shared/listing-types/route-gating.js +114 -0
- package/dist/_internal/shared/shipping/quote.d.ts +153 -0
- package/dist/_internal/shared/shipping/quote.js +124 -0
- package/dist/client.d.ts +1 -0
- package/dist/client.js +8 -0
- package/dist/features/auctions/components/AuctionDetailPageView.js +2 -1
- package/dist/features/auth/schemas/firestore.d.ts +15 -0
- package/dist/features/digital-codes/components/DigitalCodeDetailPageView.js +2 -1
- package/dist/features/homepage/components/HeroCarousel.js +4 -2
- package/dist/features/homepage/components/MarketplaceHomepageView.js +16 -1
- package/dist/features/homepage/components/WelcomeSection.d.ts +16 -3
- package/dist/features/homepage/components/WelcomeSection.js +3 -3
- package/dist/features/homepage/lib/section-renderer.d.ts +9 -0
- package/dist/features/homepage/lib/section-renderer.js +22 -5
- package/dist/features/homepage/lib/slide-gating.d.ts +52 -0
- package/dist/features/homepage/lib/slide-gating.js +98 -0
- package/dist/features/live/components/LiveItemDetailPageView.js +2 -1
- package/dist/features/media/MediaAvatar.js +1 -1
- package/dist/features/media/MediaImage.d.ts +18 -1
- package/dist/features/media/MediaImage.js +1 -1
- package/dist/features/orders/schemas/firestore.d.ts +38 -0
- package/dist/features/points/repository/points.repository.d.ts +81 -0
- package/dist/features/points/repository/points.repository.js +155 -0
- package/dist/features/points/schemas/firestore.d.ts +102 -0
- package/dist/features/points/schemas/firestore.js +99 -0
- package/dist/features/pre-orders/components/PreOrderDetailPageView.js +2 -1
- package/dist/features/products/components/PrizeDrawDetailPageView.js +2 -1
- package/dist/features/products/components/ProductDetailPageView.js +3 -1
- package/dist/features/seller/actions/seller-actions.d.ts +17 -0
- package/dist/features/seller/actions/seller-actions.js +33 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +8 -0
- package/dist/seed/carousel-slides-seed-data.js +48 -7
- package/dist/styles.css +38 -6
- package/dist/tailwind-utilities.css +1 -1
- package/dist/ui/components/ImageMosaic.d.ts +17 -1
- package/dist/ui/components/ImageMosaic.js +28 -5
- package/dist/ui/components/ImageMosaic.style.css +37 -5
- package/package.json +1 -1
|
@@ -24,7 +24,24 @@ export interface MediaImageProps {
|
|
|
24
24
|
* one slide the visitor can actually see.
|
|
25
25
|
*/
|
|
26
26
|
fetchPriority?: "high" | "low" | "auto";
|
|
27
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* CSS object-fit applied to the underlying img element. Defaults to
|
|
29
|
+
* `'contain'`.
|
|
30
|
+
*
|
|
31
|
+
* ๐ THE DEFAULT CHANGED on 2026-10-20, from `'cover'`. `cover` fills the
|
|
32
|
+
* box by cropping whatever does not fit, which is right for a photograph
|
|
33
|
+
* that continues past its frame and wrong for this catalogue: most of its
|
|
34
|
+
* imagery is a product shot centred on white, where the crop takes the
|
|
35
|
+
* product and leaves the background. `contain` fits the whole subject
|
|
36
|
+
* inside the shape and letterboxes the remainder against the tile's own
|
|
37
|
+
* surface colour.
|
|
38
|
+
*
|
|
39
|
+
* Pass `'cover'` explicitly where a FILLED box is the component's contract
|
|
40
|
+
* rather than a preference โ a full-bleed backdrop with text over it, or a
|
|
41
|
+
* circular avatar. Three call sites do: `MediaAvatar`, and the slide and
|
|
42
|
+
* card backgrounds in `HeroCarousel`. A letterboxed backdrop there would put
|
|
43
|
+
* the dim overlay and the headline on bare surface.
|
|
44
|
+
*/
|
|
28
45
|
objectFit?: "cover" | "contain";
|
|
29
46
|
/**
|
|
30
47
|
* Emoji or text to show when `src` is undefined.
|
|
@@ -67,7 +67,7 @@ function resolveSrcSet(input) {
|
|
|
67
67
|
.filter(Boolean)
|
|
68
68
|
.join(", ");
|
|
69
69
|
}
|
|
70
|
-
export function MediaImage({ src, alt, size = "card", priority = false, loading, fetchPriority, objectFit = "
|
|
70
|
+
export function MediaImage({ src, alt, size = "card", priority = false, loading, fetchPriority, objectFit = "contain", fallback, className, rounded, shadow, style, sources, onError, }) {
|
|
71
71
|
const [hasError, setHasError] = useState(false);
|
|
72
72
|
const [isLoaded, setIsLoaded] = useState(false);
|
|
73
73
|
const [retryCount, setRetryCount] = useState(0);
|
|
@@ -8,6 +8,7 @@ import type { ListingType } from "../../products/types";
|
|
|
8
8
|
import type { StatusChangeEntry } from "../../../_internal/shared/history/index";
|
|
9
9
|
import type { ReturnReason } from "../../../_internal/shared/features/orders/return-reasons";
|
|
10
10
|
import type { ReturnCustodyState } from "../../../_internal/shared/features/returns/custody";
|
|
11
|
+
import type { ActualShipmentCost, SelectedShippingQuote, ShippingReconciliation } from "../../../_internal/shared/shipping/quote";
|
|
11
12
|
import type { Encrypted, Plaintext } from "../../../security/encrypted";
|
|
12
13
|
export interface OrderSourceStandard {
|
|
13
14
|
path: "standard";
|
|
@@ -550,6 +551,43 @@ export interface OrderDocument extends BaseDocument {
|
|
|
550
551
|
shippingMethod?: ShippingMethod;
|
|
551
552
|
shippingCarrier?: string;
|
|
552
553
|
trackingUrl?: string;
|
|
554
|
+
/**
|
|
555
|
+
* The courier the BUYER picked at checkout, and what they were charged for
|
|
556
|
+
* it, frozen at order creation.
|
|
557
|
+
*
|
|
558
|
+
* ๐ Stored, never recomputed. Carrier rates move hourly, so re-quoting at
|
|
559
|
+
* reconciliation time would compare the seller's real spend against a price
|
|
560
|
+
* nobody was ever charged, and the buyer's refund would depend on when a job
|
|
561
|
+
* happened to run. Same reasoning as `sourceContext`.
|
|
562
|
+
*
|
|
563
|
+
* `chargedRate` is the โน50-rounded figure and is the ONLY one that is
|
|
564
|
+
* billed; `rawRate` is kept for the audit trail and is never rendered.
|
|
565
|
+
* Absent on orders placed before the picker shipped, and on any order whose
|
|
566
|
+
* seller ships manually โ so every reader must treat absence as "no quote
|
|
567
|
+
* was taken", never as zero.
|
|
568
|
+
*/
|
|
569
|
+
selectedShippingQuote?: SelectedShippingQuote;
|
|
570
|
+
/**
|
|
571
|
+
* What the seller actually spent on postage, recorded at dispatch.
|
|
572
|
+
*
|
|
573
|
+
* Written by the seller's ship action. Its presence is what makes an order
|
|
574
|
+
* eligible for reconciliation; its absence means the parcel has not been
|
|
575
|
+
* dispatched or the seller has not told us what it cost.
|
|
576
|
+
*/
|
|
577
|
+
actualShipmentCost?: ActualShipmentCost;
|
|
578
|
+
/**
|
|
579
|
+
* The outcome of comparing the two above โ computed ONCE, when the seller
|
|
580
|
+
* dispatches, and then left alone.
|
|
581
|
+
*
|
|
582
|
+
* ๐ Its presence is the idempotency key for the points credit. Crediting is
|
|
583
|
+
* an append to a ledger, so a job that ran twice would pay the buyer twice
|
|
584
|
+
* and nothing downstream could tell the difference between that and two
|
|
585
|
+
* genuine savings. Every writer must check this field is unset before
|
|
586
|
+
* writing a `shipping_saving` entry.
|
|
587
|
+
*/
|
|
588
|
+
shippingReconciliation?: ShippingReconciliation & {
|
|
589
|
+
reconciledAt: Date;
|
|
590
|
+
};
|
|
553
591
|
/**
|
|
554
592
|
* The carrier's label PDF, once a shipment has been booked.
|
|
555
593
|
*
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { type PointsEntryKind, type PointsLedgerEntry } from "../schemas/firestore";
|
|
2
|
+
/**
|
|
3
|
+
* The points ledger: append-only entries, and a balance that is their sum.
|
|
4
|
+
*
|
|
5
|
+
* ## ๐ Every mutation is a TRANSACTION over the ledger and the mirror
|
|
6
|
+
*
|
|
7
|
+
* `users/{uid}.pointsBalance` is a denormalised mirror of the ledger, kept so
|
|
8
|
+
* a page can show a balance in one read instead of scanning an unbounded
|
|
9
|
+
* collection. Root Cause #42 is explicit about what happens to a mirror the
|
|
10
|
+
* moment one write path forgets it, and this mirror guards MONEY-SHAPED value:
|
|
11
|
+
* drift here either hands a buyer points nobody credited, or swallows points
|
|
12
|
+
* they earned.
|
|
13
|
+
*
|
|
14
|
+
* So the entry and the mirror move together or not at all, and the mirror is
|
|
15
|
+
* incremented from the value read INSIDE the transaction rather than from a
|
|
16
|
+
* figure the caller passed in โ two concurrent credits that each read "53" and
|
|
17
|
+
* each wrote "106" would lose one of them.
|
|
18
|
+
*
|
|
19
|
+
* `redeem()` additionally re-checks the balance inside the transaction. A
|
|
20
|
+
* caller that validated the balance a moment earlier is working from a read
|
|
21
|
+
* that may already be stale, and the failure mode is a negative balance โ the
|
|
22
|
+
* one state this ledger must never reach.
|
|
23
|
+
*/
|
|
24
|
+
export declare class PointsRepository {
|
|
25
|
+
/**
|
|
26
|
+
* Current balance, from the mirror.
|
|
27
|
+
*
|
|
28
|
+
* ๐ Falls back to summing the ledger when the mirror is absent, rather than
|
|
29
|
+
* returning 0. An unseeded or pre-feature user document has no
|
|
30
|
+
* `pointsBalance`, and answering "0" for someone with real credits would
|
|
31
|
+
* silently deny them money they are owed. Absence means "not computed yet",
|
|
32
|
+
* never "nothing".
|
|
33
|
+
*/
|
|
34
|
+
getBalance(userId: string): Promise<number>;
|
|
35
|
+
/**
|
|
36
|
+
* The authoritative balance, computed from the entries themselves.
|
|
37
|
+
*
|
|
38
|
+
* Unbounded by nature, so it is NOT the read path โ it exists to repair the
|
|
39
|
+
* mirror and to let a reconciliation job prove the two agree.
|
|
40
|
+
*/
|
|
41
|
+
sumLedger(userId: string): Promise<number>;
|
|
42
|
+
/**
|
|
43
|
+
* Append an entry and move the mirror, atomically.
|
|
44
|
+
*
|
|
45
|
+
* Returns the new balance so a caller can report it without a second read.
|
|
46
|
+
*/
|
|
47
|
+
append(input: {
|
|
48
|
+
userId: string;
|
|
49
|
+
kind: PointsEntryKind;
|
|
50
|
+
amount: number;
|
|
51
|
+
description: string;
|
|
52
|
+
orderId?: string;
|
|
53
|
+
adminReason?: string;
|
|
54
|
+
actorUid?: string;
|
|
55
|
+
}): Promise<{
|
|
56
|
+
entryId: string;
|
|
57
|
+
balance: number;
|
|
58
|
+
}>;
|
|
59
|
+
/**
|
|
60
|
+
* Spend points against an order.
|
|
61
|
+
*
|
|
62
|
+
* ๐ `points` is given POSITIVE and stored NEGATIVE. The caller is saying
|
|
63
|
+
* "spend 53", and making them pass `-53` is an invitation to pass `53` by
|
|
64
|
+
* mistake and credit somebody instead of charging them.
|
|
65
|
+
*
|
|
66
|
+
* Re-reads the balance inside the transaction and refuses rather than going
|
|
67
|
+
* negative โ see the class header.
|
|
68
|
+
*/
|
|
69
|
+
redeem(input: {
|
|
70
|
+
userId: string;
|
|
71
|
+
points: number;
|
|
72
|
+
orderId: string;
|
|
73
|
+
description: string;
|
|
74
|
+
}): Promise<{
|
|
75
|
+
entryId: string;
|
|
76
|
+
balance: number;
|
|
77
|
+
}>;
|
|
78
|
+
/** Most recent entries first, for the buyer's own points page. */
|
|
79
|
+
listForUser(userId: string, limit?: number): Promise<PointsLedgerEntry[]>;
|
|
80
|
+
}
|
|
81
|
+
export declare const pointsRepository: PointsRepository;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { getAdminDb } from "../../../providers/db-firebase/index.js";
|
|
2
|
+
import { POINTS_LEDGER_COLLECTION, isPointsEntryWellFormed, } from "../schemas/firestore.js";
|
|
3
|
+
import { USER_COLLECTION } from "../../auth/schemas/firestore.js";
|
|
4
|
+
/**
|
|
5
|
+
* The points ledger: append-only entries, and a balance that is their sum.
|
|
6
|
+
*
|
|
7
|
+
* ## ๐ Every mutation is a TRANSACTION over the ledger and the mirror
|
|
8
|
+
*
|
|
9
|
+
* `users/{uid}.pointsBalance` is a denormalised mirror of the ledger, kept so
|
|
10
|
+
* a page can show a balance in one read instead of scanning an unbounded
|
|
11
|
+
* collection. Root Cause #42 is explicit about what happens to a mirror the
|
|
12
|
+
* moment one write path forgets it, and this mirror guards MONEY-SHAPED value:
|
|
13
|
+
* drift here either hands a buyer points nobody credited, or swallows points
|
|
14
|
+
* they earned.
|
|
15
|
+
*
|
|
16
|
+
* So the entry and the mirror move together or not at all, and the mirror is
|
|
17
|
+
* incremented from the value read INSIDE the transaction rather than from a
|
|
18
|
+
* figure the caller passed in โ two concurrent credits that each read "53" and
|
|
19
|
+
* each wrote "106" would lose one of them.
|
|
20
|
+
*
|
|
21
|
+
* `redeem()` additionally re-checks the balance inside the transaction. A
|
|
22
|
+
* caller that validated the balance a moment earlier is working from a read
|
|
23
|
+
* that may already be stale, and the failure mode is a negative balance โ the
|
|
24
|
+
* one state this ledger must never reach.
|
|
25
|
+
*/
|
|
26
|
+
export class PointsRepository {
|
|
27
|
+
/**
|
|
28
|
+
* Current balance, from the mirror.
|
|
29
|
+
*
|
|
30
|
+
* ๐ Falls back to summing the ledger when the mirror is absent, rather than
|
|
31
|
+
* returning 0. An unseeded or pre-feature user document has no
|
|
32
|
+
* `pointsBalance`, and answering "0" for someone with real credits would
|
|
33
|
+
* silently deny them money they are owed. Absence means "not computed yet",
|
|
34
|
+
* never "nothing".
|
|
35
|
+
*/
|
|
36
|
+
async getBalance(userId) {
|
|
37
|
+
const db = getAdminDb();
|
|
38
|
+
const snap = await db.collection(USER_COLLECTION).doc(userId).get();
|
|
39
|
+
const mirrored = snap.data()?.pointsBalance;
|
|
40
|
+
if (typeof mirrored === "number")
|
|
41
|
+
return mirrored;
|
|
42
|
+
return this.sumLedger(userId);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The authoritative balance, computed from the entries themselves.
|
|
46
|
+
*
|
|
47
|
+
* Unbounded by nature, so it is NOT the read path โ it exists to repair the
|
|
48
|
+
* mirror and to let a reconciliation job prove the two agree.
|
|
49
|
+
*/
|
|
50
|
+
async sumLedger(userId) {
|
|
51
|
+
const db = getAdminDb();
|
|
52
|
+
const rows = await db
|
|
53
|
+
.collection(POINTS_LEDGER_COLLECTION)
|
|
54
|
+
.where("userId", "==", userId)
|
|
55
|
+
.select("amount")
|
|
56
|
+
.get();
|
|
57
|
+
const total = rows.docs.reduce((sum, d) => sum + (Number(d.data().amount) || 0), 0);
|
|
58
|
+
return Math.round(total * 100) / 100;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Append an entry and move the mirror, atomically.
|
|
62
|
+
*
|
|
63
|
+
* Returns the new balance so a caller can report it without a second read.
|
|
64
|
+
*/
|
|
65
|
+
async append(input) {
|
|
66
|
+
if (!isPointsEntryWellFormed(input)) {
|
|
67
|
+
/*
|
|
68
|
+
* ๐ Throws rather than writing a row it knows is malformed. A ledger
|
|
69
|
+
* whose invariant is only enforced by its callers is a ledger whose
|
|
70
|
+
* invariant is not enforced โ and a zero-amount or wrong-signed entry is
|
|
71
|
+
* invisible in a balance while being wrong in an audit.
|
|
72
|
+
*/
|
|
73
|
+
throw new Error(`Malformed points entry: kind=${input.kind} amount=${input.amount}`);
|
|
74
|
+
}
|
|
75
|
+
const db = getAdminDb();
|
|
76
|
+
const entryRef = db.collection(POINTS_LEDGER_COLLECTION).doc();
|
|
77
|
+
const userRef = db.collection(USER_COLLECTION).doc(input.userId);
|
|
78
|
+
const balance = await db.runTransaction(async (tx) => {
|
|
79
|
+
const userSnap = await tx.get(userRef);
|
|
80
|
+
const current = typeof userSnap.data()?.pointsBalance === "number"
|
|
81
|
+
? userSnap.data().pointsBalance
|
|
82
|
+
: 0;
|
|
83
|
+
const next = Math.round((current + input.amount) * 100) / 100;
|
|
84
|
+
const entry = {
|
|
85
|
+
id: entryRef.id,
|
|
86
|
+
userId: input.userId,
|
|
87
|
+
kind: input.kind,
|
|
88
|
+
amount: input.amount,
|
|
89
|
+
description: input.description,
|
|
90
|
+
...(input.orderId ? { orderId: input.orderId } : {}),
|
|
91
|
+
...(input.adminReason ? { adminReason: input.adminReason } : {}),
|
|
92
|
+
...(input.actorUid ? { actorUid: input.actorUid } : {}),
|
|
93
|
+
createdAt: new Date(),
|
|
94
|
+
updatedAt: new Date(),
|
|
95
|
+
};
|
|
96
|
+
tx.set(entryRef, entry);
|
|
97
|
+
tx.set(userRef, { pointsBalance: next }, { merge: true });
|
|
98
|
+
return next;
|
|
99
|
+
});
|
|
100
|
+
return { entryId: entryRef.id, balance };
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Spend points against an order.
|
|
104
|
+
*
|
|
105
|
+
* ๐ `points` is given POSITIVE and stored NEGATIVE. The caller is saying
|
|
106
|
+
* "spend 53", and making them pass `-53` is an invitation to pass `53` by
|
|
107
|
+
* mistake and credit somebody instead of charging them.
|
|
108
|
+
*
|
|
109
|
+
* Re-reads the balance inside the transaction and refuses rather than going
|
|
110
|
+
* negative โ see the class header.
|
|
111
|
+
*/
|
|
112
|
+
async redeem(input) {
|
|
113
|
+
if (!Number.isFinite(input.points) || input.points <= 0) {
|
|
114
|
+
throw new Error(`Redemption must be positive, got ${input.points}`);
|
|
115
|
+
}
|
|
116
|
+
const db = getAdminDb();
|
|
117
|
+
const entryRef = db.collection(POINTS_LEDGER_COLLECTION).doc();
|
|
118
|
+
const userRef = db.collection(USER_COLLECTION).doc(input.userId);
|
|
119
|
+
const balance = await db.runTransaction(async (tx) => {
|
|
120
|
+
const userSnap = await tx.get(userRef);
|
|
121
|
+
const current = typeof userSnap.data()?.pointsBalance === "number"
|
|
122
|
+
? userSnap.data().pointsBalance
|
|
123
|
+
: 0;
|
|
124
|
+
if (current < input.points) {
|
|
125
|
+
throw new Error("INSUFFICIENT_POINTS");
|
|
126
|
+
}
|
|
127
|
+
const next = Math.round((current - input.points) * 100) / 100;
|
|
128
|
+
tx.set(entryRef, {
|
|
129
|
+
id: entryRef.id,
|
|
130
|
+
userId: input.userId,
|
|
131
|
+
kind: "redemption",
|
|
132
|
+
amount: -input.points,
|
|
133
|
+
orderId: input.orderId,
|
|
134
|
+
description: input.description,
|
|
135
|
+
createdAt: new Date(),
|
|
136
|
+
updatedAt: new Date(),
|
|
137
|
+
});
|
|
138
|
+
tx.set(userRef, { pointsBalance: next }, { merge: true });
|
|
139
|
+
return next;
|
|
140
|
+
});
|
|
141
|
+
return { entryId: entryRef.id, balance };
|
|
142
|
+
}
|
|
143
|
+
/** Most recent entries first, for the buyer's own points page. */
|
|
144
|
+
async listForUser(userId, limit = 50) {
|
|
145
|
+
const db = getAdminDb();
|
|
146
|
+
const rows = await db
|
|
147
|
+
.collection(POINTS_LEDGER_COLLECTION)
|
|
148
|
+
.where("userId", "==", userId)
|
|
149
|
+
.orderBy("createdAt", "desc")
|
|
150
|
+
.limit(limit)
|
|
151
|
+
.get();
|
|
152
|
+
return rows.docs.map((d) => d.data());
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
export const pointsRepository = new PointsRepository();
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Buyer points โ 1 point = โน1, spendable only against a future order.
|
|
3
|
+
*
|
|
4
|
+
* ## ๐ This is NOT a wallet, and the distinction is legal, not stylistic
|
|
5
|
+
*
|
|
6
|
+
* A wallet holds the customer's money: it can be withdrawn, it is a liability
|
|
7
|
+
* payable in cash, and in India storing redeemable value on behalf of a
|
|
8
|
+
* customer is PPI territory with RBI authorisation attached. Points here are a
|
|
9
|
+
* DISCOUNT ENTITLEMENT โ they arise from shipping we charged and did not
|
|
10
|
+
* spend, they can only ever reduce the price of a later order on this site,
|
|
11
|
+
* and they are never payable out.
|
|
12
|
+
*
|
|
13
|
+
* Consequences that must survive every future change:
|
|
14
|
+
*
|
|
15
|
+
* - no withdrawal path, ever
|
|
16
|
+
* - no transfer between users
|
|
17
|
+
* - never refunded as cash when an order is cancelled; points SPENT on a
|
|
18
|
+
* cancelled order are returned as points
|
|
19
|
+
* - they do not earn interest and are not a balance the user "owns"
|
|
20
|
+
*
|
|
21
|
+
* ## Why a ledger and not a counter
|
|
22
|
+
*
|
|
23
|
+
* A single `points: number` on the user document cannot answer "where did this
|
|
24
|
+
* come from", cannot be reconciled against the orders that generated it, and
|
|
25
|
+
* loses the audit trail the moment two writes race. Root Cause #102 is the
|
|
26
|
+
* same shape one domain over: a counter that could not express what it was
|
|
27
|
+
* counting silently drifted, and nothing errored because a wrong number is
|
|
28
|
+
* just a number.
|
|
29
|
+
*
|
|
30
|
+
* So: append-only entries, and the balance is their sum. `pointsBalance` on the
|
|
31
|
+
* user document is a derived MIRROR for cheap reads โ and per Root Cause #42
|
|
32
|
+
* the reader must be able to fall back to summing the ledger, because a mirror
|
|
33
|
+
* that only one write path maintains will drift.
|
|
34
|
+
*/
|
|
35
|
+
import type { BaseDocument } from "../../../_internal/shared/types/base-document";
|
|
36
|
+
/**
|
|
37
|
+
* Why points moved.
|
|
38
|
+
*
|
|
39
|
+
* ๐ A closed union with its values enumerated, so a new source is a compile
|
|
40
|
+
* error at every exhaustive read rather than an unlabelled row. The admin
|
|
41
|
+
* answer to "why do I have 53 points" is this field.
|
|
42
|
+
*/
|
|
43
|
+
export declare const POINTS_ENTRY_KIND_VALUES: readonly ["shipping_saving", "redemption", "redemption_reversal", "admin_adjustment"];
|
|
44
|
+
export type PointsEntryKind = (typeof POINTS_ENTRY_KIND_VALUES)[number];
|
|
45
|
+
export interface PointsLedgerEntry extends BaseDocument {
|
|
46
|
+
/** Auth UID of the buyer. The ledger is keyed on the person, not the order. */
|
|
47
|
+
userId: string;
|
|
48
|
+
kind: PointsEntryKind;
|
|
49
|
+
/**
|
|
50
|
+
* Signed, in points (= rupees). Positive credits, negative spends.
|
|
51
|
+
*
|
|
52
|
+
* ๐ Signed here even though `ShippingReconciliation` deliberately splits its
|
|
53
|
+
* two directions into separate non-negative fields. That split exists so a
|
|
54
|
+
* CALLER cannot misread which way the money went; a ledger is the one place
|
|
55
|
+
* the sign IS the meaning, and summing a column is how a balance is defined.
|
|
56
|
+
*/
|
|
57
|
+
amount: number;
|
|
58
|
+
/** The order that caused this entry, when there is one. */
|
|
59
|
+
orderId?: string;
|
|
60
|
+
/**
|
|
61
|
+
* Human-readable, shown to the buyer on their points page.
|
|
62
|
+
*
|
|
63
|
+
* Written at creation rather than derived at render: the explanation names
|
|
64
|
+
* couriers and amounts that are true of that moment, and re-deriving it later
|
|
65
|
+
* from an order that has since been edited would describe something that
|
|
66
|
+
* never happened.
|
|
67
|
+
*/
|
|
68
|
+
description: string;
|
|
69
|
+
/** Required for `admin_adjustment`. Never shown to the buyer. */
|
|
70
|
+
adminReason?: string;
|
|
71
|
+
/** Who made an `admin_adjustment`. */
|
|
72
|
+
actorUid?: string;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The ledger's own invariant, stated once so every writer can be checked
|
|
76
|
+
* against it.
|
|
77
|
+
*
|
|
78
|
+
* A `redemption` must be negative and every other kind must be positive โ
|
|
79
|
+
* except `admin_adjustment`, which is the one kind that may go either way and
|
|
80
|
+
* is therefore the one kind that demands a reason.
|
|
81
|
+
*/
|
|
82
|
+
export declare function isPointsEntryWellFormed(entry: Pick<PointsLedgerEntry, "kind" | "amount" | "adminReason">): boolean;
|
|
83
|
+
export declare const POINTS_LEDGER_COLLECTION = "pointsLedger";
|
|
84
|
+
/**
|
|
85
|
+
* ๐ Points cannot pay for the whole order, and this is the cap that enforces
|
|
86
|
+
* it.
|
|
87
|
+
*
|
|
88
|
+
* At 100% a buyer could place a zero-value order, which breaks the payment
|
|
89
|
+
* gateway path (a โน0 Razorpay order is not a thing), makes COD meaningless,
|
|
90
|
+
* and turns a discount entitlement into something that behaves exactly like
|
|
91
|
+
* stored value โ the one property this feature must not have. A ceiling keeps
|
|
92
|
+
* every order a real transaction.
|
|
93
|
+
*/
|
|
94
|
+
export declare const POINTS_MAX_REDEMPTION_PERCENT = 50;
|
|
95
|
+
/**
|
|
96
|
+
* How many points may be spent on an order of this value.
|
|
97
|
+
*
|
|
98
|
+
* Floors to whole points: a fractional point is not something a user can be
|
|
99
|
+
* shown or reason about, and rounding UP would spend a point that was never
|
|
100
|
+
* credited.
|
|
101
|
+
*/
|
|
102
|
+
export declare function maxRedeemablePoints(orderTotal: number, balance: number): number;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Buyer points โ 1 point = โน1, spendable only against a future order.
|
|
3
|
+
*
|
|
4
|
+
* ## ๐ This is NOT a wallet, and the distinction is legal, not stylistic
|
|
5
|
+
*
|
|
6
|
+
* A wallet holds the customer's money: it can be withdrawn, it is a liability
|
|
7
|
+
* payable in cash, and in India storing redeemable value on behalf of a
|
|
8
|
+
* customer is PPI territory with RBI authorisation attached. Points here are a
|
|
9
|
+
* DISCOUNT ENTITLEMENT โ they arise from shipping we charged and did not
|
|
10
|
+
* spend, they can only ever reduce the price of a later order on this site,
|
|
11
|
+
* and they are never payable out.
|
|
12
|
+
*
|
|
13
|
+
* Consequences that must survive every future change:
|
|
14
|
+
*
|
|
15
|
+
* - no withdrawal path, ever
|
|
16
|
+
* - no transfer between users
|
|
17
|
+
* - never refunded as cash when an order is cancelled; points SPENT on a
|
|
18
|
+
* cancelled order are returned as points
|
|
19
|
+
* - they do not earn interest and are not a balance the user "owns"
|
|
20
|
+
*
|
|
21
|
+
* ## Why a ledger and not a counter
|
|
22
|
+
*
|
|
23
|
+
* A single `points: number` on the user document cannot answer "where did this
|
|
24
|
+
* come from", cannot be reconciled against the orders that generated it, and
|
|
25
|
+
* loses the audit trail the moment two writes race. Root Cause #102 is the
|
|
26
|
+
* same shape one domain over: a counter that could not express what it was
|
|
27
|
+
* counting silently drifted, and nothing errored because a wrong number is
|
|
28
|
+
* just a number.
|
|
29
|
+
*
|
|
30
|
+
* So: append-only entries, and the balance is their sum. `pointsBalance` on the
|
|
31
|
+
* user document is a derived MIRROR for cheap reads โ and per Root Cause #42
|
|
32
|
+
* the reader must be able to fall back to summing the ledger, because a mirror
|
|
33
|
+
* that only one write path maintains will drift.
|
|
34
|
+
*/
|
|
35
|
+
/**
|
|
36
|
+
* Why points moved.
|
|
37
|
+
*
|
|
38
|
+
* ๐ A closed union with its values enumerated, so a new source is a compile
|
|
39
|
+
* error at every exhaustive read rather than an unlabelled row. The admin
|
|
40
|
+
* answer to "why do I have 53 points" is this field.
|
|
41
|
+
*/
|
|
42
|
+
export const POINTS_ENTRY_KIND_VALUES = [
|
|
43
|
+
/** Shipping charged minus shipping actually spent, credited after dispatch. */
|
|
44
|
+
"shipping_saving",
|
|
45
|
+
/** Spent against an order's total at checkout. Always negative. */
|
|
46
|
+
"redemption",
|
|
47
|
+
/** A redemption returned because the order it paid for was cancelled. */
|
|
48
|
+
"redemption_reversal",
|
|
49
|
+
/** Manual adjustment by an admin, with a mandatory reason. */
|
|
50
|
+
"admin_adjustment",
|
|
51
|
+
];
|
|
52
|
+
/**
|
|
53
|
+
* The ledger's own invariant, stated once so every writer can be checked
|
|
54
|
+
* against it.
|
|
55
|
+
*
|
|
56
|
+
* A `redemption` must be negative and every other kind must be positive โ
|
|
57
|
+
* except `admin_adjustment`, which is the one kind that may go either way and
|
|
58
|
+
* is therefore the one kind that demands a reason.
|
|
59
|
+
*/
|
|
60
|
+
export function isPointsEntryWellFormed(entry) {
|
|
61
|
+
if (!Number.isFinite(entry.amount) || entry.amount === 0)
|
|
62
|
+
return false;
|
|
63
|
+
switch (entry.kind) {
|
|
64
|
+
case "redemption":
|
|
65
|
+
return entry.amount < 0;
|
|
66
|
+
case "shipping_saving":
|
|
67
|
+
case "redemption_reversal":
|
|
68
|
+
return entry.amount > 0;
|
|
69
|
+
case "admin_adjustment":
|
|
70
|
+
return Boolean(entry.adminReason && entry.adminReason.trim().length >= 10);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export const POINTS_LEDGER_COLLECTION = "pointsLedger";
|
|
74
|
+
/**
|
|
75
|
+
* ๐ Points cannot pay for the whole order, and this is the cap that enforces
|
|
76
|
+
* it.
|
|
77
|
+
*
|
|
78
|
+
* At 100% a buyer could place a zero-value order, which breaks the payment
|
|
79
|
+
* gateway path (a โน0 Razorpay order is not a thing), makes COD meaningless,
|
|
80
|
+
* and turns a discount entitlement into something that behaves exactly like
|
|
81
|
+
* stored value โ the one property this feature must not have. A ceiling keeps
|
|
82
|
+
* every order a real transaction.
|
|
83
|
+
*/
|
|
84
|
+
export const POINTS_MAX_REDEMPTION_PERCENT = 50;
|
|
85
|
+
/**
|
|
86
|
+
* How many points may be spent on an order of this value.
|
|
87
|
+
*
|
|
88
|
+
* Floors to whole points: a fractional point is not something a user can be
|
|
89
|
+
* shown or reason about, and rounding UP would spend a point that was never
|
|
90
|
+
* credited.
|
|
91
|
+
*/
|
|
92
|
+
export function maxRedeemablePoints(orderTotal, balance) {
|
|
93
|
+
if (!Number.isFinite(orderTotal) || orderTotal <= 0)
|
|
94
|
+
return 0;
|
|
95
|
+
if (!Number.isFinite(balance) || balance <= 0)
|
|
96
|
+
return 0;
|
|
97
|
+
const cap = Math.floor((orderTotal * POINTS_MAX_REDEMPTION_PERCENT) / 100);
|
|
98
|
+
return Math.max(0, Math.min(Math.floor(balance), cap));
|
|
99
|
+
}
|
|
@@ -37,6 +37,7 @@ import { computeRelatedItems } from "../../../_internal/server/features/products
|
|
|
37
37
|
import { GroupedListingsCarousel } from "../../grouped/components/GroupedListingsCarousel.js";
|
|
38
38
|
import { getGroupsWithItemsForProduct } from "../../../_internal/server/features/grouped/data.js";
|
|
39
39
|
import { OwnerQuickActions } from "../../admin/components/OwnerQuickActions.js";
|
|
40
|
+
import { PurchaseReviewAffordance } from "../../reviews/components/PurchaseReviewAffordance.js";
|
|
40
41
|
function toDescriptionHtml(raw) {
|
|
41
42
|
if (!raw)
|
|
42
43
|
return "";
|
|
@@ -167,5 +168,5 @@ export async function PreOrderDetailPageView({ id, initialPreOrder, onReserveNow
|
|
|
167
168
|
? () => (_jsx(SublistingCarouselSection, { sublistingCategoryId: sublistingCategoryId, currentListingId: String(product.id) }))
|
|
168
169
|
: undefined, renderGroupSection: groupId
|
|
169
170
|
? () => (_jsx(ShowGroupSection, { groupId: groupId, currentSlug: String(p.slug ?? product.id), isParent: isGroupParent, groupTitle: groupTitle }))
|
|
170
|
-
: undefined, renderTabs: () => (_jsx(ProductTabsShell, { descriptionContent: descriptionHtml ? (_jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm sm:prose max-w-none dark:prose-invert", className: "text-[var(--appkit-color-text-muted)]" })) : undefined, specsContent: specs.length > 0 ? (_jsx(SpecTable, { rows: specs })) : undefined, customTabs: toCustomSectionTabs(p.customSections) })), renderBuyBar: () => (_jsx(PreOrderBuyBarPanel, { reserveTarget: reserveTarget, reservedCount: reservedCount, progressPct: progressPct, productId: String(product.id), price: price, currency: currency, depositAmount: depositAmount, depositPercent: depositPercent, isCancellable: isCancellable, tags: tags, onReserveNow: onReserveNow })), renderRelated: () => (_jsxs(Stack, { gap: "xl", children: [_jsx(GroupedListingsCarousel, { groups: groups }), _jsx(RelatedItemsSection, { relatedItems: relatedItems, relatedByBrand: relatedByBrand, relatedByTags: relatedByTags, relatedByStore: relatedByStore, categoryLabel: categoryName || category || undefined, brandLabel: brand || undefined, storeLabel: storeName || undefined })] })) }), _jsx(PreOrderBottomActions, { price: price, currency: currency })] })] }));
|
|
171
|
+
: undefined, renderTabs: () => (_jsx(ProductTabsShell, { descriptionContent: descriptionHtml ? (_jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm sm:prose max-w-none dark:prose-invert", className: "text-[var(--appkit-color-text-muted)]" })) : undefined, specsContent: specs.length > 0 ? (_jsx(SpecTable, { rows: specs })) : undefined, customTabs: toCustomSectionTabs(p.customSections) })), renderBuyBar: () => (_jsx(PreOrderBuyBarPanel, { reserveTarget: reserveTarget, reservedCount: reservedCount, progressPct: progressPct, productId: String(product.id), price: price, currency: currency, depositAmount: depositAmount, depositPercent: depositPercent, isCancellable: isCancellable, tags: tags, onReserveNow: onReserveNow })), renderRelated: () => (_jsxs(Stack, { gap: "xl", children: [_jsx(PurchaseReviewAffordance, { productId: String(product.id), storeId: product.storeId ? String(product.storeId) : undefined }), _jsx(GroupedListingsCarousel, { groups: groups }), _jsx(RelatedItemsSection, { relatedItems: relatedItems, relatedByBrand: relatedByBrand, relatedByTags: relatedByTags, relatedByStore: relatedByStore, categoryLabel: categoryName || category || undefined, brandLabel: brand || undefined, storeLabel: storeName || undefined })] })) }), _jsx(PreOrderBottomActions, { price: price, currency: currency })] })] }));
|
|
171
172
|
}
|
|
@@ -23,6 +23,7 @@ import { RelatedItemsSection } from "./RelatedItemsSection.js";
|
|
|
23
23
|
import { computeRelatedItems } from "../../../_internal/server/features/products/data.js";
|
|
24
24
|
import { GroupedListingsCarousel } from "../../grouped/components/GroupedListingsCarousel.js";
|
|
25
25
|
import { getGroupsWithItemsForProduct } from "../../../_internal/server/features/grouped/data.js";
|
|
26
|
+
import { PurchaseReviewAffordance } from "../../reviews/components/PurchaseReviewAffordance.js";
|
|
26
27
|
function toDescriptionHtml(raw) {
|
|
27
28
|
if (!raw)
|
|
28
29
|
return "";
|
|
@@ -132,5 +133,5 @@ export async function PrizeDrawDetailPageView({ id, initialPrizeDraw, currentUse
|
|
|
132
133
|
price: pricePerEntry,
|
|
133
134
|
storeId: typeof p.storeId === "string" ? p.storeId : undefined,
|
|
134
135
|
storeName: storeName ?? undefined,
|
|
135
|
-
} }), _jsxs(Container, { size: "xl", padding: "y-lg", children: [_jsxs(Row, { className: "mb-4", align: "center", justify: "between", gap: "sm", wrap: true, children: [_jsxs("nav", { "aria-label": "Breadcrumb", className: "flex items-center gap-[var(--appkit-space-1-5)] text-[length:var(--appkit-text-xs)] text-[var(--appkit-color-text-muted)] flex-wrap", children: [_jsx(Link, { href: String(ROUTES.HOME), className: "hover:text-primary-600 dark:hover:text-primary-400 transition-colors", children: "Home" }), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.PRIZE_DRAWS), className: "hover:text-primary-600 dark:hover:text-primary-400 transition-colors", children: "Prize Draws" }), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Span, { className: "truncate max-w-[200px]", color: "muted", children: title })] }), _jsx(ShareButton, { title: title })] }), _jsx(PreOrderDetailView, { renderGallery: () => (_jsx(PrizeDrawCollage, { items: items, hideWonState: true })), renderInfo: () => (_jsxs(Stack, { gap: "md", children: [_jsx(OwnerQuickActions, { resource: "PRODUCT", placement: "detail", recordLabel: String(product.title ?? product.id), storeId: product.storeId ? String(product.storeId) : undefined, editHref: String(ROUTES.STORE.PRODUCTS_EDIT(String(product.id))) }), _jsxs(Div, { children: [_jsxs(Row, { gap: "xs", wrap: true, className: "mb-2", children: [_jsx(Span, { size: "xs", weight: "semibold", className: CLS_PRIZE_BADGE, children: "Prize Draw" }), _jsx(Span, { size: "xs", weight: "semibold", className: `inline-block ${statusClass(revealStatus)}`, padding: "pill-sm", rounded: "full", children: statusLabel(revealStatus) }), maxPerUser !== null && (_jsxs(Span, { size: "xs", weight: "medium", className: "inline-block", padding: "pill-sm", rounded: "full", surface: "subtle", color: "muted", children: ["Limit: ", maxPerUser, " entries per customer"] })), maxPerUser !== null && userEntriesUsed !== null && (_jsxs(Span, { size: "xs", weight: "semibold", className: CLS_PRIZE_BADGE, children: ["You have used ", userEntriesUsed, "/", maxPerUser] }))] }), _jsx(Heading, { level: 1, className: "leading-snug", smSize: "2xl", color: "primary", size: "xl", weight: "bold", children: title })] }), _jsx(Div, { className: `${__P.p4}`, border: "subtle", rounded: "xl", surface: "muted", children: _jsxs(Stack, { gap: "sm", children: [_jsxs(Row, { justify: "between", align: "center", children: [_jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "sm", children: "Entries" }), _jsxs(Text, { size: "sm", weight: "semibold", children: [current, " / ", max, " (", remaining, " left)"] })] }), revealStart && (_jsxs(Row, { justify: "between", align: "center", children: [_jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "sm", children: "Reveal window opens" }), _jsx(Text, { size: "sm", weight: "medium", children: revealStart.toLocaleString() })] })), revealEnd && (_jsxs(Row, { justify: "between", align: "center", children: [_jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "sm", children: "Reveal window closes" }), _jsx(Text, { size: "sm", weight: "medium", children: revealEnd.toLocaleString() })] }))] }) }), descriptionHtml && (_jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm max-w-none dark:prose-invert prose-p:my-0", className: "text-[length:var(--appkit-text-sm)] leading-relaxed text-[var(--appkit-color-text-muted)] line-clamp-4" })), safeSeller && (_jsx(Div, { className: `${__P.p3}`, border: "subtle", rounded: "xl", surface: "muted", children: _jsxs(Row, { justify: "between", align: "center", children: [_jsxs(Div, { children: [_jsx(Text, { className: "text-[10px] tracking-wide mb-0.5", color: "faint", transform: "uppercase", children: "Sold by" }), _jsx(Text, { size: "sm", weight: "semibold", color: "primary", children: safeSeller })] }), storeHref && (_jsx(Link, { href: storeHref, className: "shrink-0 rounded-lg bg-primary/10 dark:bg-primary/20 px-[var(--appkit-space-3)] py-[var(--appkit-space-1-5)] text-[length:var(--appkit-text-xs)] font-semibold text-primary-700 dark:text-primary-300 hover:bg-primary/20 dark:hover:bg-primary/30 transition-colors", children: "Visit Store \u2192" }))] }) }))] })), renderTabs: () => (_jsx(ProductTabsShell, { descriptionContent: descriptionHtml ? (_jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm sm:prose max-w-none dark:prose-invert", className: "text-[var(--appkit-color-text-muted)]" })) : undefined })), renderBuyBar: () => (_jsx(Div, { padding: "5", id: "prize-draw-buy-bar", border: "subtle", surface: "muted", rounded: "xl", children: _jsx(PrizeDrawEntryActions, { productId: String(product.id), productSlug: slug, title: title, pricePerEntry: pricePerEntry, currency: currency, remainingEntries: remaining, revealStatus: revealStatus, prizeGithubFileUrl: githubUrl }) })), renderRelated: () => (_jsxs(Stack, { gap: "xl", children: [_jsx(GroupedListingsCarousel, { groups: groups }), _jsx(RelatedItemsSection, { relatedItems: relatedItems, relatedByBrand: relatedByBrand, relatedByTags: relatedByTags, relatedByStore: relatedByStore, categoryLabel: categoryName, brandLabel: brand, storeLabel: storeName ?? undefined })] })) }), _jsx(PrizeDrawBottomActions, { pricePerEntry: pricePerEntry, currency: currency, closed: revealStatus === "closed" || remaining === 0 })] })] }));
|
|
136
|
+
} }), _jsxs(Container, { size: "xl", padding: "y-lg", children: [_jsxs(Row, { className: "mb-4", align: "center", justify: "between", gap: "sm", wrap: true, children: [_jsxs("nav", { "aria-label": "Breadcrumb", className: "flex items-center gap-[var(--appkit-space-1-5)] text-[length:var(--appkit-text-xs)] text-[var(--appkit-color-text-muted)] flex-wrap", children: [_jsx(Link, { href: String(ROUTES.HOME), className: "hover:text-primary-600 dark:hover:text-primary-400 transition-colors", children: "Home" }), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.PRIZE_DRAWS), className: "hover:text-primary-600 dark:hover:text-primary-400 transition-colors", children: "Prize Draws" }), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Span, { className: "truncate max-w-[200px]", color: "muted", children: title })] }), _jsx(ShareButton, { title: title })] }), _jsx(PreOrderDetailView, { renderGallery: () => (_jsx(PrizeDrawCollage, { items: items, hideWonState: true })), renderInfo: () => (_jsxs(Stack, { gap: "md", children: [_jsx(OwnerQuickActions, { resource: "PRODUCT", placement: "detail", recordLabel: String(product.title ?? product.id), storeId: product.storeId ? String(product.storeId) : undefined, editHref: String(ROUTES.STORE.PRODUCTS_EDIT(String(product.id))) }), _jsxs(Div, { children: [_jsxs(Row, { gap: "xs", wrap: true, className: "mb-2", children: [_jsx(Span, { size: "xs", weight: "semibold", className: CLS_PRIZE_BADGE, children: "Prize Draw" }), _jsx(Span, { size: "xs", weight: "semibold", className: `inline-block ${statusClass(revealStatus)}`, padding: "pill-sm", rounded: "full", children: statusLabel(revealStatus) }), maxPerUser !== null && (_jsxs(Span, { size: "xs", weight: "medium", className: "inline-block", padding: "pill-sm", rounded: "full", surface: "subtle", color: "muted", children: ["Limit: ", maxPerUser, " entries per customer"] })), maxPerUser !== null && userEntriesUsed !== null && (_jsxs(Span, { size: "xs", weight: "semibold", className: CLS_PRIZE_BADGE, children: ["You have used ", userEntriesUsed, "/", maxPerUser] }))] }), _jsx(Heading, { level: 1, className: "leading-snug", smSize: "2xl", color: "primary", size: "xl", weight: "bold", children: title })] }), _jsx(Div, { className: `${__P.p4}`, border: "subtle", rounded: "xl", surface: "muted", children: _jsxs(Stack, { gap: "sm", children: [_jsxs(Row, { justify: "between", align: "center", children: [_jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "sm", children: "Entries" }), _jsxs(Text, { size: "sm", weight: "semibold", children: [current, " / ", max, " (", remaining, " left)"] })] }), revealStart && (_jsxs(Row, { justify: "between", align: "center", children: [_jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "sm", children: "Reveal window opens" }), _jsx(Text, { size: "sm", weight: "medium", children: revealStart.toLocaleString() })] })), revealEnd && (_jsxs(Row, { justify: "between", align: "center", children: [_jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "sm", children: "Reveal window closes" }), _jsx(Text, { size: "sm", weight: "medium", children: revealEnd.toLocaleString() })] }))] }) }), descriptionHtml && (_jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm max-w-none dark:prose-invert prose-p:my-0", className: "text-[length:var(--appkit-text-sm)] leading-relaxed text-[var(--appkit-color-text-muted)] line-clamp-4" })), safeSeller && (_jsx(Div, { className: `${__P.p3}`, border: "subtle", rounded: "xl", surface: "muted", children: _jsxs(Row, { justify: "between", align: "center", children: [_jsxs(Div, { children: [_jsx(Text, { className: "text-[10px] tracking-wide mb-0.5", color: "faint", transform: "uppercase", children: "Sold by" }), _jsx(Text, { size: "sm", weight: "semibold", color: "primary", children: safeSeller })] }), storeHref && (_jsx(Link, { href: storeHref, className: "shrink-0 rounded-lg bg-primary/10 dark:bg-primary/20 px-[var(--appkit-space-3)] py-[var(--appkit-space-1-5)] text-[length:var(--appkit-text-xs)] font-semibold text-primary-700 dark:text-primary-300 hover:bg-primary/20 dark:hover:bg-primary/30 transition-colors", children: "Visit Store \u2192" }))] }) }))] })), renderTabs: () => (_jsx(ProductTabsShell, { descriptionContent: descriptionHtml ? (_jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm sm:prose max-w-none dark:prose-invert", className: "text-[var(--appkit-color-text-muted)]" })) : undefined })), renderBuyBar: () => (_jsx(Div, { padding: "5", id: "prize-draw-buy-bar", border: "subtle", surface: "muted", rounded: "xl", children: _jsx(PrizeDrawEntryActions, { productId: String(product.id), productSlug: slug, title: title, pricePerEntry: pricePerEntry, currency: currency, remainingEntries: remaining, revealStatus: revealStatus, prizeGithubFileUrl: githubUrl }) })), renderRelated: () => (_jsxs(Stack, { gap: "xl", children: [_jsx(PurchaseReviewAffordance, { productId: String(product.id), storeId: product.storeId ? String(product.storeId) : undefined }), _jsx(GroupedListingsCarousel, { groups: groups }), _jsx(RelatedItemsSection, { relatedItems: relatedItems, relatedByBrand: relatedByBrand, relatedByTags: relatedByTags, relatedByStore: relatedByStore, categoryLabel: categoryName, brandLabel: brand, storeLabel: storeName ?? undefined })] })) }), _jsx(PrizeDrawBottomActions, { pricePerEntry: pricePerEntry, currency: currency, closed: revealStatus === "closed" || remaining === 0 })] })] }));
|
|
136
137
|
}
|
|
@@ -390,7 +390,9 @@ export async function ProductDetailPageView({ slug, initialProduct, renderOfferA
|
|
|
390
390
|
categoryProductCount: (n, cat) => `${n} in ${cat}`,
|
|
391
391
|
} }), productFeatures && features.length > 0 && (_jsx(FeatureBadgeList, { productFeatureIds: features, features: productFeatures })), !productFeatures && features.length > 0 && (_jsxs(Div, { border: "subtle", surface: "muted", padding: "inline", rounded: "xl", children: [_jsx(Text, { className: "mb-2 tracking-wide", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: "About this product" }), _jsx(Ul, { spacing: "comfortable", size: "sm", color: "primary", children: features.map((f, i) => (_jsxs(Li, { layout: "flex-start", gap: "2", children: [_jsx(Span, { className: "mt-0.5 flex-shrink-0 text-primary-500", children: "\u2022" }), f] }, i))) })] })), descriptionHtml && (_jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm max-w-none dark:prose-invert prose-p:my-0", className: "text-[length:var(--appkit-text-sm)] leading-relaxed text-[var(--appkit-color-text-muted)] line-clamp-4" })), safeSeller && (_jsx(Div, { className: `${__P.p3}`, border: "subtle", rounded: "xl", surface: "muted", children: _jsxs(Row, { justify: "between", align: "center", children: [_jsxs(Div, { children: [_jsx(Text, { className: "text-[10px] tracking-wide mb-0.5", color: "faint", transform: "uppercase", children: "Sold by" }), _jsx(Text, { size: "sm", weight: "semibold", color: "primary", children: safeSeller }), typeof p.storeRating === "number" && p.storeRating > 0 && (_jsx(Row, { gap: "xs", align: "center", className: "mt-0.5", children: _jsxs(Text, { size: "xs", color: "muted", children: [_jsx(Span, { className: "text-warning", "aria-hidden": "true", children: "\u2605" }), " ", p.storeRating.toFixed(1), typeof p.storeReviewCount === "number" &&
|
|
392
392
|
p.storeReviewCount > 0 &&
|
|
393
|
-
` ยท ${p.storeReviewCount} reviews`] }) }))] }), _jsxs(Row, { gap: "xs", align: "center", className: "shrink-0", children: [storeId && _jsx(MessageSellerButton, { storeId: storeId, productId: product.id }), storeHref && (_jsx(Link, { href: storeHref, className: "rounded-lg bg-primary/10 dark:bg-primary/20 px-[var(--appkit-space-3)] py-[var(--appkit-space-1-5)] text-[length:var(--appkit-text-xs)] font-semibold text-primary-700 dark:text-primary-300 hover:bg-primary/20 dark:hover:bg-primary/30 transition-colors", children: "Visit Store \u2192" }))] })] }) }))] })), renderActions: () => (_jsxs(Stack, { className: `${__P.p5}`, border: "subtle", gap: "md", rounded: "xl", surface: "muted", children: [price !== null && (_jsxs(Div, { children: [_jsx(PriceDisplay, { amount: price, originalAmount: originalPrice ?? undefined, currency: currency, variant: "detail" }),
|
|
393
|
+
` ยท ${p.storeReviewCount} reviews`] }) }))] }), _jsxs(Row, { gap: "xs", align: "center", className: "shrink-0", children: [storeId && _jsx(MessageSellerButton, { storeId: storeId, productId: product.id }), storeHref && (_jsx(Link, { href: storeHref, className: "rounded-lg bg-primary/10 dark:bg-primary/20 px-[var(--appkit-space-3)] py-[var(--appkit-space-1-5)] text-[length:var(--appkit-text-xs)] font-semibold text-primary-700 dark:text-primary-300 hover:bg-primary/20 dark:hover:bg-primary/30 transition-colors", children: "Visit Store \u2192" }))] })] }) }))] })), renderActions: () => (_jsxs(Stack, { className: `${__P.p5}`, border: "subtle", gap: "md", rounded: "xl", surface: "muted", children: [price !== null && (_jsxs(Div, { children: [_jsx(PriceDisplay, { amount: price, originalAmount: originalPrice ?? undefined, currency: currency, variant: "detail" }), siteSettings?.gst?.enabled === true &&
|
|
394
|
+
typeof p.gstRate === "number" &&
|
|
395
|
+
p.gstRate > 0 && (_jsxs(Text, { size: "xs", color: "muted", children: ["Incl. ", p.gstRate, "% GST"] })), _jsx(MarketPriceAnchor, { band: priceBand?.byListingType?.[normalizeListingType(p)] ?? null, windowDays: priceBand?.windowDays ?? null, currency: currency, price: price }), inStock && effectiveStock !== null && effectiveStock <= 3 && (_jsxs(Text, { className: "mt-1 text-warning", size: "xs", children: ["Only ", effectiveStock, " left \u2014 order soon!"] })), inStock && effectiveStock !== null && effectiveStock > 3 && effectiveStock <= 10 && (_jsxs(Text, { className: "mt-1", color: "muted", size: "xs", children: [effectiveStock, " in stock"] })), madeToOrder && (_jsx(Text, { className: "mt-1", color: "muted", size: "xs", children: "Made to order \u2014 printed when you order, so it never runs out." })), partHref && (_jsxs(Text, { className: "mt-1", color: "muted", size: "xs", children: [partSlotLabel ?? "Part", ".", " ", _jsx(Link, { href: partHref, className: "text-primary-600 hover:underline", children: "See what else fits \u2192" }), fitsModels.length > 0 && (_jsxs(_Fragment, { children: [" ", "Fits:", " ", fitsModels.slice(0, 6).map((m, i) => (_jsxs(Text, { as: "span", children: [i > 0 ? ", " : "", m.categoryId ? (_jsx(Link, { href: String(ROUTES.PUBLIC.CATEGORY_DETAIL(m.categoryId)), className: "text-primary-600 hover:underline", children: m.name })) : (m.name)] }, m.id)))] }))] }))] })), _jsxs(Stack, { gap: "sm", ...ctaAnchorProps(), children: [renderPrimaryActions ? (renderPrimaryActions({
|
|
394
396
|
productId: product.id,
|
|
395
397
|
productSlug: product.slug ?? slug,
|
|
396
398
|
productTitle: product.title,
|
|
@@ -83,6 +83,23 @@ export interface CustomShipOrderInput {
|
|
|
83
83
|
shippingCarrier: string;
|
|
84
84
|
trackingNumber: string;
|
|
85
85
|
trackingUrl: string;
|
|
86
|
+
/**
|
|
87
|
+
* What this parcel actually cost the seller to post, in decimal rupees.
|
|
88
|
+
*
|
|
89
|
+
* ๐ OPTIONAL, and that is the whole design. Making it required would break
|
|
90
|
+
* every existing dispatch and would force a number out of sellers who ship
|
|
91
|
+
* manually and have no quote to be measured against. Supplying it is what
|
|
92
|
+
* opts the order into reconciliation: the buyer gets points back when the
|
|
93
|
+
* seller spent less than was charged, and the seller absorbs the overage
|
|
94
|
+
* when they spent more.
|
|
95
|
+
*
|
|
96
|
+
* Absence therefore means "not reconciled", never "cost nothing" โ the same
|
|
97
|
+
* distinction `resolveShippingCost.shippingUnavailable` exists to preserve,
|
|
98
|
+
* where a silent zero "would ship every parcel free at our expense".
|
|
99
|
+
*/
|
|
100
|
+
actualShippingCost?: number;
|
|
101
|
+
/** The courier actually used, when it differs from the one the buyer picked. */
|
|
102
|
+
actualCourierName?: string;
|
|
86
103
|
}
|
|
87
104
|
export interface MarkEmiInstallmentPaidInput {
|
|
88
105
|
/** 1-based installment number, matches EmiInstallment.index. */
|