@mohasinac/appkit 4.47.0 → 4.48.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 (173) hide show
  1. package/dist/.build-stamp.json +4 -4
  2. package/dist/_internal/server/features/checkout/expand-order-items.js +3 -0
  3. package/dist/_internal/server/features/messages/actions.d.ts +68 -0
  4. package/dist/_internal/server/features/messages/actions.js +249 -0
  5. package/dist/_internal/server/features/parts/buildable.d.ts +47 -0
  6. package/dist/_internal/server/features/parts/buildable.js +159 -0
  7. package/dist/_internal/server/features/products/list-public.d.ts +17 -0
  8. package/dist/_internal/server/features/products/list-public.js +92 -5
  9. package/dist/_internal/server/features/products/og.d.ts +7 -0
  10. package/dist/_internal/server/features/products/og.js +38 -0
  11. package/dist/_internal/server/features/site-settings/adapters.d.ts +1 -1
  12. package/dist/_internal/server/features/site-settings/adapters.js +6 -0
  13. package/dist/_internal/server/jobs/core/cleanupRtdbEvents.js +28 -0
  14. package/dist/_internal/server/jobs/core/notificationPrune.js +87 -3
  15. package/dist/_internal/server/jobs/core/priceIndexRollup.js +36 -1
  16. package/dist/_internal/shared/actions/action-registry.js +8 -0
  17. package/dist/_internal/shared/features/notifications/action-url.js +8 -0
  18. package/dist/_internal/shared/features/notifications/email-eligibility.js +3 -0
  19. package/dist/_internal/shared/features/site-settings/types.d.ts +4 -0
  20. package/dist/client.d.ts +10 -0
  21. package/dist/client.js +8 -0
  22. package/dist/constants/api-endpoints.d.ts +48 -0
  23. package/dist/constants/api-endpoints.js +30 -0
  24. package/dist/constants/notification-types.d.ts +1 -1
  25. package/dist/features/about/components/GstExplainerView.d.ts +28 -0
  26. package/dist/features/about/components/GstExplainerView.js +59 -0
  27. package/dist/features/about/components/GuidesHubView.d.ts +28 -0
  28. package/dist/features/about/components/GuidesHubView.js +58 -0
  29. package/dist/features/about/components/PublicProfileView.js +5 -0
  30. package/dist/features/about/components/SpotAFakeView.d.ts +29 -0
  31. package/dist/features/about/components/SpotAFakeView.js +49 -0
  32. package/dist/features/admin/actions/notification-actions.js +4 -0
  33. package/dist/features/admin/schemas/firestore.d.ts +13 -3
  34. package/dist/features/admin/schemas/firestore.js +8 -0
  35. package/dist/features/blog/repository/blog.repository.js +21 -7
  36. package/dist/features/categories/api/route.js +41 -1
  37. package/dist/features/categories/components/BreadcrumbTrail.d.ts +39 -17
  38. package/dist/features/categories/components/BreadcrumbTrail.js +46 -50
  39. package/dist/features/categories/components/CategoryDetailPageView.js +92 -38
  40. package/dist/features/categories/components/ContextualTrail.d.ts +62 -0
  41. package/dist/features/categories/components/ContextualTrail.js +81 -0
  42. package/dist/features/categories/components/LastCategoryBeacon.d.ts +18 -0
  43. package/dist/features/categories/components/LastCategoryBeacon.js +33 -0
  44. package/dist/features/categories/repository/categories.repository.d.ts +21 -0
  45. package/dist/features/categories/repository/categories.repository.js +36 -3
  46. package/dist/features/categories/repository/price-index.repository.d.ts +25 -0
  47. package/dist/features/email/notification-templates.js +4 -0
  48. package/dist/features/events/repository/events.repository.js +10 -3
  49. package/dist/features/faq/constants/read-caps.d.ts +29 -0
  50. package/dist/features/faq/constants/read-caps.js +29 -0
  51. package/dist/features/faq/repository/faqs.repository.d.ts +0 -2
  52. package/dist/features/faq/repository/faqs.repository.js +19 -16
  53. package/dist/features/homepage/components/AdSlot.js +10 -4
  54. package/dist/features/homepage/hooks/useActiveAd.d.ts +18 -1
  55. package/dist/features/homepage/hooks/useActiveAd.js +42 -2
  56. package/dist/features/homepage/lib/section-renderer.js +12 -2
  57. package/dist/features/layout/AppLayoutShell.d.ts +4 -1
  58. package/dist/features/layout/AppLayoutShell.js +2 -2
  59. package/dist/features/layout/TitleBarLayout.d.ts +4 -1
  60. package/dist/features/layout/TitleBarLayout.js +8 -4
  61. package/dist/features/media/AvatarUpload.js +8 -1
  62. package/dist/features/media/MediaAvatar.js +1 -1
  63. package/dist/features/media/MediaImage.js +3 -1
  64. package/dist/features/media/MediaLightbox.js +4 -1
  65. package/dist/features/media/MediaPickerModal.js +6 -1
  66. package/dist/features/media/MediaVideo.js +3 -1
  67. package/dist/features/messages/components/ConversationThreadView.d.ts +3 -0
  68. package/dist/features/messages/components/ConversationThreadView.js +75 -0
  69. package/dist/features/messages/components/ConversationsInboxView.d.ts +1 -0
  70. package/dist/features/messages/components/ConversationsInboxView.js +39 -0
  71. package/dist/features/messages/components/MessageSellerButton.d.ts +17 -0
  72. package/dist/features/messages/components/MessageSellerButton.js +33 -0
  73. package/dist/features/messages/hooks/useConversationChannel.d.ts +24 -0
  74. package/dist/features/messages/hooks/useConversationChannel.js +89 -0
  75. package/dist/features/messages/hooks/useMessages.d.ts +347 -0
  76. package/dist/features/messages/hooks/useMessages.js +113 -0
  77. package/dist/features/messages/repository/conversations.repository.d.ts +44 -0
  78. package/dist/features/messages/repository/conversations.repository.js +173 -0
  79. package/dist/features/messages/repository/messages.repository.d.ts +42 -0
  80. package/dist/features/messages/repository/messages.repository.js +142 -0
  81. package/dist/features/messages/schemas/firestore.d.ts +99 -0
  82. package/dist/features/messages/schemas/firestore.js +46 -0
  83. package/dist/features/orders/repository/orders.repository.js +21 -7
  84. package/dist/features/orders/schemas/firestore.d.ts +11 -0
  85. package/dist/features/products/components/CityCollectiblesView.d.ts +31 -0
  86. package/dist/features/products/components/CityCollectiblesView.js +82 -0
  87. package/dist/features/products/components/MarketPriceAnchor.d.ts +45 -0
  88. package/dist/features/products/components/MarketPriceAnchor.js +62 -0
  89. package/dist/features/products/components/ProductDetailPageView.js +71 -5
  90. package/dist/features/products/constants/meetup-cities.d.ts +47 -0
  91. package/dist/features/products/constants/meetup-cities.js +49 -0
  92. package/dist/features/products/repository/category-assignment.d.ts +82 -11
  93. package/dist/features/products/repository/category-assignment.js +93 -12
  94. package/dist/features/products/repository/products.repository.d.ts +12 -1
  95. package/dist/features/products/repository/products.repository.js +66 -34
  96. package/dist/features/products/schemas/firestore.d.ts +24 -2
  97. package/dist/features/products/schemas/firestore.js +13 -1
  98. package/dist/features/products/schemas/index.d.ts +2 -2
  99. package/dist/features/promotions/repository/coupons.repository.js +11 -4
  100. package/dist/features/reviews/repository/reviews.repository.js +11 -4
  101. package/dist/features/scams/repository/scammer.repository.js +21 -7
  102. package/dist/features/seller/components/SellerProductShell.d.ts +1 -1
  103. package/dist/features/seller/components/SellerProductShell.js +2 -2
  104. package/dist/features/seller/repository/offer.repository.js +21 -6
  105. package/dist/features/seller/schemas/product-form.d.ts +3 -3
  106. package/dist/features/seller/schemas/product-form.js +1 -1
  107. package/dist/features/server-errors/repository/server-errors.repository.js +11 -0
  108. package/dist/features/server-errors/schemas/firestore.d.ts +17 -0
  109. package/dist/features/server-errors/schemas/firestore.js +2 -0
  110. package/dist/features/store-extensions/repository/store-extensions.repositories.d.ts +17 -1
  111. package/dist/features/store-extensions/repository/store-extensions.repositories.js +31 -0
  112. package/dist/features/store-extensions/schemas/firestore.d.ts +1 -1
  113. package/dist/features/store-extensions/schemas/firestore.js +4 -0
  114. package/dist/features/stores/repository/store.repository.js +11 -3
  115. package/dist/features/tester/seed-data/authored/community-support__messages.d.ts +2 -0
  116. package/dist/features/tester/seed-data/authored/community-support__messages.js +200 -0
  117. package/dist/features/tester/seed-data/authored/index.js +2 -0
  118. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +64 -1
  119. package/dist/index.d.ts +8 -2
  120. package/dist/index.js +1 -1
  121. package/dist/next/routing/route-map.d.ts +14 -0
  122. package/dist/next/routing/route-map.js +26 -0
  123. package/dist/providers/db-firebase/rtdb-paths.d.ts +8 -0
  124. package/dist/providers/db-firebase/rtdb-paths.js +8 -0
  125. package/dist/repositories/index.d.ts +1 -0
  126. package/dist/repositories/index.js +4 -0
  127. package/dist/schemas/registry.d.ts +3 -3
  128. package/dist/security/index.d.ts +1 -1
  129. package/dist/security/index.js +1 -1
  130. package/dist/security/pii-schemas.d.ts +17 -0
  131. package/dist/security/pii-schemas.js +15 -7
  132. package/dist/seed/_helpers/category-content-generated.d.ts +3 -3
  133. package/dist/seed/_helpers/category-content-generated.js +23 -7
  134. package/dist/seed/_helpers/category-models.d.ts +1 -1
  135. package/dist/seed/_helpers/category-models.js +25 -1
  136. package/dist/seed/_helpers/category-names.d.ts +54 -0
  137. package/dist/seed/_helpers/category-names.js +118 -0
  138. package/dist/seed/_helpers/character-features.d.ts +1 -1
  139. package/dist/seed/_helpers/character-features.js +5 -4
  140. package/dist/seed/_helpers/final-sale-fixtures.js +1 -1
  141. package/dist/seed/categories-seed-data.js +55 -1
  142. package/dist/seed/grouped-listings-seed-data.js +2 -2
  143. package/dist/seed/products-art-seed-data.js +2 -1
  144. package/dist/seed/products-auctions-seed-data.js +42 -22
  145. package/dist/seed/products-classifieds-seed-data.js +21 -3
  146. package/dist/seed/products-digital-codes-seed-data.js +2 -1
  147. package/dist/seed/products-live-items-seed-data.js +2 -1
  148. package/dist/seed/products-preorders-seed-data.js +26 -11
  149. package/dist/seed/products-prize-draws-seed-data.js +8 -1
  150. package/dist/seed/products-standard-seed-data.js +26 -25
  151. package/dist/seed/products-stickers-seed-data.js +12 -11
  152. package/dist/server.d.ts +8 -0
  153. package/dist/server.js +15 -0
  154. package/dist/styles.css +113 -1
  155. package/dist/tailwind-utilities.css +1 -1
  156. package/dist/tokens/themes/sunset.js +17 -0
  157. package/dist/tokens/tokens.css +5 -0
  158. package/dist/ui/components/Breadcrumb.d.ts +8 -1
  159. package/dist/ui/components/Breadcrumb.js +2 -2
  160. package/dist/ui/components/Breadcrumb.style.css +37 -0
  161. package/dist/ui/components/Details.d.ts +11 -1
  162. package/dist/ui/components/Details.js +2 -2
  163. package/dist/ui/components/ImageMosaic.d.ts +43 -0
  164. package/dist/ui/components/ImageMosaic.js +46 -0
  165. package/dist/ui/components/ImageMosaic.style.css +69 -0
  166. package/dist/ui/components/SplitTile.d.ts +34 -0
  167. package/dist/ui/components/SplitTile.js +28 -0
  168. package/dist/ui/components/index.style.css +1 -0
  169. package/dist/ui/index.d.ts +4 -0
  170. package/dist/ui/index.js +4 -0
  171. package/dist/utils/search-txt-query.d.ts +77 -0
  172. package/dist/utils/search-txt-query.js +50 -28
  173. package/package.json +1 -1
