@mohasinac/appkit 2.7.25 → 2.7.26

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 (40) hide show
  1. package/dist/_internal/client/features/classified/ClassifiedDetailView.d.ts +8 -0
  2. package/dist/_internal/client/features/classified/ClassifiedDetailView.js +35 -0
  3. package/dist/_internal/client/features/digital-code/CodeRevealPanel.d.ts +13 -0
  4. package/dist/_internal/client/features/digital-code/CodeRevealPanel.js +32 -0
  5. package/dist/_internal/client/features/digital-code/DigitalCodeDetailView.d.ts +17 -0
  6. package/dist/_internal/client/features/digital-code/DigitalCodeDetailView.js +14 -0
  7. package/dist/_internal/client/features/live/LiveItemDetailView.d.ts +9 -0
  8. package/dist/_internal/client/features/live/LiveItemDetailView.js +15 -0
  9. package/dist/_internal/server/features/checkout/actions.d.ts +4 -2
  10. package/dist/_internal/server/features/checkout/actions.js +93 -33
  11. package/dist/_internal/server/features/classified/actions.d.ts +11 -0
  12. package/dist/_internal/server/features/classified/actions.js +30 -0
  13. package/dist/_internal/server/features/classified/data.d.ts +6 -0
  14. package/dist/_internal/server/features/classified/data.js +10 -0
  15. package/dist/_internal/server/features/classified/index.d.ts +2 -0
  16. package/dist/_internal/server/features/classified/index.js +2 -0
  17. package/dist/_internal/server/features/digital-code/data.d.ts +6 -0
  18. package/dist/_internal/server/features/digital-code/data.js +10 -0
  19. package/dist/_internal/server/features/digital-code/index.d.ts +1 -0
  20. package/dist/_internal/server/features/digital-code/index.js +1 -0
  21. package/dist/_internal/server/features/live/data.d.ts +6 -0
  22. package/dist/_internal/server/features/live/data.js +10 -0
  23. package/dist/_internal/server/features/live/index.d.ts +1 -0
  24. package/dist/_internal/server/features/live/index.js +1 -0
  25. package/dist/_internal/shared/checkout/rules/live.rule.d.ts +2 -4
  26. package/dist/_internal/shared/checkout/rules/live.rule.js +1 -1
  27. package/dist/client.d.ts +6 -0
  28. package/dist/client.js +4 -0
  29. package/dist/features/contact/email.d.ts +1 -1
  30. package/dist/features/messages/repository/conversations.repository.d.ts +13 -0
  31. package/dist/features/messages/repository/conversations.repository.js +39 -0
  32. package/dist/features/products/schemas/firestore.d.ts +17 -2
  33. package/dist/features/products/schemas/firestore.js +4 -0
  34. package/dist/index.d.ts +6 -0
  35. package/dist/index.js +8 -0
  36. package/dist/next/routing/route-map.d.ts +15 -0
  37. package/dist/next/routing/route-map.js +7 -0
  38. package/dist/seed/products-standard-seed-data.d.ts +3 -0
  39. package/dist/seed/products-standard-seed-data.js +165 -4
  40. package/package.json +1 -1
