@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.
Files changed (86) hide show
  1. package/dist/.build-stamp.json +4 -4
  2. package/dist/_internal/server/features/checkout/actions.d.ts +22 -0
  3. package/dist/_internal/server/features/checkout/actions.js +100 -5
  4. package/dist/_internal/server/features/shipping/reconcile.d.ts +28 -0
  5. package/dist/_internal/server/features/shipping/reconcile.js +93 -0
  6. package/dist/_internal/shared/config/schema.d.ts +6 -0
  7. package/dist/_internal/shared/features/checkout/config.d.ts +1 -1
  8. package/dist/_internal/shared/features/checkout/config.js +1 -1
  9. package/dist/_internal/shared/features/site-settings/fees.d.ts +1 -1
  10. package/dist/_internal/shared/features/site-settings/fees.js +1 -1
  11. package/dist/_internal/shared/fees/calculator.d.ts +35 -3
  12. package/dist/_internal/shared/fees/calculator.js +34 -4
  13. package/dist/_internal/shared/listing-types/route-gating.d.ts +56 -0
  14. package/dist/_internal/shared/listing-types/route-gating.js +114 -0
  15. package/dist/_internal/shared/shipping/quote.d.ts +153 -0
  16. package/dist/_internal/shared/shipping/quote.js +124 -0
  17. package/dist/client.d.ts +1 -0
  18. package/dist/client.js +8 -0
  19. package/dist/errors/error-codes.d.ts +15 -0
  20. package/dist/errors/error-codes.js +15 -0
  21. package/dist/errors/index.d.ts +1 -1
  22. package/dist/errors/index.js +1 -1
  23. package/dist/features/account/components/UserBidsView.js +4 -2
  24. package/dist/features/admin/components/AdminSiteSettingsView.js +18 -2
  25. package/dist/features/admin/schemas/firestore.d.ts +37 -2
  26. package/dist/features/admin/schemas/firestore.js +12 -1
  27. package/dist/features/auctions/components/AuctionDetailPageView.js +2 -1
  28. package/dist/features/auth/schemas/firestore.d.ts +15 -0
  29. package/dist/features/cart/components/CartPriceBreakdown.js +1 -1
  30. package/dist/features/checkout/schemas/firestore.d.ts +12 -1
  31. package/dist/features/digital-codes/components/DigitalCodeDetailPageView.js +2 -1
  32. package/dist/features/homepage/components/HeroCarousel.js +4 -2
  33. package/dist/features/homepage/components/MarketplaceHomepageView.js +16 -1
  34. package/dist/features/homepage/components/WelcomeSection.d.ts +16 -3
  35. package/dist/features/homepage/components/WelcomeSection.js +3 -3
  36. package/dist/features/homepage/lib/section-renderer.d.ts +9 -0
  37. package/dist/features/homepage/lib/section-renderer.js +22 -5
  38. package/dist/features/homepage/lib/slide-gating.d.ts +52 -0
  39. package/dist/features/homepage/lib/slide-gating.js +98 -0
  40. package/dist/features/layout/NavbarLayout.js +139 -32
  41. package/dist/features/live/components/LiveItemDetailPageView.js +2 -1
  42. package/dist/features/media/MediaAvatar.js +1 -1
  43. package/dist/features/media/MediaImage.d.ts +18 -1
  44. package/dist/features/media/MediaImage.js +1 -1
  45. package/dist/features/media/messages/en.json +67 -67
  46. package/dist/features/orders/schemas/firestore.d.ts +39 -1
  47. package/dist/features/points/repository/points.repository.d.ts +81 -0
  48. package/dist/features/points/repository/points.repository.js +155 -0
  49. package/dist/features/points/schemas/firestore.d.ts +102 -0
  50. package/dist/features/points/schemas/firestore.js +99 -0
  51. package/dist/features/pre-orders/components/PreOrderDetailPageView.js +2 -1
  52. package/dist/features/products/components/PrizeDrawDetailPageView.js +2 -1
  53. package/dist/features/products/components/ProductDetailPageView.js +5 -26
  54. package/dist/features/products/components/ProductOwnerQuickActions.d.ts +11 -0
  55. package/dist/features/products/components/ProductOwnerQuickActions.js +120 -0
  56. package/dist/features/products/components/ProductsIndexListing.js +35 -5
  57. package/dist/features/products/components/ProductsIndexPageView.js +40 -7
  58. package/dist/features/seller/actions/seller-actions.d.ts +17 -0
  59. package/dist/features/seller/actions/seller-actions.js +33 -0
  60. package/dist/index.d.ts +1 -0
  61. package/dist/index.js +8 -0
  62. package/dist/providers/shipping-shiprocket/index.d.ts +13 -4
  63. package/dist/providers/shipping-shiprocket/index.js +34 -10
  64. package/dist/security/csp.d.ts +1 -16
  65. package/dist/security/csp.js +3 -5
  66. package/dist/seed/carousel-slides-seed-data.js +48 -7
  67. package/dist/seed/faq-seed-data.js +1 -1
  68. package/dist/seed/site-settings-seed-data.js +1 -1
  69. package/dist/seo/json-ld.d.ts +8 -1
  70. package/dist/seo/json-ld.js +39 -2
  71. package/dist/styles.css +419 -542
  72. package/dist/tailwind-utilities.css +1 -1
  73. package/dist/tokens/themes/index.d.ts +1 -3
  74. package/dist/tokens/themes/index.js +1 -11
  75. package/dist/tokens/tokens.css +0 -155
  76. package/dist/ui/components/GatedPrice.style.css +43 -43
  77. package/dist/ui/components/ImageMosaic.d.ts +17 -1
  78. package/dist/ui/components/ImageMosaic.js +28 -5
  79. package/dist/ui/components/ImageMosaic.style.css +37 -5
  80. package/dist/ui/components/QuickCreateModal.style.css +78 -78
  81. package/dist/ui/forms/FormShell.style.css +260 -260
  82. package/package.json +1 -1
  83. package/dist/tokens/themes/technical-dark.d.ts +0 -30
  84. package/dist/tokens/themes/technical-dark.js +0 -103
  85. package/dist/tokens/themes/technical-light.d.ts +0 -22
  86. 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 { OwnerQuickActions } from "../../admin/components/OwnerQuickActions.js";
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(OwnerQuickActions, { resource: "PRODUCT", placement: "detail", recordLabel: title || String(product.slug ?? product.id), storeId: product.storeId ? String(product.storeId) : undefined, editHref: String(ROUTES.STORE.PRODUCTS_EDIT(String(product.id))), onClone: async () => {
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" }), _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({
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
+ }