@mohasinac/appkit 4.47.0 → 4.48.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.build-stamp.json +4 -4
- package/dist/_internal/server/features/checkout/expand-order-items.js +3 -0
- package/dist/_internal/server/features/messages/actions.d.ts +68 -0
- package/dist/_internal/server/features/messages/actions.js +249 -0
- package/dist/_internal/server/features/parts/buildable.d.ts +47 -0
- package/dist/_internal/server/features/parts/buildable.js +159 -0
- package/dist/_internal/server/features/products/list-public.d.ts +17 -0
- package/dist/_internal/server/features/products/list-public.js +92 -5
- package/dist/_internal/server/features/products/og.d.ts +7 -0
- package/dist/_internal/server/features/products/og.js +38 -0
- package/dist/_internal/server/features/site-settings/adapters.d.ts +1 -1
- package/dist/_internal/server/features/site-settings/adapters.js +6 -0
- package/dist/_internal/server/jobs/core/cleanupRtdbEvents.js +28 -0
- package/dist/_internal/server/jobs/core/notificationPrune.js +87 -3
- package/dist/_internal/server/jobs/core/priceIndexRollup.js +36 -1
- package/dist/_internal/shared/actions/action-registry.js +8 -0
- package/dist/_internal/shared/features/notifications/action-url.js +8 -0
- package/dist/_internal/shared/features/notifications/email-eligibility.js +3 -0
- package/dist/_internal/shared/features/site-settings/types.d.ts +4 -0
- package/dist/client.d.ts +10 -0
- package/dist/client.js +8 -0
- package/dist/constants/api-endpoints.d.ts +48 -0
- package/dist/constants/api-endpoints.js +30 -0
- package/dist/constants/notification-types.d.ts +1 -1
- package/dist/features/about/components/GstExplainerView.d.ts +28 -0
- package/dist/features/about/components/GstExplainerView.js +59 -0
- package/dist/features/about/components/GuidesHubView.d.ts +28 -0
- package/dist/features/about/components/GuidesHubView.js +58 -0
- package/dist/features/about/components/PublicProfileView.js +5 -0
- package/dist/features/about/components/SpotAFakeView.d.ts +29 -0
- package/dist/features/about/components/SpotAFakeView.js +49 -0
- package/dist/features/admin/actions/notification-actions.js +4 -0
- package/dist/features/admin/schemas/firestore.d.ts +13 -3
- package/dist/features/admin/schemas/firestore.js +8 -0
- package/dist/features/blog/repository/blog.repository.js +21 -7
- package/dist/features/categories/api/route.js +41 -1
- package/dist/features/categories/components/BreadcrumbTrail.d.ts +39 -17
- package/dist/features/categories/components/BreadcrumbTrail.js +46 -50
- package/dist/features/categories/components/CategoryDetailPageView.js +92 -38
- package/dist/features/categories/components/ContextualTrail.d.ts +62 -0
- package/dist/features/categories/components/ContextualTrail.js +81 -0
- package/dist/features/categories/components/LastCategoryBeacon.d.ts +18 -0
- package/dist/features/categories/components/LastCategoryBeacon.js +33 -0
- package/dist/features/categories/repository/categories.repository.d.ts +21 -0
- package/dist/features/categories/repository/categories.repository.js +36 -3
- package/dist/features/categories/repository/price-index.repository.d.ts +25 -0
- package/dist/features/email/notification-templates.js +4 -0
- package/dist/features/events/repository/events.repository.js +10 -3
- package/dist/features/faq/constants/read-caps.d.ts +29 -0
- package/dist/features/faq/constants/read-caps.js +29 -0
- package/dist/features/faq/repository/faqs.repository.d.ts +0 -2
- package/dist/features/faq/repository/faqs.repository.js +19 -16
- package/dist/features/homepage/components/AdSlot.js +10 -4
- package/dist/features/homepage/hooks/useActiveAd.d.ts +18 -1
- package/dist/features/homepage/hooks/useActiveAd.js +42 -2
- package/dist/features/homepage/lib/section-renderer.js +12 -2
- package/dist/features/layout/AppLayoutShell.d.ts +4 -1
- package/dist/features/layout/AppLayoutShell.js +2 -2
- package/dist/features/layout/TitleBarLayout.d.ts +4 -1
- package/dist/features/layout/TitleBarLayout.js +8 -4
- package/dist/features/media/AvatarUpload.js +8 -1
- package/dist/features/media/MediaAvatar.js +1 -1
- package/dist/features/media/MediaImage.js +3 -1
- package/dist/features/media/MediaLightbox.js +4 -1
- package/dist/features/media/MediaPickerModal.js +6 -1
- package/dist/features/media/MediaVideo.js +3 -1
- package/dist/features/messages/components/ConversationThreadView.d.ts +3 -0
- package/dist/features/messages/components/ConversationThreadView.js +75 -0
- package/dist/features/messages/components/ConversationsInboxView.d.ts +1 -0
- package/dist/features/messages/components/ConversationsInboxView.js +39 -0
- package/dist/features/messages/components/MessageSellerButton.d.ts +17 -0
- package/dist/features/messages/components/MessageSellerButton.js +33 -0
- package/dist/features/messages/hooks/useConversationChannel.d.ts +24 -0
- package/dist/features/messages/hooks/useConversationChannel.js +89 -0
- package/dist/features/messages/hooks/useMessages.d.ts +347 -0
- package/dist/features/messages/hooks/useMessages.js +113 -0
- package/dist/features/messages/repository/conversations.repository.d.ts +44 -0
- package/dist/features/messages/repository/conversations.repository.js +173 -0
- package/dist/features/messages/repository/messages.repository.d.ts +42 -0
- package/dist/features/messages/repository/messages.repository.js +142 -0
- package/dist/features/messages/schemas/firestore.d.ts +99 -0
- package/dist/features/messages/schemas/firestore.js +46 -0
- package/dist/features/orders/repository/orders.repository.js +21 -7
- package/dist/features/orders/schemas/firestore.d.ts +11 -0
- package/dist/features/products/components/CityCollectiblesView.d.ts +31 -0
- package/dist/features/products/components/CityCollectiblesView.js +82 -0
- package/dist/features/products/components/MarketPriceAnchor.d.ts +45 -0
- package/dist/features/products/components/MarketPriceAnchor.js +62 -0
- package/dist/features/products/components/ProductDetailPageView.js +71 -5
- package/dist/features/products/constants/meetup-cities.d.ts +47 -0
- package/dist/features/products/constants/meetup-cities.js +49 -0
- package/dist/features/products/repository/category-assignment.d.ts +82 -11
- package/dist/features/products/repository/category-assignment.js +93 -12
- package/dist/features/products/repository/products.repository.d.ts +12 -1
- package/dist/features/products/repository/products.repository.js +66 -34
- package/dist/features/products/schemas/firestore.d.ts +24 -2
- package/dist/features/products/schemas/firestore.js +13 -1
- package/dist/features/products/schemas/index.d.ts +2 -2
- package/dist/features/promotions/repository/coupons.repository.js +11 -4
- package/dist/features/reviews/repository/reviews.repository.js +11 -4
- package/dist/features/scams/repository/scammer.repository.js +21 -7
- package/dist/features/seller/components/SellerProductShell.d.ts +1 -1
- package/dist/features/seller/components/SellerProductShell.js +2 -2
- package/dist/features/seller/repository/offer.repository.js +21 -6
- package/dist/features/seller/schemas/product-form.d.ts +3 -3
- package/dist/features/seller/schemas/product-form.js +1 -1
- package/dist/features/server-errors/repository/server-errors.repository.js +11 -0
- package/dist/features/server-errors/schemas/firestore.d.ts +17 -0
- package/dist/features/server-errors/schemas/firestore.js +2 -0
- package/dist/features/store-extensions/repository/store-extensions.repositories.d.ts +17 -1
- package/dist/features/store-extensions/repository/store-extensions.repositories.js +31 -0
- package/dist/features/store-extensions/schemas/firestore.d.ts +1 -1
- package/dist/features/store-extensions/schemas/firestore.js +4 -0
- package/dist/features/stores/repository/store.repository.js +11 -3
- package/dist/features/tester/seed-data/authored/community-support__messages.d.ts +2 -0
- package/dist/features/tester/seed-data/authored/community-support__messages.js +200 -0
- package/dist/features/tester/seed-data/authored/index.js +2 -0
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +64 -1
- package/dist/index.d.ts +8 -2
- package/dist/index.js +1 -1
- package/dist/next/routing/route-map.d.ts +14 -0
- package/dist/next/routing/route-map.js +26 -0
- package/dist/providers/db-firebase/rtdb-paths.d.ts +8 -0
- package/dist/providers/db-firebase/rtdb-paths.js +8 -0
- package/dist/repositories/index.d.ts +1 -0
- package/dist/repositories/index.js +4 -0
- package/dist/schemas/registry.d.ts +3 -3
- package/dist/security/index.d.ts +1 -1
- package/dist/security/index.js +1 -1
- package/dist/security/pii-schemas.d.ts +17 -0
- package/dist/security/pii-schemas.js +15 -7
- package/dist/seed/_helpers/category-content-generated.d.ts +3 -3
- package/dist/seed/_helpers/category-content-generated.js +23 -7
- package/dist/seed/_helpers/category-models.d.ts +1 -1
- package/dist/seed/_helpers/category-models.js +25 -1
- package/dist/seed/_helpers/category-names.d.ts +54 -0
- package/dist/seed/_helpers/category-names.js +118 -0
- package/dist/seed/_helpers/character-features.d.ts +1 -1
- package/dist/seed/_helpers/character-features.js +5 -4
- package/dist/seed/_helpers/final-sale-fixtures.js +1 -1
- package/dist/seed/categories-seed-data.js +55 -1
- package/dist/seed/grouped-listings-seed-data.js +2 -2
- package/dist/seed/products-art-seed-data.js +2 -1
- package/dist/seed/products-auctions-seed-data.js +42 -22
- package/dist/seed/products-classifieds-seed-data.js +21 -3
- package/dist/seed/products-digital-codes-seed-data.js +2 -1
- package/dist/seed/products-live-items-seed-data.js +2 -1
- package/dist/seed/products-preorders-seed-data.js +26 -11
- package/dist/seed/products-prize-draws-seed-data.js +8 -1
- package/dist/seed/products-standard-seed-data.js +26 -25
- package/dist/seed/products-stickers-seed-data.js +12 -11
- package/dist/server.d.ts +8 -0
- package/dist/server.js +15 -0
- package/dist/styles.css +113 -1
- package/dist/tailwind-utilities.css +1 -1
- package/dist/tokens/themes/sunset.js +17 -0
- package/dist/tokens/tokens.css +5 -0
- package/dist/ui/components/Breadcrumb.d.ts +8 -1
- package/dist/ui/components/Breadcrumb.js +2 -2
- package/dist/ui/components/Breadcrumb.style.css +37 -0
- package/dist/ui/components/Details.d.ts +11 -1
- package/dist/ui/components/Details.js +2 -2
- package/dist/ui/components/ImageMosaic.d.ts +43 -0
- package/dist/ui/components/ImageMosaic.js +46 -0
- package/dist/ui/components/ImageMosaic.style.css +69 -0
- package/dist/ui/components/SplitTile.d.ts +34 -0
- package/dist/ui/components/SplitTile.js +28 -0
- package/dist/ui/components/index.style.css +1 -0
- package/dist/ui/index.d.ts +4 -0
- package/dist/ui/index.js +4 -0
- package/dist/utils/search-txt-query.d.ts +77 -0
- package/dist/utils/search-txt-query.js +50 -28
- 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,
|
|
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
|
-
|
|
33
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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";
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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, }) {
|