@mohasinac/appkit 4.9.4 → 4.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/dist/_internal/client/features/layout/DashboardLayoutClient.js +7 -2
  2. package/dist/_internal/client/features/layout/SidebarCollapseToggle.d.ts +5 -1
  3. package/dist/_internal/client/features/layout/SidebarCollapseToggle.js +8 -2
  4. package/dist/_internal/client/features/layout/sidebarPositionClasses.d.ts +20 -0
  5. package/dist/_internal/client/features/layout/sidebarPositionClasses.js +20 -0
  6. package/dist/_internal/client/hand-mode/HandModeProvider.d.ts +21 -0
  7. package/dist/_internal/client/hand-mode/HandModeProvider.js +81 -0
  8. package/dist/_internal/client/hand-mode/index.d.ts +2 -0
  9. package/dist/_internal/client/hand-mode/index.js +1 -0
  10. package/dist/_internal/server/features/auctions/actions.js +3 -2
  11. package/dist/_internal/server/features/auctions/service.d.ts +4 -3
  12. package/dist/_internal/server/features/auctions/service.js +6 -6
  13. package/dist/_internal/server/features/bundles/data.d.ts +9 -0
  14. package/dist/_internal/server/features/bundles/data.js +14 -0
  15. package/dist/_internal/server/features/bundles/index.d.ts +1 -1
  16. package/dist/_internal/server/features/bundles/index.js +1 -1
  17. package/dist/_internal/server/features/catalogue/og.d.ts +20 -0
  18. package/dist/_internal/server/features/catalogue/og.js +27 -0
  19. package/dist/_internal/server/features/checkout/actions.d.ts +43 -0
  20. package/dist/_internal/server/features/checkout/actions.js +172 -81
  21. package/dist/_internal/server/features/checkout/index.d.ts +1 -1
  22. package/dist/_internal/server/features/checkout/index.js +1 -1
  23. package/dist/_internal/server/features/grouped/data.d.ts +12 -0
  24. package/dist/_internal/server/features/grouped/data.js +26 -1
  25. package/dist/_internal/server/features/grouped/index.d.ts +1 -1
  26. package/dist/_internal/server/features/grouped/index.js +1 -1
  27. package/dist/_internal/server/features/products/data.d.ts +23 -0
  28. package/dist/_internal/server/features/products/data.js +159 -0
  29. package/dist/_internal/server/features/products/index.d.ts +1 -1
  30. package/dist/_internal/server/features/products/index.js +1 -1
  31. package/dist/_internal/server/features/seo/robots.js +3 -2
  32. package/dist/_internal/server/features/seo/sitemap.js +70 -5
  33. package/dist/_internal/shared/config/schema.d.ts +1 -0
  34. package/dist/_internal/shared/features/auctions/config.d.ts +20 -0
  35. package/dist/_internal/shared/features/auctions/config.js +31 -1
  36. package/dist/_internal/shared/listing-types/art/config.d.ts +1 -1
  37. package/dist/_internal/shared/listing-types/art/config.js +1 -1
  38. package/dist/_internal/shared/listing-types/classified/config.d.ts +1 -1
  39. package/dist/_internal/shared/listing-types/classified/config.js +2 -2
  40. package/dist/_internal/shared/listing-types/digital-code/config.d.ts +1 -1
  41. package/dist/_internal/shared/listing-types/digital-code/config.js +2 -2
  42. package/dist/_internal/shared/listing-types/live/config.d.ts +1 -1
  43. package/dist/_internal/shared/listing-types/live/config.js +2 -2
  44. package/dist/_internal/shared/listing-types/prize-draw/config.d.ts +1 -1
  45. package/dist/_internal/shared/listing-types/prize-draw/config.js +1 -1
  46. package/dist/_internal/shared/listing-types/stickers/config.d.ts +1 -1
  47. package/dist/_internal/shared/listing-types/stickers/config.js +1 -1
  48. package/dist/client.d.ts +4 -6
  49. package/dist/client.js +10 -4
  50. package/dist/constants/api-endpoints.d.ts +6 -0
  51. package/dist/constants/api-endpoints.js +2 -0
  52. package/dist/features/about/components/AboutView.js +2 -2
  53. package/dist/features/about/components/DeveloperView.js +2 -2
  54. package/dist/features/about/schemas/firestore.d.ts +1 -0
  55. package/dist/features/account/components/UserSidebar.js +8 -2
  56. package/dist/features/account/hooks/useProfile.d.ts +2 -0
  57. package/dist/features/admin/components/AdminSidebar.js +8 -2
  58. package/dist/features/admin/components/AdminSiteSettingsView.js +12 -4
  59. package/dist/features/admin/schemas/firestore.d.ts +9 -0
  60. package/dist/features/admin/schemas/firestore.js +6 -0
  61. package/dist/features/auctions/actions/bid-actions.js +4 -1
  62. package/dist/features/auctions/components/AuctionBottomActions.d.ts +2 -1
  63. package/dist/features/auctions/components/AuctionBottomActions.js +14 -1
  64. package/dist/features/auctions/components/AuctionDetailPageView.js +21 -9
  65. package/dist/features/auctions/components/LiveMinIncrement.d.ts +18 -0
  66. package/dist/features/auctions/components/LiveMinIncrement.js +17 -0
  67. package/dist/features/auctions/components/PlaceBidFormClient.d.ts +5 -2
  68. package/dist/features/auctions/components/PlaceBidFormClient.js +7 -3
  69. package/dist/features/auth/schemas/firestore.d.ts +3 -0
  70. package/dist/features/cart/components/CartDrawer.js +4 -1
  71. package/dist/features/catalogue/components/PublicCatalogueItemDetailView.d.ts +11 -0
  72. package/dist/features/catalogue/components/PublicCatalogueItemDetailView.js +33 -0
  73. package/dist/features/catalogue/components/PublicCatalogueView.js +2 -1
  74. package/dist/features/catalogue/index.d.ts +2 -0
  75. package/dist/features/catalogue/index.js +1 -0
  76. package/dist/features/categories/components/BundleDetailView.d.ts +3 -1
  77. package/dist/features/categories/components/BundleDetailView.js +3 -2
  78. package/dist/features/classified/components/ClassifiedContactSellerPanel.d.ts +12 -0
  79. package/dist/features/classified/components/ClassifiedContactSellerPanel.js +49 -0
  80. package/dist/features/classified/components/ClassifiedDetailPageView.d.ts +7 -0
  81. package/dist/features/classified/components/ClassifiedDetailPageView.js +69 -0
  82. package/dist/features/contact/components/ContactPageView.js +8 -2
  83. package/dist/features/digital-codes/components/DigitalCodeDetailPageView.d.ts +19 -0
  84. package/dist/features/digital-codes/components/DigitalCodeDetailPageView.js +79 -0
  85. package/dist/features/grouped/components/GroupedListingsCarousel.d.ts +13 -0
  86. package/dist/features/grouped/components/GroupedListingsCarousel.js +31 -0
  87. package/dist/features/grouped/repository/grouped-listings.repository.d.ts +8 -0
  88. package/dist/features/grouped/repository/grouped-listings.repository.js +16 -0
  89. package/dist/features/homepage/components/BrandsSection.js +3 -1
  90. package/dist/features/homepage/components/FeaturedBundlesSection.js +12 -1
  91. package/dist/features/homepage/components/GoogleReviewsSection.js +2 -2
  92. package/dist/features/homepage/components/HeroCarousel.js +3 -1
  93. package/dist/features/homepage/components/SectionCarousel.js +5 -2
  94. package/dist/features/homepage/components/SocialFeedSection.js +2 -2
  95. package/dist/features/homepage/components/WhatsAppCommunitySection.js +3 -1
  96. package/dist/features/layout/BackToTop.js +3 -1
  97. package/dist/features/layout/BottomActions.js +4 -3
  98. package/dist/features/layout/BottomActionsContext.d.ts +10 -0
  99. package/dist/features/layout/BottomActionsContext.js +5 -1
  100. package/dist/features/layout/SidebarLayout.js +7 -1
  101. package/dist/features/layout/hooks/useBottomActions.d.ts +2 -0
  102. package/dist/features/layout/hooks/useBottomActions.js +4 -1
  103. package/dist/features/live/components/LiveItemDetailPageView.d.ts +9 -0
  104. package/dist/features/live/components/LiveItemDetailPageView.js +74 -0
  105. package/dist/features/media/MediaVideo.js +17 -1
  106. package/dist/features/pre-orders/components/PreOrderDetailPageView.js +9 -1
  107. package/dist/features/products/components/PrizeDrawDetailPageView.js +11 -1
  108. package/dist/features/products/components/ProductDetailPageView.js +11 -116
  109. package/dist/features/products/components/RelatedItemsSection.d.ts +16 -0
  110. package/dist/features/products/components/RelatedItemsSection.js +17 -0
  111. package/dist/features/seller/components/SellerProductShell.js +1 -1
  112. package/dist/features/seller/components/SellerSidebar.js +8 -2
  113. package/dist/features/shell/QuickFormDrawer.js +3 -1
  114. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +114 -3
  115. package/dist/hand-mode/index.d.ts +12 -0
  116. package/dist/hand-mode/index.js +11 -0
  117. package/dist/index.d.ts +2 -0
  118. package/dist/index.js +1 -0
  119. package/dist/next/routing/route-map.d.ts +2 -0
  120. package/dist/next/routing/route-map.js +7 -0
  121. package/dist/react/contexts/SessionContext.d.ts +5 -0
  122. package/dist/react/contexts/SessionContext.js +2 -0
  123. package/dist/seed/bids-seed-data.js +48 -3
  124. package/dist/seed/grouped-listings-seed-data.js +167 -0
  125. package/dist/seed/payouts-seed-data.js +4 -4
  126. package/dist/seed/products-art-seed-data.js +188 -5
  127. package/dist/seed/products-auctions-seed-data.js +215 -6
  128. package/dist/seed/products-classifieds-seed-data.js +255 -8
  129. package/dist/seed/products-digital-codes-seed-data.js +258 -19
  130. package/dist/seed/products-live-items-seed-data.js +143 -10
  131. package/dist/seed/products-preorders-seed-data.js +213 -8
  132. package/dist/seed/products-prize-draws-seed-data.js +198 -0
  133. package/dist/seed/products-standard-seed-data.js +141 -0
  134. package/dist/seed/products-stickers-seed-data.js +178 -5
  135. package/dist/seed/site-settings-seed-data.js +13 -31
  136. package/dist/seed/store-extensions-seed-data.js +1 -1
  137. package/dist/server-entry.d.ts +5 -2
  138. package/dist/server-entry.js +5 -2
  139. package/dist/server.d.ts +4 -2
  140. package/dist/server.js +4 -2
  141. package/dist/styles.css +30 -1
  142. package/dist/tailwind-utilities.css +1 -1
  143. package/dist/ui/components/Drawer.js +7 -4
  144. package/dist/ui/components/FilterDrawer.js +1 -1
  145. package/dist/ui/components/HandModeRow.d.ts +12 -0
  146. package/dist/ui/components/HandModeRow.js +18 -0
  147. package/dist/ui/components/ImageLightbox.js +37 -3
  148. package/dist/ui/components/ImageLightbox.style.css +28 -0
  149. package/dist/ui/components/Layout.d.ts +5 -1
  150. package/dist/ui/components/Layout.js +2 -1
  151. package/dist/ui/components/Layout.style.css +1 -0
  152. package/dist/ui/components/ListingFilterDrawer.js +3 -1
  153. package/dist/ui/components/Modal.js +3 -1
  154. package/dist/ui/components/SideDrawer.js +8 -5
  155. package/dist/ui/components/SideModal.js +7 -4
  156. package/dist/ui/index.d.ts +1 -0
  157. package/dist/ui/index.js +1 -0
  158. package/dist/utils/media-url.d.ts +9 -0
  159. package/dist/utils/media-url.js +39 -0
  160. package/package.json +1 -1
