@mohasinac/appkit 4.55.0 → 4.57.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.build-stamp.json +4 -4
- package/dist/_internal/server/features/checkout/actions.d.ts +53 -0
- package/dist/_internal/server/features/checkout/actions.js +156 -8
- package/dist/_internal/server/features/orders/actions.d.ts +30 -0
- package/dist/_internal/server/features/orders/actions.js +103 -0
- package/dist/_internal/server/features/orders/adapters.js +7 -0
- package/dist/_internal/server/features/shipping/reconcile.d.ts +28 -0
- package/dist/_internal/server/features/shipping/reconcile.js +93 -0
- package/dist/_internal/shared/actions/action-registry.js +53 -0
- package/dist/_internal/shared/config/schema.d.ts +6 -0
- package/dist/_internal/shared/listing-types/route-gating.d.ts +56 -0
- package/dist/_internal/shared/listing-types/route-gating.js +114 -0
- package/dist/_internal/shared/shipping/quote.d.ts +153 -0
- package/dist/_internal/shared/shipping/quote.js +123 -0
- package/dist/client.d.ts +22 -0
- package/dist/client.js +10 -0
- package/dist/features/about/components/PublicProfileView.js +2 -1
- package/dist/features/account/components/UserOrdersView.js +25 -3
- package/dist/features/admin/components/OwnerQuickActions.d.ts +1 -1
- package/dist/features/admin/components/OwnerQuickActions.js +53 -6
- package/dist/features/auctions/components/AuctionDetailPageView.js +2 -1
- package/dist/features/auth/schemas/firestore.d.ts +15 -0
- package/dist/features/blog/components/BlogPostView.js +2 -1
- package/dist/features/cart/components/CartPriceBreakdown.d.ts +2 -0
- package/dist/features/cart/components/CartPriceBreakdown.js +1 -1
- package/dist/features/categories/components/FranchiseDetailPageView.js +3 -1
- package/dist/features/digital-codes/components/DigitalCodeDetailPageView.js +2 -1
- package/dist/features/faq/components/FAQPageContent.js +17 -4
- package/dist/features/faq/constants/help-guides.d.ts +36 -0
- package/dist/features/faq/constants/help-guides.js +64 -0
- package/dist/features/homepage/components/HeroCarousel.js +7 -3
- package/dist/features/homepage/components/MarketplaceHomepageView.js +16 -1
- package/dist/features/homepage/components/WelcomeSection.d.ts +16 -3
- package/dist/features/homepage/components/WelcomeSection.js +3 -3
- package/dist/features/homepage/lib/section-renderer.d.ts +9 -0
- package/dist/features/homepage/lib/section-renderer.js +24 -6
- package/dist/features/homepage/lib/slide-gating.d.ts +52 -0
- package/dist/features/homepage/lib/slide-gating.js +98 -0
- package/dist/features/live/components/LiveItemDetailPageView.js +2 -1
- package/dist/features/media/MediaAvatar.js +1 -1
- package/dist/features/media/MediaImage.d.ts +18 -1
- package/dist/features/media/MediaImage.js +1 -1
- package/dist/features/orders/components/EmiInstallmentsPanel.d.ts +19 -0
- package/dist/features/orders/components/EmiInstallmentsPanel.js +57 -0
- package/dist/features/orders/components/index.d.ts +1 -0
- package/dist/features/orders/components/index.js +1 -0
- package/dist/features/orders/schemas/firestore.d.ts +38 -0
- package/dist/features/orders/schemas/index.d.ts +2 -2
- package/dist/features/orders/types/index.d.ts +15 -0
- package/dist/features/points/repository/points.repository.d.ts +81 -0
- package/dist/features/points/repository/points.repository.js +155 -0
- package/dist/features/points/schemas/firestore.d.ts +118 -0
- package/dist/features/points/schemas/firestore.js +116 -0
- package/dist/features/pre-orders/components/PreOrderDetailPageView.js +2 -1
- package/dist/features/products/components/PrizeDrawDetailPageView.js +2 -1
- package/dist/features/products/components/ProductDetailPageView.js +3 -1
- package/dist/features/reviews/components/ReviewDetailPageView.js +3 -2
- package/dist/features/seller/actions/seller-actions.d.ts +17 -0
- package/dist/features/seller/actions/seller-actions.js +33 -0
- package/dist/features/stores/components/StoreHeader.js +3 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +8 -0
- package/dist/next/routing/route-map.d.ts +4 -0
- package/dist/next/routing/route-map.js +2 -0
- package/dist/schemas/registry.d.ts +2 -2
- package/dist/seed/_helpers/category-models/chunk0.d.ts +2 -0
- package/dist/seed/_helpers/category-models/chunk0.js +2648 -0
- package/dist/seed/_helpers/category-models/chunk1.d.ts +2 -0
- package/dist/seed/_helpers/category-models/chunk1.js +2318 -0
- package/dist/seed/_helpers/category-models/chunk2.d.ts +2 -0
- package/dist/seed/_helpers/category-models/chunk2.js +2560 -0
- package/dist/seed/_helpers/category-models/chunk3.d.ts +2 -0
- package/dist/seed/_helpers/category-models/chunk3.js +2196 -0
- package/dist/seed/_helpers/category-models/chunk4.d.ts +2 -0
- package/dist/seed/_helpers/category-models/chunk4.js +1732 -0
- package/dist/seed/_helpers/category-models.d.ts +9 -2
- package/dist/seed/_helpers/category-models.js +19 -1368
- package/dist/seed/carousel-slides-seed-data.js +48 -7
- package/dist/server.d.ts +3 -1
- package/dist/server.js +11 -0
- package/dist/styles.css +38 -6
- package/dist/tailwind-utilities.css +1 -1
- package/dist/ui/components/ImageMosaic.d.ts +17 -1
- package/dist/ui/components/ImageMosaic.js +28 -5
- package/dist/ui/components/ImageMosaic.style.css +37 -5
- package/package.json +1 -1
|
@@ -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_FIELDS, 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(POINTS_FIELDS.USER_ID, "==", userId)
|
|
55
|
+
.select(POINTS_FIELDS.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(POINTS_FIELDS.USER_ID, "==", userId)
|
|
149
|
+
.orderBy(POINTS_FIELDS.CREATED_AT, "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,118 @@
|
|
|
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
|
+
* Firestore field names for this collection.
|
|
86
|
+
*
|
|
87
|
+
* Declared rather than written as string literals at the query site, matching
|
|
88
|
+
* every other collection here: a typo in a raw `where("userID", ...)` is a
|
|
89
|
+
* query that returns nothing and raises nothing, which on a points balance
|
|
90
|
+
* reads as "you have no points" rather than as a bug.
|
|
91
|
+
*/
|
|
92
|
+
export declare const POINTS_FIELDS: {
|
|
93
|
+
readonly ID: "id";
|
|
94
|
+
readonly USER_ID: "userId";
|
|
95
|
+
readonly KIND: "kind";
|
|
96
|
+
readonly AMOUNT: "amount";
|
|
97
|
+
readonly ORDER_ID: "orderId";
|
|
98
|
+
readonly CREATED_AT: "createdAt";
|
|
99
|
+
};
|
|
100
|
+
/**
|
|
101
|
+
* 🛑 Points cannot pay for the whole order, and this is the cap that enforces
|
|
102
|
+
* it.
|
|
103
|
+
*
|
|
104
|
+
* At 100% a buyer could place a zero-value order, which breaks the payment
|
|
105
|
+
* gateway path (a ₹0 Razorpay order is not a thing), makes COD meaningless,
|
|
106
|
+
* and turns a discount entitlement into something that behaves exactly like
|
|
107
|
+
* stored value — the one property this feature must not have. A ceiling keeps
|
|
108
|
+
* every order a real transaction.
|
|
109
|
+
*/
|
|
110
|
+
export declare const POINTS_MAX_REDEMPTION_PERCENT = 50;
|
|
111
|
+
/**
|
|
112
|
+
* How many points may be spent on an order of this value.
|
|
113
|
+
*
|
|
114
|
+
* Floors to whole points: a fractional point is not something a user can be
|
|
115
|
+
* shown or reason about, and rounding UP would spend a point that was never
|
|
116
|
+
* credited.
|
|
117
|
+
*/
|
|
118
|
+
export declare function maxRedeemablePoints(orderTotal: number, balance: number): number;
|
|
@@ -0,0 +1,116 @@
|
|
|
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
|
+
* Firestore field names for this collection.
|
|
76
|
+
*
|
|
77
|
+
* Declared rather than written as string literals at the query site, matching
|
|
78
|
+
* every other collection here: a typo in a raw `where("userID", ...)` is a
|
|
79
|
+
* query that returns nothing and raises nothing, which on a points balance
|
|
80
|
+
* reads as "you have no points" rather than as a bug.
|
|
81
|
+
*/
|
|
82
|
+
export const POINTS_FIELDS = {
|
|
83
|
+
ID: "id",
|
|
84
|
+
USER_ID: "userId",
|
|
85
|
+
KIND: "kind",
|
|
86
|
+
AMOUNT: "amount",
|
|
87
|
+
ORDER_ID: "orderId",
|
|
88
|
+
CREATED_AT: "createdAt",
|
|
89
|
+
};
|
|
90
|
+
/**
|
|
91
|
+
* 🛑 Points cannot pay for the whole order, and this is the cap that enforces
|
|
92
|
+
* it.
|
|
93
|
+
*
|
|
94
|
+
* At 100% a buyer could place a zero-value order, which breaks the payment
|
|
95
|
+
* gateway path (a ₹0 Razorpay order is not a thing), makes COD meaningless,
|
|
96
|
+
* and turns a discount entitlement into something that behaves exactly like
|
|
97
|
+
* stored value — the one property this feature must not have. A ceiling keeps
|
|
98
|
+
* every order a real transaction.
|
|
99
|
+
*/
|
|
100
|
+
export const POINTS_MAX_REDEMPTION_PERCENT = 50;
|
|
101
|
+
/**
|
|
102
|
+
* How many points may be spent on an order of this value.
|
|
103
|
+
*
|
|
104
|
+
* Floors to whole points: a fractional point is not something a user can be
|
|
105
|
+
* shown or reason about, and rounding UP would spend a point that was never
|
|
106
|
+
* credited.
|
|
107
|
+
*/
|
|
108
|
+
export function maxRedeemablePoints(orderTotal, balance) {
|
|
109
|
+
if (!Number.isFinite(orderTotal) || orderTotal <= 0)
|
|
110
|
+
return 0;
|
|
111
|
+
if (!Number.isFinite(balance) || balance <= 0)
|
|
112
|
+
return 0;
|
|
113
|
+
// audit-money-units-ok: a percentage of an order total, not a paise/rupee conversion
|
|
114
|
+
const cap = Math.floor((orderTotal * POINTS_MAX_REDEMPTION_PERCENT) / 100);
|
|
115
|
+
return Math.max(0, Math.min(Math.floor(balance), cap));
|
|
116
|
+
}
|
|
@@ -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,
|
|
@@ -2,10 +2,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import Link from "next/link.js";
|
|
3
3
|
import { reviewRepository } from "../../../repositories/index.js";
|
|
4
4
|
import { safeRead } from "../../../errors/safe-read.js";
|
|
5
|
-
import { Container, Div, Grid, Heading, Main, Nav, Section, Span, Text } from "../../../ui/index.js";
|
|
5
|
+
import { Container, Div, Grid, Heading, Main, Nav, Row, Section, Span, Text } from "../../../ui/index.js";
|
|
6
6
|
import { ROUTES } from "../../../next/routing/route-map.js";
|
|
7
7
|
import { ReviewDetailShell } from "./ReviewDetailShell.js";
|
|
8
8
|
import { ReviewCard } from "./ReviewsList.js";
|
|
9
|
+
import { OwnerQuickActions } from "../../admin/components/OwnerQuickActions.js";
|
|
9
10
|
export async function ReviewDetailPageView({ id }) {
|
|
10
11
|
// The review IS this page's subject — a read failure must surface rather than
|
|
11
12
|
// become the same `null` a removed review produces, which the "Review not
|
|
@@ -42,5 +43,5 @@ export async function ReviewDetailPageView({ id }) {
|
|
|
42
43
|
]);
|
|
43
44
|
const sameProductReviews = sameProductDocs.filter((r) => r.id !== review.id).slice(0, 6);
|
|
44
45
|
const sameStoreReviews = sameStoreDocs.filter((r) => r.id !== review.id).slice(0, 6);
|
|
45
|
-
return (_jsxs(Main, { children: [_jsx(Div, { paddingY: "y-xs-tall", className: "border-b border-neutral-100 bg-neutral-50", padding: "x-md", children: _jsxs(Nav, { layout: "flex", gap: "2xs", textSize: "xs", color: "faint", className: "mx-auto max-w-3xl", "aria-label": "Breadcrumb", children: [_jsx(Link, { href: String(ROUTES.HOME), className: "hover:text-primary transition-colors", children: "Home" }), _jsx(Span, { children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.REVIEWS), className: "hover:text-primary transition-colors", children: "Reviews" }), _jsx(Span, { children: "/" }), _jsx(Span, { className: "text-neutral-700 truncate max-w-[200px]", children: review.title ?? review.id })] }) }), _jsx(ReviewDetailShell, { review: review, storeHref: storeSlug ? String(ROUTES.PUBLIC.STORE_DETAIL(storeSlug)) : null }), sameProductReviews.length > 0 && (_jsx(Section, { padding: "y-lg", border: "subtle", className: "border-t", children: _jsxs(Container, { size: "sm", children: [_jsxs(Heading, { level: 2, className: "mb-4", size: "lg", weight: "semibold", children: ["More reviews for ", review.productTitle ?? "this product"] }), _jsx(Grid, { cols: "cardsWide", gap: "md", children: sameProductReviews.map((r) => (_jsx(ReviewCard, { review: r, context: "listing" }, r.id))) })] }) })), sameStoreReviews.length > 0 && (_jsx(Section, { padding: "y-lg", border: "subtle", className: "border-t", children: _jsxs(Container, { size: "sm", children: [_jsxs(Heading, { level: 2, className: "mb-4", size: "lg", weight: "semibold", children: ["More reviews for ", review.storeName ?? "this store"] }), _jsx(Grid, { cols: "cardsWide", gap: "md", children: sameStoreReviews.map((r) => (_jsx(ReviewCard, { review: r, context: "store" }, r.id))) })] }) }))] }));
|
|
46
|
+
return (_jsxs(Main, { children: [_jsx(Div, { paddingY: "y-xs-tall", className: "border-b border-neutral-100 bg-neutral-50", padding: "x-md", children: _jsxs(Nav, { layout: "flex", gap: "2xs", textSize: "xs", color: "faint", className: "mx-auto max-w-3xl", "aria-label": "Breadcrumb", children: [_jsx(Link, { href: String(ROUTES.HOME), className: "hover:text-primary transition-colors", children: "Home" }), _jsx(Span, { children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.REVIEWS), className: "hover:text-primary transition-colors", children: "Reviews" }), _jsx(Span, { children: "/" }), _jsx(Span, { className: "text-neutral-700 truncate max-w-[200px]", children: review.title ?? review.id })] }) }), _jsx(Row, { justify: "end", className: "mb-2", children: _jsx(OwnerQuickActions, { resource: "REVIEW", placement: "detail", ownerId: review.userId, recordLabel: review.title ?? review.id, editHref: String(ROUTES.USER.REVIEW_EDIT(review.id)) }) }), _jsx(ReviewDetailShell, { review: review, storeHref: storeSlug ? String(ROUTES.PUBLIC.STORE_DETAIL(storeSlug)) : null }), sameProductReviews.length > 0 && (_jsx(Section, { padding: "y-lg", border: "subtle", className: "border-t", children: _jsxs(Container, { size: "sm", children: [_jsxs(Heading, { level: 2, className: "mb-4", size: "lg", weight: "semibold", children: ["More reviews for ", review.productTitle ?? "this product"] }), _jsx(Grid, { cols: "cardsWide", gap: "md", children: sameProductReviews.map((r) => (_jsx(ReviewCard, { review: r, context: "listing" }, r.id))) })] }) })), sameStoreReviews.length > 0 && (_jsx(Section, { padding: "y-lg", border: "subtle", className: "border-t", children: _jsxs(Container, { size: "sm", children: [_jsxs(Heading, { level: 2, className: "mb-4", size: "lg", weight: "semibold", children: ["More reviews for ", review.storeName ?? "this store"] }), _jsx(Grid, { cols: "cardsWide", gap: "md", children: sameStoreReviews.map((r) => (_jsx(ReviewCard, { review: r, context: "store" }, r.id))) })] }) }))] }));
|
|
46
47
|
}
|
|
@@ -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. */
|