@mohasinac/appkit 4.63.4 → 4.64.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.
- package/dist/.build-stamp.json +3 -3
- package/dist/_internal/server/features/categories/adapters.d.ts +1 -1
- package/dist/_internal/server/features/categories/adapters.js +13 -0
- package/dist/_internal/server/features/checkout/actions.d.ts +2 -0
- package/dist/_internal/server/features/checkout/actions.js +22 -5
- package/dist/_internal/server/features/events/like-actions.d.ts +25 -0
- package/dist/_internal/server/features/events/like-actions.js +67 -0
- package/dist/_internal/server/features/events/likes.d.ts +65 -0
- package/dist/_internal/server/features/events/likes.js +146 -0
- package/dist/_internal/server/features/orders/adapters.js +3 -1
- package/dist/_internal/server/features/seo/index.d.ts +3 -0
- package/dist/_internal/server/features/seo/index.js +2 -0
- package/dist/_internal/server/features/seo/product-detail-types.d.ts +1 -0
- package/dist/_internal/server/features/seo/product-detail-types.js +26 -0
- package/dist/_internal/server/features/seo/robots.js +20 -1
- package/dist/_internal/server/features/seo/sitemap.js +125 -27
- package/dist/_internal/server/features/seo/static-params.d.ts +107 -0
- package/dist/_internal/server/features/seo/static-params.js +228 -0
- package/dist/_internal/server/features/seo/taxonomy-links.d.ts +44 -0
- package/dist/_internal/server/features/seo/taxonomy-links.js +85 -0
- package/dist/_internal/server/features/site-settings/adapters.js +20 -2
- package/dist/_internal/server/features/stores/adapters.d.ts +2 -2
- package/dist/_internal/server/features/stores/adapters.js +88 -1
- package/dist/_internal/server/functions/scheduled.d.ts +16 -1
- package/dist/_internal/server/functions/scheduled.js +23 -1
- package/dist/_internal/server/jobs/core/analyticsAlertEvaluate.js +5 -2
- package/dist/_internal/server/jobs/core/jobRunners.js +5 -0
- package/dist/_internal/server/jobs/core/onOrderCreate.js +104 -10
- package/dist/_internal/server/jobs/core/onOrderStatusChange.d.ts +2 -0
- package/dist/_internal/server/jobs/core/onOrderStatusChange.js +1 -0
- package/dist/_internal/server/jobs/core/onScamReportCreate.js +1 -0
- package/dist/_internal/server/jobs/core/onScamReportRejected.js +1 -0
- package/dist/_internal/server/jobs/core/onScamReportVerified.js +1 -0
- package/dist/_internal/server/jobs/core/paymentWindowTimeout.js +1 -0
- package/dist/_internal/server/jobs/core/pendingOrderTimeout.js +1 -0
- package/dist/_internal/server/jobs/core/sellerStandingRollup.d.ts +63 -0
- package/dist/_internal/server/jobs/core/sellerStandingRollup.js +412 -0
- package/dist/_internal/server/jobs/core/whatsappNotify.d.ts +35 -0
- package/dist/_internal/server/jobs/core/whatsappNotify.js +140 -0
- package/dist/_internal/server/jobs/handlers/index.d.ts +1 -0
- package/dist/_internal/server/jobs/handlers/index.js +1 -0
- package/dist/_internal/server/jobs/handlers/sellerStandingRollup.d.ts +12 -0
- package/dist/_internal/server/jobs/handlers/sellerStandingRollup.js +14 -0
- package/dist/_internal/server/notifications/channel-health.d.ts +8 -6
- package/dist/_internal/server/notifications/channel-health.js +2 -2
- package/dist/_internal/server/notifications/send-guard.d.ts +1 -1
- package/dist/_internal/server/notifications/send-guard.js +4 -2
- package/dist/_internal/shared/actions/action-registry.js +89 -0
- package/dist/_internal/shared/checkout/order-math.d.ts +1 -1
- package/dist/_internal/shared/checkout/order-math.js +1 -1
- package/dist/_internal/shared/features/checkout/config.d.ts +2 -0
- package/dist/_internal/shared/features/checkout/config.js +2 -0
- package/dist/_internal/shared/features/messaging/config.d.ts +5 -7
- package/dist/_internal/shared/features/messaging/config.js +4 -1
- package/dist/_internal/shared/features/seller-standing/config.d.ts +130 -0
- package/dist/_internal/shared/features/seller-standing/config.js +153 -0
- package/dist/_internal/shared/features/site-settings/fees.d.ts +2 -0
- package/dist/_internal/shared/features/site-settings/fees.js +2 -0
- package/dist/_internal/shared/features/site-settings/types.d.ts +27 -2
- package/dist/_internal/shared/fees/calculator.d.ts +8 -0
- package/dist/_internal/shared/fees/calculator.js +7 -0
- package/dist/cli/index.js +2 -0
- package/dist/client.d.ts +2 -2
- package/dist/client.js +1 -1
- package/dist/constants/api-endpoints.d.ts +39 -0
- package/dist/constants/api-endpoints.js +16 -0
- package/dist/constants/notification-types.d.ts +1 -1
- package/dist/constants/notification-types.js +1 -1
- package/dist/core/integration-keys.d.ts +5 -0
- package/dist/core/integration-keys.js +10 -0
- package/dist/features/about/components/FeesView.js +6 -0
- package/dist/features/about/components/HelpPageView.js +9 -1
- package/dist/features/account/components/BecomeSellerView.d.ts +15 -3
- package/dist/features/account/components/BecomeSellerView.js +2 -2
- package/dist/features/account/components/BuyerShoppingGuideView.js +1 -1
- package/dist/features/account/components/NotificationPreferencesPanel.js +4 -3
- package/dist/features/account/types/index.d.ts +2 -0
- package/dist/features/admin/actions/notification-actions.d.ts +17 -2
- package/dist/features/admin/actions/notification-actions.js +154 -9
- package/dist/features/admin/components/AdminOrderEditorView.d.ts +1 -1
- package/dist/features/admin/components/AdminOrdersView.js +2 -0
- package/dist/features/admin/components/AdminSiteSettingsView.js +66 -6
- package/dist/features/admin/components/AdminStoreEditorView.d.ts +12 -1
- package/dist/features/admin/components/AdminStoreEditorView.js +31 -2
- package/dist/features/admin/components/AdminStoresGuideView.js +1 -1
- package/dist/features/admin/components/AdminStoresView.js +59 -2
- package/dist/features/admin/components/AdminTrustGuideView.js +10 -0
- package/dist/features/admin/constants/site-settings-tabs.d.ts +12 -7
- package/dist/features/admin/constants/site-settings-tabs.js +12 -6
- package/dist/features/admin/repository/site-settings.repository.js +1 -1
- package/dist/features/admin/schemas/admin-editor-forms.d.ts +10 -0
- package/dist/features/admin/schemas/admin-editor-forms.js +34 -0
- package/dist/features/admin/schemas/admin-user-form.d.ts +61 -0
- package/dist/features/admin/schemas/admin-user-form.js +44 -3
- package/dist/features/admin/schemas/firestore.d.ts +73 -0
- package/dist/features/admin/schemas/firestore.js +8 -3
- package/dist/features/auth/checkout-value-otp.d.ts +1 -1
- package/dist/features/auth/checkout-value-otp.js +1 -1
- package/dist/features/auth/permissions/constants.d.ts +2 -2
- package/dist/features/auth/permissions/constants.js +2 -1
- package/dist/features/blog/components/BlogIndexPageView.js +2 -2
- package/dist/features/blog/components/BlogListView.js +2 -1
- package/dist/features/blog/components/BlogPostView.js +2 -1
- package/dist/features/blog/constants/category-badge.d.ts +33 -0
- package/dist/features/blog/constants/category-badge.js +40 -0
- package/dist/features/cart/components/CartPriceBreakdown.d.ts +2 -0
- package/dist/features/cart/components/CartPriceBreakdown.js +1 -1
- package/dist/features/cart/components/StoreAddonsPicker.d.ts +5 -2
- package/dist/features/cart/components/StoreAddonsPicker.js +5 -4
- package/dist/features/cart/repository/cart.repository.js +2 -1
- package/dist/features/cart/schemas/firestore.d.ts +2 -1
- package/dist/features/categories/components/TaxonomyLinkIndex.d.ts +39 -0
- package/dist/features/categories/components/TaxonomyLinkIndex.js +11 -0
- package/dist/features/categories/schemas/firestore.d.ts +25 -0
- package/dist/features/categories/utils/load-category-options.d.ts +33 -0
- package/dist/features/categories/utils/load-category-options.js +50 -0
- package/dist/features/checkout/actions/checkout-value-otp-actions.d.ts +5 -10
- package/dist/features/checkout/actions/checkout-value-otp-actions.js +88 -2
- package/dist/features/events/components/EventCard.js +4 -12
- package/dist/features/events/components/EventInsights.d.ts +27 -0
- package/dist/features/events/components/EventInsights.js +10 -0
- package/dist/features/events/components/EventLikeButton.d.ts +21 -0
- package/dist/features/events/components/EventLikeButton.js +59 -0
- package/dist/features/events/components/EventLikersRail.d.ts +23 -0
- package/dist/features/events/components/EventLikersRail.js +54 -0
- package/dist/features/events/components/EventTypeBadge.d.ts +26 -0
- package/dist/features/events/components/EventTypeBadge.js +7 -0
- package/dist/features/events/components/EventsListPageView.js +2 -2
- package/dist/features/events/components/SpinWheel.d.ts +41 -0
- package/dist/features/events/components/SpinWheel.js +128 -0
- package/dist/features/events/components/SpinWheelView.js +36 -15
- package/dist/features/events/constants/event-type-meta.d.ts +54 -0
- package/dist/features/events/constants/event-type-meta.js +109 -0
- package/dist/features/events/repository/event-likes.repository.d.ts +57 -0
- package/dist/features/events/repository/event-likes.repository.js +151 -0
- package/dist/features/events/schemas/firestore.d.ts +111 -0
- package/dist/features/events/schemas/likes.d.ts +81 -0
- package/dist/features/events/schemas/likes.js +59 -0
- package/dist/features/events/types/index.d.ts +7 -0
- package/dist/features/events/utils/carousel-slide.d.ts +42 -0
- package/dist/features/events/utils/carousel-slide.js +174 -0
- package/dist/features/events/utils/event-charts.d.ts +65 -0
- package/dist/features/events/utils/event-charts.js +172 -0
- package/dist/features/faq/constants/help-guides.js +9 -0
- package/dist/features/faq/schemas/firestore.js +1 -0
- package/dist/features/faq/types/index.d.ts +19 -0
- package/dist/features/homepage/components/BrandsSection.js +25 -1
- package/dist/features/homepage/components/HeroCarousel.js +47 -10
- package/dist/features/homepage/lib/section-renderer.js +1 -1
- package/dist/features/homepage/types/index.d.ts +7 -0
- package/dist/features/orders/actions/refund-actions.js +1 -0
- package/dist/features/orders/components/OrderAddonBadges.d.ts +3 -1
- package/dist/features/orders/components/OrderAddonBadges.js +4 -3
- package/dist/features/orders/repository/orders.repository.d.ts +8 -0
- package/dist/features/orders/repository/orders.repository.js +2 -0
- package/dist/features/orders/schemas/firestore.d.ts +4 -0
- package/dist/features/orders/types/index.d.ts +5 -2
- package/dist/features/search/action-index/settings-entries.js +9 -0
- package/dist/features/store-extensions/repository/store-extensions.repositories.d.ts +6 -1
- package/dist/features/store-extensions/repository/store-extensions.repositories.js +10 -1
- package/dist/features/store-extensions/schemas/firestore.d.ts +17 -1
- package/dist/features/store-extensions/schemas/firestore.js +10 -0
- package/dist/features/stores/components/InteractiveStoreCard.js +2 -1
- package/dist/features/stores/components/SellerStandingPanel.d.ts +31 -0
- package/dist/features/stores/components/SellerStandingPanel.js +25 -0
- package/dist/features/stores/components/SellerTierBadge.d.ts +57 -0
- package/dist/features/stores/components/SellerTierBadge.js +19 -0
- package/dist/features/stores/components/StoreCapabilitiesGuideView.js +33 -2
- package/dist/features/stores/components/StoreCreateForm.d.ts +46 -0
- package/dist/features/stores/components/StoreCreateForm.js +107 -0
- package/dist/features/stores/components/StoreGuideHubView.js +18 -5
- package/dist/features/stores/components/StoreHeader.js +2 -1
- package/dist/features/stores/components/StoreSettingsGuideView.js +6 -2
- package/dist/features/stores/repository/store.repository.d.ts +23 -1
- package/dist/features/stores/repository/store.repository.js +52 -1
- package/dist/features/stores/schemas/firestore.d.ts +76 -7
- package/dist/features/stores/schemas/firestore.js +21 -6
- package/dist/features/stores/schemas/index.d.ts +88 -2
- package/dist/features/stores/schemas/index.js +14 -0
- package/dist/features/stores/schemas/store-create.d.ts +47 -0
- package/dist/features/stores/schemas/store-create.js +86 -0
- package/dist/features/stores/types/index.d.ts +42 -0
- package/dist/features/whatsapp-bot/api/send-status/route.d.ts +12 -0
- package/dist/features/whatsapp-bot/api/send-status/route.js +38 -0
- package/dist/features/whatsapp-bot/api/webhook/route.d.ts +12 -0
- package/dist/features/whatsapp-bot/api/webhook/route.js +43 -0
- package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.d.ts +57 -0
- package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.js +76 -0
- package/dist/features/whatsapp-bot/components/WhatsAppChatButton.d.ts +9 -0
- package/dist/features/whatsapp-bot/components/WhatsAppChatButton.js +7 -0
- package/dist/features/whatsapp-bot/components/index.d.ts +3 -0
- package/dist/features/whatsapp-bot/components/index.js +2 -0
- package/dist/features/whatsapp-bot/helpers/whatsapp.d.ts +52 -0
- package/dist/features/whatsapp-bot/helpers/whatsapp.js +288 -0
- package/dist/features/whatsapp-bot/index.d.ts +3 -0
- package/dist/features/whatsapp-bot/index.js +3 -0
- package/dist/features/whatsapp-bot/manifest.d.ts +2 -0
- package/dist/features/whatsapp-bot/manifest.js +13 -0
- package/dist/features/whatsapp-bot/messages/en.json +7 -0
- package/dist/features/whatsapp-bot/server.d.ts +7 -0
- package/dist/features/whatsapp-bot/server.js +7 -0
- package/dist/features/whatsapp-bot/types/index.d.ts +126 -0
- package/dist/features/whatsapp-bot/types/index.js +1 -0
- package/dist/index.d.ts +23 -6
- package/dist/index.js +38 -12
- package/dist/next/routing/route-map.d.ts +6 -1
- package/dist/next/routing/route-map.js +14 -0
- package/dist/providers/db-firebase/base.repository.d.ts +1 -1
- package/dist/providers/db-firebase/base.repository.js +1 -1
- package/dist/repositories/index.d.ts +1 -1
- package/dist/repositories/index.js +1 -1
- package/dist/schemas/registry.d.ts +115 -0
- package/dist/schemas/registry.js +2 -1
- package/dist/security/pii-encrypt.js +1 -1
- package/dist/server.d.ts +15 -2
- package/dist/server.js +32 -2
- package/dist/styles.css +36 -1
- package/dist/tailwind-utilities.css +1 -1
- package/dist/ui/components/HorizontalScroller.js +15 -2
- package/dist/ui/components/NavigationLoader.d.ts +14 -1
- package/dist/ui/components/NavigationLoader.js +79 -30
- package/dist/ui/components/PageHeroHeading.d.ts +40 -0
- package/dist/ui/components/PageHeroHeading.js +15 -0
- package/dist/ui/components/Typography.d.ts +3 -3
- package/dist/ui/components/Typography.js +59 -31
- package/dist/ui/components/Typography.style.css +35 -0
- package/dist/ui/index.d.ts +2 -0
- package/dist/ui/index.js +1 -0
- package/package.json +1 -1
- package/scripts/seed-cli.mjs +4 -2
- package/dist/features/orders/utils/order-announcement-message.d.ts +0 -18
- package/dist/features/orders/utils/order-announcement-message.js +0 -16
|
@@ -32,6 +32,10 @@ export interface NotificationDocument extends BaseDocument {
|
|
|
32
32
|
readAt?: Date;
|
|
33
33
|
relatedId?: string;
|
|
34
34
|
relatedType?: NotificationRelatedType;
|
|
35
|
+
/** Outcome of the async whatsappNotify job for this notification's WhatsApp send, if one was enqueued. */
|
|
36
|
+
whatsappStatus?: "queued" | "sent" | "failed" | "skipped";
|
|
37
|
+
/** jobs/{id} doc that ran (or is running) the WhatsApp send for this notification. */
|
|
38
|
+
whatsappJobId?: string;
|
|
35
39
|
}
|
|
36
40
|
export declare const NOTIFICATIONS_COLLECTION: "notifications";
|
|
37
41
|
export declare const NOTIFICATIONS_INDEXED_FIELDS: readonly ["userId", "isRead", "createdAt", "type"];
|
|
@@ -110,6 +114,7 @@ export interface SiteSettingsCredentials {
|
|
|
110
114
|
/** Webhook signing secret from Razorpay's dashboard → Webhooks. Without it `verifyWebhook()` fails closed. */
|
|
111
115
|
razorpayWebhookSecret?: string;
|
|
112
116
|
resendApiKey?: string;
|
|
117
|
+
whatsappApiKey?: string;
|
|
113
118
|
metaAppId?: string;
|
|
114
119
|
metaAppSecret?: string;
|
|
115
120
|
metaPageAccessToken?: string;
|
|
@@ -124,6 +129,28 @@ export interface SiteSettingsCredentials {
|
|
|
124
129
|
/** Google Places API for Google Business Reviews (HS4) */
|
|
125
130
|
googleMapsApiKey?: string;
|
|
126
131
|
googlePlaceId?: string;
|
|
132
|
+
/** WhatsApp Business Cloud API — platform level (for admin purchase announcements) */
|
|
133
|
+
whatsappPhoneNumberId?: string;
|
|
134
|
+
whatsappCloudApiToken?: string;
|
|
135
|
+
/** Comma-separated digits-only phone numbers that receive order announcements */
|
|
136
|
+
whatsappAdminNotifyNumbers?: string;
|
|
137
|
+
/**
|
|
138
|
+
* Approved Meta message-template names, one flat string field per
|
|
139
|
+
* order-lifecycle notification type — every `SiteSettingsCredentials`
|
|
140
|
+
* field is stored as an individually-encrypted string (see
|
|
141
|
+
* `getDecryptedCredentials`/`getCredentialsMasked`, which iterate + mask
|
|
142
|
+
* every field as a string), so this can't be a `Record<string,string>`
|
|
143
|
+
* map. Business-initiated WhatsApp sends outside the 24h customer-service
|
|
144
|
+
* window require a pre-approved template.
|
|
145
|
+
*/
|
|
146
|
+
whatsappTemplateOrderPlaced?: string;
|
|
147
|
+
whatsappTemplateOrderConfirmed?: string;
|
|
148
|
+
whatsappTemplateOrderShipped?: string;
|
|
149
|
+
whatsappTemplateOrderDelivered?: string;
|
|
150
|
+
whatsappTemplateOrderCancelled?: string;
|
|
151
|
+
whatsappTemplateRefundInitiated?: string;
|
|
152
|
+
/** BCP-47 language code the templates above were approved in, e.g. "en" or "en_US". */
|
|
153
|
+
whatsappTemplateLanguage?: string;
|
|
127
154
|
}
|
|
128
155
|
export interface SiteSettingsCredentialsMasked {
|
|
129
156
|
phonepeClientId?: string;
|
|
@@ -136,6 +163,7 @@ export interface SiteSettingsCredentialsMasked {
|
|
|
136
163
|
razorpayKeySecret?: string;
|
|
137
164
|
razorpayWebhookSecret?: string;
|
|
138
165
|
resendApiKey?: string;
|
|
166
|
+
whatsappApiKey?: string;
|
|
139
167
|
metaAppId?: string;
|
|
140
168
|
metaAppSecret?: string;
|
|
141
169
|
metaPageAccessToken?: string;
|
|
@@ -147,6 +175,16 @@ export interface SiteSettingsCredentialsMasked {
|
|
|
147
175
|
deviantartClientSecret?: string;
|
|
148
176
|
googleMapsApiKey?: string;
|
|
149
177
|
googlePlaceId?: string;
|
|
178
|
+
whatsappPhoneNumberId?: string;
|
|
179
|
+
whatsappCloudApiToken?: string;
|
|
180
|
+
whatsappAdminNotifyNumbers?: string;
|
|
181
|
+
whatsappTemplateOrderPlaced?: string;
|
|
182
|
+
whatsappTemplateOrderConfirmed?: string;
|
|
183
|
+
whatsappTemplateOrderShipped?: string;
|
|
184
|
+
whatsappTemplateOrderDelivered?: string;
|
|
185
|
+
whatsappTemplateOrderCancelled?: string;
|
|
186
|
+
whatsappTemplateRefundInitiated?: string;
|
|
187
|
+
whatsappTemplateLanguage?: string;
|
|
150
188
|
}
|
|
151
189
|
export interface NotificationChannelConfig {
|
|
152
190
|
inApp: {
|
|
@@ -162,6 +200,15 @@ export interface NotificationChannelConfig {
|
|
|
162
200
|
/** Subset of NotificationTypes to email — absent means all types. */
|
|
163
201
|
types?: NotificationType[];
|
|
164
202
|
};
|
|
203
|
+
whatsapp: {
|
|
204
|
+
enabled: boolean;
|
|
205
|
+
/** Minimum priority that triggers a WhatsApp message (default: "high"). */
|
|
206
|
+
minPriority?: NotificationPriority;
|
|
207
|
+
/** Subset of NotificationTypes to WhatsApp — absent means all types. */
|
|
208
|
+
types?: NotificationType[];
|
|
209
|
+
/** When true, WhatsApp OTP is offered as an alternative to email OTP at checkout. */
|
|
210
|
+
otpEnabled?: boolean;
|
|
211
|
+
};
|
|
165
212
|
sms: {
|
|
166
213
|
enabled: boolean;
|
|
167
214
|
/** Minimum priority that triggers an SMS (default: "high"). */
|
|
@@ -452,6 +499,29 @@ export interface SiteSettingsDocument extends BaseDocument {
|
|
|
452
499
|
* ₹2,000 of "handling" on a ₹20,000 order — see `computeCodHandlingFee`.
|
|
453
500
|
*/ codHandlingFeeCap: number;
|
|
454
501
|
codHandlingFeePercent: number;
|
|
502
|
+
/**
|
|
503
|
+
* Admin master toggle for the per-order, BUYER-paid WhatsApp addon.
|
|
504
|
+
*
|
|
505
|
+
* 🛑 Defaults to FALSE. The cost of WhatsApp moved to a standing monthly
|
|
506
|
+
* charge on the SELLER (`whatsappNotifyMonthlyFee` below) — the platform
|
|
507
|
+
* owns one number, so the expense is standing rather than per order, and a
|
|
508
|
+
* buyer should not have to purchase their own delivery updates. The
|
|
509
|
+
* checkout path that charges it is left intact rather than cut out again:
|
|
510
|
+
* it is woven through the order-total arithmetic that carries D42's
|
|
511
|
+
* round-once fix, and disabling it makes the fee resolve to 0 without
|
|
512
|
+
* touching that money code.
|
|
513
|
+
*/
|
|
514
|
+
whatsappNotifyFeeEnabled: boolean;
|
|
515
|
+
/** Flat rupee fee charged to the buyer when they opt into the per-order addon. Unreachable while `whatsappNotifyFeeEnabled` is false. */
|
|
516
|
+
whatsappNotifyFee: number;
|
|
517
|
+
/**
|
|
518
|
+
* What a seller pays per month to receive WhatsApp order notifications —
|
|
519
|
+
* the admin-set price of `StoreDocument.whatsappNotifySubscribed`.
|
|
520
|
+
*
|
|
521
|
+
* Rupees per month, not per message: Meta bills us per template, but a
|
|
522
|
+
* seller cannot predict their own order count, and a per-message price
|
|
523
|
+
* would make their bill a function of how well they sold.
|
|
524
|
+
*/ whatsappNotifyMonthlyFee: number;
|
|
455
525
|
/** Admin master toggle — whether the gift-wrap addon is offered at checkout at all. */
|
|
456
526
|
giftWrapFeeEnabled: boolean;
|
|
457
527
|
/** Flat rupee fee charged to the buyer when they opt into gift wrap. */
|
|
@@ -537,6 +607,8 @@ export interface SiteSettingsDocument extends BaseDocument {
|
|
|
537
607
|
messaging?: {
|
|
538
608
|
/** User-facing email. Staff mail (daily digest, payout summary) ignores this. */
|
|
539
609
|
emailEnabled: boolean;
|
|
610
|
+
/** User-facing WhatsApp. Same exemption for staff. */
|
|
611
|
+
whatsappEnabled: boolean;
|
|
540
612
|
/**
|
|
541
613
|
* Reserved sends allowed per channel per day. Applies to staff and
|
|
542
614
|
* transactional mail too — the ceiling exists to stay inside the
|
|
@@ -544,6 +616,7 @@ export interface SiteSettingsDocument extends BaseDocument {
|
|
|
544
616
|
*/
|
|
545
617
|
dailyCeiling: {
|
|
546
618
|
email: number;
|
|
619
|
+
whatsapp: number;
|
|
547
620
|
sms: number;
|
|
548
621
|
};
|
|
549
622
|
};
|
|
@@ -15,7 +15,7 @@ export const ADMIN_CHECKOUT_BYPASS_FLAG_KEY = "adminCheckoutBypass";
|
|
|
15
15
|
* · `constants/notification-types.ts` (9) — fed the admin filter chips and
|
|
16
16
|
* the per-channel allow-list, so the admin list could not filter 18 of
|
|
17
17
|
* the 27 real types and an admin literally could not allow-list
|
|
18
|
-
* `offer_received` or `payment_review` for email
|
|
18
|
+
* `offer_received` or `payment_review` for email or WhatsApp
|
|
19
19
|
* · `features/account/types/index.ts` (5: order|offer|promo|system|message)
|
|
20
20
|
* — a different vocabulary entirely, on a `UserNotification` type with
|
|
21
21
|
* zero real consumers
|
|
@@ -195,6 +195,7 @@ export const notificationQueryHelpers = {
|
|
|
195
195
|
export const DEFAULT_NOTIFICATION_CHANNELS = {
|
|
196
196
|
inApp: { enabled: true, readOnly: true },
|
|
197
197
|
email: { enabled: false, minPriority: "normal" },
|
|
198
|
+
whatsapp: { enabled: false, minPriority: "high", otpEnabled: false },
|
|
198
199
|
sms: { enabled: false, minPriority: "high" },
|
|
199
200
|
};
|
|
200
201
|
const PRIORITY_ORDER = {
|
|
@@ -254,7 +255,8 @@ export const DEFAULT_SITE_SETTINGS_DATA = {
|
|
|
254
255
|
// half-configured site.
|
|
255
256
|
messaging: {
|
|
256
257
|
emailEnabled: false,
|
|
257
|
-
|
|
258
|
+
whatsappEnabled: false,
|
|
259
|
+
dailyCeiling: { email: 80, whatsapp: 200, sms: 50 },
|
|
258
260
|
},
|
|
259
261
|
emi: {
|
|
260
262
|
enabled: false,
|
|
@@ -277,9 +279,12 @@ export const DEFAULT_SITE_SETTINGS_DATA = {
|
|
|
277
279
|
// The buyer add-ons ship ENABLED. They were `false`, which made
|
|
278
280
|
// StoreAddonsPicker return null on both the cart and checkout, so a fresh
|
|
279
281
|
// install had no add-ons at all and no indication why. `undefined` still
|
|
280
|
-
// means disabled everywhere (
|
|
282
|
+
// means disabled everywhere (computeWhatsAppNotifyFee et al. read
|
|
281
283
|
// `if (!rates.XEnabled) return 0`) — only the DEFAULT changed, so an
|
|
282
284
|
// existing siteSettings document keeps whatever it already stored.
|
|
285
|
+
whatsappNotifyFeeEnabled: false,
|
|
286
|
+
whatsappNotifyFee: 10,
|
|
287
|
+
whatsappNotifyMonthlyFee: 199,
|
|
283
288
|
giftWrapFeeEnabled: true,
|
|
284
289
|
giftWrapFee: 49,
|
|
285
290
|
shipmentProtectionFeeEnabled: true,
|
|
@@ -44,6 +44,6 @@ export interface CheckoutValueOtpDoc {
|
|
|
44
44
|
* making the whole feature look broken.
|
|
45
45
|
*/
|
|
46
46
|
export declare function enforceCheckoutValueOtpRateLimit(db: Db, uid: string): Promise<void>;
|
|
47
|
-
/** Build the checkout-value OTP message body. */
|
|
47
|
+
/** Build the checkout-value OTP WhatsApp/SMS message body. */
|
|
48
48
|
export declare function buildCheckoutValueOtpMessage(code: string, siteName: string): string;
|
|
49
49
|
export {};
|
|
@@ -75,7 +75,7 @@ export async function enforceCheckoutValueOtpRateLimit(db, uid) {
|
|
|
75
75
|
tx.set(metaRef, { lastSentAt: new Date() }, { merge: true });
|
|
76
76
|
});
|
|
77
77
|
}
|
|
78
|
-
/** Build the checkout-value OTP message body. */
|
|
78
|
+
/** Build the checkout-value OTP WhatsApp/SMS message body. */
|
|
79
79
|
export function buildCheckoutValueOtpMessage(code, siteName) {
|
|
80
80
|
return (`*${siteName} — Order Verification*\n\n` +
|
|
81
81
|
`Your verification code for this high-value order is: *${code}*\n\n` +
|
|
@@ -83,7 +83,7 @@ export declare const ROUTE_PERMISSION_MAP: Record<string, Permission>;
|
|
|
83
83
|
*
|
|
84
84
|
* Defaults on store creation: ["suggest_brands", "create_coupons"]
|
|
85
85
|
*/
|
|
86
|
-
export type StoreCapability = "advanced_listing_types" | "host_auctions" | "host_preorders" | "create_categories" | "suggest_brands" | "create_coupons" | "bulk_listing_import" | "extended_return_window" | "verified_seller" | "featured_placement" | "promotional_banner" | "priority_support" | "multiple_stores" | "custom_store_slug" | "api_access" | "lower_commission_rate" | "early_access_features" | "advanced_analytics";
|
|
86
|
+
export type StoreCapability = "advanced_listing_types" | "host_auctions" | "host_preorders" | "create_categories" | "suggest_brands" | "create_coupons" | "bulk_listing_import" | "extended_return_window" | "verified_seller" | "featured_placement" | "promotional_banner" | "priority_support" | "multiple_stores" | "custom_store_slug" | "api_access" | "lower_commission_rate" | "early_access_features" | "advanced_analytics" | "whatsapp_catalog_sync";
|
|
87
87
|
export declare const ALL_STORE_CAPABILITIES: StoreCapability[];
|
|
88
88
|
/** Narrow an untrusted string to a real capability. */
|
|
89
89
|
export declare function isStoreCapability(value: unknown): value is StoreCapability;
|
|
@@ -118,7 +118,7 @@ export declare function hasCapability(capabilities: StoreCapability[], required:
|
|
|
118
118
|
*
|
|
119
119
|
* 🛑 The PURE half of `getStoreCapabilities`, extracted so a caller that
|
|
120
120
|
* already holds the store document does not pay a second Firestore read to ask
|
|
121
|
-
* what it is entitled to (Rule #6).
|
|
121
|
+
* what it is entitled to (Rule #6). Three WhatsApp routes were reading
|
|
122
122
|
* `store.capabilities ?? []` inline for exactly that reason, which made them a
|
|
123
123
|
* second, divergent reader of the field — they skipped the default merge, so
|
|
124
124
|
* they and `getStoreCapabilities` could disagree about the same store.
|
|
@@ -390,6 +390,7 @@ const STORE_CAPABILITY_MAP = {
|
|
|
390
390
|
lower_commission_rate: true,
|
|
391
391
|
early_access_features: true,
|
|
392
392
|
advanced_analytics: true,
|
|
393
|
+
whatsapp_catalog_sync: true,
|
|
393
394
|
};
|
|
394
395
|
export const ALL_STORE_CAPABILITIES = Object.keys(STORE_CAPABILITY_MAP);
|
|
395
396
|
/** Narrow an untrusted string to a real capability. */
|
|
@@ -441,7 +442,7 @@ export function hasCapability(capabilities, required) {
|
|
|
441
442
|
*
|
|
442
443
|
* 🛑 The PURE half of `getStoreCapabilities`, extracted so a caller that
|
|
443
444
|
* already holds the store document does not pay a second Firestore read to ask
|
|
444
|
-
* what it is entitled to (Rule #6).
|
|
445
|
+
* what it is entitled to (Rule #6). Three WhatsApp routes were reading
|
|
445
446
|
* `store.capabilities ?? []` inline for exactly that reason, which made them a
|
|
446
447
|
* second, divergent reader of the field — they skipped the default merge, so
|
|
447
448
|
* they and `getStoreCapabilities` could disagree about the same store.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { blogRepository } from "../../../repositories/index.js";
|
|
3
|
-
import { Container,
|
|
3
|
+
import { Container, Main, Section, PageHeroHeading } from "../../../ui/index.js";
|
|
4
4
|
import { AdSlot } from "../../homepage/components/AdSlot.js";
|
|
5
5
|
import { BlogIndexListing } from "./BlogIndexListing.js";
|
|
6
6
|
import { BLOG_DEFAULT_PUBLIC_SORT } from "../schemas/firestore.js";
|
|
@@ -41,5 +41,5 @@ export async function BlogIndexPageView({ searchParams = {} }) {
|
|
|
41
41
|
},
|
|
42
42
|
}
|
|
43
43
|
: undefined;
|
|
44
|
-
return (_jsx(Main, { children: _jsx(Section, { padding: "y-2xl", children: _jsxs(Container, { size: "xl", children: [_jsx(
|
|
44
|
+
return (_jsx(Main, { children: _jsx(Section, { padding: "y-2xl", children: _jsxs(Container, { size: "xl", children: [_jsx(PageHeroHeading, { className: "mb-8", pill: "From the shop floor", title: "Blog", description: "Buying guides, release notes and what we have learned importing and grading Beyblades.", meta: initialData?.meta?.total ? `${initialData.meta.total} articles` : undefined }), _jsx(AdSlot, { id: "listing-sidebar-top", className: "mb-6" }), _jsx(BlogIndexListing, { initialData: initialData }), _jsx(AdSlot, { id: "listing-sidebar-bottom", className: "mt-8" })] }) }) }));
|
|
45
45
|
}
|
|
@@ -3,6 +3,7 @@ import React from "react";
|
|
|
3
3
|
import Link from "next/link.js";
|
|
4
4
|
import { Article, Div, Grid, Heading, OverflowRow, Pagination, Row, Span, Stack, Text } from "../../../ui/index.js";
|
|
5
5
|
import { MediaImage } from "../../media/MediaImage.js";
|
|
6
|
+
import { blogCategoryBadge } from "../constants/category-badge.js";
|
|
6
7
|
import { getDefaultLocale } from "../../../core/baseline-resolver.js";
|
|
7
8
|
import { getMediaUrl } from "../../media/types/index.js";
|
|
8
9
|
import { safeDisplayName } from "../../../security/index.js";
|
|
@@ -25,7 +26,7 @@ export function BlogCard({ post, href, onClick, className = "" }) {
|
|
|
25
26
|
const isInteractive = !!(href || onClick);
|
|
26
27
|
const card = (_jsxs(Article, { role: onClick && !href ? "button" : undefined, tabIndex: onClick && !href ? 0 : undefined, onKeyDown: onClick && !href
|
|
27
28
|
? (e) => (e.key === "Enter" || e.key === "") && onClick(post)
|
|
28
|
-
: undefined, onClick: onClick && !href ? () => onClick(post) : undefined, className: `group flex flex-col h-full overflow-hidden rounded-xl border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] shadow-sm transition hover:shadow-md ${isInteractive ? "cursor-pointer" : ""} ${className}`, children: [_jsx(Div, { className: `relative aspect-video w-full ${__O.hidden} bg-[var(--appkit-color-surface)] dark:bg-[var(--appkit-color-surface-elevated)] flex-shrink-0`, children: coverImageUrl ? (_jsx(MediaImage, { src: coverImageUrl, alt: post.title, size: "card", className: "transition-transform duration-300 group-hover:scale-105" })) : (_jsx(Row, { surface: "muted", className: "h-full w-full", align: "center", justify: "center", children: _jsx(Span, { className: "opacity-30", size: "4xl", "aria-hidden": "true", children: "\u270D\uFE0F" }) })) }), _jsxs(Stack, { className: `flex-1 ${__P.p5}`, children: [_jsxs(Row, { className: "mb-2", gap: "sm", wrap: true, children: [_jsx(Span, { size: "xs", weight: "medium", className:
|
|
29
|
+
: undefined, onClick: onClick && !href ? () => onClick(post) : undefined, className: `group flex flex-col h-full overflow-hidden rounded-xl border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] shadow-sm transition hover:shadow-md ${isInteractive ? "cursor-pointer" : ""} ${className}`, children: [_jsx(Div, { className: `relative aspect-video w-full ${__O.hidden} bg-[var(--appkit-color-surface)] dark:bg-[var(--appkit-color-surface-elevated)] flex-shrink-0`, children: coverImageUrl ? (_jsx(MediaImage, { src: coverImageUrl, alt: post.title, size: "card", className: "transition-transform duration-300 group-hover:scale-105" })) : (_jsx(Row, { surface: "muted", className: "h-full w-full", align: "center", justify: "center", children: _jsx(Span, { className: "opacity-30", size: "4xl", "aria-hidden": "true", children: "\u270D\uFE0F" }) })) }), _jsxs(Stack, { className: `flex-1 ${__P.p5}`, children: [_jsxs(Row, { className: "mb-2", gap: "sm", wrap: true, children: [_jsx(Span, { size: "xs", weight: "medium", className: blogCategoryBadge(post.category), rounded: "full", padding: "pill-xs", transform: "capitalize", children: post.category }), post.isFeatured && (_jsx(Span, { size: "xs", weight: "medium", className: CLS_FEATURED_BADGE, children: "Featured" })), post.readTimeMinutes && (_jsxs(Span, { size: "xs", color: "muted", children: [post.readTimeMinutes, " min read"] }))] }), _jsx(Heading, { level: 3, className: `group-hover:text-primary`, color: "primary", truncate: 2, size: "base", weight: "semibold", children: post.title }), post.excerpt && (_jsx(Text, { className: `mt-2 flex-1`, color: "muted", truncate: 3, size: "sm", children: post.excerpt })), _jsxs(Row, { className: "mt-auto", padding: "t-sm", gap: "3", children: [post.authorAvatar ? (_jsx(Div, { className: "relative h-7 w-7 flex-shrink-0", rounded: "full", overflow: "hidden", children: _jsx(MediaImage, { src: post.authorAvatar, alt: post.authorName ?? "author", size: "avatar" }) })) : post.authorName ? (_jsx(Row, { textWeight: "bold", textSize: "xs", className: "h-7 w-7 flex-shrink-0 bg-primary/10 text-primary", align: "center", justify: "center", rounded: "full", children: post.authorName.charAt(0).toUpperCase() })) : null, _jsxs(Text, { className: "text-[var(--appkit-color-text-muted)] min-w-0", size: "xs", children: [post.authorName && (_jsx(Span, { weight: "medium", className: "text-neutral-700", children: safeDisplayName(post.authorName, "Author") })), date && _jsxs(Span, { className: "ml-1 text-neutral-400", children: ["\u00B7 ", date] })] })] }), _jsxs(Row, { align: "center", gap: "xs", className: "mt-3 text-primary", textSize: "sm", textWeight: "medium", children: [_jsx(Span, { children: "Read" }), _jsx(Span, { "aria-hidden": "true", className: "transition-transform duration-200 group-hover:translate-x-1", children: "\u2192" })] })] })] }));
|
|
29
30
|
if (href) {
|
|
30
31
|
return (_jsx(Link, { href: href, className: "block h-full", children: card }));
|
|
31
32
|
}
|
|
@@ -5,6 +5,7 @@ import Link from "next/link.js";
|
|
|
5
5
|
import { Div, Grid, Heading, RichText, Row, Scrim, Span, Stack, Text } from "../../../ui/index.js";
|
|
6
6
|
import { Iframe } from "../../../ui/components/Iframe.js";
|
|
7
7
|
import { MediaImage } from "../../media/MediaImage.js";
|
|
8
|
+
import { blogCategoryBadge } from "../constants/category-badge.js";
|
|
8
9
|
import { useBlogPost } from "../hooks/useBlog.js";
|
|
9
10
|
import { BlogCard } from "./BlogListView.js";
|
|
10
11
|
import { getMediaUrl } from "../../media/types/index.js";
|
|
@@ -56,5 +57,5 @@ export function BlogPostView({ slug, initialData, labels = {}, renderImage, rend
|
|
|
56
57
|
})
|
|
57
58
|
: "";
|
|
58
59
|
const coverImageUrl = getMediaUrl(post.coverImage);
|
|
59
|
-
return (_jsxs(Div, { className: `min-h-screen ${className}`, children: [coverImageUrl && (_jsxs(Div, { className: `relative h-72 md:h-
|
|
60
|
+
return (_jsxs(Div, { className: `min-h-screen ${className}`, children: [coverImageUrl && (_jsxs(Div, { className: `relative h-72 md:h-[28rem] ${__O.hidden}`, children: [renderImage ? (renderImage(post)) : (_jsx(MediaImage, { src: coverImageUrl, alt: post.title, size: "hero", objectFit: "cover" })), _jsx(Scrim, { direction: "bottom-up", intensity: "strong", className: "absolute inset-0" }), _jsx(Div, { className: "absolute inset-x-0 bottom-0", children: _jsxs(Stack, { className: "mx-auto max-w-3xl", gap: "sm", paddingX: "x-md", paddingY: "y-xl", children: [_jsxs(Row, { gap: "sm", wrap: true, children: [_jsx(Span, { size: "xs", weight: "medium", className: `inline-block capitalize ${blogCategoryBadge(post.category)}`, rounded: "full", padding: "pill-xs", children: post.category }), post.isFeatured && (_jsx(Span, { size: "xs", weight: "medium", className: CLS_FEATURED_BADGE, children: labels?.featured ?? "Featured" }))] }), _jsx(Heading, { level: 1, size: "3xl", mdSize: "4xl", weight: "bold", color: "inverse", shadow: "lg", children: post.title }), _jsxs(Row, { color: "inverse", textSize: "sm", wrap: true, gap: "md", className: "opacity-90", children: [post.authorName && (_jsxs(Span, { children: [labels?.author ?? "By", " ", _jsx(Span, { weight: "medium", children: post.authorName })] })), post.readTimeMinutes != null && (_jsxs(Span, { children: [post.readTimeMinutes, " ", labels?.readTime ?? "min read"] })), date && _jsx(Span, { children: date })] })] }) })] })), _jsxs(Div, { className: "max-w-3xl mx-auto", paddingY: "y-3xl", paddingX: "x-md", children: [!coverImageUrl && renderBlogPostHeader(post, date, labels), post.youtubeId && /^[A-Za-z0-9_-]{6,20}$/.test(post.youtubeId) && (_jsx(Div, { className: "mb-8", children: _jsx(Iframe, { src: `https://www.youtube-nocookie.com/embed/${post.youtubeId}`, title: `${post.title} — video`, aspect: "video", rounded: "lg" }) })), post.tags && post.tags.length > 0 && (_jsx(Row, { wrap: true, gap: "sm", className: "mb-8", children: post.tags.map((tag) => (_jsxs(Link, { href: `${String(ROUTES.BLOG.LIST)}?tags=${encodeURIComponent(tag)}`, className: "inline-block px-[var(--appkit-space-3)] py-[var(--appkit-space-1)] rounded-full bg-[var(--appkit-color-surface-elevated)] hover:bg-primary/10 text-[var(--appkit-color-text-muted)] hover:text-primary text-[length:var(--appkit-text-xs)] font-medium transition-colors", children: ["#", tag] }, tag))) })), renderAuthorBio && _jsx(Div, { className: "mb-6", children: renderAuthorBio(post) }), _jsx(Div, { surface: "card", padding: "xl", className: "mb-12", children: renderContent ? renderContent(post) : (_jsx(RichText, { html: normalizeRichTextHtml(post.content ?? ""), proseClass: "prose max-w-none dark:prose-invert" })) }), renderBackButton && _jsx(Div, { border: "default", className: "mt-10 border-t", padding: "t-xl", children: renderBackButton() })] }), _jsx(Div, { className: "mx-auto max-w-6xl", paddingX: "x-md", paddingY: "y-2xl", children: _jsxs(Stack, { gap: "xl", children: [renderBlogPostRelated(related, labels?.relatedTitle ?? "Related Posts", renderRelatedCard), renderBlogPostRelated(relatedByTags, labels?.relatedByTagsTitle ?? "You might also like", renderRelatedCard), renderBlogPostRelated(relatedByAuthor, labels?.relatedByAuthorTitle ?? `More from ${post.authorName}`, renderRelatedCard)] }) })] }));
|
|
60
61
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-category chip palette, shared by every blog surface.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this moved out of `BlogFeaturedCard`
|
|
5
|
+
*
|
|
6
|
+
* There were two palettes and only one of them had colours. `BlogFeaturedCard`
|
|
7
|
+
* carried this real five-way map — and that component is mounted ONLY on the
|
|
8
|
+
* homepage rail. `BlogCard`, which is what `/blog` and every related-posts
|
|
9
|
+
* grid actually render, painted all five categories one flat
|
|
10
|
+
* `bg-primary/10 text-primary`.
|
|
11
|
+
*
|
|
12
|
+
* So the index — the page where telling a guide from a release note is worth
|
|
13
|
+
* something, because you are scanning twenty of them — was the one surface
|
|
14
|
+
* with no category colour at all.
|
|
15
|
+
*
|
|
16
|
+
* 🛑 `Record<BlogPostCategory, string>`, so a sixth category is a compile
|
|
17
|
+
* error rather than a card that silently falls back to grey.
|
|
18
|
+
*
|
|
19
|
+
* @tag domain:blog
|
|
20
|
+
* @tag layer:constants
|
|
21
|
+
*/
|
|
22
|
+
import type { BlogPostCategory } from "../types";
|
|
23
|
+
export declare const BLOG_CATEGORY_BADGE: Record<BlogPostCategory, string>;
|
|
24
|
+
/**
|
|
25
|
+
* Neutral fallback for a value that is not a known category.
|
|
26
|
+
*
|
|
27
|
+
* 🛑 Both halves are theme-relative tokens that invert together. Never pair a
|
|
28
|
+
* `-surface` tint with a literal `text-white`: `-surface` is a near-white tint
|
|
29
|
+
* in light themes and a dark tint in dark ones, so fixed-white ink is
|
|
30
|
+
* invisible in exactly one of them (Root Cause #67).
|
|
31
|
+
*/
|
|
32
|
+
export declare const BLOG_CATEGORY_BADGE_FALLBACK = "bg-[var(--appkit-color-bg)] text-[var(--appkit-color-text-muted)]";
|
|
33
|
+
export declare function blogCategoryBadge(category: string | undefined): string;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-category chip palette, shared by every blog surface.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this moved out of `BlogFeaturedCard`
|
|
5
|
+
*
|
|
6
|
+
* There were two palettes and only one of them had colours. `BlogFeaturedCard`
|
|
7
|
+
* carried this real five-way map — and that component is mounted ONLY on the
|
|
8
|
+
* homepage rail. `BlogCard`, which is what `/blog` and every related-posts
|
|
9
|
+
* grid actually render, painted all five categories one flat
|
|
10
|
+
* `bg-primary/10 text-primary`.
|
|
11
|
+
*
|
|
12
|
+
* So the index — the page where telling a guide from a release note is worth
|
|
13
|
+
* something, because you are scanning twenty of them — was the one surface
|
|
14
|
+
* with no category colour at all.
|
|
15
|
+
*
|
|
16
|
+
* 🛑 `Record<BlogPostCategory, string>`, so a sixth category is a compile
|
|
17
|
+
* error rather than a card that silently falls back to grey.
|
|
18
|
+
*
|
|
19
|
+
* @tag domain:blog
|
|
20
|
+
* @tag layer:constants
|
|
21
|
+
*/
|
|
22
|
+
export const BLOG_CATEGORY_BADGE = {
|
|
23
|
+
news: "bg-info-surface text-info",
|
|
24
|
+
tips: "bg-success-surface text-success",
|
|
25
|
+
guides: "bg-indigo-100 text-indigo-800 dark:bg-indigo-900/40 dark:text-indigo-300",
|
|
26
|
+
updates: "bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300",
|
|
27
|
+
community: "bg-warning-surface text-warning",
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Neutral fallback for a value that is not a known category.
|
|
31
|
+
*
|
|
32
|
+
* 🛑 Both halves are theme-relative tokens that invert together. Never pair a
|
|
33
|
+
* `-surface` tint with a literal `text-white`: `-surface` is a near-white tint
|
|
34
|
+
* in light themes and a dark tint in dark ones, so fixed-white ink is
|
|
35
|
+
* invisible in exactly one of them (Root Cause #67).
|
|
36
|
+
*/
|
|
37
|
+
export const BLOG_CATEGORY_BADGE_FALLBACK = "bg-[var(--appkit-color-bg)] text-[var(--appkit-color-text-muted)]";
|
|
38
|
+
export function blogCategoryBadge(category) {
|
|
39
|
+
return (BLOG_CATEGORY_BADGE[category] ?? BLOG_CATEGORY_BADGE_FALLBACK);
|
|
40
|
+
}
|
|
@@ -22,6 +22,7 @@ export interface CartPriceBreakdownStore {
|
|
|
22
22
|
subtotal: number;
|
|
23
23
|
shippingFee: number;
|
|
24
24
|
codHandlingFee: number;
|
|
25
|
+
whatsappNotifyFee: number;
|
|
25
26
|
giftWrapFee: number;
|
|
26
27
|
shipmentProtectionFee: number;
|
|
27
28
|
saleProtectionFee: number;
|
|
@@ -44,6 +45,7 @@ export interface CartPriceBreakdownData {
|
|
|
44
45
|
subtotal: number;
|
|
45
46
|
shippingFee: number;
|
|
46
47
|
codHandlingFee: number;
|
|
48
|
+
whatsappNotifyFee: number;
|
|
47
49
|
giftWrapFee: number;
|
|
48
50
|
shipmentProtectionFee: number;
|
|
49
51
|
saleProtectionFee: number;
|
|
@@ -40,7 +40,7 @@ export function CartPriceBreakdown({ preview, itemCount, currency = "INR", unava
|
|
|
40
40
|
const subtotalLabel = itemCount !== undefined
|
|
41
41
|
? `Subtotal (${itemCount} item${itemCount !== 1 ? "s" : ""}${storeCount > 1 ? `, ${storeCount} stores` : ""})`
|
|
42
42
|
: "Subtotal";
|
|
43
|
-
return (_jsxs(Stack, { gap: "xs", className: className, children: [_jsx(Line, { label: subtotalLabel, amount: money(preview.subtotal) }), preview.shippingFee > 0 && (_jsx(Line, { label: withStoreCount("Shipping", storeCount), amount: money(preview.shippingFee) })), preview.codHandlingFee > 0 && (_jsx(Line, { label: "COD handling fee", amount: money(preview.codHandlingFee), derivation: derivations?.codHandlingFee })), preview.giftWrapFee > 0 && (_jsx(Line, { label: withStoreCount("Gift wrap", countCharged((s) => s.giftWrapFee)), amount: money(preview.giftWrapFee) })), preview.shipmentProtectionFee > 0 && (_jsx(Line, { label: withStoreCount("Shipment protection", countCharged((s) => s.shipmentProtectionFee)), amount: money(preview.shipmentProtectionFee) })), preview.saleProtectionFee > 0 && (_jsx(Line, { label: withStoreCount("Sale protection", countCharged((s) => s.saleProtectionFee)), amount: money(preview.saleProtectionFee) })), preview.emiSurcharge > 0 && (_jsx(Line, { label: "EMI surcharge", amount: money(preview.emiSurcharge), derivation: derivations?.emiSurcharge })), preview.sharedPurchaseFee > 0 && (_jsx(Line, { label: withStoreCount("Split delivery", countCharged((s) => s.sharedPurchaseFee)), amount: money(preview.sharedPurchaseFee) })), preview.platformFee > 0 && (_jsx(Line, { label: "Platform fee", amount: money(preview.platformFee), derivation: derivations?.platformFee })), preview.gstOnFee > 0 && (_jsx(Line, { label: "GST on platform fee", amount: money(preview.gstOnFee), derivation: derivations?.gstOnFee })), preview.couponDiscount > 0 && (_jsx(Line, { label: "Coupon discount", amount: `−${money(preview.couponDiscount)}`, tone: "success" })), _jsx(Div, { border: "top-subtle", paddingY: "t-sm", children: _jsx(Line, { label: "Total", amount: money(preview.total), tone: "primary", strong: preview.pointsApplied <= 0 }) }), preview.pointsApplied > 0 && (_jsxs(_Fragment, { children: [_jsx(Line, { label: `Points applied (${preview.pointsApplied.toLocaleString("en-IN")} pts)`, amount: `−${money(preview.pointsApplied)}`, tone: "success" }), _jsx(Div, { border: "top-subtle", paddingY: "t-sm", children: _jsx(Line, { label: "Amount due", amount: money(preview.amountDue), tone: "primary", strong: true }) })] })), preview.pointsApplied > 0 && preview.pointsBalance > preview.pointsApplied && (_jsxs(Text, { size: "xs", color: "muted", children: [(preview.pointsBalance - preview.pointsApplied).toLocaleString("en-IN"), " points stay in your balance \u2014 up to half of an order can be paid with points."] })), preview.pointsApplied === 0 && preview.pointsBalance > 0 && (_jsx(Text, { size: "xs", color: "muted", children: preview.stores.some((s) => s.acceptsPoints)
|
|
43
|
+
return (_jsxs(Stack, { gap: "xs", className: className, children: [_jsx(Line, { label: subtotalLabel, amount: money(preview.subtotal) }), preview.shippingFee > 0 && (_jsx(Line, { label: withStoreCount("Shipping", storeCount), amount: money(preview.shippingFee) })), preview.codHandlingFee > 0 && (_jsx(Line, { label: "COD handling fee", amount: money(preview.codHandlingFee), derivation: derivations?.codHandlingFee })), preview.whatsappNotifyFee > 0 && (_jsx(Line, { label: withStoreCount("WhatsApp updates", countCharged((s) => s.whatsappNotifyFee)), amount: money(preview.whatsappNotifyFee) })), preview.giftWrapFee > 0 && (_jsx(Line, { label: withStoreCount("Gift wrap", countCharged((s) => s.giftWrapFee)), amount: money(preview.giftWrapFee) })), preview.shipmentProtectionFee > 0 && (_jsx(Line, { label: withStoreCount("Shipment protection", countCharged((s) => s.shipmentProtectionFee)), amount: money(preview.shipmentProtectionFee) })), preview.saleProtectionFee > 0 && (_jsx(Line, { label: withStoreCount("Sale protection", countCharged((s) => s.saleProtectionFee)), amount: money(preview.saleProtectionFee) })), preview.emiSurcharge > 0 && (_jsx(Line, { label: "EMI surcharge", amount: money(preview.emiSurcharge), derivation: derivations?.emiSurcharge })), preview.sharedPurchaseFee > 0 && (_jsx(Line, { label: withStoreCount("Split delivery", countCharged((s) => s.sharedPurchaseFee)), amount: money(preview.sharedPurchaseFee) })), preview.platformFee > 0 && (_jsx(Line, { label: "Platform fee", amount: money(preview.platformFee), derivation: derivations?.platformFee })), preview.gstOnFee > 0 && (_jsx(Line, { label: "GST on platform fee", amount: money(preview.gstOnFee), derivation: derivations?.gstOnFee })), preview.couponDiscount > 0 && (_jsx(Line, { label: "Coupon discount", amount: `−${money(preview.couponDiscount)}`, tone: "success" })), _jsx(Div, { border: "top-subtle", paddingY: "t-sm", children: _jsx(Line, { label: "Total", amount: money(preview.total), tone: "primary", strong: preview.pointsApplied <= 0 }) }), preview.pointsApplied > 0 && (_jsxs(_Fragment, { children: [_jsx(Line, { label: `Points applied (${preview.pointsApplied.toLocaleString("en-IN")} pts)`, amount: `−${money(preview.pointsApplied)}`, tone: "success" }), _jsx(Div, { border: "top-subtle", paddingY: "t-sm", children: _jsx(Line, { label: "Amount due", amount: money(preview.amountDue), tone: "primary", strong: true }) })] })), preview.pointsApplied > 0 && preview.pointsBalance > preview.pointsApplied && (_jsxs(Text, { size: "xs", color: "muted", children: [(preview.pointsBalance - preview.pointsApplied).toLocaleString("en-IN"), " points stay in your balance \u2014 up to half of an order can be paid with points."] })), preview.pointsApplied === 0 && preview.pointsBalance > 0 && (_jsx(Text, { size: "xs", color: "muted", children: preview.stores.some((s) => s.acceptsPoints)
|
|
44
44
|
? "Your points could not be applied to this order."
|
|
45
45
|
: storeCount > 1
|
|
46
46
|
? "None of these sellers accepts points."
|
|
@@ -3,12 +3,13 @@ import React from "react";
|
|
|
3
3
|
* Paid add-on checkboxes for ONE store.
|
|
4
4
|
*
|
|
5
5
|
* Add-on fees are billed per order group (= per store), so they are chosen per
|
|
6
|
-
* store: ticking "
|
|
6
|
+
* store: ticking "WhatsApp updates" for one seller must not bill it for every
|
|
7
7
|
* seller in the cart. Rendered inside that store's card in the cart, and inside
|
|
8
8
|
* that store's row of the checkout summary — Buy Now skips the cart entirely,
|
|
9
9
|
* so both surfaces have to be able to edit them.
|
|
10
10
|
*/
|
|
11
11
|
export interface StoreAddonsValue {
|
|
12
|
+
whatsappNotifyAddon?: boolean;
|
|
12
13
|
giftWrapAddon?: boolean;
|
|
13
14
|
giftWrapMessage?: string;
|
|
14
15
|
shipmentProtectionAddon?: boolean;
|
|
@@ -16,6 +17,8 @@ export interface StoreAddonsValue {
|
|
|
16
17
|
}
|
|
17
18
|
/** The subset of `siteSettings.commissions` that gates and prices the add-ons. */
|
|
18
19
|
export interface StoreAddonsRates {
|
|
20
|
+
whatsappNotifyFeeEnabled?: boolean;
|
|
21
|
+
whatsappNotifyFee?: number;
|
|
19
22
|
giftWrapFeeEnabled?: boolean;
|
|
20
23
|
giftWrapFee?: number;
|
|
21
24
|
shipmentProtectionFeeEnabled?: boolean;
|
|
@@ -56,7 +59,7 @@ export interface StoreProtectionContext {
|
|
|
56
59
|
* showing an explanatory line instead.
|
|
57
60
|
*
|
|
58
61
|
* 🛑 Deliberately asks only about TIER 1. A seller who supports neither
|
|
59
|
-
* protection still has gift wrap, and a store whose only
|
|
62
|
+
* protection still has WhatsApp updates and gift wrap, and a store whose only
|
|
60
63
|
* enabled add-ons are protections it declined must still render the picker —
|
|
61
64
|
* because that is where the risk notice lives, and a hidden notice is the
|
|
62
65
|
* tri-state failure in § `StoreProtectionContext`.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { Gift, ShieldAlert, ShieldCheck, ShieldOff } from "lucide-react";
|
|
3
|
+
import { Gift, MessageCircle, ShieldAlert, ShieldCheck, ShieldOff } from "lucide-react";
|
|
4
4
|
import { Div, Row, Stack, Text } from "../../../ui/index.js";
|
|
5
5
|
import { Disclaimer } from "../../../ui/components/Disclaimer.js";
|
|
6
6
|
import { IconBox } from "../../../ui/components/IconBox.js";
|
|
@@ -16,14 +16,15 @@ import { formatCurrency } from "../../../utils/number.formatter.js";
|
|
|
16
16
|
* showing an explanatory line instead.
|
|
17
17
|
*
|
|
18
18
|
* 🛑 Deliberately asks only about TIER 1. A seller who supports neither
|
|
19
|
-
* protection still has gift wrap, and a store whose only
|
|
19
|
+
* protection still has WhatsApp updates and gift wrap, and a store whose only
|
|
20
20
|
* enabled add-ons are protections it declined must still render the picker —
|
|
21
21
|
* because that is where the risk notice lives, and a hidden notice is the
|
|
22
22
|
* tri-state failure in § `StoreProtectionContext`.
|
|
23
23
|
*/
|
|
24
24
|
export function hasAnyStoreAddon(rates) {
|
|
25
25
|
return (!!rates &&
|
|
26
|
-
!!(rates.
|
|
26
|
+
!!(rates.whatsappNotifyFeeEnabled ||
|
|
27
|
+
rates.giftWrapFeeEnabled ||
|
|
27
28
|
rates.shipmentProtectionFeeEnabled ||
|
|
28
29
|
rates.saleProtectionFeeEnabled));
|
|
29
30
|
}
|
|
@@ -51,7 +52,7 @@ export function StoreAddonsPicker({ storeId, storeSubtotal, value, onChange, rat
|
|
|
51
52
|
const shippingDeclined = !!rates.shipmentProtectionFeeEnabled && !protection?.supportsShippingProtection;
|
|
52
53
|
const saleDeclined = !!rates.saleProtectionFeeEnabled && !protection?.supportsSaleProtection;
|
|
53
54
|
const saleBlockedByFinalSale = !!rates.saleProtectionFeeEnabled && !!protection?.supportsSaleProtection && !!protection?.hasFinalSaleItem;
|
|
54
|
-
return (_jsxs(Stack, { gap: "xs", className: `min-w-0 ${className}`, children: [disabled && disabledReason && (_jsx(Text, { size: "xs", color: "muted", children: disabledReason })), rates.giftWrapFeeEnabled && (_jsxs(Row, { gap: "sm", align: "start", className: "min-w-0", children: [_jsx(IconBox, { size: "sm", tone: "brand", children: _jsx(Gift, { size: 14 }) }), _jsxs(Stack, { gap: "xs", className: "flex-1 min-w-0", children: [_jsx(FieldCheckbox, { name: `giftWrapAddon-${storeId}`, label: `Gift wrap (+${money(rates.giftWrapFee ?? 49)})`, checked: !!value.giftWrapAddon, onChange: (checked) => set({ giftWrapAddon: checked }), disabled: disabled }), showGiftMessage && value.giftWrapAddon && (_jsx(FieldTextarea, { name: `giftWrapMessage-${storeId}`, label: "Gift message (optional)", value: value.giftWrapMessage ?? "", onChange: (next) => set({ giftWrapMessage: next.slice(0, 500) }), rows: 2, maxLength: 500, showCharCount: true, placeholder: "Add a note for the recipient\u2026", disabled: disabled }))] })] })), shippingOffered && storeSubtotal > 0 && (_jsxs(Row, { gap: "sm", align: "start", className: "min-w-0", children: [_jsx(IconBox, { size: "sm", tone: "brand", children: _jsx(ShieldCheck, { size: 14 }) }), _jsx(Div, { className: "flex-1 min-w-0", children: _jsx(FieldCheckbox, { name: `shipmentProtectionAddon-${storeId}`, label: `Shipment protection — loss or damage in transit (+${money(protectionFee)})`, checked: !!value.shipmentProtectionAddon, onChange: (checked) => set({ shipmentProtectionAddon: checked }), disabled: disabled }) })] })), saleOffered && storeSubtotal > 0 && (_jsxs(Row, { gap: "sm", align: "start", className: "min-w-0", children: [_jsx(IconBox, { size: "sm", tone: "brand", children: _jsx(ShieldAlert, { size: 14 }) }), _jsx(Div, { className: "flex-1 min-w-0", children: _jsx(FieldCheckbox, { name: `saleProtectionAddon-${storeId}`, label: `Sale protection — item not as described (+${money(saleFee)})`, checked: !!value.saleProtectionAddon, onChange: (checked) => set({ saleProtectionAddon: checked }), disabled: disabled }) })] })), (shippingDeclined || saleDeclined) && (_jsxs(Row, { gap: "sm", align: "start", className: "min-w-0", children: [_jsx(IconBox, { size: "sm", tone: "muted", children: _jsx(ShieldOff, { size: 14 }) }), _jsx(Text, { size: "xs", color: "muted", className: "flex-1 min-w-0", children: shippingDeclined && saleDeclined
|
|
55
|
+
return (_jsxs(Stack, { gap: "xs", className: `min-w-0 ${className}`, children: [disabled && disabledReason && (_jsx(Text, { size: "xs", color: "muted", children: disabledReason })), rates.whatsappNotifyFeeEnabled && (_jsxs(Row, { gap: "sm", align: "start", className: "min-w-0", children: [_jsx(IconBox, { size: "sm", tone: "brand", children: _jsx(MessageCircle, { size: 14 }) }), _jsx(Div, { className: "flex-1 min-w-0", children: _jsx(FieldCheckbox, { name: `whatsappNotifyAddon-${storeId}`, label: `WhatsApp order updates (+${money(rates.whatsappNotifyFee ?? 10)})`, checked: !!value.whatsappNotifyAddon, onChange: (checked) => set({ whatsappNotifyAddon: checked }), disabled: disabled }) })] })), rates.giftWrapFeeEnabled && (_jsxs(Row, { gap: "sm", align: "start", className: "min-w-0", children: [_jsx(IconBox, { size: "sm", tone: "brand", children: _jsx(Gift, { size: 14 }) }), _jsxs(Stack, { gap: "xs", className: "flex-1 min-w-0", children: [_jsx(FieldCheckbox, { name: `giftWrapAddon-${storeId}`, label: `Gift wrap (+${money(rates.giftWrapFee ?? 49)})`, checked: !!value.giftWrapAddon, onChange: (checked) => set({ giftWrapAddon: checked }), disabled: disabled }), showGiftMessage && value.giftWrapAddon && (_jsx(FieldTextarea, { name: `giftWrapMessage-${storeId}`, label: "Gift message (optional)", value: value.giftWrapMessage ?? "", onChange: (next) => set({ giftWrapMessage: next.slice(0, 500) }), rows: 2, maxLength: 500, showCharCount: true, placeholder: "Add a note for the recipient\u2026", disabled: disabled }))] })] })), shippingOffered && storeSubtotal > 0 && (_jsxs(Row, { gap: "sm", align: "start", className: "min-w-0", children: [_jsx(IconBox, { size: "sm", tone: "brand", children: _jsx(ShieldCheck, { size: 14 }) }), _jsx(Div, { className: "flex-1 min-w-0", children: _jsx(FieldCheckbox, { name: `shipmentProtectionAddon-${storeId}`, label: `Shipment protection — loss or damage in transit (+${money(protectionFee)})`, checked: !!value.shipmentProtectionAddon, onChange: (checked) => set({ shipmentProtectionAddon: checked }), disabled: disabled }) })] })), saleOffered && storeSubtotal > 0 && (_jsxs(Row, { gap: "sm", align: "start", className: "min-w-0", children: [_jsx(IconBox, { size: "sm", tone: "brand", children: _jsx(ShieldAlert, { size: 14 }) }), _jsx(Div, { className: "flex-1 min-w-0", children: _jsx(FieldCheckbox, { name: `saleProtectionAddon-${storeId}`, label: `Sale protection — item not as described (+${money(saleFee)})`, checked: !!value.saleProtectionAddon, onChange: (checked) => set({ saleProtectionAddon: checked }), disabled: disabled }) })] })), (shippingDeclined || saleDeclined) && (_jsxs(Row, { gap: "sm", align: "start", className: "min-w-0", children: [_jsx(IconBox, { size: "sm", tone: "muted", children: _jsx(ShieldOff, { size: 14 }) }), _jsx(Text, { size: "xs", color: "muted", className: "flex-1 min-w-0", children: shippingDeclined && saleDeclined
|
|
55
56
|
? "This seller does not offer shipment or sale protection."
|
|
56
57
|
: shippingDeclined
|
|
57
58
|
? "This seller does not offer shipment protection."
|
|
@@ -577,7 +577,8 @@ export class CartRepository extends BaseRepository {
|
|
|
577
577
|
*/
|
|
578
578
|
async setStoreAddons(userId, storeId, addons) {
|
|
579
579
|
const isEmpty = !addons ||
|
|
580
|
-
(!addons.
|
|
580
|
+
(!addons.whatsappNotifyAddon &&
|
|
581
|
+
!addons.giftWrapAddon &&
|
|
581
582
|
!addons.shipmentProtectionAddon &&
|
|
582
583
|
!addons.saleProtectionAddon);
|
|
583
584
|
await this.db
|
|
@@ -272,11 +272,12 @@ export interface CartAppliedCoupon {
|
|
|
272
272
|
*
|
|
273
273
|
* Add-on fees have always been billed per order group (= per store) by
|
|
274
274
|
* `createOrderForGroup` / `previewCheckoutPricing`, but the selection used to be
|
|
275
|
-
* a single cart-wide boolean — so one tick on "
|
|
275
|
+
* a single cart-wide boolean — so one tick on "WhatsApp updates" quietly billed
|
|
276
276
|
* ₹10 × every store in the cart, with no way to opt in for just one seller.
|
|
277
277
|
* Keying the selection by storeId makes the choice match the billing.
|
|
278
278
|
*/
|
|
279
279
|
export interface CartStoreAddons {
|
|
280
|
+
whatsappNotifyAddon?: boolean;
|
|
280
281
|
giftWrapAddon?: boolean;
|
|
281
282
|
giftWrapMessage?: string;
|
|
282
283
|
/** Loss or damage IN TRANSIT. */
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
/**
|
|
3
|
+
* A plain, server-rendered index of entity links.
|
|
4
|
+
*
|
|
5
|
+
* ## Why this exists
|
|
6
|
+
*
|
|
7
|
+
* Measured on the prerendered HTML 2026-10-24: `/categories`, `/brands` and
|
|
8
|
+
* `/parts` each rendered **zero** outbound entity links, and `/franchise`
|
|
9
|
+
* rendered one. All three hubs filter and paginate entirely client-side
|
|
10
|
+
* (`useCategoriesFiltered`), which is a deliberate choice that keeps them
|
|
11
|
+
* static — but it means a crawler that lands on `/brands` finds nothing to
|
|
12
|
+
* follow, and the 28 brand pages are reachable only from the homepage strip.
|
|
13
|
+
* Franchise detail pages were reachable by no link at all.
|
|
14
|
+
*
|
|
15
|
+
* This renders the full set as real `<a href>`s in the server HTML, below the
|
|
16
|
+
* interactive grid. It is additive: the client grid above is untouched, so the
|
|
17
|
+
* filtering UX is unchanged and the page stays static (a Firestore read in a
|
|
18
|
+
* Server Component does not force dynamic rendering — only `searchParams`,
|
|
19
|
+
* `cookies()` and `headers()` do, Root Cause #94).
|
|
20
|
+
*
|
|
21
|
+
* It is also genuinely useful rather than an SEO artefact — an A–Z index is
|
|
22
|
+
* what a visitor who wants "all of them" is looking for, and hiding every
|
|
23
|
+
* entity behind a JS filter serves nobody. Nothing here is cloaked: the links
|
|
24
|
+
* are visible to a person exactly as they are to a crawler.
|
|
25
|
+
*
|
|
26
|
+
* 🛑 Deliberately NOT a `"use client"` component and deliberately not
|
|
27
|
+
* paginated. Its whole job is to be in the HTML.
|
|
28
|
+
*/
|
|
29
|
+
export interface TaxonomyLink {
|
|
30
|
+
href: string;
|
|
31
|
+
label: string;
|
|
32
|
+
}
|
|
33
|
+
export interface TaxonomyLinkIndexProps {
|
|
34
|
+
heading: string;
|
|
35
|
+
/** One short sentence saying what the list is. Rendered above the links. */
|
|
36
|
+
blurb?: string;
|
|
37
|
+
items: readonly TaxonomyLink[];
|
|
38
|
+
}
|
|
39
|
+
export declare function TaxonomyLinkIndex({ heading, blurb, items }: TaxonomyLinkIndexProps): React.JSX.Element | null;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { TextLink } from "../../../ui/components/TextLink.js";
|
|
3
|
+
import { Section } from "../../../ui/components/Semantic.js";
|
|
4
|
+
import { Container, Row } from "../../../ui/components/Layout.js";
|
|
5
|
+
import { Heading, Text } from "../../../ui/components/Typography.js";
|
|
6
|
+
export function TaxonomyLinkIndex({ heading, blurb, items }) {
|
|
7
|
+
// An empty index is worse than none — it reads as a broken section.
|
|
8
|
+
if (items.length === 0)
|
|
9
|
+
return null;
|
|
10
|
+
return (_jsx(Section, { padding: "y-xl", surface: "muted", children: _jsxs(Container, { size: "xl", children: [_jsx(Heading, { level: 2, size: "xl", weight: "semibold", color: "primary", className: "mb-2", children: heading }), blurb ? (_jsx(Text, { size: "sm", color: "muted", className: "mb-4", children: blurb })) : null, _jsx(Row, { wrap: true, gap: "comfortable", as: "nav", "aria-label": heading, children: items.map((item) => (_jsx(TextLink, { href: item.href, children: item.label }, item.href))) })] }) }));
|
|
11
|
+
}
|
|
@@ -185,6 +185,31 @@ export interface CategoryDocument extends BaseDocument {
|
|
|
185
185
|
* ranking mechanism; no Shopify competitor in the crawl has any.
|
|
186
186
|
*/
|
|
187
187
|
contentBody?: string;
|
|
188
|
+
/**
|
|
189
|
+
* Where this category's SEO signals belong, when that is NOT its own URL.
|
|
190
|
+
*
|
|
191
|
+
* A part-leaf category and a `/parts/{slot}/{code}` page are the same
|
|
192
|
+
* entity seen twice: the leaf is a (usually empty) shopping surface, the
|
|
193
|
+
* parts page is the reference entry with the weight, code, aliases, WBO
|
|
194
|
+
* legality and the models that use it. Measured 2026-10-24 the taxonomy
|
|
195
|
+
* held 4,698 categories of which exactly **20** had any stock, so for the
|
|
196
|
+
* overwhelming majority the parts page is the only one with content.
|
|
197
|
+
*
|
|
198
|
+
* Setting this makes the category page declare the parts page canonical, so
|
|
199
|
+
* the two stop competing and the signals consolidate onto the one a visitor
|
|
200
|
+
* actually wants.
|
|
201
|
+
*
|
|
202
|
+
* 🛑 Written by `scripts/gen-category-parts.mjs`, which is the ONLY place
|
|
203
|
+
* that knows both ids — it assigns the category id and the glossary id in
|
|
204
|
+
* the same loop (the pairing `PART_CATEGORY_TO_GLOSSARY_ID` records). Do not
|
|
205
|
+
* re-derive it at a call site from the slug: the id schemes differ by
|
|
206
|
+
* construction and the collision-qualification rule is not invertible.
|
|
207
|
+
*
|
|
208
|
+
* 🛑 A root-relative path (`/parts/disc/0-0wall`), never absolute — the
|
|
209
|
+
* canonical HOST has exactly one definition (appkit.config.js `seo.siteUrl`)
|
|
210
|
+
* and a second one here is Root Cause #81 waiting to happen.
|
|
211
|
+
*/
|
|
212
|
+
seoCanonicalPath?: string;
|
|
188
213
|
/** Condition-keyed description bodies. A SET, because WHS reuses two per category. */
|
|
189
214
|
descriptionTemplates?: CategoryDescriptionTemplate[];
|
|
190
215
|
/** What this category hands down to a product filed under it. */
|