@@ -1,6 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import Link from "next/link";
3
- import { SIEVE_OP, sieveAnd, sieveFilter, sortBy } from "@mohasinac/appkit";
4
3
  import { productRepository, reviewRepository } from "../../../repositories";
5
4
  const __P = {
6
5
  p3: "p-[var(--appkit-space-3)]",
@@ -32,59 +31,18 @@ import { ProductGalleryClient } from "./ProductGalleryClient";
32
31
  import { ProductTabsShell } from "./ProductTabsShell";
33
32
  import { ProductFeatureBadges } from "./ProductFeatureBadges";
34
33
  import { FeatureBadgeList } from "./FeatureBadge";
35
- import { RelatedProductsCarousel } from "./RelatedProductsCarousel";
36
34
  import { ShareButton } from "./ShareButton";
37
35
  import { CustomSectionTabContent } from "./CustomSectionTabContent";
38
36
  import { SublistingCarouselSection } from "./SublistingCarouselSection";
39
37
  import { ShowGroupSection } from "./ShowGroupSection";
40
38
  import { HistoryTracker } from "../../history/components/HistoryTracker";
39
+ import { RelatedItemsSection } from "./RelatedItemsSection";
40
+ import { computeRelatedItems, toReview } from "../../../_internal/server/features/products/data";
41
+ import { GroupedListingsCarousel } from "../../grouped/components/GroupedListingsCarousel";
42
+ import { getGroupsWithItemsForProduct } from "../../../_internal/server/features/grouped/data";
41
43
  // ---------------------------------------------------------------------------
42
44
  // Helpers
43
45
  // ---------------------------------------------------------------------------
44
- function toProductItem(doc) {
45
- return {
46
- id: String(doc.id ?? ""),
47
- title: String(doc.title ?? doc.name ?? ""),
48
- price: typeof doc.price === "number" ? doc.price : 0,
49
- originalPrice: typeof doc.originalPrice === "number" ? doc.originalPrice : undefined,
50
- mainImage: Array.isArray(doc.images)
51
- ? doc.images[0]
52
- : typeof doc.mainImage === "string"
53
- ? doc.mainImage
54
- : undefined,
55
- status: doc.status ?? "published",
56
- slug: typeof doc.slug === "string" ? doc.slug : undefined,
57
- storeName: typeof doc.storeName === "string" ? doc.storeName : undefined,
58
- rating: typeof doc.rating === "number" ? doc.rating : undefined,
59
- reviewCount: typeof doc.reviewCount === "number" ? doc.reviewCount : undefined,
60
- };
61
- }
62
- function toReview(doc) {
63
- const images = Array.isArray(doc.images) ? doc.images : undefined;
64
- const createdAt = doc.createdAt instanceof Date
65
- ? doc.createdAt.toISOString()
66
- : typeof doc.createdAt === "string"
67
- ? doc.createdAt
68
- : undefined;
69
- const rawRating = typeof doc.rating === "number" ? doc.rating : 3;
70
- const rating = Math.min(5, Math.max(1, Math.round(rawRating)));
71
- return {
72
- id: String(doc.id ?? ""),
73
- productId: String(doc.productId ?? ""),
74
- userId: String(doc.userId ?? ""),
75
- userName: String(doc.userName ?? "Anonymous"),
76
- userAvatar: typeof doc.userAvatar === "string" ? doc.userAvatar : undefined,
77
- rating,
78
- title: typeof doc.title === "string" ? doc.title : undefined,
79
- comment: typeof doc.comment === "string" ? doc.comment : undefined,
80
- images,
81
- status: doc.status ?? "approved",
82
- helpfulCount: typeof doc.helpfulCount === "number" ? doc.helpfulCount : undefined,
83
- verified: doc.verified === true,
84
- featured: doc.featured === true,
85
- createdAt,
86
- };
87
- }
88
46
  function toDescriptionHtml(raw) {
89
47
  if (!raw)
90
48
  return "";
@@ -206,74 +164,14 @@ export async function ProductDetailPageView({ slug, initialProduct, renderOfferA
206
164
  const isGroupParent = p.isGroupParent === true;
207
165
  const groupTitle = typeof p.groupTitle === "string" ? p.groupTitle : undefined;
208
166
  const storeId = typeof p.storeId === "string" ? p.storeId : null;
209
- // -- Fetch reviews + 4 related-listing signals in parallel ------------------
210
- const [reviewDocs, sameCategoryDocs, sameBrandDocs, tagOverlapDocs, sameStoreDocs] = await Promise.all([
211
- reviewRepository
212
- .findApprovedByProduct(product.id)
213
- .catch(() => []),
214
- primaryCategorySlug
215
- ? productRepository.findByCategory(primaryCategorySlug).catch(() => [])
216
- : Promise.resolve([]),
217
- brand
218
- ? productRepository
219
- .list({
220
- filters: sieveAnd(sieveFilter("status", SIEVE_OP.EQ, "published"), sieveFilter("brand", SIEVE_OP.EQ, brand)),
221
- sorts: sortBy("createdAt", "DESC"),
222
- page: 1,
223
- pageSize: 9,
224
- })
225
- .then((r) => r.items)
226
- .catch(() => [])
227
- : Promise.resolve([]),
228
- tags.length > 0
229
- ? productRepository.findByTagsOverlap(tags, 9).catch(() => [])
230
- : Promise.resolve([]),
231
- storeId
232
- ? productRepository
233
- .list({
234
- filters: sieveAnd(sieveFilter("status", SIEVE_OP.EQ, "published"), sieveFilter("storeId", SIEVE_OP.EQ, storeId)),
235
- sorts: sortBy("createdAt", "DESC"),
236
- page: 1,
237
- pageSize: 9,
238
- })
239
- .then((r) => r.items)
240
- .catch(() => [])
241
- : Promise.resolve([]),
167
+ // -- Fetch reviews + the shared 4-signal related-items computation + grouped listings in parallel --
168
+ const [reviewDocs, related, groups] = await Promise.all([
169
+ reviewRepository.findApprovedByProduct(product.id).catch(() => []),
170
+ computeRelatedItems(product),
171
+ getGroupsWithItemsForProduct(product.id),
242
172
  ]);
243
173
  const reviews = reviewDocs.map(toReview);
244
- const _now = new Date();
245
- const currentProductId = product.id;
246
- function toRelatedItems(docs) {
247
- return docs
248
- .filter((r) => {
249
- if (r.id === currentProductId)
250
- return false;
251
- const s = r.status;
252
- if (s && ["sold", "out_of_stock", "archived", "discontinued", "draft"].includes(s))
253
- return false;
254
- if (r.isSold === true)
255
- return false;
256
- if (r.availableQuantity === 0)
257
- return false;
258
- if (r.listingType === "auction" && r.auctionEndDate) {
259
- const end = r.auctionEndDate;
260
- const endDate = typeof end.toDate === "function"
261
- ? end.toDate()
262
- : end instanceof Date ? end : new Date(String(end));
263
- if (endDate <= _now)
264
- return false;
265
- }
266
- if (r.listingType === "prize-draw" && r.prizeRevealStatus === "closed")
267
- return false;
268
- return true;
269
- })
270
- .slice(0, 8)
271
- .map(toProductItem);
272
- }
273
- const relatedItems = toRelatedItems(sameCategoryDocs);
274
- const relatedByBrand = toRelatedItems(sameBrandDocs);
275
- const relatedByTags = toRelatedItems(tagOverlapDocs);
276
- const relatedByStore = toRelatedItems(sameStoreDocs);
174
+ const { relatedItems, relatedByBrand, relatedByTags, relatedByStore } = related;
277
175
  return (_jsxs(Main, { children: [_jsx(HistoryTracker, { productId: product.id, productType: "product", snapshot: {
278
176
  title: product.title,
279
177
  thumb: product.mainImage ?? product.images?.[0],
@@ -339,10 +237,7 @@ export async function ProductDetailPageView({ slug, initialProduct, renderOfferA
339
237
  content: _jsx(CustomSectionTabContent, { section: s }),
340
238
  })) })), renderBundleSection: bundleMemberships.length > 0
341
239
  ? () => (_jsxs(Div, { className: CLS_BUNDLE_BOX, children: [_jsxs(Row, { className: "mb-3", align: "center", gap: "sm", children: [_jsx(Span, { size: "base", className: CLS_BUNDLE_ICON, "aria-hidden": "true", children: "\uD83D\uDCE6" }), _jsxs(Text, { className: CLS_BUNDLE_TITLE, children: ["Part of ", bundleMemberships.length === 1 ? "a bundle" : `${bundleMemberships.length} bundles`] })] }), _jsx(Stack, { gap: "3", children: bundleMemberships.map((b) => (_jsxs(Div, { className: CLS_BUNDLE_ROW, children: [_jsxs(Div, { className: "min-w-0", children: [_jsx(Text, { className: CLS_BUNDLE_LABEL, children: "Included in bundle" }), _jsx(Text, { className: "truncate", color: "primary", size: "sm", weight: "semibold", children: b.title })] }), _jsx(Link, { href: String(ROUTES.PUBLIC.BUNDLE_DETAIL(b.id)), className: CLS_BUNDLE_CTA, children: "View Bundle \u2192" })] }, b.id))) })] }))
342
- : undefined, renderRelated: () => relatedItems.length > 0 ||
343
- relatedByBrand.length > 0 ||
344
- relatedByTags.length > 0 ||
345
- relatedByStore.length > 0 ? (_jsxs(Stack, { gap: "xl", children: [relatedItems.length > 0 && (_jsx(RelatedProductsCarousel, { items: relatedItems, title: categoryName || category ? `More in ${categoryName || category}` : "More in this category" })), relatedByBrand.length > 0 && (_jsx(RelatedProductsCarousel, { items: relatedByBrand, title: brand ? `More by ${brand}` : "More by this brand" })), relatedByTags.length > 0 && (_jsx(RelatedProductsCarousel, { items: relatedByTags, title: "You might also like" })), relatedByStore.length > 0 && (_jsx(RelatedProductsCarousel, { items: relatedByStore, title: storeName ? `More from ${storeName}` : "More from this seller" }))] })) : null }), renderPrimaryActions
240
+ : undefined, renderRelated: () => (_jsxs(Stack, { gap: "xl", children: [_jsx(GroupedListingsCarousel, { groups: groups }), _jsx(RelatedItemsSection, { relatedItems: relatedItems, relatedByBrand: relatedByBrand, relatedByTags: relatedByTags, relatedByStore: relatedByStore, categoryLabel: categoryName || category || undefined, brandLabel: brand || undefined, storeLabel: storeName || undefined })] })) }), renderPrimaryActions
346
241
  ? renderPrimaryActions({
347
242
  productId: product.id,
348
243
  productSlug: product.slug ?? slug,
@@ -0,0 +1,16 @@
1
+ import type { ProductItem } from "../types";
2
+ export interface RelatedItemsSectionProps {
3
+ relatedItems: ProductItem[];
4
+ relatedByBrand: ProductItem[];
5
+ relatedByTags: ProductItem[];
6
+ relatedByStore: ProductItem[];
7
+ categoryLabel?: string;
8
+ brandLabel?: string;
9
+ storeLabel?: string;
10
+ }
11
+ /**
12
+ * The same-shape "More in category / More by brand / You might also like /
13
+ * More from store" carousel stack, shared by every listing-type detail page
14
+ * that surfaces computeRelatedItems() results.
15
+ */
16
+ export declare function RelatedItemsSection({ relatedItems, relatedByBrand, relatedByTags, relatedByStore, categoryLabel, brandLabel, storeLabel, }: RelatedItemsSectionProps): import("react").JSX.Element | null;
@@ -0,0 +1,17 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Stack } from "../../../ui";
3
+ import { RelatedProductsCarousel } from "./RelatedProductsCarousel";
4
+ /**
5
+ * The same-shape "More in category / More by brand / You might also like /
6
+ * More from store" carousel stack, shared by every listing-type detail page
7
+ * that surfaces computeRelatedItems() results.
8
+ */
9
+ export function RelatedItemsSection({ relatedItems, relatedByBrand, relatedByTags, relatedByStore, categoryLabel, brandLabel, storeLabel, }) {
10
+ if (relatedItems.length === 0 &&
11
+ relatedByBrand.length === 0 &&
12
+ relatedByTags.length === 0 &&
13
+ relatedByStore.length === 0) {
14
+ return null;
15
+ }
16
+ return (_jsxs(Stack, { gap: "xl", children: [relatedItems.length > 0 && (_jsx(RelatedProductsCarousel, { items: relatedItems, title: categoryLabel ? `More in ${categoryLabel}` : "More in this category" })), relatedByBrand.length > 0 && (_jsx(RelatedProductsCarousel, { items: relatedByBrand, title: brandLabel ? `More by ${brandLabel}` : "More by this brand" })), relatedByTags.length > 0 && _jsx(RelatedProductsCarousel, { items: relatedByTags, title: "You might also like" }), relatedByStore.length > 0 && (_jsx(RelatedProductsCarousel, { items: relatedByStore, title: storeLabel ? `More from ${storeLabel}` : "More from this seller" }))] }));
17
+ }
@@ -87,7 +87,7 @@ function StepMedia({ values, onChange, storeSlug = "store", }) {
87
87
  }
88
88
  // ── Step 3: Auction Settings ───────────────────────────────────────────────
89
89
  function StepAuctionSettings({ values, onChange, }) {
90
- return (_jsxs(Stack, { gap: "md", children: [_jsx(Alert, { variant: "info", children: "Prices are in Indian Rupees (\u20B9). Enter the rupee value \u2014 no paise." }), _jsxs(FormGroup, { columns: 2, children: [_jsx(FormField, { name: "startingBid", label: "Starting Bid (\u20B9)", type: "number", value: toRupeesString(values.startingBid), onChange: (v) => onChange({ startingBid: fromRupeesString(v) }), placeholder: "100" }), _jsx(FormField, { name: "auctionEndDate", label: "Auction End Date & Time", type: "datetime-local", value: values.auctionEndDate ?? "", onChange: (v) => onChange({ auctionEndDate: v }) })] }), _jsxs(FormGroup, { columns: 2, children: [_jsx(FormField, { name: "reservePrice", label: "Reserve Price (\u20B9, optional)", type: "number", value: toRupeesString(values.reservePrice), onChange: (v) => onChange({ reservePrice: v ? fromRupeesString(v) : undefined }), placeholder: "0", helpText: "Minimum price below which you won't sell" }), _jsx(FormField, { name: "buyNowPrice", label: "Buy Now Price (\u20B9, optional)", type: "number", value: toRupeesString(values.buyNowPrice), onChange: (v) => onChange({ buyNowPrice: v ? fromRupeesString(v) : undefined }), placeholder: "0", helpText: "Allows instant purchase before auction ends" })] }), _jsx(FormField, { name: "minBidIncrement", label: "Minimum Bid Increment (\u20B9)", type: "number", value: toRupeesString(values.minBidIncrement), onChange: (v) => onChange({ minBidIncrement: fromRupeesString(v) }), placeholder: "50" }), _jsx(FormField, { name: "auctionShippingPaidBy", label: "Shipping paid by", type: "select", value: values.auctionShippingPaidBy ?? "winner", onChange: (v) => onChange({ auctionShippingPaidBy: v }), options: [
90
+ return (_jsxs(Stack, { gap: "md", children: [_jsx(Alert, { variant: "info", children: "Prices are in Indian Rupees (\u20B9). Enter the rupee value \u2014 no paise." }), _jsxs(FormGroup, { columns: 2, children: [_jsx(FormField, { name: "startingBid", label: "Starting Bid (\u20B9)", type: "number", value: toRupeesString(values.startingBid), onChange: (v) => onChange({ startingBid: fromRupeesString(v) }), placeholder: "100" }), _jsx(FormField, { name: "auctionEndDate", label: "Auction End Date & Time", type: "datetime-local", value: values.auctionEndDate ?? "", onChange: (v) => onChange({ auctionEndDate: v }) })] }), _jsxs(FormGroup, { columns: 2, children: [_jsx(FormField, { name: "reservePrice", label: "Reserve Price (\u20B9, optional)", type: "number", value: toRupeesString(values.reservePrice), onChange: (v) => onChange({ reservePrice: v ? fromRupeesString(v) : undefined }), placeholder: "0", helpText: "Minimum price below which you won't sell" }), _jsx(FormField, { name: "buyNowPrice", label: "Buy Now Price (\u20B9, optional)", type: "number", value: toRupeesString(values.buyNowPrice), onChange: (v) => onChange({ buyNowPrice: v ? fromRupeesString(v) : undefined }), placeholder: "0", helpText: "Allows instant purchase before auction ends" })] }), _jsx(FormField, { name: "minBidIncrement", label: "Minimum Bid Increment (\u20B9)", type: "number", value: toRupeesString(values.minBidIncrement), onChange: (v) => onChange({ minBidIncrement: fromRupeesString(v) }), placeholder: "50", helpText: "Optional \u2014 can only raise the platform's tiered minimum for this listing, never lower it. Leave blank to use the tier as-is." }), _jsx(FormField, { name: "auctionShippingPaidBy", label: "Shipping paid by", type: "select", value: values.auctionShippingPaidBy ?? "winner", onChange: (v) => onChange({ auctionShippingPaidBy: v }), options: [
91
91
  { value: "winner", label: "Winner pays shipping" },
92
92
  { value: "seller", label: "Seller includes shipping" },
93
93
  ] }), _jsx(Toggle, { checked: !!values.autoExtendable, onChange: (checked) => onChange({ autoExtendable: checked }), label: "Auto-extend auction if bid placed in final minutes" }), values.autoExtendable && (_jsx(FormField, { name: "auctionExtensionMinutes", label: "Extension period (minutes)", type: "number", value: String(values.auctionExtensionMinutes ?? 5), onChange: (v) => onChange({ auctionExtensionMinutes: Number(v) || 5 }), placeholder: "5" }))] }));
@@ -9,6 +9,8 @@ import { BottomSheet } from "../../layout/BottomSheet";
9
9
  import { SidebarCollapseToggle } from "../../../_internal/client/features/layout/SidebarCollapseToggle";
10
10
  import { useSidebarSearch } from "../../../_internal/client/features/layout/useSidebarSearch";
11
11
  import { findActiveNavGroup, findActiveNavItem } from "../../../_internal/client/features/layout/navActive";
12
+ import { getSidebarRailClasses, getSidebarOverlayClasses } from "../../../_internal/client/features/layout/sidebarPositionClasses";
13
+ import { useHandMode } from "../../../_internal/client/hand-mode";
12
14
  const __O = {
13
15
  hidden: "overflow-hidden",
14
16
  yAuto: "overflow-y-auto",
@@ -64,10 +66,14 @@ function GroupsContent({ groups, activeHref, storeName, storeLogoURL, onItemClic
64
66
  })] })] }));
65
67
  }
66
68
  function DrawerPanel({ title, onClose, children }) {
67
- return (_jsxs(Div, { className: "hidden lg:block", children: [_jsx(Div, { surface: "overlay-xs", className: "fixed inset-0 z-40 backdrop-blur-sm", onClick: onClose, "aria-hidden": "true" }), _jsxs(Stack, { border: "default", shadow: "2xl", role: "dialog", "aria-modal": "true", "aria-label": title, className: "fixed top-0 right-0 z-50 h-full w-64 border-l", surface: "default", children: [_jsxs(Row, { border: "bottom-subtle", paddingY: "y-sm-tall", className: "shrink-0", padding: "x-md", align: "center", justify: "between", children: [_jsx(Span, { size: "xs", weight: "semibold", transform: "uppercase", color: "muted", children: title }), _jsx(IconButton, { "aria-label": "Close", variant: "ghost", size: "sm", onClick: onClose, icon: _jsx("svg", { className: "w-4 h-4", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M6 18L18 6M6 6l12 12" }) }) })] }), _jsx(Div, { className: `flex-1 ${__O.yAuto}`, children: children })] })] }));
69
+ const { hand } = useHandMode();
70
+ const { edgeClass, borderClass } = getSidebarOverlayClasses(hand);
71
+ return (_jsxs(Div, { className: "hidden lg:block", children: [_jsx(Div, { surface: "overlay-xs", className: "fixed inset-0 z-40 backdrop-blur-sm", onClick: onClose, "aria-hidden": "true" }), _jsxs(Stack, { border: "default", shadow: "2xl", role: "dialog", "aria-modal": "true", "aria-label": title, className: `fixed top-0 ${edgeClass} z-50 h-full w-64 ${borderClass}`, surface: "default", children: [_jsxs(Row, { border: "bottom-subtle", paddingY: "y-sm-tall", className: "shrink-0", padding: "x-md", align: "center", justify: "between", children: [_jsx(Span, { size: "xs", weight: "semibold", transform: "uppercase", color: "muted", children: title }), _jsx(IconButton, { "aria-label": "Close", variant: "ghost", size: "sm", onClick: onClose, icon: _jsx("svg", { className: "w-4 h-4", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M6 18L18 6M6 6l12 12" }) }) })] }), _jsx(Div, { className: `flex-1 ${__O.yAuto}`, children: children })] })] }));
68
72
  }
69
73
  export function StoreSidebar({ items, groups, activeHref, storeName, storeLogoURL, mobileOpen = false, desktopOpen = false, variant = "overlay", onCloseMobile, onToggle, renderFooter, }) {
70
74
  const close = onCloseMobile ?? (() => { });
75
+ const { hand } = useHandMode();
76
+ const rail = getSidebarRailClasses(hand);
71
77
  const [mounted, setMounted] = useState(false);
72
78
  useEffect(() => { setMounted(true); }, []);
73
79
  useEffect(() => {
@@ -78,7 +84,7 @@ export function StoreSidebar({ items, groups, activeHref, storeName, storeLogoUR
78
84
  const navContent = groups ? (_jsx(GroupsContent, { groups: groups, activeHref: activeHref, storeName: storeName, storeLogoURL: storeLogoURL, onItemClick: close })) : (_jsx(FlatContent, { items: items, activeHref: activeHref, storeName: storeName, storeLogoURL: storeLogoURL, onItemClick: close }));
79
85
  if (variant === "sidebar") {
80
86
  const handleToggle = onToggle ?? close;
81
- return (_jsxs(_Fragment, { children: [desktopOpen && (_jsx(Div, { surface: "overlay-xs", className: "hidden lg:block fixed inset-0 backdrop-blur-[2px] z-30", onClick: handleToggle, "aria-hidden": "true" })), _jsxs(Div, { className: `hidden lg:flex fixed left-0 z-40 transition-transform duration-300 top-[var(--header-height,3.5rem)] h-[calc(100vh-var(--header-height,3.5rem))] w-[18rem] ${desktopOpen ? "translate-x-0" : "-translate-x-[calc(100%-1.25rem)]"}`, children: [_jsxs(Stack, { border: "default", surface: "sidePanel", className: `flex-1 border-r ${__O.hidden}`, shadow: "xl", children: [_jsx(Div, { border: "bottom-subtle", paddingY: "y-sm-tall", className: "shrink-0", padding: "x-md", children: _jsxs(Row, { className: "min-w-0", align: "center", gap: "3", children: [storeLogoURL ? (_jsx(Div, { className: CLS_STORE_AVATAR, children: _jsx(MediaImage, { src: storeLogoURL, alt: storeName ?? "", size: "thumbnail" }) })) : (_jsx(Div, { className: CLS_STORE_FALLBACK, children: storeName?.[0]?.toUpperCase() })), _jsx(Text, { className: CLS_STORE_NAME, children: storeName || panelTitle })] }) }), _jsx(Div, { className: `flex-1 ${__O.yAuto}`, children: navContent }), renderFooter && _jsx(Div, { border: "top", padding: "inline", children: renderFooter() })] }), _jsx(SidebarCollapseToggle, { expanded: desktopOpen, onToggle: handleToggle })] }), _jsx(Div, { className: "lg:hidden", children: _jsx(BottomSheet, { open: mobileOpen, onClose: close, title: panelTitle, children: groups ? (_jsx(GroupsContent, { groups: groups, activeHref: activeHref, storeName: storeName, storeLogoURL: storeLogoURL, onItemClick: close })) : (_jsx(FlatContent, { items: items, activeHref: activeHref, storeName: storeName, storeLogoURL: storeLogoURL, onItemClick: close })) }) })] }));
87
+ return (_jsxs(_Fragment, { children: [desktopOpen && (_jsx(Div, { surface: "overlay-xs", className: "hidden lg:block fixed inset-0 backdrop-blur-[2px] z-30", onClick: handleToggle, "aria-hidden": "true" })), _jsxs(Div, { className: `hidden lg:flex fixed ${rail.edgeClass} z-40 transition-transform duration-300 top-[var(--header-height,3.5rem)] h-[calc(100vh-var(--header-height,3.5rem))] w-[18rem] ${desktopOpen ? "translate-x-0" : rail.collapsedTranslateClass}`, children: [_jsxs(Stack, { border: "default", surface: "sidePanel", className: `flex-1 ${rail.borderClass} ${__O.hidden}`, shadow: "xl", children: [_jsx(Div, { border: "bottom-subtle", paddingY: "y-sm-tall", className: "shrink-0", padding: "x-md", children: _jsxs(Row, { className: "min-w-0", align: "center", gap: "3", children: [storeLogoURL ? (_jsx(Div, { className: CLS_STORE_AVATAR, children: _jsx(MediaImage, { src: storeLogoURL, alt: storeName ?? "", size: "thumbnail" }) })) : (_jsx(Div, { className: CLS_STORE_FALLBACK, children: storeName?.[0]?.toUpperCase() })), _jsx(Text, { className: CLS_STORE_NAME, children: storeName || panelTitle })] }) }), _jsx(Div, { className: `flex-1 ${__O.yAuto}`, children: navContent }), renderFooter && _jsx(Div, { border: "top", padding: "inline", children: renderFooter() })] }), _jsx(SidebarCollapseToggle, { expanded: desktopOpen, onToggle: handleToggle, edge: hand })] }), _jsx(Div, { className: "lg:hidden", children: _jsx(BottomSheet, { open: mobileOpen, onClose: close, title: panelTitle, children: groups ? (_jsx(GroupsContent, { groups: groups, activeHref: activeHref, storeName: storeName, storeLogoURL: storeLogoURL, onItemClick: close })) : (_jsx(FlatContent, { items: items, activeHref: activeHref, storeName: storeName, storeLogoURL: storeLogoURL, onItemClick: close })) }) })] }));
82
88
  }
83
89
  return (_jsxs(_Fragment, { children: [mounted && mobileOpen &&
84
90
  createPortal(_jsx(DrawerPanel, { title: panelTitle, onClose: close, children: navContent }), document.body), _jsx(Div, { className: "lg:hidden", children: _jsx(BottomSheet, { open: mobileOpen, onClose: close, title: panelTitle, children: groups ? (_jsx(GroupsContent, { groups: groups, activeHref: activeHref, storeName: storeName, storeLogoURL: storeLogoURL, onItemClick: close })) : (_jsx(FlatContent, { items: items, activeHref: activeHref, storeName: storeName, storeLogoURL: storeLogoURL, onItemClick: close })) }) })] }));
@@ -7,6 +7,7 @@ import { IconButton } from "../../ui/components/IconButton";
7
7
  import { FormField } from "../../ui/components/FormField";
8
8
  import { Toggle } from "../../ui/components/Toggle";
9
9
  import { Div, Form, Row, Stack, Text } from "../../ui";
10
+ import { useHandMode } from "../../_internal/client/hand-mode";
10
11
  function initValues(fields, defaults = {}) {
11
12
  const out = {};
12
13
  for (const f of fields) {
@@ -19,6 +20,7 @@ function initValues(fields, defaults = {}) {
19
20
  }
20
21
  const FOCUSABLE = 'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])';
21
22
  export function QuickFormDrawer({ isOpen, onClose, title, fields, defaultValues, onSubmit, submitLabel = "Save", cancelLabel = "Cancel", isLoading = false, schema, renderExtra, }) {
23
+ const { hand } = useHandMode();
22
24
  const drawerRef = useRef(null);
23
25
  const [values, setValues] = useState(() => initValues(fields, defaultValues));
24
26
  const [errors, setErrors] = useState({});
@@ -122,7 +124,7 @@ export function QuickFormDrawer({ isOpen, onClose, title, fields, defaultValues,
122
124
  if (!isOpen)
123
125
  return null;
124
126
  const busy = submitting || isLoading;
125
- return (_jsxs(_Fragment, { children: [_jsx(Div, { surface: "overlay-xs", className: "fixed inset-0 backdrop-blur-[2px] [z-index:calc(var(--appkit-z-modal)+1)]", "aria-hidden": "true", onClick: handleClose }), _jsxs(Div, { ref: drawerRef, role: "dialog", "aria-modal": "true", "aria-label": title, layout: "flex-col", shadow: "2xl", className: "fixed inset-y-0 right-0 bg-[var(--appkit-color-surface)] w-full lg:w-[40%] [z-index:calc(var(--appkit-z-modal)+2)]", children: [_jsxs(Row, { className: "flex-shrink-0 border-b border-[var(--appkit-color-border)]", padding: "md", align: "center", gap: "3", children: [_jsx(Text, { className: "flex-1 text-[var(--appkit-color-text)]", size: "base", weight: "semibold", children: title }), _jsx(IconButton, { type: "button", onClick: handleClose, "aria-label": "Close", variant: "ghost", rounded: "lg", className: "text-[var(--appkit-color-text-muted)] hover:bg-[var(--appkit-color-border-subtle)]", children: _jsx(X, { className: "w-5 h-5" }) })] }), _jsxs(Form, { className: "flex-1", overflow: "y-auto", paddingX: "x-md", paddingY: "y-md-lg", spacing: "md", onSubmit: handleSubmit, noValidate: true, children: [fields.map((field) => {
127
+ return (_jsxs(_Fragment, { children: [_jsx(Div, { surface: "overlay-xs", className: "fixed inset-0 backdrop-blur-[2px] [z-index:calc(var(--appkit-z-modal)+1)]", "aria-hidden": "true", onClick: handleClose }), _jsxs(Div, { ref: drawerRef, role: "dialog", "aria-modal": "true", "aria-label": title, layout: "flex-col", shadow: "2xl", className: `fixed inset-y-0 ${hand === "left" ? "left-0" : "right-0"} bg-[var(--appkit-color-surface)] w-full lg:w-[40%] [z-index:calc(var(--appkit-z-modal)+2)]`, children: [_jsxs(Row, { className: "flex-shrink-0 border-b border-[var(--appkit-color-border)]", padding: "md", align: "center", gap: "3", reverse: hand === "left", children: [_jsx(Text, { className: "flex-1 text-[var(--appkit-color-text)]", size: "base", weight: "semibold", children: title }), _jsx(IconButton, { type: "button", onClick: handleClose, "aria-label": "Close", variant: "ghost", rounded: "lg", className: "text-[var(--appkit-color-text-muted)] hover:bg-[var(--appkit-color-border-subtle)]", children: _jsx(X, { className: "w-5 h-5" }) })] }), _jsxs(Form, { className: "flex-1", overflow: "y-auto", paddingX: "x-md", paddingY: "y-md-lg", spacing: "md", onSubmit: handleSubmit, noValidate: true, children: [fields.map((field) => {
126
128
  const value = values[field.name];
127
129
  if (field.type === "toggle") {
128
130
  return (_jsxs(Stack, { gap: "xs", children: [_jsx(Toggle, { checked: !!value, onChange: (checked) => set(field.name, checked), label: field.label }), field.helperText && (_jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "xs", children: field.helperText }))] }, field.name));
@@ -55,7 +55,29 @@ const rawTesterChecklistItems = [
55
55
  cases: [
56
56
  { key: "email-signup", label: "Sign up with email works", href: "/auth/register" },
57
57
  { key: "google-oauth", label: "Sign up / log in with Google works", href: "/auth/login" },
58
- { key: "google-link-existing", label: "Linking Google sign-in to an existing email/password account works" },
58
+ {
59
+ key: "google-link-existing",
60
+ label: "Signing in with Google using the SAME email as an existing password account logs into that same account automatically — no duplicate account, no explicit linking needed",
61
+ description: "This is the auto-merge path: register with email/password (email A), log out, then \"Sign in with Google\" using a Google account whose email is also A. You should land back in the same account (same orders/wishlist/etc.), and Settings > Account tab should now show Google as Connected.",
62
+ href: "/auth/login",
63
+ },
64
+ {
65
+ key: "google-link-different-email",
66
+ label: "Linking a Google account whose email differs from your original signup email requires the explicit \"Connect Google\" button (My Account dashboard or Settings > Account tab) — it does not happen automatically just by signing in with Google",
67
+ description: "Sign up/log in normally with email A (password). While logged in as A, use the \"Connect Google\" button and complete the popup with a Google account using a DIFFERENT email B. Afterward, Settings > Account tab should show Google as Connected with email B, while your account's primary login email is still A — the account now has two associated emails, not a second account.",
68
+ href: "/user/settings",
69
+ },
70
+ {
71
+ key: "google-link-confirmation-shown",
72
+ label: "After Google is connected, the My Account dashboard shows a \"Google account connected\" confirmation with a Connected badge and your linked email — it does not just silently stop showing the \"Connect Google\" prompt",
73
+ description: "Fixed 2026-08-20 — the /user dashboard only ever handled the unlinked case; once linked, the whole alert block disappeared with no confirmation, unlike Settings > Account tab which already showed a Connected badge. Link a Google account (or use an already-linked test account) and confirm the green confirmation alert appears on /user, not just an absence of the blue prompt.",
74
+ href: "/user",
75
+ },
76
+ {
77
+ key: "google-link-conflict-rejected",
78
+ label: "Trying to link a Google account whose email is already used by a DIFFERENT existing account is rejected with a clear error — it does not silently merge the two accounts or move the email over",
79
+ href: "/user/settings",
80
+ },
59
81
  { key: "google-popup-blocked-fallback", label: "If the Google sign-in popup is blocked, the fallback (RTDB signal + postMessage) still completes sign-in" },
60
82
  { key: "login", label: "Log in with email + password works", href: "/auth/login" },
61
83
  { key: "logout", label: "Log out works and clears the session" },
@@ -83,6 +105,16 @@ const rawTesterChecklistItems = [
83
105
  description: "Each of the three links should open your own public-facing profile page (/profile/[your uid]) — not the edit-profile page. If your profile visibility is set to Private, confirm the page still loads for you (the owner) even though other users would get a 404.",
84
106
  href: "/user",
85
107
  },
108
+ {
109
+ key: "hand-mode-toggle-exists",
110
+ label: "A \"Left-hand mode\" toggle is visible on the Appearance tab of Settings, and also on the /user/profile page, and switching it saves without a page reload",
111
+ href: "/user/settings",
112
+ },
113
+ {
114
+ key: "hand-mode-persists-reload",
115
+ label: "After turning on Left-hand mode, reloading the page (and logging out/back in) keeps the setting on",
116
+ href: "/user/settings",
117
+ },
86
118
  ],
87
119
  },
88
120
  {
@@ -156,6 +188,11 @@ const rawTesterChecklistItems = [
156
188
  label: "Applying multiple filters together (price range + brand + category + condition) narrows results correctly, and clearing filters restores the full list — on Products, Auctions, and Pre-Orders",
157
189
  href: "/products",
158
190
  },
191
+ {
192
+ key: "filter-drawer-flips-hand-mode",
193
+ label: "With Left-hand mode ON, the product/auction filter drawer opens from the left instead of the right",
194
+ href: "/products",
195
+ },
159
196
  {
160
197
  key: "search-filter-sort-combo",
161
198
  label: "Typing a search query, then applying a filter, then changing sort — all three stay applied together and pagination reflects the combined result count (not just the last action applied)",
@@ -173,7 +210,11 @@ const rawTesterChecklistItems = [
173
210
  pageLabel: "Product / Auction / Pre-order Detail",
174
211
  cases: [
175
212
  { key: "standard-detail", label: "Standard product detail page loads correctly" },
176
- { key: "auction-detail", label: "Auction detail page shows current bid + countdown correctly" },
213
+ {
214
+ key: "auction-detail",
215
+ label: "Auction detail page shows current bid + a live, ticking countdown correctly",
216
+ description: "The countdown (\"Ends in 2d 5h 30m\", ticking every second — not a static date) must appear directly above every \"Place a bid\" button: the info panel, the desktop and mobile compact bid-summary cards, inside the \"Place your bid\" modal above the submit button, and in the mobile sticky bottom bar (as a row above the current-bid/bid-count line). It should switch to \"Ended\" once the end time passes.",
217
+ },
177
218
  { key: "preorder-detail", label: "Pre-order detail page shows expected ship date correctly" },
178
219
  {
179
220
  key: "image-gallery",
@@ -252,6 +293,24 @@ const rawTesterChecklistItems = [
252
293
  label: "Choosing manual payment (UPI/Cash) at checkout shows a how-it-works guide (payment steps, refund summary, what happens if an item sells out) and requires a consent checkbox before the \"Pay via UPI / Cash\" button becomes clickable",
253
294
  href: "/checkout",
254
295
  },
296
+ {
297
+ key: "checkout-cash-payment-no-validation-error",
298
+ label: "Checking the consent box and clicking \"Pay via UPI / Cash\" places the order successfully — no red \"Validation failed\" error appears",
299
+ description: "Fixed 2026-08-20 — the checkout API's schema only accepted paymentMethod \"cod\"/\"online\"/\"upi_manual\"/\"emi\"; the manual-payment button has always sent \"cash\", which was never in that list, so every attempt was rejected with a generic \"Validation failed\" message even with the out-of-stock policy and consent checkbox correctly filled in. Add items to cart, reach the payment step, check the consent box, and click \"Pay via UPI / Cash\" — confirm it succeeds and lands on the order confirmation / payment-proof page.",
300
+ href: "/checkout",
301
+ },
302
+ {
303
+ key: "checkout-order-summary-full-breakdown",
304
+ label: "The checkout Order Summary panel shows Shipping, COD handling fee (when COD is the likely method), the selected add-on fees (WhatsApp updates / gift wrap / shipment protection — only the ones checked), GST (when enabled), and Coupon discount as separate line items with icons before you pay — not just Subtotal and Total",
305
+ description: "Fixed 2026-08-20 — the Order Summary previously showed Total = Subtotal − coupon discount only; shipping and every fee were computed and charged server-side but never shown to the buyer beforehand. Reach the payment step with an address selected, toggle an add-on checkbox, and confirm the matching line appears in the summary a moment later and the Total updates to match — then confirm the same Total appears on the order confirmation page afterward.",
306
+ href: "/checkout",
307
+ },
308
+ {
309
+ key: "checkout-razorpay-charge-includes-shipping",
310
+ label: "When Razorpay online payment is enabled, the amount charged in the Razorpay checkout modal matches the order's recorded Total exactly, including shipping fee",
311
+ description: "Fixed 2026-08-20 — the Razorpay pre-charge amount (computed before opening the payment modal) omitted the seller's shipping fee entirely, while the order created afterward recorded a higher total that included it, so the buyer was silently charged less via the gateway than what got recorded as owed. Only testable when Site Settings → Payments → Razorpay is enabled and the seller has a configured shipping fee. Compare the amount shown in the Razorpay modal against the order's Total on the confirmation page — they should match exactly.",
312
+ href: "/checkout",
313
+ },
255
314
  ],
256
315
  },
257
316
  {
@@ -293,6 +352,20 @@ const rawTesterChecklistItems = [
293
352
  { key: "win-auction", label: "Winning an auction creates a payable order correctly" },
294
353
  { key: "bid-history", label: "My Bids page shows accurate bid history, paginated with the most recent bid first", href: "/user/bids" },
295
354
  { key: "bid-history-auction-detail-pagination", label: "An auction detail page's Bid History section shows the most recent bid first and paginates once there are more bids than fit on one page (try the L-Drago auction — 13 seeded bids)" },
355
+ {
356
+ key: "bid-increment-tiered",
357
+ label: "The \"min increment\" shown on an auction matches the admin-configured tier for the current bid amount, not a flat ₹1 — and a bid below that increment is rejected",
358
+ description: "Default tiers: current bid ≤₹100 → ₹10 increment, ≤₹1,000 → ₹100, ≤₹5,000 → ₹200, ≤₹10,000 → ₹500, above that → ₹1,000. On an auction with no per-listing \"Minimum Bid Increment\" set, confirm the displayed \"min increment\" matches the tier for its current bid, and that trying to bid only current+1 (below the tier) is rejected with a minimum-increment error, while current+tier (or more) is accepted.",
359
+ },
360
+ {
361
+ key: "bid-increment-override-floor-raising",
362
+ label: "A seller's per-listing \"Minimum Bid Increment\" can require MORE than the admin tier, but can never let a bid undercut the tier",
363
+ description: "Set a listing's \"Minimum Bid Increment\" below the current tier's value (e.g. 1) and confirm the effective minimum bid still enforces the tier, not the smaller override. Then set it above the tier (e.g. 500 when the tier is 100) and confirm the higher override is now enforced instead.",
364
+ },
365
+ {
366
+ key: "bid-increment-live-tier-change",
367
+ label: "The displayed \"min increment\" on an open auction updates live (without a page refresh) if another bidder's bid pushes the current bid across a tier boundary",
368
+ },
296
369
  ],
297
370
  },
298
371
  {
@@ -447,6 +520,7 @@ const rawTesterChecklistItems = [
447
520
  { key: "list-preorder", label: "Listing a pre-order works" },
448
521
  { key: "edit-listing", label: "Editing an existing listing works" },
449
522
  { key: "media-upload", label: "Uploading product images/video during listing works" },
523
+ { key: "seller-quick-add-drawer-flips", label: "With Left-hand mode ON, the seller's quick-add-listing side drawer opens from the left instead of the right" },
450
524
  ],
451
525
  },
452
526
  {
@@ -734,11 +808,32 @@ const rawTesterChecklistItems = [
734
808
  },
735
809
  ],
736
810
  },
811
+ {
812
+ pageKey: "hand-mode-layout",
813
+ pageLabel: "Left-Hand Mode",
814
+ cases: [
815
+ { key: "sidebar-flips", label: "With Left-hand mode ON, the admin/store/user dashboard's persistent left navigation sidebar (and its collapse handle) moves to the right side of the screen; with it OFF (default), the sidebar stays on the left", href: "/admin/dashboard" },
816
+ { key: "drawers-flip", label: "With Left-hand mode ON, side drawers/panels (filters, quick-add forms, cart, edit/create panels) that normally slide in from the right now slide in from the left", href: "/products" },
817
+ { key: "close-buttons-flip", label: "With Left-hand mode ON, the X close button on drawers/panels/modals sits on the left edge of its header; with it OFF (default), it sits on the right edge" },
818
+ { key: "back-to-top-cta-flips", label: "With Left-hand mode ON, the floating \"back to top\" button appears in the bottom-left corner instead of bottom-right after scrolling down a long listing page", href: "/products" },
819
+ { key: "quick-links-unaffected", label: "Turning on Left-hand mode does not reorder or mirror the My Account / Admin dashboard quick-links tile grid — only its surrounding column shifts with the sidebar", href: "/user" },
820
+ { key: "hero-carousel-arrows-flip", label: "With Left-hand mode ON, the homepage hero carousel's Prev/Next arrow pair appears in the bottom-left corner instead of bottom-right; the dots stay centered either way", href: "/" },
821
+ { key: "gallery-arrows-unaffected", label: "Left-hand mode does NOT change a product image gallery/lightbox's Prev/Next arrows — Prev stays on the left edge and Next stays on the right edge in both modes" },
822
+ { key: "homepage-section-buttons-mirror", label: "With Left-hand mode ON, homepage section header rows (e.g. Brands, Featured Bundles, Google Reviews) have their \"View All\" button on the opposite side from default — title and button swap sides", href: "/" },
823
+ { key: "hand-mode-no-fouc", label: "On a hard page reload with Left-hand mode already ON, panels/sidebars render on the left immediately — there is no visible flash of them briefly appearing on the right before snapping left" },
824
+ ],
825
+ },
737
826
  {
738
827
  pageKey: "footer-theme",
739
828
  pageLabel: "Footer & Dark Mode",
740
829
  cases: [
741
830
  { key: "footer-dark-mode", label: "Footer background and all link/text colors switch correctly between light and dark mode", href: "/" },
831
+ {
832
+ key: "footer-github-icon",
833
+ label: "The footer's social icon row (brand column, bottom-left) shows a GitHub icon alongside Instagram/Twitter/WhatsApp, and clicking it opens the developer's real GitHub profile in a new tab",
834
+ description: "Added 2026-08-20 — GITHUB was a new socialUrls key with no footer icon before this. Confirm the icon renders (not a broken/missing glyph) and the link target is a real, working github.com profile, not a placeholder or dead link.",
835
+ href: "/",
836
+ },
742
837
  ],
743
838
  },
744
839
  {
@@ -776,7 +871,17 @@ const rawTesterChecklistItems = [
776
871
  cases: [
777
872
  { key: "homepage-loads", label: "The homepage loads without errors on both desktop and mobile", href: "/" },
778
873
  { key: "about-us-in-main-nav", label: "\"About Us\" appears as an item in the main public top navigation, not just the footer", href: "/about" },
779
- { key: "developer-page-loads", label: "The Developer page loads correctly and is linked from the footer's Support column next to About Us", href: "/developer" },
874
+ {
875
+ key: "about-team-real-founder",
876
+ label: "The About page's \"Who's Behind LetItRip\" team section shows the real founder's name (not a placeholder persona) and a working \"GitHub ↗\" link on their card",
877
+ description: "Updated 2026-08-20 — the founder card previously used a fictional placeholder name with no GitHub link. Confirm the founder card's name matches the real developer and the GitHub link opens their actual profile.",
878
+ href: "/about",
879
+ },
880
+ {
881
+ key: "developer-page-loads",
882
+ label: "The Developer page loads correctly, is linked from the footer's Support column next to About Us, shows the real developer's name, and its \"GitHub ↗\" link opens their actual GitHub profile",
883
+ href: "/developer",
884
+ },
780
885
  { key: "products-listing-page", label: "The products listing page loads and paginates correctly", href: "/products" },
781
886
  { key: "auctions-listing-page", label: "The auctions listing page loads correctly", href: "/auctions" },
782
887
  { key: "preorders-listing-page", label: "The pre-orders listing page loads correctly", href: "/pre-orders" },
@@ -1017,6 +1122,12 @@ const rawTesterChecklistItems = [
1017
1122
  pageLabel: "Site & System",
1018
1123
  cases: [
1019
1124
  { key: "site-settings-admin", label: "Admin site settings page saves correctly", href: "/admin/site" },
1125
+ {
1126
+ key: "auction-bid-tiers-admin",
1127
+ label: "Admin can add, edit, and remove bid-increment tiers in Site Settings → Auction Config, and the changes persist after saving and reloading the page",
1128
+ description: "Each tier row has an editable \"up to (₹)\" amount and \"increment (₹)\" amount; the last row is fixed as \"and above\" (open-ended) and can't have its threshold edited. Add a new tier, edit an existing increment, remove a tier, save, then reload the Site Settings page and confirm the tier list matches what was saved — not reverted to the old defaults.",
1129
+ href: "/admin/site",
1130
+ },
1020
1131
  { key: "admin-dashboard-widgets", label: "Admin dashboard widgets show accurate data", href: "/admin/dashboard" },
1021
1132
  { key: "analytics-admin", label: "Admin analytics dashboard shows accurate data", href: "/admin/analytics" },
1022
1133
  { key: "maintenance-pages-admin", label: "The maintenance pages (analysis, client-errors, cloud-logs, function-errors, payment-rollbacks, server-errors + detail) all load correctly", href: "/admin/maintenance" },
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Public re-export of the appkit hand-mode primitives.
3
+ *
4
+ * `HandModeProvider`, `useHandMode`, and the related types live in
5
+ * `_internal/client/hand-mode/` because they are consumed by both the public
6
+ * `client.ts` barrel and other internal modules (drawers, sidebars). This
7
+ * file is the canonical public surface — `client.ts` re-exports through
8
+ * here so the `audit-appkit-reexports` rule (no `_internal/` in public
9
+ * barrels) stays green. Mirrors `theme/index.ts`.
10
+ */
11
+ export { HandModeProvider, useHandMode } from "../_internal/client/hand-mode";
12
+ export type { HandMode, HandModeContextValue, HandModeProviderProps, } from "../_internal/client/hand-mode";
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Public re-export of the appkit hand-mode primitives.
3
+ *
4
+ * `HandModeProvider`, `useHandMode`, and the related types live in
5
+ * `_internal/client/hand-mode/` because they are consumed by both the public
6
+ * `client.ts` barrel and other internal modules (drawers, sidebars). This
7
+ * file is the canonical public surface — `client.ts` re-exports through
8
+ * here so the `audit-appkit-reexports` rule (no `_internal/` in public
9
+ * barrels) stays green. Mirrors `theme/index.ts`.
10
+ */
11
+ export { HandModeProvider, useHandMode } from "../_internal/client/hand-mode";
package/dist/index.d.ts CHANGED
@@ -2321,6 +2321,8 @@ export { CatalogueItemEditorView } from "./features/catalogue/index";
2321
2321
  export type { CatalogueItemEditorViewProps } from "./features/catalogue/index";
2322
2322
  export { PublicCatalogueView } from "./features/catalogue/index";
2323
2323
  export type { PublicCatalogueViewProps } from "./features/catalogue/index";
2324
+ export { PublicCatalogueItemDetailView } from "./features/catalogue/index";
2325
+ export type { PublicCatalogueItemDetailViewProps } from "./features/catalogue/index";
2324
2326
  export { AdminCatalogueApprovalsView } from "./features/catalogue/index";
2325
2327
  export { PAYOUT_FIELDS } from "./features/payments/index";
2326
2328
  export { PAYOUT_INDEXED_FIELDS } from "./features/payments/index";
package/dist/index.js CHANGED
@@ -4215,6 +4215,7 @@ export { approveCatalogueListingAction, rejectCatalogueListingAction, } from "./
4215
4215
  export { UserCatalogueView } from "./features/catalogue/index";
4216
4216
  export { CatalogueItemEditorView } from "./features/catalogue/index";
4217
4217
  export { PublicCatalogueView } from "./features/catalogue/index";
4218
+ export { PublicCatalogueItemDetailView } from "./features/catalogue/index";
4218
4219
  export { AdminCatalogueApprovalsView } from "./features/catalogue/index";
4219
4220
  // [SCHEMA]-Schema / data-shape constant â€" Zod validator, default-value object, or Firestore collection/field name constant.
4220
4221
  // PAYOUT_FIELDS - Constant used across modules.
@@ -105,6 +105,7 @@ export declare const DEFAULT_ROUTE_MAP: {
105
105
  readonly LIVE: "/live";
106
106
  readonly LIVE_DETAIL: (slug: string) => string;
107
107
  readonly ART: "/art";
108
+ readonly CATALOGUE_ITEM_DETAIL: (ownerSlug: string, itemId: string) => string;
108
109
  };
109
110
  readonly ERRORS: {
110
111
  readonly UNAUTHORIZED: "/unauthorized";
@@ -470,6 +471,7 @@ export declare const ROUTES: {
470
471
  readonly LIVE: "/live";
471
472
  readonly LIVE_DETAIL: (slug: string) => string;
472
473
  readonly ART: "/art";
474
+ readonly CATALOGUE_ITEM_DETAIL: (ownerSlug: string, itemId: string) => string;
473
475
  };
474
476
  readonly ERRORS: {
475
477
  readonly UNAUTHORIZED: "/unauthorized";
@@ -99,6 +99,13 @@ export const DEFAULT_ROUTE_MAP = {
99
99
  // detail page stays PRODUCT_DETAIL (`/products/{slug}`), already wired in
100
100
  // `_internal/shared/listing-types/{art,stickers}/config.ts`.
101
101
  ART: "/art",
102
+ // Personal catalogue (Feature B) — a user's own photo inventory of owned
103
+ // items, distinct from marketplace listings. Public when the owner sets
104
+ // an item's visibility to "public". The owner's full catalogue listing
105
+ // already exists at PROFILE(userId)/catalogue (PublicProfileView's
106
+ // "Catalogue" tab) — this is only the per-item detail route, which had
107
+ // no page at all before this.
108
+ CATALOGUE_ITEM_DETAIL: (ownerSlug, itemId) => `/catalogue/${ownerSlug}/${itemId}`,
102
109
  },
103
110
  ERRORS: {
104
111
  UNAUTHORIZED: "/unauthorized",
@@ -41,6 +41,11 @@ export interface SessionUser {
41
41
  permissions?: string[];
42
42
  /** Hash of the last dismissed announcement banner. */
43
43
  dismissedBannerHash?: string;
44
+ /** Persisted UI layout preferences (collapsed sections, left-hand mode, ...). */
45
+ uiPreferences?: {
46
+ collapsedSections?: string[];
47
+ handMode?: "left" | "right";
48
+ };
44
49
  }
45
50
  export interface SessionContextValue {
46
51
  user: SessionUser | null;
@@ -79,6 +79,7 @@ function buildSessionUser(authUser, serverData) {
79
79
  ? new Date(serverData.scamAwarenessAcknowledgedAt)
80
80
  : null,
81
81
  dismissedBannerHash: serverData.dismissedBannerHash,
82
+ uiPreferences: serverData.uiPreferences,
82
83
  };
83
84
  }
84
85
  // ---------------------------------------------------------------------------
@@ -150,6 +151,7 @@ export function SessionProvider({ children, initialUser, endpoints: endpointOver
150
151
  ? new Date(data.scamAwarenessAcknowledgedAt)
151
152
  : null,
152
153
  dismissedBannerHash: data.dismissedBannerHash,
154
+ uiPreferences: data.uiPreferences,
153
155
  };
154
156
  }
155
157
  catch (_err) {