@@ -100,6 +100,31 @@ export interface PriceIndexBand {
100
100
  * in unknown condition", and only the first is true.
101
101
  */
102
102
  byCondition?: Record<string, PriceIndexConditionBand>;
103
+ /**
104
+ * I9 — the per-COLOURWAY slice, for a part whose one glossary row is two
105
+ * physically different units.
106
+ *
107
+ * 🛑 Why colourway earns a dimension when nothing else does. This index
108
+ * already slices by condition precisely because condition is the dominant
109
+ * price variable — and here two units of ONE part differ by more than
110
+ * condition ever does: the silver Metal Ball ships with L-Drago Guardian and
111
+ * is *"very rare and costly"*, the transparent-black one ships with Poison
112
+ * Zurafa and is ordinary. Same mould, same weight, same stats, one code, two
113
+ * markets. A single band across both reports a median no buyer or seller of
114
+ * either can use.
115
+ *
116
+ * Keyed on the NORMALISED colourway (`normaliseColourway`), so "Transparent
117
+ * Black", "transparent-black" and sloppy spacing are one bucket rather than
118
+ * three single-sample ones.
119
+ *
120
+ * Absent — never `{}` — when no sale in the window recorded a colourway, for
121
+ * the same reason `byCondition` is: "we do not know" and "we measured and
122
+ * found none" are different statements and only the first is true. Most
123
+ * buckets will fall under `PRICE_INDEX_MIN_CONFIDENT_SAMPLES` and render as
124
+ * "not enough data yet", which is the honest outcome and already the
125
+ * behaviour.
126
+ */
127
+ byColourway?: Record<string, PriceIndexConditionBand>;
103
128
  lastSold?: {
104
129
  amount: number;
105
130
  at: Date;
@@ -87,6 +87,10 @@ export const NOTIFICATION_EMAIL_TEMPLATES = {
87
87
  */
88
88
  support_ticket_update: { lead: "There's an update on your support ticket.", cta: "View ticket" },
89
89
  scam_report_update: { lead: "There's an update on a scam report you filed.", cta: "View report" },
90
+ // D9 — email-INELIGIBLE (EMAIL_ELIGIBLE_TYPES), same reasoning as
91
+ // support_ticket_update: the conversation lives in the UI. A real entry
92
+ // anyway, for the same future-override reason as scam_report_update.
93
+ message_received: { lead: "You have a new message.", cta: "View conversation" },
90
94
  };
91
95
  /**
92
96
  * Render one notification as an email.
@@ -7,7 +7,7 @@ import { BaseRepository, prepareForFirestore, parseSieveDateValue, } from "../..
7
7
  import { EVENT_FIELDS, EVENTS_COLLECTION, } from "../schemas/index.js";
8
8
  import { increment } from "../../../contracts/field-ops.js";
9
9
  import { buildEventSearchTxt } from "../../../utils/search-txt-builders.js";
10
- import { planSearchTxt, refineSearchTxt, emptySearchResult, } from "../../../utils/search-txt-query.js";
10
+ import { planSearchTxt, beginSearchWindow, emptySearchResult, } from "../../../utils/search-txt-query.js";
11
11
  class EventRepository extends BaseRepository {
12
12
  constructor() {
13
13
  super(EVENTS_COLLECTION);
@@ -29,8 +29,15 @@ class EventRepository extends BaseRepository {
29
29
  if (plan.head) {
30
30
  baseQuery = baseQuery.where(EVENT_FIELDS.SEARCH_TXT, "array-contains", plan.head);
31
31
  }
32
- const result = await this.sieveQuery(model, EventRepository.SIEVE_FIELDS, { baseQuery });
33
- return refineSearchTxt(result, plan.rest);
32
+ /*
33
+ * One bounded window, one raised `maxPageSize`, one page slice — see
34
+ * `beginSearchWindow`. Hand-writing the three is how the other ten
35
+ * repositories each ended up with two of them, which reads as a working
36
+ * multi-word search and silently cannot reach page 2.
37
+ */
38
+ const win = beginSearchWindow(model, plan);
39
+ const result = await this.sieveQuery(win.model, EventRepository.SIEVE_FIELDS, { baseQuery, maxPageSize: win.maxPageSize(100) });
40
+ return win.finish(result);
34
41
  }
35
42
  /**
36
43
  * How many events exist per `type` and per `status` — the numbers behind the
@@ -0,0 +1,29 @@
1
+ /**
2
+ * How many FAQ rows the homepage and footer reads may fetch — F1 #36.
3
+ *
4
+ * 🛑 `getHomepageFAQs()` had NO `.limit()`, so it fetched every
5
+ * `showOnHomepage` row and the section sliced to `displayCount` in memory: 20
6
+ * Firestore reads per render of `/` — the most-crawled route in the app — to
7
+ * display 10, growing with every FAQ anyone flags. A real Hobby cost item
8
+ * rather than a correctness one, on a project that has already been paused for
9
+ * exceeding its quota.
10
+ *
11
+ * 🛑 A cap rather than exactly `displayCount`, because the section renders a
12
+ * "+N" badge from the full count: reading only what it shows would make that
13
+ * badge permanently "+0". The cap bounds the cost, and the renderer stops
14
+ * CLAIMING a number once the read saturates — an unknown count must never
15
+ * render as a precise one (Root Cause #72's rule, one surface over).
16
+ *
17
+ * 🛑 It lives in `constants/` and NOT on the repository, even though the
18
+ * repository is its main consumer: the homepage section renderer needs the
19
+ * same number, and importing a repository module there would put
20
+ * `providers/db-firebase` — and therefore firebase-admin — into the import
21
+ * chain of a render module (Root Cause #6/#24).
22
+ *
23
+ * @tag domain:faq
24
+ * @tag layer:constant
25
+ * @tag access:isomorphic
26
+ */
27
+ export declare const FAQ_HOMEPAGE_READ_CAP = 24;
28
+ /** The same bound for the footer list, which renders a fixed handful of links. */
29
+ export declare const FAQ_FOOTER_READ_CAP = 24;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * How many FAQ rows the homepage and footer reads may fetch — F1 #36.
3
+ *
4
+ * 🛑 `getHomepageFAQs()` had NO `.limit()`, so it fetched every
5
+ * `showOnHomepage` row and the section sliced to `displayCount` in memory: 20
6
+ * Firestore reads per render of `/` — the most-crawled route in the app — to
7
+ * display 10, growing with every FAQ anyone flags. A real Hobby cost item
8
+ * rather than a correctness one, on a project that has already been paused for
9
+ * exceeding its quota.
10
+ *
11
+ * 🛑 A cap rather than exactly `displayCount`, because the section renders a
12
+ * "+N" badge from the full count: reading only what it shows would make that
13
+ * badge permanently "+0". The cap bounds the cost, and the renderer stops
14
+ * CLAIMING a number once the read saturates — an unknown count must never
15
+ * render as a precise one (Root Cause #72's rule, one surface over).
16
+ *
17
+ * 🛑 It lives in `constants/` and NOT on the repository, even though the
18
+ * repository is its main consumer: the homepage section renderer needs the
19
+ * same number, and importing a repository module there would put
20
+ * `providers/db-firebase` — and therefore firebase-admin — into the import
21
+ * chain of a render module (Root Cause #6/#24).
22
+ *
23
+ * @tag domain:faq
24
+ * @tag layer:constant
25
+ * @tag access:isomorphic
26
+ */
27
+ export const FAQ_HOMEPAGE_READ_CAP = 24;
28
+ /** The same bound for the footer list, which renders a fixed handful of links. */
29
+ export const FAQ_FOOTER_READ_CAP = 24;
@@ -8,8 +8,6 @@ export declare class FAQsRepository {
8
8
  constructor(repo: IRepository<FAQ>);
9
9
  findAll(query?: SieveQuery): Promise<PagedResult<FAQ>>;
10
10
  findByCategory(category: FAQCategory): Promise<FAQ[]>;
11
- findForHomepage(): Promise<FAQ[]>;
12
- findForFooter(): Promise<FAQ[]>;
13
11
  findById(id: string): Promise<FAQ | null>;
14
12
  create(data: Omit<FAQ, "id">): Promise<FAQ>;
15
13
  update(id: string, data: Partial<FAQ>): Promise<FAQ>;
@@ -3,6 +3,7 @@ import { DatabaseError } from "../../../errors/index.js";
3
3
  import { increment } from "../../../contracts/field-ops.js";
4
4
  import { FAQ_FIELDS } from "../../../constants/field-names.js";
5
5
  import { buildSearchTxt, matchesAllSearchTerms, parseSearchTxtQuery, } from "../../../utils/search-txt.js";
6
+ import { FAQ_HOMEPAGE_READ_CAP, FAQ_FOOTER_READ_CAP } from "../constants/read-caps.js";
6
7
  import { FAQS_COLLECTION, createFAQId, slugifyQuestion, } from "../schemas/index.js";
7
8
  export class FAQsRepository {
8
9
  constructor(repo) {
@@ -23,22 +24,22 @@ export class FAQsRepository {
23
24
  });
24
25
  return result.data;
25
26
  }
26
- async findForHomepage() {
27
- const result = await this.repo.findAll({
28
- filters: "isActive==true,showOnHomepage==true",
29
- sort: "priority",
30
- perPage: 10,
31
- });
32
- return result.data;
33
- }
34
- async findForFooter() {
35
- const result = await this.repo.findAll({
36
- filters: "isActive==true,showInFooter==true",
37
- sort: "order",
38
- perPage: 20,
39
- });
40
- return result.data;
41
- }
27
+ /*
28
+ * 🛑 `findForHomepage()` and `findForFooter()` DELETED — F1 #35.
29
+ *
30
+ * Both had zero callers across `appkit/src`, `src` and `functions/src`: the
31
+ * homepage really uses `getHomepageFAQs()` and the footer `getFooterFAQs()`,
32
+ * further down this file. And the first was not merely dead, it was a loaded
33
+ * gun: `sort: "priority"` with no `order` resolves to ASCENDING in
34
+ * `base.ts`, and `isActive + showOnHomepage + priority ASC` is not a
35
+ * declared composite (the DESC pairing is, twice), so it returned `code 9
36
+ * FAILED_PRECONDITION` when measured. Behind `safeRead`'s fallback that is
37
+ * an empty FAQ section with no error anywhere (Root Cause #59).
38
+ *
39
+ * Ascending was also wrong on its own terms — it puts the LEAST important
40
+ * FAQs on a homepage. Deleting both is the fix; adding an index for a query
41
+ * nothing runs would have made the dead code look supported.
42
+ */
42
43
  async findById(id) {
43
44
  return this.repo.findById(id);
44
45
  }
@@ -170,6 +171,7 @@ export class FirebaseFAQsRepository extends BaseRepository {
170
171
  .where(FAQ_FIELDS.SHOW_ON_HOMEPAGE, "==", true)
171
172
  .where(FAQ_FIELDS.IS_ACTIVE, "==", true)
172
173
  .orderBy(FAQ_FIELDS.PRIORITY, "desc")
174
+ .limit(FAQ_HOMEPAGE_READ_CAP)
173
175
  .get();
174
176
  return snapshot.docs.map((doc) => this.mapDoc(doc));
175
177
  }
@@ -179,6 +181,7 @@ export class FirebaseFAQsRepository extends BaseRepository {
179
181
  .where(FAQ_FIELDS.SHOW_IN_FOOTER, "==", true)
180
182
  .where(FAQ_FIELDS.IS_ACTIVE, "==", true)
181
183
  .orderBy(FAQ_FIELDS.ORDER, "asc")
184
+ .limit(FAQ_FOOTER_READ_CAP)
182
185
  .get();
183
186
  return snapshot.docs.map((doc) => this.mapDoc(doc));
184
187
  }
@@ -56,7 +56,15 @@ function ManualAdBanner({ ad }) {
56
56
  */
57
57
  export function AdSlot({ id, manualContent, className = "" }) {
58
58
  const [hydrated, setHydrated] = useState(false);
59
- const { ad: activeAd } = useActiveAd(id);
59
+ const slotRef = useRef(null);
60
+ /*
61
+ * H3 — `useActiveAd` takes the sentinel below and does not fetch until it is
62
+ * near the viewport. The sentinel has to be rendered BEFORE the fetch
63
+ * resolves, or there is nothing to observe and the slot could never become
64
+ * "near" — so the early returns below happen AFTER this hook runs, not
65
+ * before, and the sentinel wrapper renders unconditionally once hydrated.
66
+ */
67
+ const { ad: activeAd } = useActiveAd(id, slotRef);
60
68
  useEffect(() => {
61
69
  setHydrated(true);
62
70
  }, []);
@@ -70,9 +78,7 @@ export function AdSlot({ id, manualContent, className = "" }) {
70
78
  }
71
79
  const resolvedManualContent = manualContent ?? (activeAd ? _jsx(ManualAdBanner, { ad: activeAd }) : null);
72
80
  const content = renderProvider(config.provider, config, resolvedManualContent);
73
- if (!content)
74
- return null;
75
- return (_jsx(Div, { className: className, "data-ad-slot-id": id, "aria-label": "Advertisement", children: content }));
81
+ return (_jsx(Div, { ref: slotRef, className: className, "data-ad-slot-id": id, "aria-label": "Advertisement", children: content }));
76
82
  }
77
83
  // ---------------------------------------------------------------------------
78
84
  // Provider dispatch
@@ -15,7 +15,24 @@ export type ActiveAdRecord = {
15
15
  requiresConsent?: boolean;
16
16
  creative: ActiveAdCreative;
17
17
  };
18
- export declare function useActiveAd(slotId: AdSlotId): {
18
+ /**
19
+ * H3 — fetch the slot's ad only once it is actually NEAR the viewport.
20
+ *
21
+ * 🛑 The gap this closes: every `<AdSlot>` called this on mount unconditionally
22
+ * — four on the homepage alone, more on auctions/blog/promotions/search — so an
23
+ * ad slot at the bottom of a page a visitor never scrolls to still fired its
24
+ * request on first paint. `/api/ads` is CDN-cached (`s-maxage=600`), so this was
25
+ * never a Firestore-read problem; it is a wasted-request problem on exactly the
26
+ * connections that feel it most — a mobile visitor on a metered connection pays
27
+ * for five ad lookups to see the one above the fold.
28
+ *
29
+ * "Lazy/below-the-fold" is enforced with `enabled`, not by delaying the mount:
30
+ * the slot still reserves no layout space until it has content (avoiding CLS),
31
+ * and the query simply does not fire until `near` flips true.
32
+ */
33
+ export declare function useActiveAd(slotId: AdSlotId, ref: {
34
+ current: Element | null;
35
+ }): {
19
36
  loading: boolean;
20
37
  ad: ActiveAdRecord | null;
21
38
  };
@@ -1,13 +1,53 @@
1
1
  "use client";
2
+ import { useEffect, useState } from "react";
2
3
  import { useQuery } from "@tanstack/react-query";
3
4
  import { apiClient } from "../../../http/ApiClient.js";
4
5
  import { AD_ENDPOINTS } from "../../../constants/api-endpoints.js";
5
- export function useActiveAd(slotId) {
6
+ /**
7
+ * H3 — fetch the slot's ad only once it is actually NEAR the viewport.
8
+ *
9
+ * 🛑 The gap this closes: every `<AdSlot>` called this on mount unconditionally
10
+ * — four on the homepage alone, more on auctions/blog/promotions/search — so an
11
+ * ad slot at the bottom of a page a visitor never scrolls to still fired its
12
+ * request on first paint. `/api/ads` is CDN-cached (`s-maxage=600`), so this was
13
+ * never a Firestore-read problem; it is a wasted-request problem on exactly the
14
+ * connections that feel it most — a mobile visitor on a metered connection pays
15
+ * for five ad lookups to see the one above the fold.
16
+ *
17
+ * "Lazy/below-the-fold" is enforced with `enabled`, not by delaying the mount:
18
+ * the slot still reserves no layout space until it has content (avoiding CLS),
19
+ * and the query simply does not fire until `near` flips true.
20
+ */
21
+ export function useActiveAd(slotId, ref) {
22
+ const [near, setNear] = useState(false);
23
+ useEffect(() => {
24
+ if (near)
25
+ return;
26
+ const el = ref.current;
27
+ if (!el || typeof IntersectionObserver === "undefined") {
28
+ // No observer in this environment (old browser, SSR hydration edge) —
29
+ // fail OPEN to fetching immediately rather than never rendering an ad at
30
+ // all. A slightly early request is a better failure than a permanently
31
+ // silent slot.
32
+ setNear(true);
33
+ return;
34
+ }
35
+ const observer = new IntersectionObserver((entries) => {
36
+ if (entries.some((e) => e.isIntersecting))
37
+ setNear(true);
38
+ },
39
+ // 200px below the current viewport, so the ad has time to resolve before
40
+ // a visitor actually scrolls to it — "lazy" is not the same as "late".
41
+ { rootMargin: "200px" });
42
+ observer.observe(el);
43
+ return () => observer.disconnect();
44
+ }, [near, ref]);
6
45
  const { data, isPending } = useQuery({
7
46
  queryKey: ["active-ad", slotId],
8
47
  queryFn: () => apiClient.get(AD_ENDPOINTS.ACTIVE_BY_SLOT(slotId)),
9
48
  staleTime: 60000,
10
49
  retry: 1,
50
+ enabled: near,
11
51
  });
12
- return { loading: isPending, ad: data ?? null };
52
+ return { loading: near && isPending, ad: data ?? null };
13
53
  }
@@ -5,6 +5,7 @@ import { cleanTitle, parseWelcomeDescription } from "./section-helpers.js";
5
5
  import { DEFAULT_TRUST_FEATURES, DEFAULT_SECURITY_ITEMS } from "./section-defaults.js";
6
6
  import { SECTION_TITLE, SECTION_COPY, VIEW_MORE_LABEL, WELCOME_TRUST_CHIPS, } from "../constants/section-copy.js";
7
7
  import { FAQ_CATEGORY_LABELS } from "../constants/faq-category-labels.js";
8
+ import { FAQ_HOMEPAGE_READ_CAP } from "../../faq/constants/read-caps.js";
8
9
  import { AnnouncementBar, hashBannerMessage } from "../components/AnnouncementBar.js";
9
10
  import { HeroCarousel } from "../components/HeroCarousel.js";
10
11
  import { StatsCounterSection } from "../components/StatsCounterSection.js";
@@ -164,9 +165,18 @@ function renderSectionElement(section, newsletterFormSlot, faqItems, slides, liv
164
165
  label: FAQ_CATEGORY_LABELS[cat] ?? cat,
165
166
  }));
166
167
  const slicedItems = cfg?.displayCount ? faqItems.slice(0, cfg.displayCount) : faqItems;
168
+ /*
169
+ * 🛑 `totalFaqs` is only a REAL total while the repository read did not
170
+ * saturate its cap (F1 #36 bounded that read). At the cap the true count
171
+ * is unknown, so the "+N" badge must not state one — `moreCount: 0`
172
+ * suppresses the badge while the "See all" link still invites. An
173
+ * unknown count rendered as a precise one is the defect, not the missing
174
+ * badge.
175
+ */
176
+ const readSaturated = faqItems.length >= FAQ_HOMEPAGE_READ_CAP;
167
177
  const totalFaqs = faqItems.length;
168
- const hasMore = totalFaqs > slicedItems.length;
169
- return (_jsx(FAQSection, { title: cleanTitle(cfg?.title) || SECTION_TITLE.faq, subtitle: cfg?.subtitle, tabs: tabs, showCategoryTabs: cfg?.showCategoryTabs ?? false, allowMultipleOpen: cfg?.allowMultipleOpen ?? false, defaultOpenCount: cfg?.defaultOpenCount ?? 0, items: slicedItems, viewMoreHref: cfg?.linkToFullPage ? ROUTES.PUBLIC.FAQS : undefined, viewMoreLabel: cfg?.viewMoreLabel || VIEW_MORE_LABEL.faq, hasMore: hasMore, moreCount: totalFaqs - slicedItems.length }));
178
+ const hasMore = totalFaqs > slicedItems.length || readSaturated;
179
+ return (_jsx(FAQSection, { title: cleanTitle(cfg?.title) || SECTION_TITLE.faq, subtitle: cfg?.subtitle, tabs: tabs, showCategoryTabs: cfg?.showCategoryTabs ?? false, allowMultipleOpen: cfg?.allowMultipleOpen ?? false, defaultOpenCount: cfg?.defaultOpenCount ?? 0, items: slicedItems, viewMoreHref: cfg?.linkToFullPage ? ROUTES.PUBLIC.FAQS : undefined, viewMoreLabel: cfg?.viewMoreLabel || VIEW_MORE_LABEL.faq, hasMore: hasMore, moreCount: readSaturated ? 0 : totalFaqs - slicedItems.length }));
170
180
  }
171
181
  case "blog-articles": {
172
182
  const cfg = config;
@@ -37,6 +37,9 @@ export interface AppLayoutShellProps {
37
37
  promotionsHref?: string;
38
38
  cartHref: string;
39
39
  wishlistHref?: string;
40
+ /** D9 — chat. Absent entirely when `siteSettings.chat.enabled !== true`; the consumer decides, this shell only forwards. */
41
+ messagesHref?: string;
42
+ unreadMessageCount?: number;
40
43
  /** Auth UID passed through to TitleBar for wishlist merge-on-login and sync. */
41
44
  userId?: string | null;
42
45
  profileHref: string;
@@ -125,4 +128,4 @@ export interface AppLayoutShellProps {
125
128
  };
126
129
  };
127
130
  }
128
- export declare function AppLayoutShell({ children, navItems, sidebarItems, sidebarSections, sidebarPrimaryActions, sidebarTitle, hiddenNavItems, user, brandName, brandShortName, siteLogoUrl, logoHref, promotionsHref, cartHref, wishlistHref, userId, profileHref, loginHref, registerHref, homeHref, shopHref, footer, searchSlot, searchSlotRenderer, titleBarNavSlot, titleBarNotificationSlot, titleBarDevSlot, titleBarPromoStripText, showThemeToggle, suppressDashboardNav, hideSidebarToggle, onLogout, adminHref, storeHref, sellerHref, userOrdersHref, userWishlistHref, userSettingsHref, sidebarLocaleSlot, showThemeToggleInSidebar, sidebarProfileLabels, eventBannerSlot, showBottomNav, onTourStart, contentClassName, lightBackground, darkBackground, }: AppLayoutShellProps): React.JSX.Element;
131
+ export declare function AppLayoutShell({ children, navItems, sidebarItems, sidebarSections, sidebarPrimaryActions, sidebarTitle, hiddenNavItems, user, brandName, brandShortName, siteLogoUrl, logoHref, promotionsHref, cartHref, wishlistHref, messagesHref, unreadMessageCount, userId, profileHref, loginHref, registerHref, homeHref, shopHref, footer, searchSlot, searchSlotRenderer, titleBarNavSlot, titleBarNotificationSlot, titleBarDevSlot, titleBarPromoStripText, showThemeToggle, suppressDashboardNav, hideSidebarToggle, onLogout, adminHref, storeHref, sellerHref, userOrdersHref, userWishlistHref, userSettingsHref, sidebarLocaleSlot, showThemeToggleInSidebar, sidebarProfileLabels, eventBannerSlot, showBottomNav, onTourStart, contentClassName, lightBackground, darkBackground, }: AppLayoutShellProps): React.JSX.Element;
@@ -127,7 +127,7 @@ function SidebarContent({ sidebarItems, sidebarSections, sidebarPrimaryActions,
127
127
  ].join(" "), children: action.label }, `${action.href}-${action.label}`))) })), isAuthenticated && (_jsx(CollapsibleNavGroup, { title: labels.sectionTitle, hasActive: !!activeProfileLink, children: _jsxs(Ul, { spacing: "2xs", children: [_jsx(Li, { children: _jsx(TextLink, { href: profileHref, variant: "none", className: activeProfileLink?.href === profileHref ? navItemActiveClass : navItemClass, "aria-current": activeProfileLink?.href === profileHref ? "page" : undefined, children: labels.profile }) }), userOrdersHref && (_jsx(Li, { children: _jsx(TextLink, { href: userOrdersHref, variant: "none", className: activeProfileLink?.href === userOrdersHref ? navItemActiveClass : navItemClass, "aria-current": activeProfileLink?.href === userOrdersHref ? "page" : undefined, children: labels.orders }) })), userWishlistHref && (_jsx(Li, { children: _jsx(TextLink, { href: userWishlistHref, variant: "none", className: activeProfileLink?.href === userWishlistHref ? navItemActiveClass : navItemClass, "aria-current": activeProfileLink?.href === userWishlistHref ? "page" : undefined, children: labels.wishlist }) })), userSettingsHref && (_jsx(Li, { children: _jsx(TextLink, { href: userSettingsHref, variant: "none", className: activeProfileLink?.href === userSettingsHref ? navItemActiveClass : navItemClass, "aria-current": activeProfileLink?.href === userSettingsHref ? "page" : undefined, children: labels.settings }) }))] }) })), isAuthenticated && user?.stats && (_jsxs(Div, { layout: "grid", gap: "2", className: "grid-cols-2", children: [user.stats.totalOrders != null && (_jsxs(Div, { className: CLS_STAT_BOX, children: [_jsx(Text, { className: "leading-none", color: "primary", size: "lg", weight: "bold", children: user.stats.totalOrders }), _jsx(Text, { className: CLS_STAT_LABEL, children: "Orders" })] })), user.stats.reviewsCount != null && (_jsxs(Div, { className: CLS_STAT_BOX, children: [_jsx(Text, { className: "leading-none", color: "primary", size: "lg", weight: "bold", children: user.stats.reviewsCount }), _jsx(Text, { className: CLS_STAT_LABEL, children: "Reviews" })] })), user.stats.auctionsWon != null && (_jsxs(Div, { className: CLS_STAT_BOX, children: [_jsx(Text, { className: "leading-none", color: "primary", size: "lg", weight: "bold", children: user.stats.auctionsWon }), _jsx(Text, { className: CLS_STAT_LABEL, children: "Auctions Won" })] })), user.stats.itemsSold != null && (_jsxs(Div, { className: CLS_STAT_BOX, children: [_jsx(Text, { className: "leading-none", color: "primary", size: "lg", weight: "bold", children: user.stats.itemsSold }), _jsx(Text, { className: CLS_STAT_LABEL, children: "Items Sold" })] }))] })), isAuthenticated && isAdminOrSeller && (adminHref || resolvedStoreHref) && (_jsx(CollapsibleNavGroup, { title: labels.dashboardSectionTitle, hasActive: !!activeDashboardLink, children: _jsxs(Ul, { spacing: "2xs", children: [adminHref && role === "admin" && (_jsx(Li, { children: _jsx(TextLink, { href: adminHref, variant: "none", className: activeDashboardLink?.href === adminHref ? navItemActiveClass : navItemClass, "aria-current": activeDashboardLink?.href === adminHref ? "page" : undefined, children: labels.adminDashboard }) })), resolvedStoreHref && isAdminOrSeller && (_jsx(Li, { children: _jsx(TextLink, { href: resolvedStoreHref, variant: "none", className: activeDashboardLink?.href === resolvedStoreHref ? navItemActiveClass : navItemClass, "aria-current": activeDashboardLink?.href === resolvedStoreHref ? "page" : undefined, children: labels.storeDashboard }) }))] }) })), normalizedSections.map((section, sectionIndex) => (_jsx(CollapsibleSidebarSection, { section: section, navItemClass: navItemClass, navItemActiveClass: navItemActiveClass, activeHref: activeHref }, `sidebar-section-${sectionIndex}`))), (sidebarLocaleSlot || showThemeToggleInSidebar || (isAuthenticated && onLogout)) && (_jsxs(Stack, { border: "default", className: "border-t border-[var(--appkit-color-border-subtle)]", padding: "t-md", gap: "3", children: [sidebarLocaleSlot, showThemeToggleInSidebar && (_jsxs("button", { type: "button", onClick: toggleTheme, className: "flex w-full items-center justify-end gap-[var(--appkit-space-3)] rounded-lg px-[var(--appkit-space-3)] py-[var(--appkit-space-2)] text-[length:var(--appkit-text-sm)] text-[var(--appkit-color-text-muted)] transition-colors hover:bg-primary-surface hover:text-primary-800", children: [_jsx(Span, { "aria-hidden": "true", children: theme === "dark" ? "☀️" : "🌙" }), activeThemeName] })), isAuthenticated && onLogout && (_jsx("button", { type: "button", onClick: () => { onLogout(); onAfterLogout(); }, className: CLS_LOGOUT_BTN, children: labels.logout }))] }))] }));
