@mohasinac/appkit 2.4.7 → 2.4.8
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/features/admin/components/AdminProductEditorView.js +4 -1
- package/dist/features/auth/hooks/useAuth.js +66 -14
- package/dist/features/auth/repository/user.repository.d.ts +1 -0
- package/dist/features/auth/repository/user.repository.js +10 -6
- package/dist/features/pre-orders/components/PreOrderDetailPageView.js +6 -0
- package/dist/features/products/components/GroupSettingsPanel.d.ts +13 -0
- package/dist/features/products/components/GroupSettingsPanel.js +192 -0
- package/dist/features/products/components/PreOrderDetailView.d.ts +3 -1
- package/dist/features/products/components/PreOrderDetailView.js +5 -2
- package/dist/features/products/components/ProductDetailPageView.js +6 -0
- package/dist/features/products/components/ProductDetailView.d.ts +3 -1
- package/dist/features/products/components/ProductDetailView.js +7 -3
- package/dist/features/products/components/ProductForm.d.ts +6 -1
- package/dist/features/products/components/ProductForm.js +2 -2
- package/dist/features/products/components/ShowGroupSection.d.ts +8 -0
- package/dist/features/products/components/ShowGroupSection.js +56 -0
- package/dist/features/products/components/index.d.ts +3 -0
- package/dist/features/products/components/index.js +2 -0
- package/dist/features/products/repository/products.repository.d.ts +8 -0
- package/dist/features/products/repository/products.repository.js +81 -0
- package/dist/features/products/schemas/firestore.d.ts +7 -2
- package/dist/features/products/schemas/firestore.js +10 -0
- package/dist/features/products/types/index.d.ts +5 -0
- package/dist/features/wishlist/schemas/index.d.ts +2 -2
- package/dist/tailwind-utilities.css +1 -1
- package/package.json +1 -1
|
@@ -6,6 +6,7 @@ import { Button, DynamicSelect, InlineCreateSelect, Form, Stack, StackedViewShel
|
|
|
6
6
|
import { apiClient } from "../../../http";
|
|
7
7
|
import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints";
|
|
8
8
|
import { ProductForm } from "../../products/components/ProductForm";
|
|
9
|
+
import { GroupSettingsPanel } from "../../products/components/GroupSettingsPanel";
|
|
9
10
|
import { CategoryQuickCreateForm } from "./CategoryQuickCreateForm";
|
|
10
11
|
import { BrandQuickCreateForm } from "./BrandQuickCreateForm";
|
|
11
12
|
function modeFromProduct(product) {
|
|
@@ -134,7 +135,9 @@ export function AdminProductEditorView({ productId, onSaved, onDeleted, ...rest
|
|
|
134
135
|
_jsxs(Form, { onSubmit: (e) => {
|
|
135
136
|
e.preventDefault();
|
|
136
137
|
saveMutation.mutate();
|
|
137
|
-
}, className: "space-y-6", children: [_jsxs(Stack, { gap: "xs", children: [_jsx(Text, { className: "text-sm font-medium text-zinc-900 dark:text-zinc-100", children: "Listing type" }), _jsxs(Tabs, { value: mode, onChange: handleModeChange, children: [_jsxs(TabsList, { children: [_jsx(TabsTrigger, { value: "standard", children: "Standard" }), _jsx(TabsTrigger, { value: "auction", children: "Auction" }), _jsx(TabsTrigger, { value: "preorder", children: "Pre-order" })] }), _jsx(TabsContent, { value: "standard" }), _jsx(TabsContent, { value: "auction" }), _jsx(TabsContent, { value: "preorder" })] })] }), _jsxs(Stack, { gap: "xs", children: [_jsx(Text, { className: "text-sm font-medium text-zinc-900 dark:text-zinc-100", children: "Store" }), _jsx(DynamicSelect, { value: product.storeId ?? null, onChange: handleStoreSelect, loadOptions: loadStoreOptions, placeholder: "Search stores\u2026", searchPlaceholder: "Type store name\u2026", noResultsText: "No stores found", ariaLabel: "Store" })] }), _jsx(ProductForm, { product: product, onChange: setProduct,
|
|
138
|
+
}, className: "space-y-6", children: [_jsxs(Stack, { gap: "xs", children: [_jsx(Text, { className: "text-sm font-medium text-zinc-900 dark:text-zinc-100", children: "Listing type" }), _jsxs(Tabs, { value: mode, onChange: handleModeChange, children: [_jsxs(TabsList, { children: [_jsx(TabsTrigger, { value: "standard", children: "Standard" }), _jsx(TabsTrigger, { value: "auction", children: "Auction" }), _jsx(TabsTrigger, { value: "preorder", children: "Pre-order" })] }), _jsx(TabsContent, { value: "standard" }), _jsx(TabsContent, { value: "auction" }), _jsx(TabsContent, { value: "preorder" })] })] }), _jsxs(Stack, { gap: "xs", children: [_jsx(Text, { className: "text-sm font-medium text-zinc-900 dark:text-zinc-100", children: "Store" }), _jsx(DynamicSelect, { value: product.storeId ?? null, onChange: handleStoreSelect, loadOptions: loadStoreOptions, placeholder: "Search stores\u2026", searchPlaceholder: "Type store name\u2026", noResultsText: "No stores found", ariaLabel: "Store" })] }), _jsx(ProductForm, { product: product, onChange: setProduct, renderGroupSettings: isEdit && productId
|
|
139
|
+
? (p) => (_jsx(GroupSettingsPanel, { productId: productId, productSlug: p.slug ?? productId, groupId: p.groupId, isGroupParent: p.isGroupParent, groupParentSlug: p.groupParentSlug, groupChildSlugs: p.groupChildSlugs, groupTitle: p.groupTitle, isAuction: !!p.isAuction, storeProductsEndpoint: "/api/admin/products", onGroupChanged: () => productQuery.refetch() }))
|
|
140
|
+
: undefined, renderCategorySelector: ({ label, value, onChange, disabled }) => (_jsxs(Stack, { gap: "xs", children: [_jsx(Text, { className: "text-sm font-medium text-zinc-900 dark:text-zinc-100", children: label }), _jsx(InlineCreateSelect, { value: value || null, onChange: (v) => onChange(v ?? ""), loadOptions: loadCategoryOptions, placeholder: "Search categories\u2026", searchPlaceholder: "Type category name\u2026", noResultsText: "No categories found", ariaLabel: label, disabled: disabled, createLabel: "Category", renderCreateForm: ({ onCreated, onCancel }) => (_jsx(CategoryQuickCreateForm, { onSaved: (id, name) => onCreated({ value: id, label: name }), onCancel: onCancel })) })] })), renderBrandSelector: (args) => (_jsxs(Stack, { gap: "xs", children: [_jsx(Text, { className: "text-sm font-medium text-zinc-900 dark:text-zinc-100", children: args.label }), _jsx(InlineCreateSelect, { value: args.multi ? null : (args.value || null), onChange: (v) => args.onValueChange(v ?? ""), loadOptions: loadBrandOptions, placeholder: "Search brands\u2026", searchPlaceholder: "Type brand name\u2026", noResultsText: "No brands found", ariaLabel: args.label, disabled: args.disabled, createLabel: "Brand", renderCreateForm: ({ onCreated, onCancel }) => (_jsx(BrandQuickCreateForm, { onSaved: (id, name) => onCreated({ value: id, label: name }), onCancel: onCancel })) })] })) }), _jsxs("div", { className: "flex gap-3 pt-2", children: [_jsx(Button, { type: "submit", isLoading: isSubmitting, disabled: !product.title || isSubmitting, children: isEdit ? "Save changes" : "Create product" }), isEdit && (_jsx(Button, { type: "button", variant: "danger", isLoading: deleteMutation.isPending, onClick: () => {
|
|
138
141
|
if (confirm("Delete this product? This cannot be undone.")) {
|
|
139
142
|
deleteMutation.mutate();
|
|
140
143
|
}
|
|
@@ -51,45 +51,96 @@ export function useLogin(options) {
|
|
|
51
51
|
export function useGoogleLogin(options) {
|
|
52
52
|
const authEvent = useAuthEvent();
|
|
53
53
|
const [initiating, setInitiating] = useState(false);
|
|
54
|
+
// popupPending stays true from popup open until auth resolves (success/error/timeout)
|
|
55
|
+
const [popupPending, setPopupPending] = useState(false);
|
|
56
|
+
// calledRef prevents both RTDB and postMessage from firing the callbacks
|
|
57
|
+
const calledRef = useRef(false);
|
|
54
58
|
const onSuccessRef = useRef(options?.onSuccess);
|
|
55
59
|
const onErrorRef = useRef(options?.onError);
|
|
56
60
|
const onSessionSyncedRef = useRef(options?.onSessionSynced);
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}, [options?.
|
|
60
|
-
useEffect(() => {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
61
|
+
const authEventResetRef = useRef(authEvent.reset);
|
|
62
|
+
useEffect(() => { onSuccessRef.current = options?.onSuccess; }, [options?.onSuccess]);
|
|
63
|
+
useEffect(() => { onErrorRef.current = options?.onError; }, [options?.onError]);
|
|
64
|
+
useEffect(() => { onSessionSyncedRef.current = options?.onSessionSynced; }, [options?.onSessionSynced]);
|
|
65
|
+
useEffect(() => { authEventResetRef.current = authEvent.reset; }, [authEvent.reset]);
|
|
66
|
+
// RTDB status watcher — fast path when RTDB is available.
|
|
67
|
+
// FAILED is intentionally not forwarded to onError here: RTDB connection
|
|
68
|
+
// failures (e.g. missing database URL) should fall through to the postMessage
|
|
69
|
+
// fallback so the user still gets a result from the popup.
|
|
66
70
|
useEffect(() => {
|
|
67
71
|
if (authEvent.status === RealtimeEventStatus.SUCCESS) {
|
|
72
|
+
setPopupPending(false);
|
|
73
|
+
if (calledRef.current)
|
|
74
|
+
return;
|
|
75
|
+
calledRef.current = true;
|
|
68
76
|
Promise.resolve(onSessionSyncedRef.current?.()).then(() => {
|
|
69
77
|
onSuccessRef.current?.(authEvent.data);
|
|
70
78
|
});
|
|
71
79
|
}
|
|
72
|
-
else if (authEvent.status === RealtimeEventStatus.
|
|
73
|
-
|
|
74
|
-
|
|
80
|
+
else if (authEvent.status === RealtimeEventStatus.TIMEOUT) {
|
|
81
|
+
// RTDB timed out AND postMessage never arrived (popup likely closed without completing)
|
|
82
|
+
setPopupPending(false);
|
|
83
|
+
if (calledRef.current)
|
|
84
|
+
return;
|
|
85
|
+
calledRef.current = true;
|
|
86
|
+
onErrorRef.current?.(new Error(authEvent.error ?? "Sign-in timed out. Please try again."));
|
|
75
87
|
}
|
|
88
|
+
// FAILED: do not call onError — wait for the postMessage from /auth/close
|
|
76
89
|
}, [authEvent.status, authEvent.error, authEvent.data]);
|
|
90
|
+
// postMessage fallback — fires when /auth/close sends window.opener.postMessage.
|
|
91
|
+
// This covers two cases:
|
|
92
|
+
// 1. RTDB is unavailable (the primary channel failed)
|
|
93
|
+
// 2. RTDB fires FAILED (connection issue) — postMessage still arrives from the popup
|
|
94
|
+
// calledRef guards against double-resolution when both RTDB and postMessage fire.
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
const handleMessage = (event) => {
|
|
97
|
+
if (event.origin !== window.location.origin)
|
|
98
|
+
return;
|
|
99
|
+
if (!event.data || event.data.type !== "letitrip_auth_close")
|
|
100
|
+
return;
|
|
101
|
+
if (calledRef.current)
|
|
102
|
+
return;
|
|
103
|
+
calledRef.current = true;
|
|
104
|
+
setPopupPending(false);
|
|
105
|
+
authEventResetRef.current();
|
|
106
|
+
if (event.data.status === "success") {
|
|
107
|
+
const data = event.data.uid
|
|
108
|
+
? { uid: event.data.uid, role: event.data.role ?? "user", isNewUser: Boolean(event.data.isNewUser) }
|
|
109
|
+
: null;
|
|
110
|
+
Promise.resolve(onSessionSyncedRef.current?.()).then(() => {
|
|
111
|
+
onSuccessRef.current?.(data);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
onErrorRef.current?.(new Error(event.data.error ?? "Sign-in failed. Please try again."));
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
window.addEventListener("message", handleMessage);
|
|
119
|
+
return () => window.removeEventListener("message", handleMessage);
|
|
120
|
+
}, []); // mount once — uses refs, no stale closure risk
|
|
77
121
|
const mutate = useCallback(async () => {
|
|
122
|
+
calledRef.current = false; // reset for each new auth flow
|
|
78
123
|
const popup = window.open(`${window.location.origin}/auth.html`, "oauth_google", "width=500,height=660,left=400,top=100");
|
|
79
124
|
if (!popup) {
|
|
80
125
|
onErrorRef.current?.(new Error("Popup blocked. Please allow popups for this site."));
|
|
81
126
|
return;
|
|
82
127
|
}
|
|
128
|
+
setPopupPending(true);
|
|
83
129
|
try {
|
|
84
130
|
setInitiating(true);
|
|
85
131
|
authEvent.reset();
|
|
86
|
-
const { eventId, customToken } = await apiClient.post(AUTH_ENDPOINTS.EVENT_INIT, {});
|
|
132
|
+
const { eventId, customToken, rtdbEnabled } = await apiClient.post(AUTH_ENDPOINTS.EVENT_INIT, {});
|
|
87
133
|
const url = `${window.location.origin}${AUTH_ENDPOINTS.GOOGLE_START}?eventId=${encodeURIComponent(eventId)}`;
|
|
88
134
|
localStorage.setItem("letitrip_oauth_redirect", url);
|
|
89
|
-
|
|
135
|
+
// Only subscribe to RTDB if it's available — skipping avoids an immediate
|
|
136
|
+
// FAILED status (token sign-in error) that would block the postMessage fallback.
|
|
137
|
+
if (rtdbEnabled !== false) {
|
|
138
|
+
authEvent.subscribe(eventId, customToken);
|
|
139
|
+
}
|
|
90
140
|
}
|
|
91
141
|
catch (err) {
|
|
92
142
|
popup.close();
|
|
143
|
+
setPopupPending(false);
|
|
93
144
|
onErrorRef.current?.(err instanceof Error ? err : new Error("Failed to start sign-in."));
|
|
94
145
|
}
|
|
95
146
|
finally {
|
|
@@ -97,6 +148,7 @@ export function useGoogleLogin(options) {
|
|
|
97
148
|
}
|
|
98
149
|
}, [authEvent]);
|
|
99
150
|
const isLoading = initiating ||
|
|
151
|
+
popupPending ||
|
|
100
152
|
authEvent.status === RealtimeEventStatus.SUBSCRIBING ||
|
|
101
153
|
authEvent.status === RealtimeEventStatus.PENDING;
|
|
102
154
|
return { mutate, isLoading };
|
|
@@ -8,6 +8,7 @@ export declare class UserRepository extends BaseRepository<UserDocument> {
|
|
|
8
8
|
private decryptUser;
|
|
9
9
|
private encryptUserData;
|
|
10
10
|
protected mapDoc<D = UserDocument>(snap: DocumentSnapshot): D;
|
|
11
|
+
createWithId(id: string, data: Partial<UserDocument>): Promise<UserDocument>;
|
|
11
12
|
create(input: Omit<UserDocument, "id" | "createdAt" | "updatedAt">): Promise<UserDocument>;
|
|
12
13
|
findByEmail(email: string): Promise<UserDocument | null>;
|
|
13
14
|
findByPhone(phoneNumber: string): Promise<UserDocument | null>;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { DatabaseError } from "../../../errors";
|
|
2
2
|
import { BaseRepository, getFirestoreCount, prepareForFirestore, } from "../../../providers/db-firebase";
|
|
3
|
-
import { USER_PII_FIELDS,
|
|
3
|
+
import { USER_PII_FIELDS, decryptPayoutDetails, decryptPiiFields, decryptShippingConfig, encryptPayoutDetails, encryptPiiFields, encryptShippingConfig, piiBlindIndex, } from "../../../security";
|
|
4
4
|
import { USER_COLLECTION, USER_FIELDS, createUserId, } from "../schemas";
|
|
5
5
|
export class UserRepository extends BaseRepository {
|
|
6
6
|
constructor() {
|
|
@@ -17,12 +17,12 @@ export class UserRepository extends BaseRepository {
|
|
|
17
17
|
return decrypted;
|
|
18
18
|
}
|
|
19
19
|
encryptUserData(data) {
|
|
20
|
+
// encryptPiiFields encrypts each PII field in-place AND adds the corresponding
|
|
21
|
+
// blind-index sibling (e.g. email → emailIndex) from the plaintext value.
|
|
22
|
+
// addPiiIndices is intentionally NOT called here — it spreads the original
|
|
23
|
+
// plaintext `data` back into the result, which would overwrite the encrypted
|
|
24
|
+
// ciphertext with the original plaintext values, defeating the encryption.
|
|
20
25
|
let encrypted = encryptPiiFields(data, [...USER_PII_FIELDS]);
|
|
21
|
-
encrypted = addPiiIndices(data, USER_PII_INDEX_MAP);
|
|
22
|
-
encrypted = {
|
|
23
|
-
...encryptPiiFields(data, [...USER_PII_FIELDS]),
|
|
24
|
-
...encrypted,
|
|
25
|
-
};
|
|
26
26
|
if (encrypted.payoutDetails) {
|
|
27
27
|
encrypted.payoutDetails =
|
|
28
28
|
encryptPayoutDetails(encrypted.payoutDetails);
|
|
@@ -37,6 +37,10 @@ export class UserRepository extends BaseRepository {
|
|
|
37
37
|
const raw = super.mapDoc(snap);
|
|
38
38
|
return this.decryptUser(raw);
|
|
39
39
|
}
|
|
40
|
+
async createWithId(id, data) {
|
|
41
|
+
const encrypted = this.encryptUserData(data);
|
|
42
|
+
return super.createWithId(id, encrypted);
|
|
43
|
+
}
|
|
40
44
|
async create(input) {
|
|
41
45
|
const firstName = input.displayName?.split(" ")[0] || "user";
|
|
42
46
|
const lastName = input.displayName?.split(" ").slice(1).join(" ") || "account";
|
|
@@ -16,6 +16,7 @@ import { ProductGalleryClient } from "../../products/components/ProductGalleryCl
|
|
|
16
16
|
import { ProductFeatureBadges } from "../../products/components/ProductFeatureBadges";
|
|
17
17
|
import { ShareButton } from "../../products/components/ShareButton";
|
|
18
18
|
import { SublistingCarouselSection } from "../../products/components/SublistingCarouselSection";
|
|
19
|
+
import { ShowGroupSection } from "../../products/components/ShowGroupSection";
|
|
19
20
|
function toDescriptionHtml(raw) {
|
|
20
21
|
if (!raw)
|
|
21
22
|
return "";
|
|
@@ -97,6 +98,9 @@ export async function PreOrderDetailPageView({ id, onReserveNow }) {
|
|
|
97
98
|
: [];
|
|
98
99
|
const descriptionHtml = toDescriptionHtml(p.description);
|
|
99
100
|
const sublistingCategoryId = typeof p.sublistingCategoryId === "string" ? p.sublistingCategoryId : null;
|
|
101
|
+
const groupId = typeof p.groupId === "string" ? p.groupId : null;
|
|
102
|
+
const isGroupParent = p.isGroupParent === true;
|
|
103
|
+
const groupTitle = typeof p.groupTitle === "string" ? p.groupTitle : undefined;
|
|
100
104
|
return (_jsx(Main, { children: _jsxs(Container, { size: "xl", className: "px-4 py-6", children: [_jsxs("div", { className: "mb-4 flex items-center justify-between flex-wrap gap-2", children: [_jsxs("nav", { "aria-label": "Breadcrumb", className: "flex items-center gap-1.5 text-xs text-zinc-500 dark:text-zinc-400 flex-wrap", children: [_jsx(Link, { href: String(ROUTES.HOME), className: "hover:text-primary-600 transition-colors", children: "Home" }), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.PRE_ORDERS), className: "hover:text-primary-600 transition-colors", children: "Pre-Orders" }), category && (_jsxs(_Fragment, { children: [_jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.CATEGORY_DETAIL(category)), className: "hover:text-primary-600 transition-colors", children: categoryName || category })] })), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Span, { className: "text-zinc-700 dark:text-zinc-300 truncate max-w-[200px]", children: title })] }), _jsx(ShareButton, { title: title })] }), _jsx(PreOrderDetailView, { renderGallery: () => (_jsx(ProductGalleryClient, { images: images, productName: title })), renderInfo: () => (_jsxs(Stack, { gap: "sm", children: [_jsxs(Div, { children: [_jsxs(Row, { gap: "xs", className: "mb-1.5 flex-wrap", children: [_jsx(Span, { className: "inline-block rounded-full bg-indigo-100 dark:bg-indigo-900/30 px-2.5 py-0.5 text-xs font-semibold text-indigo-700 dark:text-indigo-300", children: "Pre-Order" }), productionStatus && (_jsx(Span, { className: "inline-block rounded-full bg-zinc-100 dark:bg-zinc-800 px-2.5 py-0.5 text-xs font-medium text-zinc-600 dark:text-zinc-300", children: PRODUCTION_STATUS_LABELS[productionStatus] ?? productionStatus }))] }), _jsx(Heading, { level: 1, className: "text-xl font-bold leading-snug text-zinc-900 dark:text-zinc-50 sm:text-2xl", children: title })] }), price !== null && (_jsx(Span, { className: "text-2xl font-bold text-zinc-900 dark:text-zinc-50", children: formatCurrency(price, currency) })), deliveryDate && (_jsxs(Row, { align: "center", gap: "xs", className: "text-sm text-zinc-600 dark:text-zinc-400", children: [_jsx(Span, { children: "\uD83D\uDCC5" }), _jsx(Span, { children: "Estimated delivery:" }), _jsx(Span, { className: "font-medium", children: deliveryDate.toLocaleDateString(undefined, { year: "numeric", month: "long" }) })] })), _jsx(ProductFeatureBadges, { featured: featured, freeShipping: freeShipping, condition: condition ?? undefined, returnable: isCancellable, labels: {
|
|
101
105
|
featured: "Featured",
|
|
102
106
|
fasterDelivery: "Faster Delivery",
|
|
@@ -113,6 +117,8 @@ export async function PreOrderDetailPageView({ id, onReserveNow }) {
|
|
|
113
117
|
categoryProductCount: (n, cat) => `${n} in ${cat}`,
|
|
114
118
|
} }), (categoryName || category || brand) && (_jsxs(Row, { align: "center", gap: "xs", className: "text-xs text-zinc-400 dark:text-zinc-500 flex-wrap", children: [category ? (_jsx(Link, { href: String(ROUTES.PUBLIC.CATEGORY_DETAIL(category)), className: "hover:text-primary-600 dark:hover:text-primary-400 transition-colors", children: categoryName || category })) : (categoryName ? _jsx(Span, { children: categoryName }) : null), brand && (categoryName || category) && (_jsx(Span, { className: "text-zinc-300 dark:text-zinc-600", children: "\u00B7" })), brand && (brandSlug ? (_jsx(Link, { href: String(ROUTES.PUBLIC.BRAND_DETAIL(brandSlug)), className: "hover:text-primary-600 dark:hover:text-primary-400 transition-colors", children: brand })) : (_jsx(Span, { children: brand })))] })), features.length > 0 && (_jsxs(Div, { className: "rounded-xl border border-zinc-100 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-900/60 px-4 py-3", children: [_jsx(Text, { className: "mb-2 text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400", children: "About this product" }), _jsx("ul", { className: "space-y-1.5", children: features.map((f, i) => (_jsxs("li", { className: "flex items-start gap-2 text-sm text-zinc-700 dark:text-zinc-300", children: [_jsx(Span, { className: "mt-0.5 flex-shrink-0 text-primary-500", children: "\u2022" }), f] }, i))) })] })), descriptionHtml && (_jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm max-w-none dark:prose-invert prose-p:my-0", className: "text-sm leading-relaxed text-zinc-600 dark:text-zinc-400 line-clamp-4" })), safeSeller && (_jsx(Div, { className: "rounded-xl border border-zinc-100 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-900/60 p-3", children: _jsxs(Row, { justify: "between", align: "center", children: [_jsxs(Div, { children: [_jsx(Text, { className: "text-[10px] uppercase tracking-wide text-zinc-400 dark:text-zinc-500 mb-0.5", children: "Sold by" }), _jsx(Text, { className: "text-sm font-semibold text-zinc-800 dark:text-zinc-200", children: safeSeller })] }), storeHref && (_jsx(Link, { href: storeHref, className: "shrink-0 rounded-lg bg-primary/10 dark:bg-primary/20 px-3 py-1.5 text-xs font-semibold text-primary-700 dark:text-primary-300 hover:bg-primary/20 dark:hover:bg-primary/30 transition-colors", children: "Visit Store \u2192" }))] }) }))] })), renderSublistingSection: sublistingCategoryId
|
|
115
119
|
? () => (_jsx(SublistingCarouselSection, { sublistingCategoryId: sublistingCategoryId, currentListingId: String(product.id) }))
|
|
120
|
+
: undefined, renderGroupSection: groupId
|
|
121
|
+
? () => (_jsx(ShowGroupSection, { groupId: groupId, currentSlug: String(p.slug ?? product.id), isParent: isGroupParent, groupTitle: groupTitle }))
|
|
116
122
|
: undefined, renderTabs: () => (_jsx(ProductTabsShell, { descriptionContent: descriptionHtml ? (_jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm sm:prose max-w-none dark:prose-invert", className: "text-zinc-700 dark:text-zinc-300" })) : undefined, specsContent: specs.length > 0 ? (_jsx("dl", { className: "divide-y divide-zinc-100 dark:divide-zinc-800 rounded-xl border border-zinc-100 dark:border-zinc-800 overflow-hidden text-sm", children: specs.map((s, i) => (_jsxs("div", { className: "flex gap-4 px-4 py-3 bg-white dark:bg-zinc-900 even:bg-zinc-50 dark:even:bg-zinc-800/50", children: [_jsx("dt", { className: "w-36 flex-shrink-0 font-medium text-zinc-700 dark:text-zinc-300", children: s.name }), _jsxs("dd", { className: "flex-1 text-zinc-600 dark:text-zinc-400", children: [s.value, s.unit ? ` ${s.unit}` : ""] })] }, i))) })) : undefined, customTabs: customSections.map((s) => ({
|
|
117
123
|
id: s.id,
|
|
118
124
|
label: s.title,
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface GroupSettingsPanelProps {
|
|
2
|
+
productId: string;
|
|
3
|
+
productSlug: string;
|
|
4
|
+
groupId?: string;
|
|
5
|
+
isGroupParent?: boolean;
|
|
6
|
+
groupParentSlug?: string;
|
|
7
|
+
groupChildSlugs?: string[];
|
|
8
|
+
groupTitle?: string;
|
|
9
|
+
isAuction?: boolean;
|
|
10
|
+
storeProductsEndpoint: string;
|
|
11
|
+
onGroupChanged: () => void;
|
|
12
|
+
}
|
|
13
|
+
export declare function GroupSettingsPanel({ productId, productSlug, groupId, isGroupParent, groupParentSlug, groupChildSlugs, groupTitle, isAuction, storeProductsEndpoint, onGroupChanged, }: GroupSettingsPanelProps): import("react/jsx-runtime").JSX.Element | null;
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { Button, Div, FormField, Heading, Modal, Row, SideDrawer, Stack, Text, Tabs, TabsContent, TabsList, TabsTrigger, DynamicSelect, useToast, } from "../../../ui";
|
|
5
|
+
import { apiClient } from "../../../http";
|
|
6
|
+
const CONDITION_OPTIONS = [
|
|
7
|
+
{ value: "new", label: "New" },
|
|
8
|
+
{ value: "used", label: "Used" },
|
|
9
|
+
{ value: "like_new", label: "Like New" },
|
|
10
|
+
{ value: "graded", label: "Graded" },
|
|
11
|
+
{ value: "refurbished", label: "Refurbished" },
|
|
12
|
+
];
|
|
13
|
+
export function GroupSettingsPanel({ productId, productSlug, groupId, isGroupParent, groupParentSlug, groupChildSlugs, groupTitle, isAuction, storeProductsEndpoint, onGroupChanged, }) {
|
|
14
|
+
const { showToast } = useToast();
|
|
15
|
+
const [open, setOpen] = useState(false);
|
|
16
|
+
const [loading, setLoading] = useState(false);
|
|
17
|
+
const [editTitle, setEditTitle] = useState(groupTitle ?? "");
|
|
18
|
+
const [showAddModal, setShowAddModal] = useState(false);
|
|
19
|
+
const [addTab, setAddTab] = useState("create");
|
|
20
|
+
const [createForm, setCreateForm] = useState({
|
|
21
|
+
title: "",
|
|
22
|
+
price: "",
|
|
23
|
+
condition: "new",
|
|
24
|
+
});
|
|
25
|
+
const [linkTarget, setLinkTarget] = useState(null);
|
|
26
|
+
const [children, setChildren] = useState(null);
|
|
27
|
+
if (isAuction)
|
|
28
|
+
return null;
|
|
29
|
+
const groupEndpoint = `${storeProductsEndpoint}/${productId}/group`;
|
|
30
|
+
const childrenEndpoint = `${groupEndpoint}/children`;
|
|
31
|
+
async function loadChildren() {
|
|
32
|
+
if (!groupId || !isGroupParent)
|
|
33
|
+
return;
|
|
34
|
+
setLoading(true);
|
|
35
|
+
try {
|
|
36
|
+
const res = (await apiClient.get(`/api/products/group/${encodeURIComponent(groupId)}`));
|
|
37
|
+
setChildren(res.data?.items ?? []);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
setChildren([]);
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
setLoading(false);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
async function startGroup() {
|
|
47
|
+
setLoading(true);
|
|
48
|
+
try {
|
|
49
|
+
await apiClient.post(groupEndpoint, { slug: productSlug });
|
|
50
|
+
showToast("Group started. You are now the parent listing.", "success");
|
|
51
|
+
onGroupChanged();
|
|
52
|
+
}
|
|
53
|
+
catch (e) {
|
|
54
|
+
showToast(e?.message ?? "Failed to start group.", "error");
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
setLoading(false);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
async function saveTitle() {
|
|
61
|
+
setLoading(true);
|
|
62
|
+
try {
|
|
63
|
+
await apiClient.patch(groupEndpoint, { groupTitle: editTitle });
|
|
64
|
+
showToast("Group title saved.", "success");
|
|
65
|
+
onGroupChanged();
|
|
66
|
+
}
|
|
67
|
+
catch (e) {
|
|
68
|
+
showToast(e?.message ?? "Failed to save title.", "error");
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
setLoading(false);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
async function dissolveGroup() {
|
|
75
|
+
if (!confirm("Dissolve this group? All members will be unlinked. This cannot be undone."))
|
|
76
|
+
return;
|
|
77
|
+
setLoading(true);
|
|
78
|
+
try {
|
|
79
|
+
await apiClient.delete(groupEndpoint);
|
|
80
|
+
showToast("Group dissolved.", "success");
|
|
81
|
+
onGroupChanged();
|
|
82
|
+
}
|
|
83
|
+
catch (e) {
|
|
84
|
+
showToast(e?.message ?? "Failed to dissolve group.", "error");
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
setLoading(false);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
async function unlinkChild(childId) {
|
|
91
|
+
if (!confirm("Remove this listing from the group?"))
|
|
92
|
+
return;
|
|
93
|
+
setLoading(true);
|
|
94
|
+
try {
|
|
95
|
+
await apiClient.delete(`${childrenEndpoint}/${childId}`);
|
|
96
|
+
showToast("Listing removed from group.", "success");
|
|
97
|
+
setChildren((prev) => prev?.filter((c) => c.id !== childId) ?? null);
|
|
98
|
+
onGroupChanged();
|
|
99
|
+
}
|
|
100
|
+
catch (e) {
|
|
101
|
+
showToast(e?.message ?? "Failed to unlink.", "error");
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
setLoading(false);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
async function addCreateChild() {
|
|
108
|
+
if (!createForm.title || !createForm.price) {
|
|
109
|
+
showToast("Title and price are required.", "error");
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
setLoading(true);
|
|
113
|
+
try {
|
|
114
|
+
await apiClient.post(childrenEndpoint, {
|
|
115
|
+
mode: "create",
|
|
116
|
+
title: createForm.title,
|
|
117
|
+
price: Math.round(parseFloat(createForm.price) * 100),
|
|
118
|
+
condition: createForm.condition,
|
|
119
|
+
parentId: productId,
|
|
120
|
+
});
|
|
121
|
+
showToast("Child listing created and linked.", "success");
|
|
122
|
+
setShowAddModal(false);
|
|
123
|
+
setCreateForm({ title: "", price: "", condition: "new" });
|
|
124
|
+
loadChildren();
|
|
125
|
+
onGroupChanged();
|
|
126
|
+
}
|
|
127
|
+
catch (e) {
|
|
128
|
+
showToast(e?.message ?? "Failed to create child.", "error");
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
setLoading(false);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
async function addLinkChild() {
|
|
135
|
+
if (!linkTarget) {
|
|
136
|
+
showToast("Select a listing to link.", "error");
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
setLoading(true);
|
|
140
|
+
try {
|
|
141
|
+
await apiClient.post(childrenEndpoint, { mode: "link", childId: linkTarget, parentId: productId });
|
|
142
|
+
showToast("Listing linked to group.", "success");
|
|
143
|
+
setShowAddModal(false);
|
|
144
|
+
setLinkTarget(null);
|
|
145
|
+
loadChildren();
|
|
146
|
+
onGroupChanged();
|
|
147
|
+
}
|
|
148
|
+
catch (e) {
|
|
149
|
+
showToast(e?.message ?? "Failed to link listing.", "error");
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
setLoading(false);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
async function leaveGroup() {
|
|
156
|
+
if (!confirm("Leave this group? This listing will become standalone."))
|
|
157
|
+
return;
|
|
158
|
+
setLoading(true);
|
|
159
|
+
try {
|
|
160
|
+
await apiClient.delete(`${storeProductsEndpoint}/${productId}/group/leave`);
|
|
161
|
+
showToast("Left the group.", "success");
|
|
162
|
+
onGroupChanged();
|
|
163
|
+
}
|
|
164
|
+
catch (e) {
|
|
165
|
+
showToast(e?.message ?? "Failed to leave group.", "error");
|
|
166
|
+
}
|
|
167
|
+
finally {
|
|
168
|
+
setLoading(false);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
async function loadLinkOptions(query, page) {
|
|
172
|
+
const params = new URLSearchParams({ page: String(page), pageSize: "25" });
|
|
173
|
+
if (query)
|
|
174
|
+
params.set("q", query);
|
|
175
|
+
params.set("filters", "isAuction==false");
|
|
176
|
+
const res = (await apiClient.get(`${storeProductsEndpoint}?${params.toString()}`));
|
|
177
|
+
const items = (res.products ?? res.items ?? []).map((p) => ({ value: p.id, label: p.title }));
|
|
178
|
+
return { items, hasMore: false };
|
|
179
|
+
}
|
|
180
|
+
const childSlugsCount = groupChildSlugs?.length ?? 0;
|
|
181
|
+
return (_jsxs(Div, { children: [_jsxs("button", { type: "button", onClick: () => {
|
|
182
|
+
const next = !open;
|
|
183
|
+
setOpen(next);
|
|
184
|
+
if (next && isGroupParent && children === null)
|
|
185
|
+
loadChildren();
|
|
186
|
+
}, className: "w-full flex items-center justify-between py-2 text-left group", "aria-expanded": open, children: [_jsx(Heading, { level: 3, className: "text-sm font-semibold text-zinc-700 dark:text-zinc-300", children: "Group Settings" }), _jsx("span", { className: "text-xs text-zinc-400 dark:text-zinc-500 group-hover:text-zinc-600 dark:group-hover:text-zinc-300 transition-colors", children: open ? "▲" : "▼" })] }), open && (_jsxs(Stack, { gap: "sm", className: "mt-3 p-4 rounded-lg border border-zinc-200 dark:border-zinc-700 bg-zinc-50/50 dark:bg-zinc-800/30", children: [!groupId && !groupParentSlug && (_jsxs(Stack, { gap: "xs", children: [_jsx(Text, { className: "text-xs text-zinc-500 dark:text-zinc-400", children: "Group related listings together \u2014 e.g. a set, bundle, or multi-part item. Parts can be sold individually but shown together." }), _jsx(Button, { type: "button", variant: "secondary", size: "sm", onClick: startGroup, isLoading: loading, children: "Start a group" })] })), isGroupParent && groupId && (_jsxs(Stack, { gap: "md", children: [_jsxs(Row, { align: "start", gap: "sm", className: "flex-wrap", children: [_jsx("div", { className: "flex-1 min-w-[200px]", children: _jsx(FormField, { name: "groupTitle", label: "Group title", type: "text", value: editTitle, onChange: setEditTitle, placeholder: "e.g. Human Toy Complete Set" }) }), _jsx("div", { className: "pt-6", children: _jsx(Button, { type: "button", variant: "secondary", size: "sm", onClick: saveTitle, isLoading: loading, children: "Save title" }) })] }), _jsxs(Div, { children: [_jsxs(Text, { className: "text-xs font-semibold text-zinc-600 dark:text-zinc-400 mb-2", children: ["Members (", childSlugsCount + 1, " \u2014 including this listing)"] }), loading && !children ? (_jsx(Text, { className: "text-xs text-zinc-400", children: "Loading\u2026" })) : (_jsxs("div", { className: "divide-y divide-zinc-100 dark:divide-zinc-800", children: [_jsxs(Row, { align: "center", gap: "sm", className: "py-2", children: [_jsx("span", { className: "rounded bg-[var(--appkit-color-primary,#6366f1)]/10 text-[var(--appkit-color-primary,#6366f1)] text-[10px] font-semibold px-1.5 py-0.5", children: "Parent" }), _jsx(Text, { className: "text-sm text-zinc-800 dark:text-zinc-200 flex-1", children: productSlug })] }), (children ?? []).filter((c) => c.id !== productId).map((child) => (_jsxs(Row, { align: "center", gap: "sm", className: "py-2", children: [child.images?.[0] ? (
|
|
187
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
188
|
+
_jsx("img", { src: child.images[0], alt: child.title, className: "w-8 h-8 rounded-full object-cover border border-zinc-200 dark:border-zinc-700" })) : (_jsx("div", { className: "w-8 h-8 rounded-full bg-zinc-100 dark:bg-zinc-800" })), _jsx(Text, { className: "text-sm text-zinc-800 dark:text-zinc-200 flex-1 truncate", children: child.title }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => unlinkChild(child.id), isLoading: loading, className: "text-red-500 hover:text-red-600 text-xs", children: "Unlink" })] }, child.id)))] }))] }), _jsxs(Row, { gap: "sm", className: "flex-wrap", children: [_jsx(Button, { type: "button", variant: "secondary", size: "sm", onClick: () => setShowAddModal(true), children: "Add child listing" }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: dissolveGroup, isLoading: loading, className: "text-red-500 hover:text-red-600", children: "Dissolve group" })] })] })), !isGroupParent && groupParentSlug && (_jsxs(Stack, { gap: "xs", children: [_jsxs(Text, { className: "text-sm text-zinc-600 dark:text-zinc-400", children: ["Part of:", " ", _jsx("span", { className: "font-medium text-zinc-800 dark:text-zinc-200", children: groupTitle ?? groupParentSlug })] }), _jsxs(Text, { className: "text-xs text-zinc-500 dark:text-zinc-400", children: ["Parent listing: ", _jsx("code", { className: "font-mono", children: groupParentSlug })] }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: leaveGroup, isLoading: loading, className: "text-red-500 hover:text-red-600 w-fit", children: "Leave group" })] }))] })), (groupChildSlugs?.length ?? 0) >= 4 ? (_jsx(SideDrawer, { isOpen: showAddModal, onClose: () => setShowAddModal(false), title: "Add child listing", children: _jsx(AddChildContent, { addTab: addTab, setAddTab: setAddTab, createForm: createForm, setCreateForm: setCreateForm, productSlug: productSlug, linkTarget: linkTarget, setLinkTarget: setLinkTarget, loadLinkOptions: loadLinkOptions, onAddCreate: addCreateChild, onAddLink: addLinkChild, loading: loading }) })) : (_jsx(Modal, { open: showAddModal, onClose: () => setShowAddModal(false), title: "Add child listing", size: "lg", children: _jsx(AddChildContent, { addTab: addTab, setAddTab: setAddTab, createForm: createForm, setCreateForm: setCreateForm, productSlug: productSlug, linkTarget: linkTarget, setLinkTarget: setLinkTarget, loadLinkOptions: loadLinkOptions, onAddCreate: addCreateChild, onAddLink: addLinkChild, loading: loading }) }))] }));
|
|
189
|
+
}
|
|
190
|
+
function AddChildContent({ addTab, setAddTab, createForm, setCreateForm, productSlug, linkTarget, setLinkTarget, loadLinkOptions, onAddCreate, onAddLink, loading, }) {
|
|
191
|
+
return (_jsx(Stack, { gap: "md", children: _jsxs(Tabs, { value: addTab, onChange: (v) => setAddTab(v), children: [_jsxs(TabsList, { children: [_jsx(TabsTrigger, { value: "create", children: "Create new child" }), _jsx(TabsTrigger, { value: "link", children: "Link existing" })] }), _jsx(TabsContent, { value: "create", children: _jsxs(Stack, { gap: "sm", className: "mt-4", children: [_jsx(FormField, { name: "childTitle", label: "Title *", type: "text", value: createForm.title, onChange: (v) => setCreateForm({ ...createForm, title: v }), placeholder: `${productSlug}-part` }), _jsx(FormField, { name: "childPrice", label: "Price (\u20B9) *", type: "number", value: createForm.price, onChange: (v) => setCreateForm({ ...createForm, price: v }), placeholder: "0" }), _jsxs("div", { children: [_jsx(Text, { className: "text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1", children: "Condition" }), _jsx("select", { value: createForm.condition, onChange: (e) => setCreateForm({ ...createForm, condition: e.target.value }), className: "w-full rounded-md border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-800 dark:text-zinc-200 focus:outline-none focus:ring-2 focus:ring-[var(--appkit-color-primary,#6366f1)]", children: CONDITION_OPTIONS.map((o) => (_jsx("option", { value: o.value, children: o.label }, o.value))) })] }), _jsx(Text, { className: "text-xs text-zinc-400 dark:text-zinc-500", children: "Other fields (category, brand, shipping, return policy) are inherited from this parent listing. Need more control? Edit the full listing after saving." }), _jsx(Button, { type: "button", onClick: onAddCreate, isLoading: loading, disabled: !createForm.title || !createForm.price, children: "Create and link child" })] }) }), _jsx(TabsContent, { value: "link", children: _jsxs(Stack, { gap: "sm", className: "mt-4", children: [_jsx(Text, { className: "text-xs text-zinc-500 dark:text-zinc-400", children: "Search your existing products or pre-orders. Auctions cannot be linked." }), _jsx(DynamicSelect, { value: linkTarget, onChange: (v) => setLinkTarget(v), loadOptions: loadLinkOptions, placeholder: "Search listings\u2026", searchPlaceholder: "Type title or slug\u2026", noResultsText: "No matching listings found", ariaLabel: "Listing to link" }), _jsx(Button, { type: "button", onClick: onAddLink, isLoading: loading, disabled: !linkTarget, children: "Link to group" })] }) })] }) }));
|
|
192
|
+
}
|
|
@@ -10,7 +10,9 @@ export interface PreOrderDetailViewProps extends Omit<DetailViewShellProps, "mai
|
|
|
10
10
|
renderBuyBar?: () => React.ReactNode;
|
|
11
11
|
/** Rendered between the main grid and the below-fold tabs (e.g. sub-listing carousel). */
|
|
12
12
|
renderSublistingSection?: () => React.ReactNode;
|
|
13
|
+
/** Rendered below the sub-listing section and above the tabs (e.g. group carousel). */
|
|
14
|
+
renderGroupSection?: () => React.ReactNode;
|
|
13
15
|
renderTabs?: () => React.ReactNode;
|
|
14
16
|
renderRelated?: () => React.ReactNode;
|
|
15
17
|
}
|
|
16
|
-
export declare function PreOrderDetailView({ renderGallery, renderInfo, renderBuyBar, renderSublistingSection, renderTabs, renderRelated, isLoading, ...rest }: PreOrderDetailViewProps): import("react/jsx-runtime").JSX.Element;
|
|
18
|
+
export declare function PreOrderDetailView({ renderGallery, renderInfo, renderBuyBar, renderSublistingSection, renderGroupSection, renderTabs, renderRelated, isLoading, ...rest }: PreOrderDetailViewProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import React from "react";
|
|
3
3
|
import { DetailViewShell } from "../../../ui";
|
|
4
|
-
export function PreOrderDetailView({ renderGallery, renderInfo, renderBuyBar, renderSublistingSection, renderTabs, renderRelated, isLoading = false, ...rest }) {
|
|
4
|
+
export function PreOrderDetailView({ renderGallery, renderInfo, renderBuyBar, renderSublistingSection, renderGroupSection, renderTabs, renderRelated, isLoading = false, ...rest }) {
|
|
5
|
+
const sublistingNode = renderSublistingSection?.();
|
|
6
|
+
const groupNode = renderGroupSection?.();
|
|
7
|
+
const afterMainNode = sublistingNode || groupNode ? (_jsxs(React.Fragment, { children: [sublistingNode, groupNode] })) : undefined;
|
|
5
8
|
return (_jsx(DetailViewShell, { portal: "public", ...rest, layout: "grid-2", isLoading: isLoading, mainSlots: [
|
|
6
9
|
renderGallery?.(isLoading),
|
|
7
10
|
_jsxs(React.Fragment, { children: [renderInfo?.(isLoading), renderBuyBar?.()] }, "info"),
|
|
8
|
-
], afterMain:
|
|
11
|
+
], afterMain: afterMainNode, belowFold: [renderTabs?.(), renderRelated?.()] }));
|
|
9
12
|
}
|
|
@@ -17,6 +17,7 @@ import { BuyBar } from "./BuyBar";
|
|
|
17
17
|
import { ShareButton } from "./ShareButton";
|
|
18
18
|
import { CustomSectionTabContent } from "./CustomSectionTabContent";
|
|
19
19
|
import { SublistingCarouselSection } from "./SublistingCarouselSection";
|
|
20
|
+
import { ShowGroupSection } from "./ShowGroupSection";
|
|
20
21
|
// ---------------------------------------------------------------------------
|
|
21
22
|
// Helpers
|
|
22
23
|
// ---------------------------------------------------------------------------
|
|
@@ -157,6 +158,9 @@ export async function ProductDetailPageView({ slug, renderOfferAction, }) {
|
|
|
157
158
|
: null;
|
|
158
159
|
const descriptionHtml = toDescriptionHtml(p.description);
|
|
159
160
|
const sublistingCategoryId = typeof p.sublistingCategoryId === "string" ? p.sublistingCategoryId : null;
|
|
161
|
+
const groupId = typeof p.groupId === "string" ? p.groupId : null;
|
|
162
|
+
const isGroupParent = p.isGroupParent === true;
|
|
163
|
+
const groupTitle = typeof p.groupTitle === "string" ? p.groupTitle : undefined;
|
|
160
164
|
// -- Fetch reviews + related in parallel ------------------------------------
|
|
161
165
|
const [reviewDocs, relatedDocs] = await Promise.all([
|
|
162
166
|
reviewRepository
|
|
@@ -214,6 +218,8 @@ export async function ProductDetailPageView({ slug, renderOfferAction, }) {
|
|
|
214
218
|
{ icon: "⭐", label: "Quality\nGuarantee" },
|
|
215
219
|
].map(({ icon, label }) => (_jsxs(Div, { className: "flex flex-col items-center gap-1 text-xs text-zinc-500 dark:text-zinc-400 min-w-[60px]", children: [_jsx(Span, { className: "text-base", children: icon }), _jsx(Span, { className: "whitespace-pre-line leading-tight", children: label })] }, label))) }) })] })), renderSublistingSection: sublistingCategoryId
|
|
216
220
|
? () => (_jsx(SublistingCarouselSection, { sublistingCategoryId: sublistingCategoryId, currentListingId: String(product.id) }))
|
|
221
|
+
: undefined, renderGroupSection: groupId
|
|
222
|
+
? () => (_jsx(ShowGroupSection, { groupId: groupId, currentSlug: String(p.slug ?? product.id), isParent: isGroupParent, groupTitle: groupTitle }))
|
|
217
223
|
: undefined, renderTabs: () => (_jsx(ProductTabsShell, { descriptionContent: descriptionHtml ? (_jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm sm:prose max-w-none dark:prose-invert", className: "text-zinc-700 dark:text-zinc-300" })) : undefined, specsContent: specs.length > 0 ? (_jsx("dl", { className: "divide-y divide-zinc-100 dark:divide-zinc-800 rounded-xl border border-zinc-100 dark:border-zinc-800 overflow-hidden text-sm", children: specs.map((s, i) => (_jsxs("div", { className: "flex gap-4 px-4 py-3 bg-white dark:bg-zinc-900 even:bg-zinc-50 dark:even:bg-zinc-800/50", children: [_jsx("dt", { className: "w-36 flex-shrink-0 font-medium text-zinc-700 dark:text-zinc-300", children: s.name }), _jsxs("dd", { className: "flex-1 text-zinc-600 dark:text-zinc-400", children: [s.value, s.unit ? ` ${s.unit}` : ""] })] }, i))) })) : undefined, ingredientsContent: ingredients.length > 0 ? (_jsx("ul", { className: "space-y-2", children: ingredients.map((item, i) => (_jsxs("li", { className: "flex items-start gap-2 text-sm text-zinc-700 dark:text-zinc-300", children: [_jsx(Span, { className: "mt-1 flex-shrink-0 h-1.5 w-1.5 rounded-full bg-primary-400" }), item] }, i))) })) : undefined, howToUseContent: howToUse.length > 0 ? (_jsx("ol", { className: "space-y-3", children: howToUse.map((step, i) => (_jsxs("li", { className: "flex items-start gap-3 text-sm text-zinc-700 dark:text-zinc-300", children: [_jsx(Span, { className: "flex-shrink-0 flex h-6 w-6 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900/30 text-xs font-bold text-primary-700 dark:text-primary-300", children: i + 1 }), step] }, i))) })) : undefined, reviewsContent: _jsx(ReviewsList, { reviews: reviews, emptyLabel: "No reviews yet \u2014 be the first to review this product." }), customTabs: customSections.map((s) => ({
|
|
218
224
|
id: s.id,
|
|
219
225
|
label: s.title,
|
|
@@ -10,6 +10,8 @@ export interface ProductDetailViewProps extends Omit<DetailViewShellProps, "main
|
|
|
10
10
|
renderActions?: () => React.ReactNode;
|
|
11
11
|
/** Rendered between the main grid and the below-fold tabs (e.g. sub-listing carousel). */
|
|
12
12
|
renderSublistingSection?: () => React.ReactNode;
|
|
13
|
+
/** Rendered below the sub-listing section and above the tabs (e.g. group carousel). */
|
|
14
|
+
renderGroupSection?: () => React.ReactNode;
|
|
13
15
|
renderTabs?: () => React.ReactNode;
|
|
14
16
|
renderRelated?: () => React.ReactNode;
|
|
15
17
|
/**
|
|
@@ -23,4 +25,4 @@ export interface ProductDetailViewProps extends Omit<DetailViewShellProps, "main
|
|
|
23
25
|
*/
|
|
24
26
|
stickyRailOffset?: string;
|
|
25
27
|
}
|
|
26
|
-
export declare function ProductDetailView({ renderGallery, renderInfo, renderActions, renderSublistingSection, renderTabs, renderRelated, isLoading, stickyActionRail, stickyRailOffset, ...rest }: ProductDetailViewProps): import("react/jsx-runtime").JSX.Element;
|
|
28
|
+
export declare function ProductDetailView({ renderGallery, renderInfo, renderActions, renderSublistingSection, renderGroupSection, renderTabs, renderRelated, isLoading, stickyActionRail, stickyRailOffset, ...rest }: ProductDetailViewProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -1,9 +1,13 @@
|
|
|
1
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import React from "react";
|
|
2
3
|
import { DetailViewShell } from "../../../ui";
|
|
3
|
-
export function ProductDetailView({ renderGallery, renderInfo, renderActions, renderSublistingSection, renderTabs, renderRelated, isLoading = false, stickyActionRail = true, stickyRailOffset = "top-20", ...rest }) {
|
|
4
|
+
export function ProductDetailView({ renderGallery, renderInfo, renderActions, renderSublistingSection, renderGroupSection, renderTabs, renderRelated, isLoading = false, stickyActionRail = true, stickyRailOffset = "top-20", ...rest }) {
|
|
5
|
+
const sublistingNode = renderSublistingSection?.();
|
|
6
|
+
const groupNode = renderGroupSection?.();
|
|
7
|
+
const afterMainNode = sublistingNode || groupNode ? (_jsxs(React.Fragment, { children: [sublistingNode, groupNode] })) : undefined;
|
|
4
8
|
return (_jsx(DetailViewShell, { portal: "public", ...rest, layout: "grid-3", isLoading: isLoading, stickyActionRail: stickyActionRail, stickyRailOffset: stickyRailOffset, mainSlots: [
|
|
5
9
|
renderGallery?.(isLoading),
|
|
6
10
|
renderInfo?.(isLoading),
|
|
7
11
|
renderActions?.(),
|
|
8
|
-
], afterMain:
|
|
12
|
+
], afterMain: afterMainNode, belowFold: [renderTabs?.(), renderRelated?.()] }));
|
|
9
13
|
}
|
|
@@ -60,6 +60,11 @@ export interface ProductFormProps {
|
|
|
60
60
|
onMediaAbort?: (stagedUrls: string[]) => void | Promise<void>;
|
|
61
61
|
/** Currency prefix for numeric money inputs (e.g. "₹", "$", "€"). */
|
|
62
62
|
currencyPrefix?: string;
|
|
63
|
+
/**
|
|
64
|
+
* Render a Group Settings panel (GP2). Only passed when editing an existing
|
|
65
|
+
* non-auction product. Returns null/undefined to omit.
|
|
66
|
+
*/
|
|
67
|
+
renderGroupSettings?: (product: ProductFormValue) => React.ReactNode;
|
|
63
68
|
}
|
|
64
|
-
export declare function ProductForm({ product, onChange, isReadonly, renderDescriptionEditor, renderCategorySelector, renderBrandSelector, renderStoreAddressSelector, onMediaAbort, currencyPrefix, }: ProductFormProps): import("react/jsx-runtime").JSX.Element;
|
|
69
|
+
export declare function ProductForm({ product, onChange, isReadonly, renderDescriptionEditor, renderCategorySelector, renderBrandSelector, renderStoreAddressSelector, onMediaAbort, currencyPrefix, renderGroupSettings, }: ProductFormProps): import("react/jsx-runtime").JSX.Element;
|
|
65
70
|
export {};
|
|
@@ -16,7 +16,7 @@ export const PRODUCT_STATUS_OPTIONS = [
|
|
|
16
16
|
{ value: "discontinued", label: "Discontinued" },
|
|
17
17
|
{ value: "sold", label: "Sold" },
|
|
18
18
|
];
|
|
19
|
-
export function ProductForm({ product, onChange, isReadonly = false, renderDescriptionEditor, renderCategorySelector, renderBrandSelector, renderStoreAddressSelector, onMediaAbort, currencyPrefix = "", }) {
|
|
19
|
+
export function ProductForm({ product, onChange, isReadonly = false, renderDescriptionEditor, renderCategorySelector, renderBrandSelector, renderStoreAddressSelector, onMediaAbort, currencyPrefix = "", renderGroupSettings, }) {
|
|
20
20
|
const t = useTranslations("adminProducts");
|
|
21
21
|
const { upload } = useMediaUpload();
|
|
22
22
|
const galleryIndexRef = useRef(0);
|
|
@@ -137,5 +137,5 @@ export function ProductForm({ product, onChange, isReadonly = false, renderDescr
|
|
|
137
137
|
value: product.pickupAddressId || "",
|
|
138
138
|
onChange: (value) => update({ pickupAddressId: value }),
|
|
139
139
|
disabled: isReadonly,
|
|
140
|
-
}) })) : (_jsx(FormField, { name: "pickupAddressId", label: t("formPickupAddress"), type: "text", value: product.pickupAddressId || "", onChange: (value) => update({ pickupAddressId: value }), disabled: isReadonly })), _jsx(SublistingCategorySelect, { value: product.sublistingCategoryId ?? "", onChange: (id) => update({ sublistingCategoryId: id || undefined }), disabled: isReadonly }), _jsxs(Div, { children: [_jsx(Heading, { level: 3, className: "mb-1 text-sm font-semibold text-zinc-700 dark:text-zinc-300", children: "Custom Sections" }), _jsx(Text, { className: "mb-3 text-xs text-zinc-500 dark:text-zinc-400", children: "Add up to 3 custom tabs to your product page \u2014 e.g. \"Box Contents\", \"Compatibility\", \"Grading Details\"." }), _jsx(CustomSectionsEditor, { sections: product.customSections ?? [], onChange: (sections) => update({ customSections: sections }) })] }), _jsx(FormField, { name: "shippingInfo", label: t("formShipping"), type: "textarea", value: product.shippingInfo || "", onChange: (value) => update({ shippingInfo: value }), disabled: isReadonly, placeholder: "Shipping information..." }), _jsx(FormField, { name: "returnPolicy", label: t("formReturnPolicy"), type: "textarea", value: product.returnPolicy || "", onChange: (value) => update({ returnPolicy: value }), disabled: isReadonly, placeholder: "Return policy details..." }), product.storeName && (_jsx(FormField, { name: "storeName", label: t("formSeller"), type: "text", value: product.storeName, onChange: () => { }, disabled: true }))] }));
|
|
140
|
+
}) })) : (_jsx(FormField, { name: "pickupAddressId", label: t("formPickupAddress"), type: "text", value: product.pickupAddressId || "", onChange: (value) => update({ pickupAddressId: value }), disabled: isReadonly })), _jsx(SublistingCategorySelect, { value: product.sublistingCategoryId ?? "", onChange: (id) => update({ sublistingCategoryId: id || undefined }), disabled: isReadonly }), !product.isAuction && renderGroupSettings?.(product), _jsxs(Div, { children: [_jsx(Heading, { level: 3, className: "mb-1 text-sm font-semibold text-zinc-700 dark:text-zinc-300", children: "Custom Sections" }), _jsx(Text, { className: "mb-3 text-xs text-zinc-500 dark:text-zinc-400", children: "Add up to 3 custom tabs to your product page \u2014 e.g. \"Box Contents\", \"Compatibility\", \"Grading Details\"." }), _jsx(CustomSectionsEditor, { sections: product.customSections ?? [], onChange: (sections) => update({ customSections: sections }) })] }), _jsx(FormField, { name: "shippingInfo", label: t("formShipping"), type: "textarea", value: product.shippingInfo || "", onChange: (value) => update({ shippingInfo: value }), disabled: isReadonly, placeholder: "Shipping information..." }), _jsx(FormField, { name: "returnPolicy", label: t("formReturnPolicy"), type: "textarea", value: product.returnPolicy || "", onChange: (value) => update({ returnPolicy: value }), disabled: isReadonly, placeholder: "Return policy details..." }), product.storeName && (_jsx(FormField, { name: "storeName", label: t("formSeller"), type: "text", value: product.storeName, onChange: () => { }, disabled: true }))] }));
|
|
141
141
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
interface Props {
|
|
2
|
+
groupId: string;
|
|
3
|
+
currentSlug: string;
|
|
4
|
+
isParent: boolean;
|
|
5
|
+
groupTitle?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function ShowGroupSection({ groupId, currentSlug, isParent, groupTitle }: Props): import("react/jsx-runtime").JSX.Element | null;
|
|
8
|
+
export {};
|