@mohasinac/appkit 2.7.24 → 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.
- package/dist/_internal/client/features/classified/ClassifiedDetailView.d.ts +8 -0
- package/dist/_internal/client/features/classified/ClassifiedDetailView.js +35 -0
- package/dist/_internal/client/features/digital-code/CodeRevealPanel.d.ts +13 -0
- package/dist/_internal/client/features/digital-code/CodeRevealPanel.js +32 -0
- package/dist/_internal/client/features/digital-code/DigitalCodeDetailView.d.ts +17 -0
- package/dist/_internal/client/features/digital-code/DigitalCodeDetailView.js +14 -0
- package/dist/_internal/client/features/live/LiveItemDetailView.d.ts +9 -0
- package/dist/_internal/client/features/live/LiveItemDetailView.js +15 -0
- package/dist/_internal/server/features/checkout/actions.d.ts +4 -2
- package/dist/_internal/server/features/checkout/actions.js +93 -33
- package/dist/_internal/server/features/classified/actions.d.ts +11 -0
- package/dist/_internal/server/features/classified/actions.js +30 -0
- package/dist/_internal/server/features/classified/data.d.ts +6 -0
- package/dist/_internal/server/features/classified/data.js +10 -0
- package/dist/_internal/server/features/classified/index.d.ts +2 -0
- package/dist/_internal/server/features/classified/index.js +2 -0
- package/dist/_internal/server/features/digital-code/data.d.ts +6 -0
- package/dist/_internal/server/features/digital-code/data.js +10 -0
- package/dist/_internal/server/features/digital-code/index.d.ts +1 -0
- package/dist/_internal/server/features/digital-code/index.js +1 -0
- package/dist/_internal/server/features/live/data.d.ts +6 -0
- package/dist/_internal/server/features/live/data.js +10 -0
- package/dist/_internal/server/features/live/index.d.ts +1 -0
- package/dist/_internal/server/features/live/index.js +1 -0
- package/dist/_internal/shared/checkout/rules/live.rule.d.ts +2 -4
- package/dist/_internal/shared/checkout/rules/live.rule.js +1 -1
- package/dist/client.d.ts +6 -0
- package/dist/client.js +4 -0
- package/dist/features/admin/components/DataTable.js +2 -2
- package/dist/features/contact/email.d.ts +1 -1
- package/dist/features/messages/repository/conversations.repository.d.ts +13 -0
- package/dist/features/messages/repository/conversations.repository.js +39 -0
- package/dist/features/products/schemas/firestore.d.ts +17 -2
- package/dist/features/products/schemas/firestore.js +4 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +8 -0
- package/dist/next/routing/route-map.d.ts +15 -0
- package/dist/next/routing/route-map.js +7 -0
- package/dist/seed/products-standard-seed-data.d.ts +3 -0
- package/dist/seed/products-standard-seed-data.js +165 -4
- package/dist/ui/components/BulkActionBar.js +1 -1
- package/dist/ui/components/CountdownDisplay.js +1 -1
- package/dist/ui/components/EmptyState.js +1 -1
- package/dist/ui/components/ListingToolbar.js +1 -1
- package/dist/ui/components/SiteLogo.js +2 -2
- package/dist/ui/components/TablePagination.js +2 -2
- package/dist/ui/components/Toast.js +2 -2
- 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
|
-
|
|
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
|
-
|
|
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
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,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
|
-
*
|
|
5
|
-
*
|
|
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;
|
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";
|
|
@@ -35,7 +35,7 @@ function SelectableRow({ row, columns, isSelected, onToggle, renderRowActions, o
|
|
|
35
35
|
}
|
|
36
36
|
: undefined;
|
|
37
37
|
const isInteractive = Boolean(onRowClick ?? rowHref);
|
|
38
|
-
return (_jsxs("tr", { onClick: handleClick, onKeyDown: handleKeyDown, role: isInteractive ? "link" : undefined, tabIndex: isInteractive ? 0 : undefined, onMouseDown: selectionEnabled && !isSelected ? longPress.onMouseDown : undefined, onMouseUp: selectionEnabled && !isSelected ? longPress.onMouseUp : undefined, onMouseLeave: selectionEnabled && !isSelected ? longPress.onMouseLeave : undefined, onTouchStart: selectionEnabled && !isSelected ? longPress.onTouchStart : undefined, onTouchEnd: selectionEnabled && !isSelected ? longPress.onTouchEnd : undefined, className: `border-b border-neutral-100 dark:border-slate-700 hover:bg-neutral-50 dark:hover:bg-slate-800 ${isInteractive ? "cursor-pointer" : ""} ${isSelected ? "bg-primary/5 dark:bg-primary/10" : ""}`, children: [selectionEnabled && (_jsx("td", { className: "relative w-10 px-2 py-3", onClick: (e) => e.stopPropagation(), children: _jsx(BaseListingCard.Checkbox, { selected: isSelected, onSelect: (e) => { e.preventDefault(); onToggle?.(row.id, !isSelected); }, label: isSelected ? "Deselect row" : "Select row", position: "top-1/2 left-2 -translate-y-1/2" }) })), columns.map((col) => (_jsx("td", { className: `px-4 py-3 text-neutral-700 dark:text-zinc-300 ${col.className ?? ""}`, children: col.render
|
|
38
|
+
return (_jsxs("tr", { "data-testid": "data-table-row", onClick: handleClick, onKeyDown: handleKeyDown, role: isInteractive ? "link" : undefined, tabIndex: isInteractive ? 0 : undefined, onMouseDown: selectionEnabled && !isSelected ? longPress.onMouseDown : undefined, onMouseUp: selectionEnabled && !isSelected ? longPress.onMouseUp : undefined, onMouseLeave: selectionEnabled && !isSelected ? longPress.onMouseLeave : undefined, onTouchStart: selectionEnabled && !isSelected ? longPress.onTouchStart : undefined, onTouchEnd: selectionEnabled && !isSelected ? longPress.onTouchEnd : undefined, className: `border-b border-neutral-100 dark:border-slate-700 hover:bg-neutral-50 dark:hover:bg-slate-800 ${isInteractive ? "cursor-pointer" : ""} ${isSelected ? "bg-primary/5 dark:bg-primary/10" : ""}`, children: [selectionEnabled && (_jsx("td", { className: "relative w-10 px-2 py-3", onClick: (e) => e.stopPropagation(), children: _jsx(BaseListingCard.Checkbox, { selected: isSelected, onSelect: (e) => { e.preventDefault(); onToggle?.(row.id, !isSelected); }, label: isSelected ? "Deselect row" : "Select row", position: "top-1/2 left-2 -translate-y-1/2", "data-testid": "row-checkbox" }) })), columns.map((col) => (_jsx("td", { className: `px-4 py-3 text-neutral-700 dark:text-zinc-300 ${col.className ?? ""}`, children: col.render
|
|
39
39
|
? col.render(row)
|
|
40
40
|
: String(row[col.key] ?? "") }, col.key))), renderRowActions && (_jsx("td", { className: "px-2 py-3", onClick: (e) => e.stopPropagation(), children: renderRowActions(row) }))] }));
|
|
41
41
|
}
|
|
@@ -43,5 +43,5 @@ export function DataTable({ columns: columnsProp, rows, isLoading, sortKey, sort
|
|
|
43
43
|
const columns = (columnsProp ?? DEFAULT_COLUMNS);
|
|
44
44
|
const selectionEnabled = Boolean(onToggleSelect);
|
|
45
45
|
const allRowsSelected = selectionEnabled && rows.length > 0 && rows.every((r) => selectedIds?.has(r.id));
|
|
46
|
-
return (_jsxs(Div, { className: "overflow-hidden rounded-xl border border-neutral-200 dark:border-slate-700 bg-white dark:bg-slate-900", children: [_jsx(Div, { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm", children: [_jsx("thead", { children: _jsxs("tr", { className: "border-b border-neutral-200 dark:border-slate-700 bg-neutral-50 dark:bg-slate-800", children: [selectionEnabled && (_jsx("th", { scope: "col", className: "w-10 px-2 py-3", children: onToggleSelectAll && (_jsx("input", { type: "checkbox", "aria-label": allRowsSelected ? "Deselect all" : "Select all", checked: allRowsSelected, onChange: () => onToggleSelectAll(!allRowsSelected), className: "h-4 w-4 rounded border-zinc-300 dark:border-slate-600 accent-zinc-900 dark:accent-zinc-100" })) })), columns.map((col) => (_jsxs("th", { scope: "col", onClick: col.sortable && onSort ? () => onSort(col.key) : undefined, className: `px-4 py-3 text-left font-semibold text-neutral-900 dark:text-zinc-100 ${col.sortable && onSort ? "cursor-pointer select-none hover:text-primary" : ""} ${col.className ?? ""}`, children: [col.header, col.sortable && sortKey === col.key && (_jsx(Span, { className: "ml-1", children: sortDir === "asc" ? "↑" : "↓" }))] }, col.key))), renderRowActions && _jsx("th", { scope: "col", className: "w-12 px-2 py-3" })] }) }), _jsx("tbody", { children: isLoading ? (Array.from({ length: 5 }).map((_, i) => (_jsxs("tr", { className: "border-b border-neutral-100 dark:border-slate-700", children: [selectionEnabled && _jsx("td", { className: "w-10 px-2 py-3" }), columns.map((col) => (_jsx("td", { className: "px-4 py-3", children: _jsx(Div, { className: "h-4 w-full animate-pulse rounded bg-neutral-200 dark:bg-slate-700" }) }, col.key)))] }, i)))) : rows.length === 0 ? (_jsx("tr", { children: _jsx("td", { colSpan: columns.length + (selectionEnabled ? 1 : 0) + (renderRowActions ? 1 : 0), className: "px-4 py-12 text-center text-neutral-500 dark:text-zinc-400", children: emptyLabel }) })) : (rows.map((row) => (_jsx(SelectableRow, { row: row, columns: columns, isSelected: selectedIds?.has(row.id) ?? false, onToggle: onToggleSelect, renderRowActions: renderRowActions, onRowClick: onRowClick, rowHref: getRowHref?.(row), selectionEnabled: selectionEnabled }, row.id)))) })] }) }), totalPages > 1 && onPageChange && (_jsx(Div, { className: "flex items-center justify-end gap-2 border-t border-neutral-200 dark:border-slate-700 px-4 py-3", children: Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (_jsx(Button, { onClick: () => onPageChange(p), variant: p === currentPage ? "primary" : "ghost", size: "sm", className: `h-8 w-8 rounded text-xs font-medium transition ${p === currentPage ? "bg-neutral-900 text-white" : "text-neutral-600 dark:text-zinc-300 hover:bg-neutral-100 dark:hover:bg-slate-800"}`, children: p }, p))) }))] }));
|
|
46
|
+
return (_jsxs(Div, { className: "overflow-hidden rounded-xl border border-neutral-200 dark:border-slate-700 bg-white dark:bg-slate-900", children: [_jsx(Div, { className: "overflow-x-auto", children: _jsxs("table", { "data-testid": "data-table", className: "w-full text-sm", children: [_jsx("thead", { children: _jsxs("tr", { className: "border-b border-neutral-200 dark:border-slate-700 bg-neutral-50 dark:bg-slate-800", children: [selectionEnabled && (_jsx("th", { scope: "col", className: "w-10 px-2 py-3", children: onToggleSelectAll && (_jsx("input", { type: "checkbox", "data-testid": "select-all-checkbox", "aria-label": allRowsSelected ? "Deselect all" : "Select all", checked: allRowsSelected, onChange: () => onToggleSelectAll(!allRowsSelected), className: "h-4 w-4 rounded border-zinc-300 dark:border-slate-600 accent-zinc-900 dark:accent-zinc-100" })) })), columns.map((col) => (_jsxs("th", { scope: "col", onClick: col.sortable && onSort ? () => onSort(col.key) : undefined, className: `px-4 py-3 text-left font-semibold text-neutral-900 dark:text-zinc-100 ${col.sortable && onSort ? "cursor-pointer select-none hover:text-primary" : ""} ${col.className ?? ""}`, children: [col.header, col.sortable && sortKey === col.key && (_jsx(Span, { className: "ml-1", children: sortDir === "asc" ? "↑" : "↓" }))] }, col.key))), renderRowActions && _jsx("th", { scope: "col", className: "w-12 px-2 py-3" })] }) }), _jsx("tbody", { children: isLoading ? (Array.from({ length: 5 }).map((_, i) => (_jsxs("tr", { className: "border-b border-neutral-100 dark:border-slate-700", children: [selectionEnabled && _jsx("td", { className: "w-10 px-2 py-3" }), columns.map((col) => (_jsx("td", { className: "px-4 py-3", children: _jsx(Div, { className: "h-4 w-full animate-pulse rounded bg-neutral-200 dark:bg-slate-700" }) }, col.key)))] }, i)))) : rows.length === 0 ? (_jsx("tr", { children: _jsx("td", { colSpan: columns.length + (selectionEnabled ? 1 : 0) + (renderRowActions ? 1 : 0), className: "px-4 py-12 text-center text-neutral-500 dark:text-zinc-400", children: emptyLabel }) })) : (rows.map((row) => (_jsx(SelectableRow, { row: row, columns: columns, isSelected: selectedIds?.has(row.id) ?? false, onToggle: onToggleSelect, renderRowActions: renderRowActions, onRowClick: onRowClick, rowHref: getRowHref?.(row), selectionEnabled: selectionEnabled }, row.id)))) })] }) }), totalPages > 1 && onPageChange && (_jsx(Div, { className: "flex items-center justify-end gap-2 border-t border-neutral-200 dark:border-slate-700 px-4 py-3", children: Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (_jsx(Button, { onClick: () => onPageChange(p), variant: p === currentPage ? "primary" : "ghost", size: "sm", className: `h-8 w-8 rounded text-xs font-medium transition ${p === currentPage ? "bg-neutral-900 text-white" : "text-neutral-600 dark:text-zinc-300 hover:bg-neutral-100 dark:hover:bg-slate-800"}`, children: p }, p))) }))] }));
|
|
47
47
|
}
|
|
@@ -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:
|
|
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 =
|
|
4131
|
-
...p
|
|
4132
|
-
|
|
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
|
+
];
|
|
@@ -53,7 +53,7 @@ export function BulkActionBar({ selectedCount, onClearSelection, actions = [], l
|
|
|
53
53
|
selectedAction?.onClick();
|
|
54
54
|
setPickerOpen(false);
|
|
55
55
|
};
|
|
56
|
-
return (_jsxs("div", { ref: containerRef, className: "appkit-bulk-bar", role: "region", "aria-live": "polite", "aria-label": l.bulkActions, "data-section": "bulkactionbar-div-460", children: [_jsx("div", { className: "appkit-bulk-bar__stripe" }), _jsxs("div", { className: "appkit-bulk-bar__row", "data-section": "bulkactionbar-div-461", children: [_jsxs(Button, { type: "button", variant: "ghost", onClick: onClearSelection, className: "appkit-bulk-bar__count-pill", "aria-label": l.clearSelection, children: [_jsx(X, { className: "appkit-bulk-bar__count-icon", "aria-hidden": "true" }), _jsxs(Span, { className: "appkit-bulk-bar__count-label", children: [selectedCount, " ", l.selected] })] }), actions.length > 0 && (_jsxs(Button, { type: "button", variant: "ghost", onClick: () => setPickerOpen((o) => !o), "aria-haspopup": "listbox", "aria-expanded": pickerOpen, className: [
|
|
56
|
+
return (_jsxs("div", { ref: containerRef, "data-testid": "bulk-action-bar", className: "appkit-bulk-bar", role: "region", "aria-live": "polite", "aria-label": l.bulkActions, "data-section": "bulkactionbar-div-460", children: [_jsx("div", { className: "appkit-bulk-bar__stripe" }), _jsxs("div", { className: "appkit-bulk-bar__row", "data-section": "bulkactionbar-div-461", children: [_jsxs(Button, { type: "button", variant: "ghost", onClick: onClearSelection, className: "appkit-bulk-bar__count-pill", "aria-label": l.clearSelection, children: [_jsx(X, { className: "appkit-bulk-bar__count-icon", "aria-hidden": "true" }), _jsxs(Span, { "data-testid": "bulk-selected-count", className: "appkit-bulk-bar__count-label", children: [selectedCount, " ", l.selected] })] }), actions.length > 0 && (_jsxs(Button, { type: "button", variant: "ghost", onClick: () => setPickerOpen((o) => !o), "aria-haspopup": "listbox", "aria-expanded": pickerOpen, className: [
|
|
57
57
|
"appkit-bulk-bar__picker-trigger",
|
|
58
58
|
selectedAction?.variant === "danger"
|
|
59
59
|
? "appkit-bulk-bar__picker-trigger--danger"
|
|
@@ -45,5 +45,5 @@ export function CountdownDisplay({ targetDate, format = "auto", expiredLabel = "
|
|
|
45
45
|
}, []);
|
|
46
46
|
const remaining = useMemo(() => getRemaining(targetDate), [targetDate, tick]);
|
|
47
47
|
const label = remaining ? formatLabel(remaining, format) : expiredLabel;
|
|
48
|
-
return (_jsx(Span, { variant: "inherit", className: classNames("appkit-countdown", className), children: label }));
|
|
48
|
+
return (_jsx(Span, { variant: "inherit", "data-testid": "countdown", className: classNames("appkit-countdown", className), children: label }));
|
|
49
49
|
}
|
|
@@ -12,5 +12,5 @@ const UI_EMPTY = {
|
|
|
12
12
|
export function EmptyState({ icon, title, description, action, actionLabel, onAction, actionHref, className = "", }) {
|
|
13
13
|
const resolvedAction = action ??
|
|
14
14
|
(actionLabel && actionHref ? (_jsx(TextLink, { href: actionHref, children: actionLabel })) : actionLabel && onAction ? (_jsx(Button, { type: "button", variant: "primary", onClick: onAction, children: actionLabel })) : null);
|
|
15
|
-
return (_jsxs(Card, { variant: "outlined", className: [UI_EMPTY.base, className].filter(Boolean).join(" "), children: [icon ? _jsx("div", { className: UI_EMPTY.icon, "data-section": "emptystate-div-492", children: icon }) : null, _jsx(Heading, { level: 3, children: title }), description ? (_jsx(Text, { variant: "secondary", className: UI_EMPTY.description, children: description })) : null, resolvedAction ? (_jsx("div", { className: UI_EMPTY.action, "data-section": "emptystate-div-493", children: resolvedAction })) : null] }));
|
|
15
|
+
return (_jsxs(Card, { variant: "outlined", "data-testid": "empty-state", className: [UI_EMPTY.base, className].filter(Boolean).join(" "), children: [icon ? _jsx("div", { className: UI_EMPTY.icon, "data-section": "emptystate-div-492", children: icon }) : null, _jsx(Heading, { level: 3, children: title }), description ? (_jsx(Text, { variant: "secondary", className: UI_EMPTY.description, children: description })) : null, resolvedAction ? (_jsx("div", { className: UI_EMPTY.action, "data-section": "emptystate-div-493", children: resolvedAction })) : null] }));
|
|
16
16
|
}
|
|
@@ -29,7 +29,7 @@ export function ListingToolbar({ filterCount = 0, onFiltersClick, searchValue =
|
|
|
29
29
|
}
|
|
30
30
|
};
|
|
31
31
|
const allSelected = bulkTotalCount > 0 && bulkSelectedCount === bulkTotalCount;
|
|
32
|
-
return (_jsx("div", { className: `sticky top-[var(--header-height,0px)] z-20 border-b border-zinc-200 dark:border-slate-700 bg-white/95 dark:bg-slate-900/95 backdrop-blur-sm py-2 px-3 sm:py-2.5 sm:px-4 ${className}`, children: _jsxs("div", { className: "flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-2.5", children: [bulkMode ? (_jsxs("div", { className: "flex flex-1 items-center gap-2", children: [_jsxs("button", { type: "button", onClick: onBulkSelectAll, className: "flex items-center gap-1.5 rounded-lg border border-zinc-300 dark:border-slate-600 px-3 py-1.5 text-sm font-medium text-zinc-700 dark:text-zinc-200 hover:bg-zinc-50 dark:hover:bg-slate-800 transition-colors", children: [allSelected
|
|
32
|
+
return (_jsx("div", { "data-testid": "listing-toolbar", className: `sticky top-[var(--header-height,0px)] z-20 border-b border-zinc-200 dark:border-slate-700 bg-white/95 dark:bg-slate-900/95 backdrop-blur-sm py-2 px-3 sm:py-2.5 sm:px-4 ${className}`, children: _jsxs("div", { className: "flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-2.5", children: [bulkMode ? (_jsxs("div", { className: "flex flex-1 items-center gap-2", children: [_jsxs("button", { type: "button", onClick: onBulkSelectAll, className: "flex items-center gap-1.5 rounded-lg border border-zinc-300 dark:border-slate-600 px-3 py-1.5 text-sm font-medium text-zinc-700 dark:text-zinc-200 hover:bg-zinc-50 dark:hover:bg-slate-800 transition-colors", children: [allSelected
|
|
33
33
|
? _jsx(CheckSquare, { className: "h-4 w-4 text-[var(--appkit-color-primary,theme(colors.violet.600))]" })
|
|
34
34
|
: _jsx(Square, { className: "h-4 w-4" }), allSelected ? l.deselectAll : l.selectAll(bulkTotalCount)] }), _jsx("span", { className: "text-sm text-zinc-500 dark:text-zinc-400", children: l.selected(bulkSelectedCount) }), _jsx("button", { type: "button", onClick: onBulkClear, className: "text-xs text-zinc-400 hover:text-rose-500 dark:text-zinc-500 transition-colors", children: l.clearSelection })] })) : onSearchChange ? (_jsxs("div", { className: "flex flex-1 items-center overflow-hidden rounded-lg border border-zinc-300 dark:border-slate-600 bg-white dark:bg-slate-900 min-w-0", children: [_jsx("input", { type: "text", value: searchValue, onChange: (e) => onSearchChange(e.target.value), onKeyDown: handleKeyDown, placeholder: searchPlaceholder, className: "min-w-0 flex-1 bg-transparent px-3 py-2 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 outline-none" }), _jsx("button", { type: "button", onClick: onSearchCommit, className: "flex shrink-0 items-center justify-center px-2.5 py-2 text-zinc-400 hover:text-[var(--appkit-color-primary,theme(colors.violet.600))] transition-colors", "aria-label": l.search, children: _jsx(Search, { className: "h-4 w-4" }) })] })) : null, _jsxs("div", { className: "flex items-center gap-1.5 sm:gap-2 shrink-0", children: [onFiltersClick && (_jsxs("button", { type: "button", onClick: onFiltersClick, className: "relative flex shrink-0 items-center gap-1.5 rounded-lg border border-zinc-300 dark:border-slate-600 px-2.5 py-1.5 sm:px-3.5 sm:py-2 text-sm font-medium text-zinc-700 dark:text-zinc-200 hover:bg-zinc-50 dark:hover:bg-slate-800 transition-colors", children: [_jsx(SlidersHorizontal, { className: "h-4 w-4" }), _jsx("span", { className: "hidden sm:inline", children: l.filters }), filterCount > 0 && (_jsx("span", { className: "absolute -top-1.5 -right-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-[var(--appkit-color-primary,theme(colors.violet.600))] text-[10px] font-bold text-white", children: filterCount }))] })), sortOptions && sortValue !== undefined && onSortChange && (_jsxs("div", { className: "flex items-center gap-1.5 text-sm text-zinc-500 dark:text-zinc-400", children: [_jsx("span", { className: "hidden md:inline whitespace-nowrap text-xs", children: l.sort }), _jsx(SortDropdown, { value: sortValue, onChange: onSortChange, options: sortOptions })] })), !hideViewToggle && onViewChange && (_jsxs("div", { className: "flex items-center rounded-lg border border-zinc-300 dark:border-slate-600 overflow-hidden", children: [_jsx("button", { type: "button", onClick: () => onViewChange("grid"), "aria-label": l.gridView, className: `${VIEW_BTN_BASE} ${view === "grid" ? VIEW_BTN_ACTIVE : VIEW_BTN_INACTIVE}`, children: _jsx(LayoutGrid, { className: "h-4 w-4" }) }), _jsx("button", { type: "button", onClick: () => onViewChange("list"), "aria-label": l.listView, className: `${VIEW_BTN_BASE} ${view === "list" ? VIEW_BTN_ACTIVE : VIEW_BTN_INACTIVE}`, children: _jsx(List, { className: "h-4 w-4" }) }), showTableView && (_jsx("button", { type: "button", onClick: () => onViewChange("table"), "aria-label": l.tableView, className: `${VIEW_BTN_BASE} ${view === "table" ? VIEW_BTN_ACTIVE : VIEW_BTN_INACTIVE}`, children: _jsx(Table2, { className: "h-4 w-4" }) }))] })), onResetAll && hasActiveState && (_jsx("button", { type: "button", onClick: onResetAll, "aria-label": l.resetAll, title: l.resetAll, className: "flex shrink-0 items-center justify-center rounded-lg border border-zinc-300 dark:border-slate-600 p-1.5 sm:p-2 text-zinc-500 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-slate-800 hover:text-rose-500 dark:hover:text-rose-400 transition-colors", children: _jsx(RotateCcw, { className: "h-4 w-4" }) })), extra] })] }) }));
|
|
35
35
|
}
|
|
@@ -11,8 +11,8 @@ export function SiteLogo({ className = "h-7", title = "LetItRip.in", variant = "
|
|
|
11
11
|
if (src) {
|
|
12
12
|
return (
|
|
13
13
|
// eslint-disable-next-line @next/next/no-img-element
|
|
14
|
-
_jsx("img", { src: src, alt: title, className: `block w-auto object-contain ${className}` }));
|
|
14
|
+
_jsx("img", { src: src, alt: title, "data-testid": "site-logo", className: `block w-auto object-contain ${className}` }));
|
|
15
15
|
}
|
|
16
16
|
const fill = variant === "gradient" ? `url(#${GRADIENT_ID})` : "currentColor";
|
|
17
|
-
return (_jsxs("svg", { xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 214 56", role: "img", "aria-label": title, className: `block w-auto ${className}`, children: [_jsx("title", { children: title }), variant === "gradient" && (_jsx("defs", { children: _jsxs("linearGradient", { id: GRADIENT_ID, x1: "0%", y1: "0%", x2: "100%", y2: "0%", children: [_jsx("stop", { offset: "0%", style: { stopColor: "var(--appkit-color-primary-700, #1343de)" } }), _jsx("stop", { offset: "55%", style: { stopColor: "var(--appkit-color-cobalt, #3570fc)" } }), _jsx("stop", { offset: "100%", style: { stopColor: "var(--appkit-color-secondary-400, #84e122)" } })] }) })), _jsx("text", { x: "0", y: "44", fontFamily: "ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif", fontWeight: "800", fontSize: "44", letterSpacing: "-1.5", fill: fill, children: "LetItRip" }), _jsx("rect", { x: "169", y: "5", width: "43", height: "21", rx: "10.5", fill: fill, opacity: "0.12" }), _jsx("text", { x: "174", y: "21", fontFamily: "ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif", fontWeight: "700", fontSize: "15", letterSpacing: "0.2", fill: fill, children: ".in" })] }));
|
|
17
|
+
return (_jsxs("svg", { xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 214 56", role: "img", "aria-label": title, "data-testid": "site-logo", className: `block w-auto ${className}`, children: [_jsx("title", { children: title }), variant === "gradient" && (_jsx("defs", { children: _jsxs("linearGradient", { id: GRADIENT_ID, x1: "0%", y1: "0%", x2: "100%", y2: "0%", children: [_jsx("stop", { offset: "0%", style: { stopColor: "var(--appkit-color-primary-700, #1343de)" } }), _jsx("stop", { offset: "55%", style: { stopColor: "var(--appkit-color-cobalt, #3570fc)" } }), _jsx("stop", { offset: "100%", style: { stopColor: "var(--appkit-color-secondary-400, #84e122)" } })] }) })), _jsx("text", { x: "0", y: "44", fontFamily: "ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif", fontWeight: "800", fontSize: "44", letterSpacing: "-1.5", fill: fill, children: "LetItRip" }), _jsx("rect", { x: "169", y: "5", width: "43", height: "21", rx: "10.5", fill: fill, opacity: "0.12" }), _jsx("text", { x: "174", y: "21", fontFamily: "ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif", fontWeight: "700", fontSize: "15", letterSpacing: "0.2", fill: fill, children: ".in" })] }));
|
|
18
18
|
}
|
|
@@ -13,7 +13,7 @@ export function TablePagination({ currentPage, totalPages, pageSize, total, onPa
|
|
|
13
13
|
perPage: labels?.perPage ?? "Per page",
|
|
14
14
|
};
|
|
15
15
|
if (compact) {
|
|
16
|
-
return (_jsx(Div, { role: "navigation", "aria-label": l.paginationLabel, className: [
|
|
16
|
+
return (_jsx(Div, { role: "navigation", "aria-label": l.paginationLabel, "data-testid": "pagination", className: [
|
|
17
17
|
"appkit-table-pagination appkit-table-pagination--compact",
|
|
18
18
|
className,
|
|
19
19
|
]
|
|
@@ -22,7 +22,7 @@ export function TablePagination({ currentPage, totalPages, pageSize, total, onPa
|
|
|
22
22
|
}
|
|
23
23
|
const from = total === 0 ? 0 : (currentPage - 1) * pageSize + 1;
|
|
24
24
|
const to = Math.min(currentPage * pageSize, total);
|
|
25
|
-
return (_jsxs(Div, { role: "navigation", "aria-label": l.paginationLabel, className: ["appkit-table-pagination", className]
|
|
25
|
+
return (_jsxs(Div, { role: "navigation", "aria-label": l.paginationLabel, "data-testid": "pagination", className: ["appkit-table-pagination", className]
|
|
26
26
|
.filter(Boolean)
|
|
27
27
|
.join(" "), children: [_jsxs(Text, { size: "xs", variant: "none", className: "appkit-table-pagination__summary", children: [l.showing, " ", _jsxs(Span, { className: "appkit-table-pagination__summary-value", children: [from, "\u2013", to] }), " ", l.of, " ", _jsx(Span, { className: "appkit-table-pagination__summary-value", children: new Intl.NumberFormat().format(total) }), " ", l.results] }), _jsx(Div, { className: "appkit-table-pagination__controls", children: _jsx(Pagination, { currentPage: currentPage, totalPages: totalPages, onPageChange: onPageChange, disabled: isLoading }) }), onPageSizeChange && (_jsxs(Div, { className: "appkit-table-pagination__size", children: [_jsx("label", { htmlFor: "page-size-select", className: "appkit-table-pagination__size-label", children: l.perPage }), _jsx(Select, { id: "page-size-select", value: String(pageSize), onValueChange: (value) => onPageSizeChange(Number(value)), disabled: isLoading, "aria-label": l.perPage, className: "appkit-table-pagination__size-select", options: pageSizeOptions.map((s) => ({
|
|
28
28
|
value: String(s),
|
|
@@ -45,7 +45,7 @@ export function ToastProvider({ children, position = "top-right", }) {
|
|
|
45
45
|
window.setTimeout(() => hideToast(id), duration);
|
|
46
46
|
}
|
|
47
47
|
}, [hideToast]);
|
|
48
|
-
return (_jsxs(ToastContext.Provider, { value: { showToast, hideToast }, children: [children, _jsx("div", { "aria-live": "polite", "aria-atomic": "true", className: [UI_TOAST.container, UI_TOAST.positions[position]]
|
|
48
|
+
return (_jsxs(ToastContext.Provider, { value: { showToast, hideToast }, children: [children, _jsx("div", { "aria-live": "polite", "aria-atomic": "true", "data-testid": "toast-container", className: [UI_TOAST.container, UI_TOAST.positions[position]]
|
|
49
49
|
.filter(Boolean)
|
|
50
50
|
.join(" "), "data-section": "toast-div-623", children: toasts.map((toast) => (_jsx(ToastRow, { toast: toast, onClose: hideToast }, toast.id))) })] }));
|
|
51
51
|
}
|
|
@@ -56,5 +56,5 @@ function ToastRow({ toast, onClose, }) {
|
|
|
56
56
|
warning: _jsx("span", { "aria-hidden": "true", children: "!" }),
|
|
57
57
|
info: _jsx("span", { "aria-hidden": "true", children: "i" }),
|
|
58
58
|
};
|
|
59
|
-
return (_jsxs("div", { role: "alert", className: [UI_TOAST.row, UI_TOAST.variants[toast.variant]].join(" "), "data-section": "toast-div-624", children: [_jsx("div", { className: UI_TOAST.icon, "data-section": "toast-div-625", children: iconMap[toast.variant] }), _jsx(Text, { as: "div", size: "sm", weight: "medium", className: "flex-1 pr-1", children: toast.message }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => onClose(toast.id), className: "min-h-0 shrink-0 px-2 py-1", "aria-label": "Close notification", children: "\u00D7" })] }));
|
|
59
|
+
return (_jsxs("div", { role: "alert", "data-testid": "toast", className: [UI_TOAST.row, UI_TOAST.variants[toast.variant]].join(" "), "data-section": "toast-div-624", children: [_jsx("div", { className: UI_TOAST.icon, "data-section": "toast-div-625", children: iconMap[toast.variant] }), _jsx(Text, { as: "div", size: "sm", weight: "medium", className: "flex-1 pr-1", children: toast.message }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => onClose(toast.id), className: "min-h-0 shrink-0 px-2 py-1", "aria-label": "Close notification", children: "\u00D7" })] }));
|
|
60
60
|
}
|