@mohasinac/appkit 4.55.0-next.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 +100 -5
- 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/features/checkout/config.d.ts +1 -1
- package/dist/_internal/shared/features/checkout/config.js +1 -1
- package/dist/_internal/shared/features/site-settings/fees.d.ts +1 -1
- package/dist/_internal/shared/features/site-settings/fees.js +1 -1
- package/dist/_internal/shared/fees/calculator.d.ts +35 -3
- package/dist/_internal/shared/fees/calculator.js +34 -4
- 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/errors/error-codes.d.ts +15 -0
- package/dist/errors/error-codes.js +15 -0
- package/dist/errors/index.d.ts +1 -1
- package/dist/errors/index.js +1 -1
- package/dist/features/account/components/UserBidsView.js +4 -2
- package/dist/features/admin/components/AdminSiteSettingsView.js +18 -2
- package/dist/features/admin/schemas/firestore.d.ts +37 -2
- package/dist/features/admin/schemas/firestore.js +12 -1
- package/dist/features/auctions/components/AuctionDetailPageView.js +2 -1
- package/dist/features/auth/schemas/firestore.d.ts +15 -0
- package/dist/features/cart/components/CartPriceBreakdown.js +1 -1
- package/dist/features/checkout/schemas/firestore.d.ts +12 -1
- 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/layout/NavbarLayout.js +139 -32
- 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/media/messages/en.json +67 -67
- package/dist/features/orders/schemas/firestore.d.ts +39 -1
- 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 +5 -26
- package/dist/features/products/components/ProductOwnerQuickActions.d.ts +11 -0
- package/dist/features/products/components/ProductOwnerQuickActions.js +120 -0
- package/dist/features/products/components/ProductsIndexListing.js +35 -5
- package/dist/features/products/components/ProductsIndexPageView.js +40 -7
- 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/providers/shipping-shiprocket/index.d.ts +13 -4
- package/dist/providers/shipping-shiprocket/index.js +34 -10
- package/dist/security/csp.d.ts +1 -16
- package/dist/security/csp.js +3 -5
- package/dist/seed/carousel-slides-seed-data.js +48 -7
- package/dist/seed/faq-seed-data.js +1 -1
- package/dist/seed/site-settings-seed-data.js +1 -1
- package/dist/seo/json-ld.d.ts +8 -1
- package/dist/seo/json-ld.js +39 -2
- package/dist/styles.css +419 -542
- package/dist/tailwind-utilities.css +1 -1
- package/dist/tokens/themes/index.d.ts +1 -3
- package/dist/tokens/themes/index.js +1 -11
- package/dist/tokens/tokens.css +0 -155
- package/dist/ui/components/GatedPrice.style.css +43 -43
- 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/dist/ui/components/QuickCreateModal.style.css +78 -78
- package/dist/ui/forms/FormShell.style.css +260 -260
- package/package.json +1 -1
- package/dist/tokens/themes/technical-dark.d.ts +0 -30
- package/dist/tokens/themes/technical-dark.js +0 -103
- package/dist/tokens/themes/technical-light.d.ts +0 -22
- package/dist/tokens/themes/technical-light.js +0 -89
|
@@ -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
|
}
|
|
@@ -31,8 +31,7 @@ import { ctaAnchorProps } from "../../layout/cta-anchor.js";
|
|
|
31
31
|
import { ContextualTrail } from "../../categories/components/ContextualTrail.js";
|
|
32
32
|
import { productCrumbs, withTrailHead } from "../../categories/utils/breadcrumb-chain.js";
|
|
33
33
|
import { getDefaultCurrency } from "../../../core/baseline-resolver.js";
|
|
34
|
-
import {
|
|
35
|
-
import { SELLER_ENDPOINTS } from "../../../constants/api-endpoints.js";
|
|
34
|
+
import { ProductOwnerQuickActions } from "./ProductOwnerQuickActions.js";
|
|
36
35
|
import { PurchaseReviewAffordance } from "../../reviews/components/PurchaseReviewAffordance.js";
|
|
37
36
|
import { Button, Container, Div, Heading, Main, PriceDisplay, RichText, Row, Section, Span, Stack, Text, Ul, Ol, Li, SpecTable, Icon, } from "../../../ui/index.js";
|
|
38
37
|
import { normalizeRichTextHtml } from "../../../utils/string.formatter.js";
|
|
@@ -353,29 +352,7 @@ export async function ProductDetailPageView({ slug, initialProduct, renderOfferA
|
|
|
353
352
|
price: product.price,
|
|
354
353
|
storeId: product.storeId,
|
|
355
354
|
storeName: product.storeName,
|
|
356
|
-
} }), _jsxs(Container, { size: "xl", padding: "y-lg", children: [_jsx(ProductDetailView, { renderBreadcrumb: () => (_jsxs(Row, { className: "mb-4", align: "center", justify: "between", gap: "sm", wrap: true, children: [_jsx(ContextualTrail, { trails: trails, alternates: { ids: categoryIds, names: categoryIdNames } }), _jsx(ShareButton, { title: title || undefined })] })), renderGallery: () => (_jsx(ProductGalleryClient, { images: images, video: productVideo, productName: title || undefined })), renderInfo: () => (_jsxs(Stack, { gap: "md", children: [_jsx(
|
|
357
|
-
const res = await fetch(SELLER_ENDPOINTS.PRODUCT_DUPLICATE(String(product.id)), { method: "POST" });
|
|
358
|
-
if (!res.ok)
|
|
359
|
-
return;
|
|
360
|
-
const json = (await res.json());
|
|
361
|
-
const newId = json?.data?.id;
|
|
362
|
-
/*
|
|
363
|
-
* Land on the COPY's editor. The endpoint returns the new
|
|
364
|
-
* draft's id precisely so a one-click clone ends somewhere
|
|
365
|
-
* useful rather than back on the public page, where the
|
|
366
|
-
* draft is invisible.
|
|
367
|
-
*/
|
|
368
|
-
if (newId && typeof window !== "undefined") {
|
|
369
|
-
window.location.assign(String(ROUTES.STORE.PRODUCTS_EDIT(newId)));
|
|
370
|
-
}
|
|
371
|
-
}, onDelete: async () => {
|
|
372
|
-
await fetch(SELLER_ENDPOINTS.PRODUCT_BY_ID(String(product.id)), {
|
|
373
|
-
method: "DELETE",
|
|
374
|
-
});
|
|
375
|
-
if (typeof window !== "undefined") {
|
|
376
|
-
window.location.assign(String(ROUTES.PUBLIC.PRODUCTS));
|
|
377
|
-
}
|
|
378
|
-
} }), _jsxs(Div, { children: [condition && (_jsx(Span, { size: "xs", weight: "medium", className: "mb-2 inline-block", padding: "pill-sm", rounded: "full", surface: "subtle", color: "muted", transform: "capitalize", children: condition === "new"
|
|
355
|
+
} }), _jsxs(Container, { size: "xl", padding: "y-lg", children: [_jsx(ProductDetailView, { renderBreadcrumb: () => (_jsxs(Row, { className: "mb-4", align: "center", justify: "between", gap: "sm", wrap: true, children: [_jsx(ContextualTrail, { trails: trails, alternates: { ids: categoryIds, names: categoryIdNames } }), _jsx(ShareButton, { title: title || undefined })] })), renderGallery: () => (_jsx(ProductGalleryClient, { images: images, video: productVideo, productName: title || undefined })), renderInfo: () => (_jsxs(Stack, { gap: "md", children: [_jsx(ProductOwnerQuickActions, { productId: String(product.id), recordLabel: title || String(product.slug ?? product.id), storeId: product.storeId ? String(product.storeId) : undefined }), _jsxs(Div, { children: [condition && (_jsx(Span, { size: "xs", weight: "medium", className: "mb-2 inline-block", padding: "pill-sm", rounded: "full", surface: "subtle", color: "muted", transform: "capitalize", children: condition === "new"
|
|
379
356
|
? "Brand New"
|
|
380
357
|
: condition === "like_new"
|
|
381
358
|
? "Like New"
|
|
@@ -413,7 +390,9 @@ export async function ProductDetailPageView({ slug, initialProduct, renderOfferA
|
|
|
413
390
|
categoryProductCount: (n, cat) => `${n} in ${cat}`,
|
|
414
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" &&
|
|
415
392
|
p.storeReviewCount > 0 &&
|
|
416
|
-
` · ${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({
|
|
417
396
|
productId: product.id,
|
|
418
397
|
productSlug: product.slug ?? slug,
|
|
419
398
|
productTitle: product.title,
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface ProductOwnerQuickActionsProps {
|
|
2
|
+
/** The product's document id — what both endpoints are keyed on. */
|
|
3
|
+
productId: string;
|
|
4
|
+
/** Store SLUG. `SessionUser.storeId` holds the slug, not the Auth UID. */
|
|
5
|
+
storeId?: string;
|
|
6
|
+
/** Names the record in the delete confirmation. */
|
|
7
|
+
recordLabel?: string;
|
|
8
|
+
placement?: "card" | "detail";
|
|
9
|
+
className?: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function ProductOwnerQuickActions({ productId, storeId, recordLabel, placement, className, }: ProductOwnerQuickActionsProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
/**
|
|
4
|
+
* The product flavour of `OwnerQuickActions` — pencil, admin clone, bin.
|
|
5
|
+
*
|
|
6
|
+
* ## 🛑 Why this file exists at all
|
|
7
|
+
*
|
|
8
|
+
* `OwnerQuickActions` takes `onClone` / `onDelete` as FUNCTIONS, and
|
|
9
|
+
* `ProductDetailPageView` is a Server Component. A function cannot cross that
|
|
10
|
+
* boundary: React throws
|
|
11
|
+
*
|
|
12
|
+
* Event handlers cannot be passed to Client Component props.
|
|
13
|
+
* {resource: "PRODUCT", …, onClone: function onClone, onDelete: …}
|
|
14
|
+
*
|
|
15
|
+
* at render, which took down EVERY `/products/[slug]` page in production with
|
|
16
|
+
* an opaque React error — two digests, one per handler. It is not a schema
|
|
17
|
+
* mismatch and no `<Suspense>` boundary affects it: Suspense governs when a
|
|
18
|
+
* subtree may stream, serialization governs what may be sent, and a closure is
|
|
19
|
+
* never sendable.
|
|
20
|
+
*
|
|
21
|
+
* So the closures have to be authored on the client side of the boundary. This
|
|
22
|
+
* component is that side: the Server Component hands it strings, and it builds
|
|
23
|
+
* the handlers here.
|
|
24
|
+
*
|
|
25
|
+
* ## 🛑 Why not just put `"use client"` on the detail view
|
|
26
|
+
*
|
|
27
|
+
* It renders the whole product page, pulls server-resolved data through a
|
|
28
|
+
* dozen render props, and is the SEO surface for every listing. Marking it a
|
|
29
|
+
* Client Component would drag all of that into the browser bundle to fix two
|
|
30
|
+
* buttons. The boundary belongs at the smallest thing that needs it.
|
|
31
|
+
*
|
|
32
|
+
* ## 🛑 Why ids, not endpoint strings
|
|
33
|
+
*
|
|
34
|
+
* An earlier shape passed `cloneEndpoint` / `deleteEndpoint` as props. That is
|
|
35
|
+
* one more pair of strings a caller can get wrong, and the clone's redirect
|
|
36
|
+
* target depends on the id the endpoint RETURNS, which no caller can know in
|
|
37
|
+
* advance. Taking the product id and deriving all three here keeps the
|
|
38
|
+
* endpoints next to the code that reads their responses.
|
|
39
|
+
*
|
|
40
|
+
* @tag domain:products
|
|
41
|
+
* @tag layer:components
|
|
42
|
+
* @tag access:client
|
|
43
|
+
*/
|
|
44
|
+
import { useCallback } from "react";
|
|
45
|
+
import { OwnerQuickActions } from "../../admin/components/OwnerQuickActions.js";
|
|
46
|
+
import { SELLER_ENDPOINTS } from "../../../constants/api-endpoints.js";
|
|
47
|
+
import { ROUTES } from "../../../next/routing/route-map.js";
|
|
48
|
+
import { useToast } from "../../../ui/index.js";
|
|
49
|
+
import { normalizeError } from "../../../errors/normalize.js";
|
|
50
|
+
import { toUserMessage } from "../../../errors/error-display-map.js";
|
|
51
|
+
export function ProductOwnerQuickActions({ productId, storeId, recordLabel, placement = "detail", className, }) {
|
|
52
|
+
const { showToast } = useToast();
|
|
53
|
+
/*
|
|
54
|
+
* Clone reuses POST /api/store/products/[id]/duplicate, which already strips
|
|
55
|
+
* identity, analytics, bid state and group membership and forces
|
|
56
|
+
* status: draft — hence no confirmation on it, and admin-only.
|
|
57
|
+
*/
|
|
58
|
+
const handleClone = useCallback(async () => {
|
|
59
|
+
/*
|
|
60
|
+
* 🛑 Clone reports its own failures. `OwnerQuickActions` catches and toasts
|
|
61
|
+
* for DELETE only — it invokes clone as `void onClone?.()`, so a rejection
|
|
62
|
+
* here would be an unhandled promise and nothing at all would appear on
|
|
63
|
+
* screen. The previous inline version was worse than silent: it did
|
|
64
|
+
* `if (!res.ok) return`, so a refused clone looked exactly like a button
|
|
65
|
+
* that does nothing, and the obvious response is to press it again.
|
|
66
|
+
*/
|
|
67
|
+
try {
|
|
68
|
+
const res = await fetch(SELLER_ENDPOINTS.PRODUCT_DUPLICATE(productId), {
|
|
69
|
+
method: "POST",
|
|
70
|
+
});
|
|
71
|
+
if (!res.ok) {
|
|
72
|
+
const body = (await res.json().catch(() => null));
|
|
73
|
+
showToast(toUserMessage(body?.code ?? "UNKNOWN"), "error");
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const json = (await res.json());
|
|
77
|
+
const newId = json?.data?.id;
|
|
78
|
+
/*
|
|
79
|
+
* Land on the COPY's editor. The endpoint returns the new draft's id
|
|
80
|
+
* precisely so a one-click clone ends somewhere useful rather than back
|
|
81
|
+
* on the public page, where a draft is invisible.
|
|
82
|
+
*/
|
|
83
|
+
if (newId && typeof window !== "undefined") {
|
|
84
|
+
window.location.assign(String(ROUTES.STORE.PRODUCTS_EDIT(newId)));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
/*
|
|
88
|
+
* A 2xx with no id is a successful call that cannot be acted on — the
|
|
89
|
+
* copy may well exist, so saying "failed" would be wrong, and saying
|
|
90
|
+
* nothing leaves the admin believing the click was ignored.
|
|
91
|
+
*/
|
|
92
|
+
showToast("Copy created, but its editor could not be opened.", "info");
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
const normalized = normalizeError(err);
|
|
96
|
+
showToast(toUserMessage(normalized.code ?? "UNKNOWN"), "error");
|
|
97
|
+
}
|
|
98
|
+
}, [productId, showToast]);
|
|
99
|
+
/*
|
|
100
|
+
* 🛑 Throws on a failed DELETE rather than returning quietly.
|
|
101
|
+
* `OwnerQuickActions.handleConfirmedDelete` catches and toasts — and because
|
|
102
|
+
* success navigates away, a swallowed failure is indistinguishable from "it
|
|
103
|
+
* worked but the page did not move". The previous inline version ignored
|
|
104
|
+
* `res.ok` entirely and navigated regardless, so a refused delete sent the
|
|
105
|
+
* owner to the product list as though it had succeeded.
|
|
106
|
+
*/
|
|
107
|
+
const handleDelete = useCallback(async () => {
|
|
108
|
+
const res = await fetch(SELLER_ENDPOINTS.PRODUCT_BY_ID(productId), {
|
|
109
|
+
method: "DELETE",
|
|
110
|
+
});
|
|
111
|
+
if (!res.ok) {
|
|
112
|
+
const body = (await res.json().catch(() => null));
|
|
113
|
+
throw Object.assign(new Error("Delete failed"), { code: body?.code });
|
|
114
|
+
}
|
|
115
|
+
if (typeof window !== "undefined") {
|
|
116
|
+
window.location.assign(String(ROUTES.PUBLIC.PRODUCTS));
|
|
117
|
+
}
|
|
118
|
+
}, [productId]);
|
|
119
|
+
return (_jsx(OwnerQuickActions, { resource: "PRODUCT", placement: placement, className: className, recordLabel: recordLabel, storeId: storeId, editHref: String(ROUTES.STORE.PRODUCTS_EDIT(productId)), onClone: handleClone, onDelete: handleDelete }));
|
|
120
|
+
}
|