@@ -0,0 +1,8 @@
1
+ import type { ProductDocument } from "../../../../features/products/schemas/firestore";
2
+ import type { startClassifiedConversationAction } from "../../../server/features/classified/actions";
3
+ export interface ClassifiedDetailViewProps {
4
+ product: ProductDocument | null;
5
+ isLoading?: boolean;
6
+ onContactSeller: typeof startClassifiedConversationAction;
7
+ }
8
+ export declare function ClassifiedDetailView({ product, isLoading, onContactSeller, }: ClassifiedDetailViewProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,35 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useState } from "react";
4
+ import Link from "next/link";
5
+ import { Badge, Container, Div, Heading, Row, Section, Stack, Text, } from "../../../../ui";
6
+ import { ROUTES } from "../../../../next/routing/route-map";
7
+ import { formatCurrency } from "../../../../utils/number.formatter";
8
+ export function ClassifiedDetailView({ product, isLoading = false, onContactSeller, }) {
9
+ const [pending, setPending] = useState(false);
10
+ const [conversation, setConversation] = useState(null);
11
+ const [error, setError] = useState(null);
12
+ if (isLoading || !product) {
13
+ return (_jsx(Container, { children: _jsx(Div, { className: "py-16 text-center", children: _jsx(Text, { className: "text-muted-foreground", children: isLoading ? "Loading…" : "Classified listing not found." }) }) }));
14
+ }
15
+ const meta = product.classified;
16
+ const price = formatCurrency(product.price, product.currency ?? "INR");
17
+ const location = meta?.meetupArea
18
+ ? [meta.meetupArea.locality, meta.meetupArea.city].filter(Boolean).join(", ")
19
+ : null;
20
+ async function handleContactSeller() {
21
+ setPending(true);
22
+ setError(null);
23
+ try {
24
+ const conv = await onContactSeller({ productId: product.id });
25
+ setConversation(conv);
26
+ }
27
+ catch (e) {
28
+ setError(e instanceof Error ? e.message : "Something went wrong");
29
+ }
30
+ finally {
31
+ setPending(false);
32
+ }
33
+ }
34
+ return (_jsx(Container, { children: _jsx(Section, { className: "py-8", children: _jsxs(Stack, { gap: "lg", children: [product.images.length > 0 && (_jsx(Div, { className: "overflow-hidden rounded-lg bg-muted", children: _jsx("img", { src: product.mainImage || product.images[0], alt: product.title, className: "h-80 w-full object-contain" }) })), _jsxs(Stack, { gap: "sm", children: [_jsxs(Row, { className: "flex-wrap gap-2", children: [_jsx(Badge, { variant: "secondary", children: "Classified" }), meta?.negotiable && _jsx(Badge, { variant: "secondary", children: "Negotiable" }), meta?.acceptsShipping && _jsx(Badge, { variant: "secondary", children: "Shipping available" })] }), _jsx(Heading, { level: 1, className: "text-2xl font-bold", children: product.title }), _jsx(Text, { className: "text-2xl font-semibold text-primary", children: price }), location && (_jsx(Text, { className: "text-sm text-muted-foreground", children: location })), _jsx(Text, { className: "text-muted-foreground", children: product.description })] }), conversation ? (_jsxs(Div, { className: "rounded-lg border border-border bg-muted/40 p-4", children: [_jsx(Text, { className: "mb-2 font-medium", children: "Conversation started!" }), _jsx(Link, { href: ROUTES.USER.MESSAGES, className: "text-primary underline underline-offset-2", children: "Go to your messages \u2192" })] })) : (_jsxs(Stack, { gap: "sm", children: [error && (_jsx(Text, { className: "text-sm text-destructive", children: error })), _jsx("button", { type: "button", disabled: pending, onClick: handleContactSeller, className: "w-full rounded-lg bg-primary px-6 py-3 font-semibold text-primary-foreground transition hover:bg-primary/90 disabled:opacity-60", children: pending ? "Opening chat…" : "Contact Seller" })] }))] }) }) }));
35
+ }
@@ -0,0 +1,13 @@
1
+ export interface RevealedCode {
2
+ code: string;
3
+ orderId: string;
4
+ claimedAt?: Date | string;
5
+ expiresAt?: Date | string;
6
+ }
7
+ export interface CodeRevealPanelProps {
8
+ orderId: string;
9
+ redemptionInstructions?: string;
10
+ /** Injected from the page so the panel never hard-codes the API path. */
11
+ fetchCode: (orderId: string) => Promise<RevealedCode>;
12
+ }
13
+ export declare function CodeRevealPanel({ orderId, redemptionInstructions, fetchCode, }: CodeRevealPanelProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,32 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useState } from "react";
4
+ import { Div, Stack, Text } from "../../../../ui";
5
+ export function CodeRevealPanel({ orderId, redemptionInstructions, fetchCode, }) {
6
+ const [pending, setPending] = useState(false);
7
+ const [revealed, setRevealed] = useState(null);
8
+ const [error, setError] = useState(null);
9
+ const [copied, setCopied] = useState(false);
10
+ async function handleReveal() {
11
+ setPending(true);
12
+ setError(null);
13
+ try {
14
+ const data = await fetchCode(orderId);
15
+ setRevealed(data);
16
+ }
17
+ catch (e) {
18
+ setError(e instanceof Error ? e.message : "Could not retrieve code");
19
+ }
20
+ finally {
21
+ setPending(false);
22
+ }
23
+ }
24
+ async function handleCopy() {
25
+ if (!revealed?.code)
26
+ return;
27
+ await navigator.clipboard.writeText(revealed.code);
28
+ setCopied(true);
29
+ setTimeout(() => setCopied(false), 2000);
30
+ }
31
+ return (_jsx(Div, { className: "rounded-lg border border-border bg-muted/40 p-4", children: _jsxs(Stack, { gap: "sm", children: [_jsx(Text, { className: "font-medium", children: "Your Digital Code" }), !revealed ? (_jsxs(Stack, { gap: "sm", children: [error && _jsx(Text, { className: "text-sm text-destructive", children: error }), _jsx("button", { type: "button", disabled: pending, onClick: handleReveal, className: "w-full rounded-lg bg-primary px-6 py-3 font-semibold text-primary-foreground transition hover:bg-primary/90 disabled:opacity-60", children: pending ? "Loading…" : "Reveal Code" })] })) : (_jsxs(Stack, { gap: "sm", children: [_jsxs(Div, { className: "flex items-center gap-2 rounded-md border border-border bg-background px-3 py-2 font-mono text-lg", children: [_jsx("span", { className: "flex-1 select-all", children: revealed.code }), _jsx("button", { type: "button", onClick: handleCopy, className: "shrink-0 text-xs text-muted-foreground hover:text-foreground", children: copied ? "Copied!" : "Copy" })] }), redemptionInstructions && (_jsx(Text, { className: "text-sm text-muted-foreground", children: redemptionInstructions }))] }))] }) }));
32
+ }
@@ -0,0 +1,17 @@
1
+ import type { ProductDocument } from "../../../../features/products/schemas/firestore";
2
+ import type { RevealedCode } from "./CodeRevealPanel";
3
+ export interface DigitalCodeDetailViewProps {
4
+ product: ProductDocument | null;
5
+ isLoading?: boolean;
6
+ /**
7
+ * Set when the buyer has a confirmed order for this product and wants to
8
+ * reveal their code. The page shim wires this from the order context.
9
+ */
10
+ orderId?: string;
11
+ /**
12
+ * Injected from the page so the panel never hard-codes the API path.
13
+ * Required when orderId is set.
14
+ */
15
+ fetchCode?: (orderId: string) => Promise<RevealedCode>;
16
+ }
17
+ export declare function DigitalCodeDetailView({ product, isLoading, orderId, fetchCode, }: DigitalCodeDetailViewProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,14 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Badge, Container, Div, Heading, Row, Section, Stack, Text, } from "../../../../ui";
4
+ import { formatCurrency } from "../../../../utils/number.formatter";
5
+ import { CodeRevealPanel } from "./CodeRevealPanel";
6
+ export function DigitalCodeDetailView({ product, isLoading = false, orderId, fetchCode, }) {
7
+ if (isLoading || !product) {
8
+ return (_jsx(Container, { children: _jsx(Div, { className: "py-16 text-center", children: _jsx(Text, { className: "text-muted-foreground", children: isLoading ? "Loading…" : "Digital code listing not found." }) }) }));
9
+ }
10
+ const meta = product.digitalCode;
11
+ const price = formatCurrency(product.price, product.currency ?? "INR");
12
+ const codesLeft = meta?.codesAvailable ?? 0;
13
+ return (_jsx(Container, { children: _jsx(Section, { className: "py-8", children: _jsxs(Stack, { gap: "lg", children: [product.images.length > 0 && (_jsx(Div, { className: "overflow-hidden rounded-lg bg-muted", children: _jsx("img", { src: product.mainImage || product.images[0], alt: product.title, className: "h-80 w-full object-contain" }) })), _jsxs(Stack, { gap: "sm", children: [_jsxs(Row, { className: "flex-wrap gap-2", children: [_jsx(Badge, { variant: "secondary", children: "Digital Code" }), codesLeft > 0 ? (_jsxs(Badge, { variant: "active", children: [codesLeft, " available"] })) : (_jsx(Badge, { variant: "danger", children: "Sold out" }))] }), _jsx(Heading, { level: 1, className: "text-2xl font-bold", children: product.title }), _jsx(Text, { className: "text-2xl font-semibold text-primary", children: price }), _jsx(Text, { className: "text-muted-foreground", children: product.description })] }), orderId && fetchCode ? (_jsx(CodeRevealPanel, { orderId: orderId, redemptionInstructions: meta?.redemptionInstructions, fetchCode: fetchCode })) : (_jsxs(Div, { className: "rounded-lg border border-border bg-muted/40 p-4", children: [_jsx(Text, { className: "text-sm text-muted-foreground", children: "After purchase, return to your order to reveal the code instantly." }), meta?.redemptionInstructions && (_jsx(Text, { className: "mt-2 text-sm", children: meta.redemptionInstructions }))] }))] }) }) }));
14
+ }
@@ -0,0 +1,9 @@
1
+ import React from "react";
2
+ import type { ProductDocument } from "../../../../features/products/schemas/firestore";
3
+ export interface LiveItemDetailViewProps {
4
+ product: ProductDocument | null;
5
+ isLoading?: boolean;
6
+ /** Render-prop for the add-to-cart CTA — wired by the page shim. */
7
+ renderActions?: () => React.ReactNode;
8
+ }
9
+ export declare function LiveItemDetailView({ product, isLoading, renderActions, }: LiveItemDetailViewProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,15 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Badge, Container, Div, Heading, Row, Section, Stack, Text, } from "../../../../ui";
4
+ import { formatCurrency } from "../../../../utils/number.formatter";
5
+ export function LiveItemDetailView({ product, isLoading = false, renderActions, }) {
6
+ if (isLoading || !product) {
7
+ return (_jsx(Container, { children: _jsx(Div, { className: "py-16 text-center", children: _jsx(Text, { className: "text-muted-foreground", children: isLoading ? "Loading…" : "Live listing not found." }) }) }));
8
+ }
9
+ const meta = product.liveItem;
10
+ const price = formatCurrency(product.price, product.currency ?? "INR");
11
+ const jurisdictions = meta?.jurisdictionAllowed ?? [];
12
+ const transport = meta?.transport;
13
+ return (_jsx(Container, { children: _jsx(Section, { className: "py-8", children: _jsxs(Stack, { gap: "lg", children: [product.images.length > 0 && (_jsx(Div, { className: "overflow-hidden rounded-lg bg-muted", children: _jsx("img", { src: product.mainImage || product.images[0], alt: product.title, className: "h-80 w-full object-contain" }) })), _jsxs(Stack, { gap: "sm", children: [_jsxs(Row, { className: "flex-wrap gap-2", children: [_jsx(Badge, { variant: "secondary", children: "Live Item" }), meta?.vendorVerified && (_jsx(Badge, { variant: "active", children: "Verified Seller" })), meta?.cites && (_jsxs(Badge, { variant: "warning", children: ["CITES: ", meta.cites] }))] }), _jsx(Heading, { level: 1, className: "text-2xl font-bold", children: product.title }), meta?.species && (_jsxs(Text, { className: "text-sm italic text-muted-foreground", children: [meta.species, meta.sex && meta.sex !== "n/a" && ` · ${meta.sex}`, meta.ageMonths !== undefined && ` · ${meta.ageMonths}mo`] })), _jsx(Text, { className: "text-2xl font-semibold text-primary", children: price }), _jsx(Text, { className: "text-muted-foreground", children: product.description })] }), jurisdictions.length > 0 && (_jsxs(Div, { className: "rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm", children: [_jsx(Text, { className: "font-medium text-amber-900", children: "Delivery restrictions" }), _jsxs(Text, { className: "mt-1 text-amber-800", children: ["This item can only be shipped to: ", jurisdictions.join(", ")] })] })), transport && (_jsxs(Div, { className: "rounded-lg border border-border bg-muted/40 p-4 text-sm", children: [_jsx(Text, { className: "font-medium", children: "Transport" }), _jsxs(Text, { className: "text-muted-foreground", children: ["Method: ", transport.method, transport.handlingFeeInPaise !== undefined &&
14
+ ` · Handling: ${formatCurrency(transport.handlingFeeInPaise, "INR")}`, transport.insuranceIncluded && " · Insurance included"] })] })), meta?.careInfo && (_jsxs(Div, { className: "rounded-lg border border-border bg-muted/40 p-4 text-sm", children: [_jsx(Text, { className: "font-medium", children: "Care information" }), _jsx(Text, { className: "mt-1 text-muted-foreground", children: meta.careInfo })] })), renderActions?.()] }) }) }));
15
+ }
@@ -12,7 +12,8 @@ export interface CreateCheckoutOrderInput {
12
12
  userId: string;
13
13
  userName: string;
14
14
  userEmail: string;
15
- addressId: string;
15
+ /** Required for physical carts; omitted for digital-code-only carts (no shipping). */
16
+ addressId?: string;
16
17
  paymentMethod: CheckoutPaymentMethod;
17
18
  notes?: string;
18
19
  excludedProductIds?: string[];
@@ -44,7 +45,8 @@ export interface VerifyAndPlaceRazorpayOrderInput {
44
45
  razorpay_order_id: string;
45
46
  razorpay_payment_id: string;
46
47
  razorpay_signature: string;
47
- addressId: string;
48
+ /** Required for physical carts; omitted for digital-code-only carts. */
49
+ addressId?: string;
48
50
  notes?: string;
49
51
  }
50
52
  /**
@@ -26,6 +26,29 @@ import { CHECKOUT_DEFAULT_COMMISSIONS } from "../../../shared/features/checkout/
26
26
  import { formatShippingAddress } from "./data";
27
27
  import { enforceMaxPerUserForCart, computePrizeRevealDeadline, } from "./prize-bundle-gates";
28
28
  import { getListingRule, runSyncPreflight, } from "../../../shared/checkout/rules";
29
+ import { cartIsDigitalOnly } from "../../../shared/listing-types/cart-shipping";
30
+ /**
31
+ * SB-UNI-O — Live-item jurisdiction guard.
32
+ * Throws ValidationError if any live cart item's allowed states don't include
33
+ * the buyer's delivery address state (case-insensitive substring match).
34
+ * No-ops when the cart has no live items or when jurisdictionAllowed is empty.
35
+ */
36
+ function assertLiveJurisdiction(cartItemsArg, productByIdArg, buyerState) {
37
+ const normalised = buyerState.trim().toLowerCase();
38
+ for (const item of cartItemsArg) {
39
+ if ((item.listingType ?? "standard") !== "live")
40
+ continue;
41
+ const product = productByIdArg.get(item.productId);
42
+ if (!product?.liveItem?.jurisdictionAllowed?.length)
43
+ continue;
44
+ const allowed = product.liveItem.jurisdictionAllowed;
45
+ const allowed_lower = allowed.map((s) => s.toLowerCase());
46
+ const ok = allowed_lower.some((a) => a === normalised || a.includes(normalised) || normalised.includes(a));
47
+ if (!ok) {
48
+ throw new ValidationError(`Delivery to "${buyerState}" is not permitted for "${product.title}". Allowed regions: ${allowed.join(", ")}.`);
49
+ }
50
+ }
51
+ }
29
52
  /**
30
53
  * Fire-and-forget in-app notifications when an order is created.
31
54
  *
@@ -92,19 +115,31 @@ export async function createCheckoutOrderAction(input) {
92
115
  if (cartItems.length === 0) {
93
116
  throw new ValidationError(ERROR_MESSAGES.CHECKOUT.CART_EMPTY);
94
117
  }
95
- const addressDoc = await unitOfWork.addresses.findById(addressId);
96
- const address = addressDoc && addressDoc.ownerType === "user" && addressDoc.ownerId === uid
97
- ? addressDoc
98
- : null;
99
- if (!address) {
100
- failedCheckoutRepository
101
- .logCheckout(uid, "address_not_found", "Address not found", { addressId, paymentMethod })
102
- .catch(() => { });
103
- throw new NotFoundError(ERROR_MESSAGES.CHECKOUT.ADDRESS_REQUIRED);
118
+ const isDigitalCart = cartIsDigitalOnly(cartItems);
119
+ let shippingAddress;
120
+ let resolvedAddress = null;
121
+ if (!isDigitalCart) {
122
+ if (!addressId) {
123
+ failedCheckoutRepository
124
+ .logCheckout(uid, "address_not_found", "Address required for physical cart", { addressId: "", paymentMethod })
125
+ .catch(() => { });
126
+ throw new NotFoundError(ERROR_MESSAGES.CHECKOUT.ADDRESS_REQUIRED);
127
+ }
128
+ const addressDoc = await unitOfWork.addresses.findById(addressId);
129
+ resolvedAddress =
130
+ addressDoc && addressDoc.ownerType === "user" && addressDoc.ownerId === uid
131
+ ? addressDoc
132
+ : null;
133
+ if (!resolvedAddress) {
134
+ failedCheckoutRepository
135
+ .logCheckout(uid, "address_not_found", "Address not found", { addressId, paymentMethod })
136
+ .catch(() => { });
137
+ throw new NotFoundError(ERROR_MESSAGES.CHECKOUT.ADDRESS_REQUIRED);
138
+ }
139
+ shippingAddress = formatShippingAddress(resolvedAddress);
104
140
  }
105
- const shippingAddress = formatShippingAddress(address);
106
141
  const db = getAdminDb();
107
- const otpRef = consentOtpRef(db, uid, addressId);
142
+ const otpRef = isDigitalCart ? null : consentOtpRef(db, uid, addressId);
108
143
  // SB6-C — pre-tx fetch products so we can run the maxPerUser cap check
109
144
  // (count queries can't run inside a Firestore transaction). The same
110
145
  // product docs are re-read inside the transaction below for stock + prize
@@ -133,21 +168,28 @@ export async function createCheckoutOrderAction(input) {
133
168
  })
134
169
  .filter((pair) => pair !== null);
135
170
  await enforceMaxPerUserForCart({ userId: uid, items: preTxPairs });
171
+ // SB-UNI-O 2026-05-15 — Live-item jurisdiction guard (pre-tx, uses pre-tx product map).
172
+ if (!isDigitalCart && resolvedAddress) {
173
+ assertLiveJurisdiction(cartItems, preTxProductById, resolvedAddress.state);
174
+ }
136
175
  let stockResult;
137
176
  try {
138
177
  stockResult = await db.runTransaction(async (tx) => {
139
- const otpSnap = await tx.get(otpRef);
140
- const otpData = otpSnap.exists
141
- ? otpSnap.data()
142
- : null;
143
- const isConsentValid = otpData?.verified === true &&
144
- otpData.expiresAt &&
145
- (resolveDate(otpData.expiresAt)?.getTime() ?? 0) > Date.now();
146
- if (!isConsentValid) {
147
- const reason = !otpData ? "otp_not_verified" : "consent_expired";
148
- throw Object.assign(new ApiError(403, "Order verification required. Please complete OTP verification before placing this order."), { _failReason: reason });
178
+ let emailOtpUsed = false;
179
+ if (!isDigitalCart && otpRef) {
180
+ const otpSnap = await tx.get(otpRef);
181
+ const otpData = otpSnap.exists
182
+ ? otpSnap.data()
183
+ : null;
184
+ const isConsentValid = otpData?.verified === true &&
185
+ otpData.expiresAt &&
186
+ (resolveDate(otpData.expiresAt)?.getTime() ?? 0) > Date.now();
187
+ if (!isConsentValid) {
188
+ const reason = !otpData ? "otp_not_verified" : "consent_expired";
189
+ throw Object.assign(new ApiError(403, "Order verification required. Please complete OTP verification before placing this order."), { _failReason: reason });
190
+ }
191
+ emailOtpUsed = otpData.verifiedVia !== "sms" && otpData.verifiedVia !== "admin_bypass";
149
192
  }
150
- const emailOtpUsed = otpData.verifiedVia !== "sms" && otpData.verifiedVia !== "admin_bypass";
151
193
  // SB-UNI-5 2026-05-13 — bundle-aware stock fan-out. Build the unique
152
194
  // product-id set across the whole cart (regular items + each bundle's
153
195
  // members) and fetch each one ONCE. Validation walks per cart-line and
@@ -217,7 +259,9 @@ export async function createCheckoutOrderAction(input) {
217
259
  selectedItemIds: null,
218
260
  updatedAt: new Date(),
219
261
  }, { merge: true });
220
- tx.delete(otpRef);
262
+ if (otpRef) {
263
+ tx.delete(otpRef);
264
+ }
221
265
  }
222
266
  return { available: availableItems, unavailable: unavailableItems, emailOtpUsed };
223
267
  });
@@ -517,17 +561,26 @@ export async function verifyAndPlaceRazorpayOrderAction(input) {
517
561
  if (!cart.items || cart.items.length === 0) {
518
562
  throw new ValidationError(ERROR_MESSAGES.CHECKOUT.CART_EMPTY);
519
563
  }
520
- const addressDoc = await unitOfWork.addresses.findById(addressId);
521
- const address = addressDoc && addressDoc.ownerType === "user" && addressDoc.ownerId === uid
522
- ? addressDoc
523
- : null;
524
- if (!address) {
525
- throw new NotFoundError(ERROR_MESSAGES.CHECKOUT.ADDRESS_REQUIRED);
564
+ const isDigitalCartRazorpay = cartIsDigitalOnly(cart.items);
565
+ let shippingAddress;
566
+ let resolvedAddressRzp = null;
567
+ if (!isDigitalCartRazorpay) {
568
+ if (!addressId) {
569
+ throw new NotFoundError(ERROR_MESSAGES.CHECKOUT.ADDRESS_REQUIRED);
570
+ }
571
+ const addressDoc = await unitOfWork.addresses.findById(addressId);
572
+ resolvedAddressRzp =
573
+ addressDoc && addressDoc.ownerType === "user" && addressDoc.ownerId === uid
574
+ ? addressDoc
575
+ : null;
576
+ if (!resolvedAddressRzp) {
577
+ throw new NotFoundError(ERROR_MESSAGES.CHECKOUT.ADDRESS_REQUIRED);
578
+ }
579
+ shippingAddress = formatShippingAddress(resolvedAddressRzp);
526
580
  }
527
- const shippingAddress = formatShippingAddress(address);
528
581
  const db = getAdminDb();
529
- const otpRef = consentOtpRef(db, uid, addressId);
530
- {
582
+ if (!isDigitalCartRazorpay && addressId) {
583
+ const otpRef = consentOtpRef(db, uid, addressId);
531
584
  const otpSnap = await otpRef.get();
532
585
  const otpData = otpSnap.exists
533
586
  ? otpSnap.data()
@@ -574,6 +627,10 @@ export async function verifyAndPlaceRazorpayOrderAction(input) {
574
627
  // Bundle items bypass the cart flow so skip the prize-pool cap.
575
628
  const preflightPairs = productChecks.filter((p) => p.product !== null && p.product !== undefined && !p.item.bundleProductIds?.length);
576
629
  runSyncPreflight(preflightPairs);
630
+ // SB-UNI-O 2026-05-15 — Live-item jurisdiction guard.
631
+ if (!isDigitalCartRazorpay && resolvedAddressRzp) {
632
+ assertLiveJurisdiction(cart.items, productByIdPaid, resolvedAddressRzp.state);
633
+ }
577
634
  // SB-UNI-5 — validate every required member product across the cart with
578
635
  // cumulative decrement awareness (two bundles sharing a member must NOT
579
636
  // both succeed unless the product has enough stock for the sum).
@@ -813,7 +870,10 @@ export async function verifyAndPlaceRazorpayOrderAction(input) {
813
870
  selectedItemIds: null,
814
871
  });
815
872
  });
816
- otpRef.delete().catch(() => { });
873
+ if (!isDigitalCartRazorpay && addressId) {
874
+ const otpRefForDelete = consentOtpRef(db, uid, addressId);
875
+ otpRefForDelete.delete().catch(() => { });
876
+ }
817
877
  if (emailsToSend.length > 0) {
818
878
  Promise.all(emailsToSend.map((e) => sendOrderConfirmationEmail(e))).catch((err) => serverLogger.error("Order confirmation email error:", err));
819
879
  }
@@ -0,0 +1,11 @@
1
+ import type { ConversationDocument } from "../../../../features/messages/schemas/firestore";
2
+ export interface StartConversationInput {
3
+ productId: string;
4
+ }
5
+ /**
6
+ * Open (or resume) a buyer↔seller conversation for a classified listing.
7
+ * Called when the buyer clicks "Contact Seller" on the classified PDP.
8
+ * Idempotent — returns the existing conversation if one already exists for
9
+ * this buyer × store × product tuple.
10
+ */
11
+ export declare function startClassifiedConversationAction(input: StartConversationInput): Promise<ConversationDocument>;
@@ -0,0 +1,30 @@
1
+ "use server";
2
+ import { conversationsRepository } from "../../../../features/messages/repository/conversations.repository";
3
+ import { requireRoleUser } from "../../../../providers/auth-firebase/helpers";
4
+ import { storeRepository, productRepository } from "../../../../repositories";
5
+ /**
6
+ * Open (or resume) a buyer↔seller conversation for a classified listing.
7
+ * Called when the buyer clicks "Contact Seller" on the classified PDP.
8
+ * Idempotent — returns the existing conversation if one already exists for
9
+ * this buyer × store × product tuple.
10
+ */
11
+ export async function startClassifiedConversationAction(input) {
12
+ const user = await requireRoleUser(["user", "buyer", "seller", "admin"]);
13
+ const product = await productRepository.findByIdOrSlug(input.productId);
14
+ if (!product || product.listingType !== "classified") {
15
+ throw new Error("Product not found or not a classified listing");
16
+ }
17
+ const store = await storeRepository.findById(product.storeId);
18
+ if (!store) {
19
+ throw new Error("Store not found");
20
+ }
21
+ return conversationsRepository.findOrCreateByContext({
22
+ buyerId: user.uid,
23
+ buyerDisplayName: user.name ?? user.email ?? user.uid,
24
+ storeId: product.storeId,
25
+ storeName: store.storeName,
26
+ sellerDisplayName: store.storeName,
27
+ productId: product.id,
28
+ productTitle: product.title,
29
+ });
30
+ }
@@ -0,0 +1,6 @@
1
+ import type { ProductDocument } from "../../../../features/products/schemas/firestore";
2
+ export interface ClassifiedDataOptions {
3
+ _reserved?: never;
4
+ }
5
+ /** Fetch a single classified listing by slug, deduped per RSC render. */
6
+ export declare const getClassifiedForDetail: (slug: string, _opts?: ClassifiedDataOptions) => Promise<ProductDocument | null>;
@@ -0,0 +1,10 @@
1
+ import { cache } from "react";
2
+ import { productRepository } from "../../../../repositories";
3
+ /** Fetch a single classified listing by slug, deduped per RSC render. */
4
+ export const getClassifiedForDetail = cache(async (slug, _opts) => {
5
+ void _opts;
6
+ const product = await productRepository.findByIdOrSlug(slug).catch(() => undefined);
7
+ if (!product || product.listingType !== "classified")
8
+ return null;
9
+ return product;
10
+ });
@@ -0,0 +1,2 @@
1
+ export * from "./data";
2
+ export * from "./actions";
@@ -0,0 +1,2 @@
1
+ export * from "./data";
2
+ export * from "./actions";
@@ -0,0 +1,6 @@
1
+ import type { ProductDocument } from "../../../../features/products/schemas/firestore";
2
+ export interface DigitalCodeDataOptions {
3
+ _reserved?: never;
4
+ }
5
+ /** Fetch a single digital-code listing by slug, deduped per RSC render. */
6
+ export declare const getDigitalCodeForDetail: (slug: string, _opts?: DigitalCodeDataOptions) => Promise<ProductDocument | null>;
@@ -0,0 +1,10 @@
1
+ import { cache } from "react";
2
+ import { productRepository } from "../../../../repositories";
3
+ /** Fetch a single digital-code listing by slug, deduped per RSC render. */
4
+ export const getDigitalCodeForDetail = cache(async (slug, _opts) => {
5
+ void _opts;
6
+ const product = await productRepository.findByIdOrSlug(slug).catch(() => undefined);
7
+ if (!product || product.listingType !== "digital-code")
8
+ return null;
9
+ return product;
10
+ });
@@ -0,0 +1 @@
1
+ export * from "./data";
@@ -0,0 +1 @@
1
+ export * from "./data";
@@ -0,0 +1,6 @@
1
+ import type { ProductDocument } from "../../../../features/products/schemas/firestore";
2
+ export interface LiveDataOptions {
3
+ _reserved?: never;
4
+ }
5
+ /** Fetch a single live-item listing by slug, deduped per RSC render. */
6
+ export declare const getLiveItemForDetail: (slug: string, _opts?: LiveDataOptions) => Promise<ProductDocument | null>;
@@ -0,0 +1,10 @@
1
+ import { cache } from "react";
2
+ import { productRepository } from "../../../../repositories";
3
+ /** Fetch a single live-item listing by slug, deduped per RSC render. */
4
+ export const getLiveItemForDetail = cache(async (slug, _opts) => {
5
+ void _opts;
6
+ const product = await productRepository.findByIdOrSlug(slug).catch(() => undefined);
7
+ if (!product || product.listingType !== "live")
8
+ return null;
9
+ return product;
10
+ });
@@ -0,0 +1 @@
1
+ export * from "./data";
@@ -0,0 +1 @@
1
+ export * from "./data";
@@ -1,10 +1,8 @@
1
1
  /**
2
2
  * Live-item listing rule — buyer jurisdiction check + vendor verification.
3
3
  *
4
- * Live animals/plants require a verified seller and that the buyer's delivery
5
- * state is in the product's `jurisdictionAllowed` list. Cart eligibility is
6
- * blocked at the action layer via `capabilityFor("live").requiresJurisdictionCheck`
7
- * until the full gate (SB-UNI-O) is implemented.
4
+ * SB-UNI-O 2026-05-15: cart is now open; jurisdiction + vendor-verified checks
5
+ * run at checkout preflight (see checkout/actions.ts claimLiveItemPreflight).
8
6
  */
9
7
  import type { ListingCheckoutRule } from "./types";
10
8
  export declare const liveRule: ListingCheckoutRule;
@@ -2,5 +2,5 @@ import { DEFAULT_LISTING_RULE } from "./_defaults";
2
2
  export const liveRule = {
3
3
  ...DEFAULT_LISTING_RULE,
4
4
  orderType: "standard",
5
- cartEligible: false, // blocked until SB-UNI-O jurisdiction + vendor-verified gate
5
+ cartEligible: true,
6
6
  };
package/dist/client.d.ts CHANGED
@@ -200,3 +200,9 @@ export { ActionPermissionsManager } from "./features/site-settings/components/Ac
200
200
  export type { ActionPermissionsManagerProps } from "./features/site-settings/components/ActionPermissionsManager";
201
201
  export { NavPermissionsManager } from "./features/site-settings/components/NavPermissionsManager";
202
202
  export type { NavPermissionsManagerProps, NavGroup as NavPermissionsGroup, NavItem as NavPermissionsItem } from "./features/site-settings/components/NavPermissionsManager";
203
+ export { ClassifiedDetailView } from "./_internal/client/features/classified/ClassifiedDetailView";
204
+ export type { ClassifiedDetailViewProps } from "./_internal/client/features/classified/ClassifiedDetailView";
205
+ export { DigitalCodeDetailView } from "./_internal/client/features/digital-code/DigitalCodeDetailView";
206
+ export type { DigitalCodeDetailViewProps } from "./_internal/client/features/digital-code/DigitalCodeDetailView";
207
+ export { LiveItemDetailView } from "./_internal/client/features/live/LiveItemDetailView";
208
+ export type { LiveItemDetailViewProps } from "./_internal/client/features/live/LiveItemDetailView";
package/dist/client.js CHANGED
@@ -229,3 +229,7 @@ export { ScamAwarenessModal } from "./features/scams/components/ScamAwarenessMod
229
229
  // [CLIENT-ONLY] — Admin panel components for action/nav permission management.
230
230
  export { ActionPermissionsManager } from "./features/site-settings/components/ActionPermissionsManager";
231
231
  export { NavPermissionsManager } from "./features/site-settings/components/NavPermissionsManager";
232
+ // ── Classified + digital-code + live-item client views ────────────────────────
233
+ export { ClassifiedDetailView } from "./_internal/client/features/classified/ClassifiedDetailView";
234
+ export { DigitalCodeDetailView } from "./_internal/client/features/digital-code/DigitalCodeDetailView";
235
+ export { LiveItemDetailView } from "./_internal/client/features/live/LiveItemDetailView";
@@ -26,7 +26,7 @@ export interface OrderConfirmationEmailParams {
26
26
  quantity: number;
27
27
  totalPrice: number;
28
28
  currency: string;
29
- shippingAddress: string;
29
+ shippingAddress?: string;
30
30
  paymentMethod: string;
31
31
  items?: Array<{
32
32
  productId: string;
@@ -14,6 +14,19 @@ export declare class ConversationFullError extends Error {
14
14
  }
15
15
  export declare class ConversationsRepository {
16
16
  private collection;
17
+ /**
18
+ * Find an existing buyer×store×product conversation or create a new one.
19
+ * Idempotent — safe to call on every "Contact Seller" click.
20
+ */
21
+ findOrCreateByContext(params: {
22
+ buyerId: string;
23
+ buyerDisplayName: string;
24
+ storeId: string;
25
+ storeName: string;
26
+ sellerDisplayName: string;
27
+ productId?: string;
28
+ productTitle?: string;
29
+ }): Promise<ConversationDocument>;
17
30
  findById(id: string): Promise<ConversationDocument | null>;
18
31
  listByBuyer(buyerId: string): Promise<ConversationDocument[]>;
19
32
  listByStore(storeId: string): Promise<ConversationDocument[]>;
@@ -65,6 +65,45 @@ export class ConversationsRepository {
65
65
  collection() {
66
66
  return getAdminDb().collection(CONVERSATIONS_COLLECTION);
67
67
  }
68
+ /**
69
+ * Find an existing buyer×store×product conversation or create a new one.
70
+ * Idempotent — safe to call on every "Contact Seller" click.
71
+ */
72
+ async findOrCreateByContext(params) {
73
+ const db = getAdminDb();
74
+ const coll = this.collection();
75
+ const { buyerId, storeId, productId } = params;
76
+ // Stable composite key: buyerId + storeId + (productId | "general")
77
+ const productKey = productId ?? "general";
78
+ const stableId = `conv-${buyerId}-${storeId}-${productKey}`;
79
+ const ref = coll.doc(stableId);
80
+ return db.runTransaction(async (tx) => {
81
+ const snap = await tx.get(ref);
82
+ if (snap.exists) {
83
+ return normaliseDoc(snap.id, snap.data() ?? {});
84
+ }
85
+ const now = new Date();
86
+ const newDoc = {
87
+ buyerId: params.buyerId,
88
+ buyerDisplayName: params.buyerDisplayName,
89
+ storeId: params.storeId,
90
+ storeName: params.storeName,
91
+ sellerDisplayName: params.sellerDisplayName,
92
+ productId: params.productId,
93
+ productTitle: params.productTitle,
94
+ messages: [],
95
+ lastMessage: "",
96
+ lastMessageAt: now,
97
+ unreadBuyer: 0,
98
+ unreadSeller: 0,
99
+ status: "active",
100
+ createdAt: now,
101
+ updatedAt: now,
102
+ };
103
+ tx.set(ref, newDoc);
104
+ return normaliseDoc(stableId, newDoc);
105
+ });
106
+ }
68
107
  async findById(id) {
69
108
  const snap = await this.collection().doc(id).get();
70
109
  if (!snap.exists)
@@ -2,7 +2,7 @@
2
2
  * Products Firestore Document Types & Constants
3
3
  */
4
4
  import { type GenerateProductIdInput, type GenerateAuctionIdInput, type GeneratePreOrderIdInput } from "../../../utils/id-generators";
5
- import type { ProductStatus } from "../types";
5
+ import type { ProductStatus, ListingType } from "../types";
6
6
  export interface ProductVideoField {
7
7
  url: string;
8
8
  thumbnailUrl: string;
@@ -191,8 +191,9 @@ export interface ProductDocument {
191
191
  * booleans. Every product document carries this; queries route through
192
192
  * `where("listingType", "==", X)` against the `listingType+...` composite
193
193
  * indexes in `appkit/firebase/base/firestore.indexes.json`.
194
+ * SB-UNI-F — extended with classified | digital-code | live.
194
195
  */
195
- listingType: "standard" | "auction" | "pre-order" | "prize-draw";
196
+ listingType: ListingType;
196
197
  /** Hard cap on units a single user may purchase (SB1-B; bundle/prize-draw). */
197
198
  maxPerUser?: number;
198
199
  /** Reverse pointers — bundle ids that include this product. */
@@ -268,6 +269,20 @@ export declare const PRODUCT_UPDATABLE_FIELDS: readonly ["title", "description",
268
269
  export type ProductCreateInput = Omit<ProductDocument, "id" | "createdAt" | "updatedAt" | "availableQuantity" | "bidCount" | "currentBid" | "auctionOriginalEndDate">;
269
270
  export type ProductUpdateInput = Partial<Pick<ProductDocument, (typeof PRODUCT_UPDATABLE_FIELDS)[number]>>;
270
271
  export type ProductAdminUpdateInput = Partial<Omit<ProductDocument, "id" | "createdAt">>;
272
+ export declare const PRODUCT_CODES_SUBCOLLECTION: "codes";
273
+ export type ProductCodeStatus = "available" | "claimed" | "revoked";
274
+ export interface ProductCodeDocument {
275
+ id: string;
276
+ productId: string;
277
+ code: string;
278
+ status: ProductCodeStatus;
279
+ orderId?: string;
280
+ claimedByUserId?: string;
281
+ claimedAt?: Date;
282
+ expiresAt?: Date;
283
+ createdAt: Date;
284
+ updatedAt: Date;
285
+ }
271
286
  export declare const productQueryHelpers: {
272
287
  readonly byStore: (storeId: string) => readonly ["storeId", "==", string];
273
288
  readonly byStatus: (status: ProductStatus) => readonly ["status", "==", ProductStatus];
@@ -175,6 +175,10 @@ export const PRODUCT_UPDATABLE_FIELDS = [
175
175
  "groupChildSlugs",
176
176
  "groupTitle",
177
177
  ];
178
+ // ── SB-UNI-N 2026-05-15 — Digital-code pool subcollection ───────────────────
179
+ // Codes live at `products/{productId}/codes/{codeId}` — seller-only write,
180
+ // buyer reads exactly the one record assigned to their order via the reveal API.
181
+ export const PRODUCT_CODES_SUBCOLLECTION = "codes";
178
182
  export const productQueryHelpers = {
179
183
  byStore: (storeId) => ["storeId", "==", storeId],
180
184
  byStatus: (status) => ["status", "==", status],
package/dist/index.d.ts CHANGED
@@ -3058,3 +3058,9 @@ export { ActionPermissionsManager } from "./features/site-settings/components/Ac
3058
3058
  export type { ActionPermissionsManagerProps } from "./features/site-settings/components/ActionPermissionsManager";
3059
3059
  export { NavPermissionsManager } from "./features/site-settings/components/NavPermissionsManager";
3060
3060
  export type { NavPermissionsManagerProps, NavGroup as NavPermissionsGroup, NavItem as NavPermissionsItem } from "./features/site-settings/components/NavPermissionsManager";
3061
+ export { getClassifiedForDetail } from "./_internal/server/features/classified/data";
3062
+ export { startClassifiedConversationAction } from "./_internal/server/features/classified/actions";
3063
+ export { getDigitalCodeForDetail } from "./_internal/server/features/digital-code/data";
3064
+ export { PRODUCT_CODES_SUBCOLLECTION } from "./features/products/schemas/firestore";
3065
+ export type { ProductCodeDocument } from "./features/products/schemas/firestore";
3066
+ export { getLiveItemForDetail } from "./_internal/server/features/live/data";
package/dist/index.js CHANGED
@@ -5502,3 +5502,11 @@ export { SIEVE_OP, SIEVE_PIPE_OPS, sieveFilter, sieveMultiEq, expandSieveParam,
5502
5502
  // [CLIENT-ONLY] — Admin panel components for action/nav permission management.
5503
5503
  export { ActionPermissionsManager } from "./features/site-settings/components/ActionPermissionsManager";
5504
5504
  export { NavPermissionsManager } from "./features/site-settings/components/NavPermissionsManager";
5505
+ // ── Classified listing feature ─────────────────────────────────────────────────
5506
+ export { getClassifiedForDetail } from "./_internal/server/features/classified/data";
5507
+ export { startClassifiedConversationAction } from "./_internal/server/features/classified/actions";
5508
+ // ── Digital-code listing feature ──────────────────────────────────────────────
5509
+ export { getDigitalCodeForDetail } from "./_internal/server/features/digital-code/data";
5510
+ export { PRODUCT_CODES_SUBCOLLECTION } from "./features/products/schemas/firestore";
5511
+ // ── Live-item listing feature ─────────────────────────────────────────────────
5512
+ export { getLiveItemForDetail } from "./_internal/server/features/live/data";
@@ -82,6 +82,12 @@ export declare const DEFAULT_ROUTE_MAP: {
82
82
  readonly SCAM_REPORT: "/scams/report";
83
83
  readonly SCAM_TYPES: "/scams/types";
84
84
  readonly SCAM_FAQS: "/scams/faqs";
85
+ readonly CLASSIFIED: "/classified";
86
+ readonly CLASSIFIED_DETAIL: (slug: string) => string;
87
+ readonly DIGITAL_CODES: "/digital-codes";
88
+ readonly DIGITAL_CODE_DETAIL: (slug: string) => string;
89
+ readonly LIVE: "/live";
90
+ readonly LIVE_DETAIL: (slug: string) => string;
85
91
  };
86
92
  readonly ERRORS: {
87
93
  readonly UNAUTHORIZED: "/unauthorized";
@@ -163,6 +169,7 @@ export declare const DEFAULT_ROUTE_MAP: {
163
169
  readonly PRIZE_DRAWS: "/store/prize-draws";
164
170
  readonly PRIZE_DRAWS_NEW: "/store/prize-draws/new";
165
171
  readonly PRIZE_DRAWS_EDIT: (id: string) => string;
172
+ readonly PRODUCT_CODES: (id: string) => string;
166
173
  };
167
174
  readonly ADMIN: {
168
175
  readonly DASHBOARD: "/admin/dashboard";
@@ -321,6 +328,12 @@ export declare const ROUTES: {
321
328
  readonly SCAM_REPORT: "/scams/report";
322
329
  readonly SCAM_TYPES: "/scams/types";
323
330
  readonly SCAM_FAQS: "/scams/faqs";
331
+ readonly CLASSIFIED: "/classified";
332
+ readonly CLASSIFIED_DETAIL: (slug: string) => string;
333
+ readonly DIGITAL_CODES: "/digital-codes";
334
+ readonly DIGITAL_CODE_DETAIL: (slug: string) => string;
335
+ readonly LIVE: "/live";
336
+ readonly LIVE_DETAIL: (slug: string) => string;
324
337
  };
325
338
  readonly ERRORS: {
326
339
  readonly UNAUTHORIZED: "/unauthorized";
@@ -402,6 +415,7 @@ export declare const ROUTES: {
402
415
  readonly PRIZE_DRAWS: "/store/prize-draws";
403
416
  readonly PRIZE_DRAWS_NEW: "/store/prize-draws/new";
404
417
  readonly PRIZE_DRAWS_EDIT: (id: string) => string;
418
+ readonly PRODUCT_CODES: (id: string) => string;
405
419
  };
406
420
  readonly ADMIN: {
407
421
  readonly DASHBOARD: "/admin/dashboard";
@@ -531,6 +545,7 @@ export declare const SELLER_ROUTES: {
531
545
  readonly PRIZE_DRAWS: "/store/prize-draws";
532
546
  readonly PRIZE_DRAWS_NEW: "/store/prize-draws/new";
533
547
  readonly PRIZE_DRAWS_EDIT: (id: string) => string;
548
+ readonly PRODUCT_CODES: (id: string) => string;
534
549
  };
535
550
  export declare const PUBLIC_ROUTES: readonly ["/", string, string, string, string, string, string, string, string, string, string, string, string, string, string, string, "/unauthorized", "/auth/login", "/auth/register", "/auth/forgot-password", "/auth/reset-password", "/auth/verify-email"];
536
551
  export declare const PROTECTED_ROUTES: readonly [string, string, string, string, string, string, string];
@@ -70,6 +70,12 @@ export const DEFAULT_ROUTE_MAP = {
70
70
  SCAM_REPORT: "/scams/report",
71
71
  SCAM_TYPES: "/scams/types",
72
72
  SCAM_FAQS: "/scams/faqs",
73
+ CLASSIFIED: "/classified",
74
+ CLASSIFIED_DETAIL: (slug) => `/classified/${slug}`,
75
+ DIGITAL_CODES: "/digital-codes",
76
+ DIGITAL_CODE_DETAIL: (slug) => `/digital-codes/${slug}`,
77
+ LIVE: "/live",
78
+ LIVE_DETAIL: (slug) => `/live/${slug}`,
73
79
  },
74
80
  ERRORS: {
75
81
  UNAUTHORIZED: "/unauthorized",
@@ -152,6 +158,7 @@ export const DEFAULT_ROUTE_MAP = {
152
158
  PRIZE_DRAWS: "/store/prize-draws",
153
159
  PRIZE_DRAWS_NEW: "/store/prize-draws/new",
154
160
  PRIZE_DRAWS_EDIT: (id) => `/store/prize-draws/${id}/edit`,
161
+ PRODUCT_CODES: (id) => `/store/products/${id}/codes`,
155
162
  },
156
163
  ADMIN: {
157
164
  DASHBOARD: "/admin/dashboard",
@@ -9,5 +9,8 @@ import type { ProductDocument } from "../features/products/schemas";
9
9
  * SB1-G Phase 4 (S22 2026-05-12): every standard product is stamped with
10
10
  * `listingType: "standard"`. The legacy `isAuction` / `isPreOrder` booleans
11
11
  * are gone from `ProductDocument`; this map-wrapper is the canonical write site.
12
+ *
13
+ * SB-UNI-Phase2 (2026-05-15): 3 extra seed products appended after the map
14
+ * with explicit listingType — classified, digital-code, live.
12
15
  */
13
16
  export declare const productsStandardSeedData: Partial<ProductDocument>[];
@@ -4126,8 +4126,169 @@ const _rawProductsStandardSeedData = [
4126
4126
  * SB1-G Phase 4 (S22 2026-05-12): every standard product is stamped with
4127
4127
  * `listingType: "standard"`. The legacy `isAuction` / `isPreOrder` booleans
4128
4128
  * are gone from `ProductDocument`; this map-wrapper is the canonical write site.
4129
+ *
4130
+ * SB-UNI-Phase2 (2026-05-15): 3 extra seed products appended after the map
4131
+ * with explicit listingType — classified, digital-code, live.
4129
4132
  */
4130
- export const productsStandardSeedData = _rawProductsStandardSeedData.map((p) => ({
4131
- ...p,
4132
- listingType: "standard",
4133
- }));
4133
+ export const productsStandardSeedData = [
4134
+ ..._rawProductsStandardSeedData.map((p) => ({
4135
+ ...p,
4136
+ listingType: "standard",
4137
+ })),
4138
+ // ── SB-UNI-M classified seed (OLX-style meetup) ──────────────────────────
4139
+ {
4140
+ id: "classified-funko-pop-lot-bangalore",
4141
+ slug: "classified-funko-pop-lot-bangalore",
4142
+ listingType: "classified",
4143
+ title: "Funko Pop Lot — 12 Anime Figures (Bangalore Meetup / Pickup)",
4144
+ description: "Selling a bulk lot of 12 Funko Pop anime figures I've collected over 3 years — decluttering my shelf. Includes Naruto, Goku SSJ3, Tanjiro, Deku Full Cowl, Levi, Sasuke, Vegeta, Bakugo, Killua, Zero Two, Gojo, and Luffy Gear 5. All are in original window boxes — mint on card, never opened. I'm preferring local meetup in Bangalore (Koramangala / BTM Layout). Shipping possible at buyer's cost. Price is firm but includes all 12 figures. Not splitting — lot only.",
4145
+ category: "category-poseable-figures",
4146
+ categoryName: "Poseable Action Figures",
4147
+ brand: "Funko",
4148
+ brandSlug: "brand-funko",
4149
+ price: 1499900,
4150
+ currency: SCHEMA_DEFAULTS.CURRENCY,
4151
+ stockQuantity: 1,
4152
+ availableQuantity: 1,
4153
+ mainImage: "https://images.unsplash.com/photo-1578292992345-7ee038c5fb7c?w=800&h=800&fit=crop",
4154
+ images: [
4155
+ "https://images.unsplash.com/photo-1578292992345-7ee038c5fb7c?w=800&h=800&fit=crop",
4156
+ "https://images.unsplash.com/photo-1578632767115-351597cf2477?w=800&h=800&fit=crop",
4157
+ ],
4158
+ status: PRODUCT_FIELDS.STATUS_VALUES.PUBLISHED,
4159
+ storeName: "Vintage Vault",
4160
+ storeId: "store-vintage-vault",
4161
+ featured: false,
4162
+ isPromoted: false,
4163
+ tags: ["funko-pop", "anime", "lot", "bangalore", "meetup", "bulk", "classified"],
4164
+ condition: PRODUCT_FIELDS.CONDITION_VALUES.NEW,
4165
+ specifications: [
4166
+ { name: "Quantity", value: "12 figures" },
4167
+ { name: "Location", value: "Bangalore, Karnataka" },
4168
+ { name: "Condition", value: "Mint On Card (all 12)" },
4169
+ ],
4170
+ features: [
4171
+ "12 anime Funko Pops in original boxes",
4172
+ "Mint On Card — never opened",
4173
+ "Bangalore meetup preferred (Koramangala / BTM)",
4174
+ "Shipping possible at buyer's cost",
4175
+ "Lot only — not splitting",
4176
+ ],
4177
+ shippingInfo: "Bangalore meetup preferred. Shipping at buyer's cost in bubble-padded box.",
4178
+ returnPolicy: "No returns on classified listings — inspect at meetup.",
4179
+ allowOffers: false,
4180
+ classified: {
4181
+ meetupArea: { city: "Bangalore", locality: "Koramangala", pincode: "560034" },
4182
+ contactMethod: "chat",
4183
+ acceptsShipping: true,
4184
+ negotiable: false,
4185
+ },
4186
+ createdAt: daysAgo(3),
4187
+ updatedAt: daysAgo(1),
4188
+ },
4189
+ // ── SB-UNI-N digital-code seed (Steam-style instant delivery) ─────────────
4190
+ {
4191
+ id: "digitalcode-steam-cyberpunk-2077",
4192
+ slug: "digitalcode-steam-cyberpunk-2077",
4193
+ listingType: "digital-code",
4194
+ title: "Cyberpunk 2077 — Steam PC Key (Instant Delivery)",
4195
+ description: "Genuine Cyberpunk 2077 Steam PC key purchased directly from CD Projekt RED's authorised distributor. Activates on Steam — no region lock (global). Includes the base game and the free 2.0 update with the Phantom Liberty expansion sold separately. Code is delivered instantly to your order detail page after payment confirmation — no waiting, no emails. One code per order.",
4196
+ category: "category-sealed-product",
4197
+ categoryName: "Sealed Product",
4198
+ price: 149900,
4199
+ currency: SCHEMA_DEFAULTS.CURRENCY,
4200
+ stockQuantity: 50,
4201
+ availableQuantity: 50,
4202
+ mainImage: "https://images.unsplash.com/photo-1612287230202-1ff1d85d1bdf?w=800&h=800&fit=crop",
4203
+ images: [
4204
+ "https://images.unsplash.com/photo-1612287230202-1ff1d85d1bdf?w=800&h=800&fit=crop",
4205
+ ],
4206
+ status: PRODUCT_FIELDS.STATUS_VALUES.PUBLISHED,
4207
+ storeName: "LetItRip Official",
4208
+ storeId: "store-letitrip-official",
4209
+ featured: false,
4210
+ isPromoted: true,
4211
+ tags: ["pc-game", "steam-key", "cyberpunk-2077", "cdpr", "digital", "instant-delivery"],
4212
+ condition: PRODUCT_FIELDS.CONDITION_VALUES.NEW,
4213
+ specifications: [
4214
+ { name: "Platform", value: "Steam (PC)" },
4215
+ { name: "Region", value: "Global (no region lock)" },
4216
+ { name: "Delivery", value: "Instant — code on order page" },
4217
+ { name: "Publisher", value: "CD Projekt RED" },
4218
+ ],
4219
+ features: [
4220
+ "Instant code delivery — available immediately after payment",
4221
+ "Global Steam key — no region restrictions",
4222
+ "Includes base game + 2.0 update",
4223
+ "Genuine key from authorised distributor",
4224
+ ],
4225
+ shippingInfo: "No shipping — digital key delivered instantly to your order page.",
4226
+ returnPolicy: "No returns on activated digital keys. Unactivated keys: 48-hour return window.",
4227
+ allowOffers: false,
4228
+ digitalCode: {
4229
+ codeDeliveryMethod: "auto-claim",
4230
+ codePoolSize: 50,
4231
+ codesAvailable: 50,
4232
+ redemptionInstructions: "1. Open Steam → Games → Activate a Product on Steam. 2. Enter your code. 3. Follow the prompts. Your game will appear in your library within 5 minutes.",
4233
+ },
4234
+ createdAt: daysAgo(7),
4235
+ updatedAt: daysAgo(1),
4236
+ },
4237
+ // ── SB-UNI-O live-item seed (exotic animal — jurisdiction gated) ──────────
4238
+ {
4239
+ id: "live-axolotl-leucistic-juvenile",
4240
+ slug: "live-axolotl-leucistic-juvenile",
4241
+ listingType: "live",
4242
+ title: "Leucistic Axolotl — Juvenile (6 cm), Live Animal",
4243
+ description: "Beautiful leucistic (white with black eyes) axolotl juvenile, approximately 6 cm at time of listing and growing fast. Hatched from a captive breeding programme in Pune. Currently eating frozen bloodworms and pellets — transitioned off live brine shrimp. Healthy, active, full limb set, no signs of stress. Axolotls are legal to own in Maharashtra, Karnataka, and Tamil Nadu; delivery is restricted to these states only (see jurisdiction note below). Packed by specialist courier with oxygen pouch, insulated box, and live arrival guarantee.",
4244
+ category: "category-scale-figures",
4245
+ categoryName: "Scale Figures",
4246
+ price: 249900,
4247
+ currency: SCHEMA_DEFAULTS.CURRENCY,
4248
+ stockQuantity: 1,
4249
+ availableQuantity: 1,
4250
+ mainImage: "https://images.unsplash.com/photo-1560762484-813fc97650a0?w=800&h=800&fit=crop",
4251
+ images: [
4252
+ "https://images.unsplash.com/photo-1560762484-813fc97650a0?w=800&h=800&fit=crop",
4253
+ ],
4254
+ status: PRODUCT_FIELDS.STATUS_VALUES.PUBLISHED,
4255
+ storeName: "LetItRip Official",
4256
+ storeId: "store-letitrip-official",
4257
+ featured: false,
4258
+ isPromoted: false,
4259
+ tags: ["axolotl", "live-animal", "leucistic", "exotic-pet", "amphibian", "aquatic"],
4260
+ condition: PRODUCT_FIELDS.CONDITION_VALUES.NEW,
4261
+ specifications: [
4262
+ { name: "Species", value: "Ambystoma mexicanum (Axolotl)" },
4263
+ { name: "Morph", value: "Leucistic (white body, black eyes)" },
4264
+ { name: "Size", value: "~6 cm at listing" },
4265
+ { name: "Age", value: "~3 months" },
4266
+ { name: "Diet", value: "Frozen bloodworms + pellets" },
4267
+ ],
4268
+ features: [
4269
+ "Captive-bred — not wild-caught",
4270
+ "Full limb set — healthy and active",
4271
+ "Eating well on frozen bloodworms + pellets",
4272
+ "Live arrival guarantee",
4273
+ "Specialist oxygen-pouched insulated shipping",
4274
+ ],
4275
+ shippingInfo: "Specialist live animal courier — oxygen pouch + insulated box. Delivery only within Maharashtra, Karnataka, Tamil Nadu.",
4276
+ returnPolicy: "Live arrival guarantee — photo/video of DOA required within 1 hour. No returns on live animals.",
4277
+ allowOffers: false,
4278
+ liveItem: {
4279
+ species: "Ambystoma mexicanum",
4280
+ ageMonths: 3,
4281
+ sex: "unknown",
4282
+ careInfo: "Axolotls require cold water (16–18°C), a cycled tank of minimum 40L for one adult, and no substrate smaller than their head (swallowing risk). Feed frozen bloodworms or axolotl pellets every 2 days. Do not house with fish.",
4283
+ transport: {
4284
+ method: "specialist",
4285
+ handlingFeeInPaise: 49900,
4286
+ insuranceIncluded: true,
4287
+ },
4288
+ jurisdictionAllowed: ["Maharashtra", "Karnataka", "Tamil Nadu"],
4289
+ vendorVerified: true,
4290
+ },
4291
+ createdAt: daysAgo(2),
4292
+ updatedAt: daysAgo(1),
4293
+ },
4294
+ ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mohasinac/appkit",
3
- "version": "2.7.25",
3
+ "version": "2.7.26",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public"