@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.
Files changed (26) hide show
  1. package/dist/features/admin/components/AdminProductEditorView.js +4 -1
  2. package/dist/features/auth/hooks/useAuth.js +66 -14
  3. package/dist/features/auth/repository/user.repository.d.ts +1 -0
  4. package/dist/features/auth/repository/user.repository.js +10 -6
  5. package/dist/features/pre-orders/components/PreOrderDetailPageView.js +6 -0
  6. package/dist/features/products/components/GroupSettingsPanel.d.ts +13 -0
  7. package/dist/features/products/components/GroupSettingsPanel.js +192 -0
  8. package/dist/features/products/components/PreOrderDetailView.d.ts +3 -1
  9. package/dist/features/products/components/PreOrderDetailView.js +5 -2
  10. package/dist/features/products/components/ProductDetailPageView.js +6 -0
  11. package/dist/features/products/components/ProductDetailView.d.ts +3 -1
  12. package/dist/features/products/components/ProductDetailView.js +7 -3
  13. package/dist/features/products/components/ProductForm.d.ts +6 -1
  14. package/dist/features/products/components/ProductForm.js +2 -2
  15. package/dist/features/products/components/ShowGroupSection.d.ts +8 -0
  16. package/dist/features/products/components/ShowGroupSection.js +56 -0
  17. package/dist/features/products/components/index.d.ts +3 -0
  18. package/dist/features/products/components/index.js +2 -0
  19. package/dist/features/products/repository/products.repository.d.ts +8 -0
  20. package/dist/features/products/repository/products.repository.js +81 -0
  21. package/dist/features/products/schemas/firestore.d.ts +7 -2
  22. package/dist/features/products/schemas/firestore.js +10 -0
  23. package/dist/features/products/types/index.d.ts +5 -0
  24. package/dist/features/wishlist/schemas/index.d.ts +2 -2
  25. package/dist/tailwind-utilities.css +1 -1
  26. 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, 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
+ }, 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
- useEffect(() => {
58
- onSuccessRef.current = options?.onSuccess;
59
- }, [options?.onSuccess]);
60
- useEffect(() => {
61
- onErrorRef.current = options?.onError;
62
- }, [options?.onError]);
63
- useEffect(() => {
64
- onSessionSyncedRef.current = options?.onSessionSynced;
65
- }, [options?.onSessionSynced]);
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.FAILED ||
73
- authEvent.status === RealtimeEventStatus.TIMEOUT) {
74
- onErrorRef.current?.(new Error(authEvent.error ?? "Sign-in failed. Please try again."));
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
- authEvent.subscribe(eventId, customToken);
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, USER_PII_INDEX_MAP, addPiiIndices, decryptPayoutDetails, decryptPiiFields, decryptShippingConfig, encryptPayoutDetails, encryptPiiFields, encryptShippingConfig, piiBlindIndex, } from "../../../security";
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: renderSublistingSection?.(), belowFold: [renderTabs?.(), renderRelated?.()] }));
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: renderSublistingSection?.(), belowFold: [renderTabs?.(), renderRelated?.()] }));
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 {};