128
128
  }
129
129
  // ─── Main component ────────────────────────────────────────────────────────────
130
- export function AppLayoutShell({ children, navItems, sidebarItems = [], sidebarSections, sidebarPrimaryActions, sidebarTitle = "Navigation", hiddenNavItems, user, brandName, brandShortName, siteLogoUrl, logoHref, promotionsHref, cartHref, wishlistHref, userId, profileHref, loginHref, registerHref, homeHref, shopHref, footer, searchSlot, searchSlotRenderer, titleBarNavSlot, titleBarNotificationSlot, titleBarDevSlot, titleBarPromoStripText, showThemeToggle = false, suppressDashboardNav = false, hideSidebarToggle = false, onLogout, adminHref, storeHref, sellerHref, userOrdersHref, userWishlistHref, userSettingsHref, sidebarLocaleSlot, showThemeToggleInSidebar = false, sidebarProfileLabels, eventBannerSlot, showBottomNav = true, onTourStart, contentClassName, lightBackground = DEFAULT_LIGHT_BG, darkBackground = DEFAULT_DARK_BG, }) {
130
+ export function AppLayoutShell({ children, navItems, sidebarItems = [], sidebarSections, sidebarPrimaryActions, sidebarTitle = "Navigation", hiddenNavItems, user, brandName, brandShortName, siteLogoUrl, logoHref, promotionsHref, cartHref, wishlistHref, messagesHref, unreadMessageCount, userId, profileHref, loginHref, registerHref, homeHref, shopHref, footer, searchSlot, searchSlotRenderer, titleBarNavSlot, titleBarNotificationSlot, titleBarDevSlot, titleBarPromoStripText, showThemeToggle = false, suppressDashboardNav = false, hideSidebarToggle = false, onLogout, adminHref, storeHref, sellerHref, userOrdersHref, userWishlistHref, userSettingsHref, sidebarLocaleSlot, showThemeToggleInSidebar = false, sidebarProfileLabels, eventBannerSlot, showBottomNav = true, onTourStart, contentClassName, lightBackground = DEFAULT_LIGHT_BG, darkBackground = DEFAULT_DARK_BG, }) {
131
131
  // QueryClientProvider used to be created here so AppLayoutShell could stand
132
132
  // alone. The real cause of the 2026-06-10/11 "No QueryClient set" prod crash
133
133
  // turned out to be a peer-dep duplicate of @tanstack/query-core under
@@ -194,7 +194,7 @@ export function AppLayoutShell({ children, navItems, sidebarItems = [], sidebarS
194
194
  opacity: darkBackground.overlay?.opacity ?? 0,
195
195
  },
196
196
  };
197
- return (_jsx(_Fragment, { children: _jsxs(Stack, { className: "min-h-screen w-full overflow-x-clip transition-colors duration-300", children: [_jsx(BackgroundRenderer, { mode: theme === "dark" ? "dark" : "light", lightMode: normalizedLightBackground, darkMode: normalizedDarkBackground }), _jsxs(Div, { ref: headerRef, className: "sticky top-0 z-50 w-full", children: [_jsx(TitleBar, { onToggleSidebar: handleTogglePublicSidebar, sidebarOpen: sidebarOpen, onSearchToggle: () => setSearchOpen((prev) => !prev), searchOpen: searchOpen, brandName: brandName, brandShortName: brandShortName, siteLogoUrl: siteLogoUrl, logoHref: logoHref, promotionsHref: promotionsHref, cartHref: cartHref, wishlistHref: wishlistHref, userId: userId, profileHref: profileHref, loginHref: loginHref, registerHref: registerHref, user: user, navSlot: titleBarNavSlot, notificationSlot: titleBarNotificationSlot, devSlot: titleBarDevSlot, promoStripText: titleBarPromoStripText, isDark: theme === "dark", onToggleTheme: showThemeToggle ? toggleTheme : undefined, onTourStart: onTourStart, onBeforeToggleDashboardNav: handleBeforeDashboardNavToggle, suppressDashboardNav: suppressDashboardNav, hideSidebarToggle: hideSidebarToggle }), _jsx(NavbarWithSettings, { navItems: navItems, hiddenNavItems: hiddenNavItems, permissions: authUser?.permissions }), searchOpen && (searchSlotRenderer ? searchSlotRenderer(() => setSearchOpen(false)) : searchSlot), eventBannerSlot] }), _jsx(AutoBreadcrumbs, {}), _jsxs(Div, { layout: "flex", className: "relative w-full flex-1 overflow-x-clip", children: [_jsx(SidebarLayout, { isOpen: sidebarOpen, ariaLabel: "Secondary navigation", header: user ? (_jsx(SidebarUserHeader, { user: user, onClose: () => setSidebarOpen(false) })) : (_jsx(SidebarGuestHeader, { sidebarTitle: sidebarTitle, onClose: () => setSidebarOpen(false) })), onClose: () => setSidebarOpen(false), children: sidebarContent }), _jsx(Main, { id: "main-content",
197
+ return (_jsx(_Fragment, { children: _jsxs(Stack, { className: "min-h-screen w-full overflow-x-clip transition-colors duration-300", children: [_jsx(BackgroundRenderer, { mode: theme === "dark" ? "dark" : "light", lightMode: normalizedLightBackground, darkMode: normalizedDarkBackground }), _jsxs(Div, { ref: headerRef, className: "sticky top-0 z-50 w-full", children: [_jsx(TitleBar, { onToggleSidebar: handleTogglePublicSidebar, sidebarOpen: sidebarOpen, onSearchToggle: () => setSearchOpen((prev) => !prev), searchOpen: searchOpen, brandName: brandName, brandShortName: brandShortName, siteLogoUrl: siteLogoUrl, logoHref: logoHref, promotionsHref: promotionsHref, cartHref: cartHref, wishlistHref: wishlistHref, messagesHref: messagesHref, unreadMessageCount: unreadMessageCount, userId: userId, profileHref: profileHref, loginHref: loginHref, registerHref: registerHref, user: user, navSlot: titleBarNavSlot, notificationSlot: titleBarNotificationSlot, devSlot: titleBarDevSlot, promoStripText: titleBarPromoStripText, isDark: theme === "dark", onToggleTheme: showThemeToggle ? toggleTheme : undefined, onTourStart: onTourStart, onBeforeToggleDashboardNav: handleBeforeDashboardNavToggle, suppressDashboardNav: suppressDashboardNav, hideSidebarToggle: hideSidebarToggle }), _jsx(NavbarWithSettings, { navItems: navItems, hiddenNavItems: hiddenNavItems, permissions: authUser?.permissions }), searchOpen && (searchSlotRenderer ? searchSlotRenderer(() => setSearchOpen(false)) : searchSlot), eventBannerSlot] }), _jsx(AutoBreadcrumbs, {}), _jsxs(Div, { layout: "flex", className: "relative w-full flex-1 overflow-x-clip", children: [_jsx(SidebarLayout, { isOpen: sidebarOpen, ariaLabel: "Secondary navigation", header: user ? (_jsx(SidebarUserHeader, { user: user, onClose: () => setSidebarOpen(false) })) : (_jsx(SidebarGuestHeader, { sidebarTitle: sidebarTitle, onClose: () => setSidebarOpen(false) })), onClose: () => setSidebarOpen(false), children: sidebarContent }), _jsx(Main, { id: "main-content",
198
198
  // No bottom-chrome clearance here on purpose. FooterLayout is a
199
199
  // SIBLING rendered after <Main>, so a margin here only gaps
200
200
  // main-to-footer — it never clears the bottom of the page, which is
@@ -33,6 +33,9 @@ export interface TitleBarLayoutProps {
33
33
  * navigates to the user notifications page by default.
34
34
  */
35
35
  notificationsHref?: string;
36
+ /** D9 — chat. Rendered beside the notification bell when provided; absent entirely when chat is off (`siteSettings.chat.enabled !== true`), not merely empty. */
37
+ messagesHref?: string;
38
+ unreadMessageCount?: number;
36
39
  loginHref?: string;
37
40
  registerHref?: string;
38
41
  user?: TitleBarUser | null;
@@ -73,4 +76,4 @@ export interface TitleBarLayoutProps {
73
76
  *
74
77
  * Receives all domain data as props — zero domain imports.
75
78
  */
76
- export declare function TitleBarLayout({ onToggleSidebar, sidebarOpen, onSearchToggle, searchOpen, brandName, brandShortName, siteLogoUrl, logoHref, promotionsHref, compareHref, wishlistHref, wishlistCount, cartHref, cartCount, profileHref, unreadNotificationCount, notificationsHref, loginHref, registerHref, user, notificationSlot, devSlot, navSlot, promoStripText, isDark, onToggleTheme, onTourStart, hasDashboardNav, onToggleDashboardNav, hideSidebarToggle, id, className, }: TitleBarLayoutProps): React.JSX.Element;
79
+ export declare function TitleBarLayout({ onToggleSidebar, sidebarOpen, onSearchToggle, searchOpen, brandName, brandShortName, siteLogoUrl, logoHref, promotionsHref, compareHref, wishlistHref, wishlistCount, cartHref, cartCount, profileHref, unreadNotificationCount, notificationsHref, messagesHref, unreadMessageCount, loginHref, registerHref, user, notificationSlot, devSlot, navSlot, promoStripText, isDark, onToggleTheme, onTourStart, hasDashboardNav, onToggleDashboardNav, hideSidebarToggle, id, className, }: TitleBarLayoutProps): React.JSX.Element;
@@ -39,7 +39,7 @@ const countBadge = "appkit-hand-badge absolute -top-0.5 flex items-center justif
39
39
  *
40
40
  * Receives all domain data as props — zero domain imports.
41
41
  */
42
- export function TitleBarLayout({ onToggleSidebar, sidebarOpen, onSearchToggle, searchOpen, brandName, brandShortName, siteLogoUrl, logoHref, promotionsHref, compareHref, wishlistHref, wishlistCount = 0, cartHref, cartCount = 0, profileHref, unreadNotificationCount = 0, notificationsHref, loginHref, registerHref, user, notificationSlot, devSlot, navSlot, promoStripText, isDark = false, onToggleTheme, onTourStart, hasDashboardNav, onToggleDashboardNav, hideSidebarToggle = false, id = "titlebar", className = "", }) {
42
+ export function TitleBarLayout({ onToggleSidebar, sidebarOpen, onSearchToggle, searchOpen, brandName, brandShortName, siteLogoUrl, logoHref, promotionsHref, compareHref, wishlistHref, wishlistCount = 0, cartHref, cartCount = 0, profileHref, unreadNotificationCount = 0, notificationsHref, messagesHref, unreadMessageCount = 0, loginHref, registerHref, user, notificationSlot, devSlot, navSlot, promoStripText, isDark = false, onToggleTheme, onTourStart, hasDashboardNav, onToggleDashboardNav, hideSidebarToggle = false, id = "titlebar", className = "", }) {
43
43
  // ── Element builders ────────────────────────────────────────────────────────
44
44
  const promotionsEl = promotionsHref ? (_jsxs(Link, { href: promotionsHref, "aria-label": "Today's deals", className: "flex items-center gap-[var(--appkit-space-1)] px-[var(--appkit-space-3)] py-[var(--appkit-space-1)] rounded-full text-[length:var(--appkit-text-xs)] font-bold bg-primary-100 text-primary-700 dark:bg-secondary-900/40 dark:text-secondary-400 hover:bg-primary-surface transition-colors border border-primary-200/60 dark:border-secondary-700/40", children: [_jsx(Icon, { name: "deals", size: "md", filled: true }), _jsx(Span, { className: "hidden sm:inline", children: "Today's Deals" })] })) : null;
45
45
  const themeBtn = onToggleTheme ? (_jsx(IconButton, { size: "touch", variant: "ghost", "aria-label": isDark ? "Switch to light mode" : "Switch to dark mode", onClick: onToggleTheme, className: iconBtn, icon: _jsx(Icon, { name: isDark ? "themeLight" : "themeDark", size: "lg" }) })) : null;
@@ -52,6 +52,10 @@ export function TitleBarLayout({ onToggleSidebar, sidebarOpen, onSearchToggle, s
52
52
  // icon (per layout request). Falls back to invisible when no href is set;
53
53
  // the count badge mirrors the wishlist/cart pattern.
54
54
  const notificationsEl = notificationsHref ? (_jsxs(Link, { href: notificationsHref, "aria-label": `Notifications${unreadNotificationCount > 0 ? `, ${unreadNotificationCount} unread` : ""}`, className: `relative ${iconBtn}`, children: [_jsx(Icon, { name: "notification", size: "lg" }), unreadNotificationCount > 0 && (_jsx(Span, { className: countBadge, children: unreadNotificationCount > 99 ? "99+" : unreadNotificationCount }))] })) : null;
55
+ // D9 — chat. Same shape as the notification bell: absent entirely when no
56
+ // href is supplied (the consumer only passes one when
57
+ // `siteSettings.chat.enabled === true`), never rendered-but-disabled.
58
+ const messagesEl = messagesHref ? (_jsxs(Link, { href: messagesHref, "aria-label": `Messages${unreadMessageCount > 0 ? `, ${unreadMessageCount} unread` : ""}`, className: `relative ${iconBtn}`, children: [_jsx(Icon, { name: "message", size: "lg" }), unreadMessageCount > 0 && (_jsx(Span, { className: countBadge, children: unreadMessageCount > 99 ? "99+" : unreadMessageCount }))] })) : null;
55
59
  const wishlistEl = wishlistHref ? (_jsxs(Link, { href: wishlistHref, "aria-label": `Wishlist${wishlistCount > 0 ? `, ${wishlistCount} items` : ""}`, className: `relative ${iconBtn}`, "data-tour": "nav-wishlist", children: [_jsx(Icon, { name: "wishlist", size: "lg" }), wishlistCount > 0 && (_jsx(Span, { className: countBadge, children: wishlistCount > 9 ? "9+" : wishlistCount }))] })) : null;
56
60
  const cartEl = cartHref ? (_jsxs(Link, { href: cartHref, "aria-label": `Cart${cartCount > 0 ? `, ${cartCount} items` : ""}`, className: `relative ${iconBtn}`, "data-tour": "nav-cart", children: [_jsx(Icon, { name: "cart", size: "lg" }), cartCount > 0 && (_jsx(Span, { className: countBadge, children: cartCount > 9 ? "9+" : cartCount }))] })) : null;
57
61
  const profileEl = profileHref ? (_jsx(Link, { href: profileHref, "aria-label": user ? `Profile — ${user.displayName ?? user.email}` : "Sign in", className: `relative ${iconBtn}`, "data-tour": "nav-profile", children: _jsx(Avatar, { src: user?.photoURL ?? undefined, name: user?.displayName ?? user?.email ?? undefined, alt: user?.displayName ?? "Profile", size: "sm" }) })) : null;
@@ -59,9 +63,9 @@ export function TitleBarLayout({ onToggleSidebar, sidebarOpen, onSearchToggle, s
59
63
  // Mirrors with TB1 so Register (the primary CTA) keeps the outer, more
60
64
  // reachable edge instead of ending up tucked inside after the flip.
61
65
  _jsxs(Row, { gap: "xs", reverse: "hand", className: "hidden lg:flex", children: [loginHref && (_jsx(Link, { href: loginHref, className: "px-[var(--appkit-space-3)] py-[var(--appkit-space-1-5)] text-[length:var(--appkit-text-sm)] font-medium text-[var(--appkit-color-text-muted)] hover:text-primary-700 dark:hover:text-secondary-400 transition-colors rounded-lg hover:bg-primary-surface", children: "Sign in" })), registerHref && (_jsx(Link, { href: registerHref, className: "px-[var(--appkit-space-3)] py-[var(--appkit-space-1-5)] text-[length:var(--appkit-text-sm)] font-semibold rounded-lg bg-primary text-white hover:bg-primary/90 transition-colors btn-glow shadow-sm", children: "Register" }))] })) : null;
62
- const hasTb2 = !!(notificationsEl || wishlistEl || cartEl || profileEl);
66
+ const hasTb2 = !!(notificationsEl || messagesEl || wishlistEl || cartEl || profileEl);
63
67
  // ── Render ───────────────────────────────────────────────────────────────────
64
- return (_jsxs(BlockHeader, { id: id, className: `sticky top-0 z-50 bg-[var(--appkit-color-bg)]/95 backdrop-blur-md border-b border-[var(--appkit-color-border-subtle)] shadow-sm ${className}`, children: [promoStripText && (_jsx(Section, { color: "inverse", tone: "accent-banner", className: "text-[length:var(--appkit-text-xs)] text-center font-medium", padding: "y-2xs", children: promoStripText })), _jsxs(Div, { paddingX: "x-page", className: "container mx-auto max-w-[1920px]", children: [_jsxs(Row, { justify: "between", gap: "none", reverse: "hand", className: "relative h-14", children: [_jsx(Row, { gap: "3", children: _jsxs(Link, { href: logoHref, "aria-label": brandName, className: "flex items-center gap-[var(--appkit-space-2)] transition-opacity hover:opacity-80", children: [_jsx(Div, { className: "flex md:hidden", children: _jsx(SiteMark, { title: brandShortName ?? brandName, size: "sm" }) }), _jsx(SiteLogo, { title: brandName, size: "md" })] }) }), _jsx(Row, { className: "hidden md:flex absolute inset-y-0 left-1/2 -translate-x-1/2", align: "center", children: _jsx(Link, { href: logoHref, "aria-label": brandName, className: "flex items-center transition-opacity hover:opacity-80", children: _jsx(SiteMark, { src: siteLogoUrl, title: brandName, size: "lg" }) }) }), _jsxs(Row, { gap: "xs", reverse: "hand", children: [devSlot, navSlot, compareEl, notificationSlot, notificationsEl && _jsx(Div, { className: "hidden lg:flex", children: notificationsEl }), wishlistEl && _jsx(Div, { className: "hidden lg:flex", children: wishlistEl }), cartEl && _jsx(Div, { className: "hidden lg:flex", children: cartEl }), user
68
+ return (_jsxs(BlockHeader, { id: id, className: `sticky top-0 z-50 bg-[var(--appkit-color-bg)]/95 backdrop-blur-md border-b border-[var(--appkit-color-border-subtle)] shadow-sm ${className}`, children: [promoStripText && (_jsx(Section, { color: "inverse", tone: "accent-banner", className: "text-[length:var(--appkit-text-xs)] text-center font-medium", padding: "y-2xs", children: promoStripText })), _jsxs(Div, { paddingX: "x-page", className: "container mx-auto max-w-[1920px]", children: [_jsxs(Row, { justify: "between", gap: "none", reverse: "hand", className: "relative h-14", children: [_jsx(Row, { gap: "3", children: _jsxs(Link, { href: logoHref, "aria-label": brandName, className: "flex items-center gap-[var(--appkit-space-2)] transition-opacity hover:opacity-80", children: [_jsx(Div, { className: "flex md:hidden", children: _jsx(SiteMark, { title: brandShortName ?? brandName, size: "sm" }) }), _jsx(SiteLogo, { title: brandName, size: "md" })] }) }), _jsx(Row, { className: "hidden md:flex absolute inset-y-0 left-1/2 -translate-x-1/2", align: "center", children: _jsx(Link, { href: logoHref, "aria-label": brandName, className: "flex items-center transition-opacity hover:opacity-80", children: _jsx(SiteMark, { src: siteLogoUrl, title: brandName, size: "lg" }) }) }), _jsxs(Row, { gap: "xs", reverse: "hand", children: [devSlot, navSlot, compareEl, notificationSlot, notificationsEl && _jsx(Div, { className: "hidden lg:flex", children: notificationsEl }), messagesEl && _jsx(Div, { className: "hidden lg:flex", children: messagesEl }), wishlistEl && _jsx(Div, { className: "hidden lg:flex", children: wishlistEl }), cartEl && _jsx(Div, { className: "hidden lg:flex", children: cartEl }), user
65
69
  ? profileEl && _jsx(Div, { className: "hidden lg:flex", children: profileEl })
66
70
  : authButtonsEl ?? (profileEl && _jsx(Div, { className: "hidden lg:flex", children: profileEl })), searchBtn, promotionsEl, tourBtn, themeBtn, hamburgerBtn] })] }), hasTb2 && (
67
71
  // `justify="end"` is CORRECT alongside `reverse="hand"` — do not
@@ -70,5 +74,5 @@ export function TitleBarLayout({ onToggleSidebar, sidebarOpen, onSearchToggle, s
70
74
  // left-hand mode, in mirrored order (profile leftmost). Adding a raw
71
75
  // `justify-start` className would also trip audit-inline-styles'
72
76
  // RAW_JUSTIFY_ON_ROW.
73
- _jsxs(Row, { border: "subtle", as: "nav", "aria-label": "Account actions", justify: "end", gap: "xs", reverse: "hand", className: "flex lg:hidden h-10 border-t px-[var(--appkit-space-1)]", children: [notificationsEl, wishlistEl, cartEl, profileEl] }))] })] }));
77
+ _jsxs(Row, { border: "subtle", as: "nav", "aria-label": "Account actions", justify: "end", gap: "xs", reverse: "hand", className: "flex lg:hidden h-10 border-t px-[var(--appkit-space-1)]", children: [notificationsEl, messagesEl, wishlistEl, cartEl, profileEl] }))] })] }));
74
78
  }
@@ -2,7 +2,14 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { IMAGE_ACCEPT_ATTR } from "../../_internal/shared/media/limits.js";
4
4
  import { useEffect, useRef, useState } from "react";
5
- import { Alert, Button, Div, Input, Progress, Row, Stack, Text, useToast } from "../../ui/index.js";
5
+ import { Alert } from "../../ui/components/Alert.js";
6
+ import { Button } from "../../ui/components/Button.js";
7
+ import { Div } from "../../ui/components/Div.js";
8
+ import { Input } from "../../ui/components/Input.js";
9
+ import { Progress } from "../../ui/components/Progress.js";
10
+ import { Row, Stack } from "../../ui/components/Layout.js";
11
+ import { Text } from "../../ui/components/Typography.js";
12
+ import { useToast } from "../../ui/components/Toast.js";
6
13
  import { useMediaUpload } from "./hooks/useMedia.js";
7
14
  import { ImageCropModal } from "./modals/ImageCropModal.js";
8
15
  import { AvatarDisplay } from "../../ui/components/AvatarDisplay.js";
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { MediaImage } from "./MediaImage.js";
3
- import { Div } from "../../ui/index.js";
3
+ import { Div } from "../../ui/components/Div.js";
4
4
  const sizeClasses = {
5
5
  sm: "w-8 h-8",
6
6
  md: "w-10 h-10",
@@ -2,7 +2,9 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import Image from "next/image.js";
4
4
  import { useEffect, useRef, useState } from "react";
5
- import { Div, Row, Span } from "../../ui/index.js";
5
+ import { Div } from "../../ui/components/Div.js";
6
+ import { Row } from "../../ui/components/Layout.js";
7
+ import { Span } from "../../ui/components/Typography.js";
6
8
  import { ROUNDED_MAP, SHADOW_MAP } from "../../ui/components/surface-tokens.js";
7
9
  import { MEDIA_PROXY_PREFIX, resolveMediaUrl } from "../../utils/media-url.js";
8
10
  const SIZE_HINTS = {
@@ -3,7 +3,10 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useEffect, useCallback, useRef, useState } from "react";
4
4
  import { X, ZoomIn, ZoomOut, Maximize, Minimize, ChevronLeft, ChevronRight, RotateCcw, } from "lucide-react";
5
5
  import { MediaImage } from "./MediaImage.js";
6
- import { Button, Div, Row, Span } from "../../ui/index.js";
6
+ import { Button } from "../../ui/components/Button.js";
7
+ import { Div } from "../../ui/components/Div.js";
8
+ import { Row } from "../../ui/components/Layout.js";
9
+ import { Span } from "../../ui/components/Typography.js";
7
10
  const CLS_CLOSE_HOVER = "hover:!bg-error-surface";
8
11
  const MIN_ZOOM = 1;
9
12
  const MAX_ZOOM = 4;
@@ -3,7 +3,12 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { normalizeError } from "../../errors/normalize.js";
4
4
  import React, { useState, useCallback } from "react";
5
5
  import { Modal } from "../../ui/components/Modal.js";
6
- import { Button, Div, Input, Label, Row, Stack, Text, Alert } from "../../ui/index.js";
6
+ import { Button } from "../../ui/components/Button.js";
7
+ import { Div } from "../../ui/components/Div.js";
8
+ import { Input } from "../../ui/components/Input.js";
9
+ import { Label, Text } from "../../ui/components/Typography.js";
10
+ import { Row, Stack } from "../../ui/components/Layout.js";
11
+ import { Alert } from "../../ui/components/Alert.js";
7
12
  import { MediaImage } from "./MediaImage.js";
8
13
  import { MediaUploadField } from "./upload/MediaUploadField.js";
9
14
  import { useMediaUpload, useMediaCleanup } from "./index.js";
@@ -1,7 +1,9 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useRef, useEffect } from "react";
4
- import { Div, Row, Span } from "../../ui/index.js";
4
+ import { Div } from "../../ui/components/Div.js";
5
+ import { Row } from "../../ui/components/Layout.js";
6
+ import { Span } from "../../ui/components/Typography.js";
5
7
  import { resolveMediaUrl, resolveVideoUrl, getYouTubeVideoId } from "../../utils/media-url.js";
6
8
  import { useSiteSettings } from "../../core/hooks/useSiteSettings.js";
7
9
  export function MediaVideo({ src, thumbnailUrl, alt = "Video", controls = true, autoPlayMuted = false, loop = false, trimStart, trimEnd, objectFit = "cover", watermark, }) {
@@ -0,0 +1,3 @@
1
+ export declare function ConversationThreadView({ conversationId }: {
2
+ conversationId: string;
3
+ }): import("react").JSX.Element | null;