create-nextblock 0.16.1 → 0.16.2
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/docker-template/.dockerignore +1 -1
- package/libs/db/tsconfig.lib.json +3 -3
- package/libs/editor/tsconfig.lib.json +3 -3
- package/libs/ui/tsconfig.lib.json +3 -3
- package/libs/utils/tsconfig.json +3 -3
- package/package.json +50 -50
- package/project.json +19 -19
- package/templates/nextblock-template/.dockerignore +1 -1
- package/templates/nextblock-template/.swcrc +30 -30
- package/templates/nextblock-template/AGENTS.md +9 -9
- package/templates/nextblock-template/CLAUDE.md +1 -1
- package/templates/nextblock-template/app/(auth-pages)/layout.tsx +9 -9
- package/templates/nextblock-template/app/(auth-pages)/post-sign-in/page.tsx +27 -27
- package/templates/nextblock-template/app/.well-known/ucp/route.ts +16 -16
- package/templates/nextblock-template/app/ToasterProvider.tsx +26 -26
- package/templates/nextblock-template/app/[slug]/pageClientActions.ts +7 -7
- package/templates/nextblock-template/app/actions/contactSellerActions.test.ts +280 -280
- package/templates/nextblock-template/app/actions/contactSellerActions.ts +222 -222
- package/templates/nextblock-template/app/actions/email-retry.test.ts +62 -62
- package/templates/nextblock-template/app/actions/email.ts +241 -241
- package/templates/nextblock-template/app/actions/formActions.ts +245 -245
- package/templates/nextblock-template/app/actions/interactions.ts +489 -489
- package/templates/nextblock-template/app/actions/threadActions.ts +166 -166
- package/templates/nextblock-template/app/actions/visibilityActions.ts +210 -210
- package/templates/nextblock-template/app/api/ai/seo/alt-text/route.ts +221 -221
- package/templates/nextblock-template/app/api/ai/seo/metadata/route.ts +186 -186
- package/templates/nextblock-template/app/api/checkout/freemius/sync/route.ts +29 -29
- package/templates/nextblock-template/app/api/checkout/route.ts +162 -162
- package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +3092 -3092
- package/templates/nextblock-template/app/api/mcp/route.ts +415 -415
- package/templates/nextblock-template/app/api/media/record/route.ts +160 -160
- package/templates/nextblock-template/app/api/search/route.ts +43 -43
- package/templates/nextblock-template/app/api/view/route.ts +114 -114
- package/templates/nextblock-template/app/api/visual-editing/block-draft/route.ts +47 -47
- package/templates/nextblock-template/app/api/visual-editing/product-draft/route.ts +47 -47
- package/templates/nextblock-template/app/auth/callback/route.ts +31 -31
- package/templates/nextblock-template/app/cart/page.tsx +7 -7
- package/templates/nextblock-template/app/checkout/UcpCartHydrator.tsx +20 -20
- package/templates/nextblock-template/app/checkout/page.tsx +57 -57
- package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +558 -558
- package/templates/nextblock-template/app/cms/blocks/components/BlockEditorModal.tsx +241 -241
- package/templates/nextblock-template/app/cms/blocks/components/MediaLibraryModal.tsx +149 -149
- package/templates/nextblock-template/app/cms/blocks/editors/FormBlockEditor.tsx +304 -304
- package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +406 -406
- package/templates/nextblock-template/app/cms/components/ContactReminderBanner.tsx +75 -75
- package/templates/nextblock-template/app/cms/components/CortexAiActiveContext.tsx +23 -23
- package/templates/nextblock-template/app/cms/components/CortexAiPageContext.tsx +58 -58
- package/templates/nextblock-template/app/cms/components/FeatureImageField.tsx +254 -254
- package/templates/nextblock-template/app/cms/components/FeedbackModal.tsx +36 -36
- package/templates/nextblock-template/app/cms/components/PaymentsReminderBanner.tsx +58 -58
- package/templates/nextblock-template/app/cms/components/VisibilityBadge.tsx +62 -62
- package/templates/nextblock-template/app/cms/components/VisibilityControl.tsx +542 -542
- package/templates/nextblock-template/app/cms/coupons/[id]/edit/page.tsx +16 -16
- package/templates/nextblock-template/app/cms/coupons/page.tsx +16 -16
- package/templates/nextblock-template/app/cms/dashboard/actions.ts +228 -228
- package/templates/nextblock-template/app/cms/dashboard/components/DashboardComponents.tsx +200 -200
- package/templates/nextblock-template/app/cms/inquiries/actions.ts +66 -66
- package/templates/nextblock-template/app/cms/inquiries/page.tsx +12 -12
- package/templates/nextblock-template/app/cms/interactions/page.tsx +12 -12
- package/templates/nextblock-template/app/cms/layout.tsx +101 -101
- package/templates/nextblock-template/app/cms/media/components/FolderNavigator.tsx +273 -273
- package/templates/nextblock-template/app/cms/media/components/FolderTree.tsx +122 -122
- package/templates/nextblock-template/app/cms/media/components/MediaGridClient.tsx +69 -69
- package/templates/nextblock-template/app/cms/messages/MessagesClient.tsx +661 -661
- package/templates/nextblock-template/app/cms/messages/actions.ts +404 -404
- package/templates/nextblock-template/app/cms/messages/loadInbox.ts +333 -333
- package/templates/nextblock-template/app/cms/messages/page.tsx +87 -87
- package/templates/nextblock-template/app/cms/messages/require-admin.ts +37 -37
- package/templates/nextblock-template/app/cms/navigation/components/NavigationMenuDnd.tsx +3 -3
- package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +649 -649
- package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +618 -618
- package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +370 -370
- package/templates/nextblock-template/app/cms/products/attributes/page.tsx +12 -12
- package/templates/nextblock-template/app/cms/products/inventory/page.tsx +13 -13
- package/templates/nextblock-template/app/cms/products/productFormData.ts +133 -133
- package/templates/nextblock-template/app/cms/products/settings/page.tsx +5 -5
- package/templates/nextblock-template/app/cms/revisions/actions.ts +332 -332
- package/templates/nextblock-template/app/cms/revisions/service.test.ts +498 -498
- package/templates/nextblock-template/app/cms/revisions/service.ts +569 -569
- package/templates/nextblock-template/app/cms/revisions/utils.ts +304 -304
- package/templates/nextblock-template/app/cms/settings/cortex-ai/CortexAiSettingsClient.tsx +948 -948
- package/templates/nextblock-template/app/cms/settings/cortex-ai/McpServerSettingsCard.tsx +628 -628
- package/templates/nextblock-template/app/cms/settings/cortex-ai/mcp-actions.ts +230 -230
- package/templates/nextblock-template/app/cms/settings/cortex-ai/require-admin.ts +34 -34
- package/templates/nextblock-template/app/cms/settings/currencies/actions.ts +331 -331
- package/templates/nextblock-template/app/cms/settings/currencies/page.tsx +494 -494
- package/templates/nextblock-template/app/cms/settings/email/components/EmailForm.tsx +227 -227
- package/templates/nextblock-template/app/cms/settings/extra-translations/ExtraTranslationsWorkspace.tsx +767 -767
- package/templates/nextblock-template/app/cms/settings/extra-translations/actions.ts +276 -276
- package/templates/nextblock-template/app/cms/settings/extra-translations/page.tsx +93 -93
- package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeEditor.tsx +382 -382
- package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeManager.tsx +267 -267
- package/templates/nextblock-template/app/cms/settings/global-css/page.tsx +40 -40
- package/templates/nextblock-template/app/cms/settings/global-css/theme-actions.ts +259 -259
- package/templates/nextblock-template/app/cms/settings/logos/[id]/edit/page.tsx +7 -7
- package/templates/nextblock-template/app/cms/settings/logos/components/BrandingSettingsForm.tsx +339 -339
- package/templates/nextblock-template/app/cms/settings/logos/new/page.tsx +8 -8
- package/templates/nextblock-template/app/cms/settings/seo/RedirectsCard.tsx +514 -514
- package/templates/nextblock-template/app/cms/settings/seo/RobotsCard.tsx +529 -529
- package/templates/nextblock-template/app/cms/settings/seo/SeoSettingsClient.tsx +57 -57
- package/templates/nextblock-template/app/cms/settings/seo/actions.ts +448 -448
- package/templates/nextblock-template/app/cms/settings/seo/mappers.ts +93 -93
- package/templates/nextblock-template/app/cms/settings/seo/page.tsx +46 -46
- package/templates/nextblock-template/app/cms/settings/seo/require-admin.ts +47 -47
- package/templates/nextblock-template/app/cms/settings/site-scripts/page.tsx +51 -51
- package/templates/nextblock-template/app/cms/settings/taxes/page.tsx +21 -21
- package/templates/nextblock-template/app/cms/shipping/page.tsx +20 -20
- package/templates/nextblock-template/app/cms/users/components/DeleteUserButton.tsx +12 -12
- package/templates/nextblock-template/app/layout.tsx +671 -671
- package/templates/nextblock-template/app/lib/seo.ts +319 -319
- package/templates/nextblock-template/app/lib/ucp/protocol.ts +190 -190
- package/templates/nextblock-template/app/lib/ucp/server.test.ts +56 -56
- package/templates/nextblock-template/app/product/[slug]/page.tsx +502 -502
- package/templates/nextblock-template/app/profile/ProfilePageHeader.tsx +16 -16
- package/templates/nextblock-template/app/profile/ProfilePageMissingState.tsx +9 -9
- package/templates/nextblock-template/app/profile/account-links.ts +22 -22
- package/templates/nextblock-template/app/profile/orders/CustomerOrdersPageClient.tsx +124 -124
- package/templates/nextblock-template/app/profile/orders/page.tsx +19 -19
- package/templates/nextblock-template/app/profile/password/PasswordSettingsPageClient.tsx +128 -128
- package/templates/nextblock-template/app/profile/password/actions.ts +59 -59
- package/templates/nextblock-template/app/profile/password/page.tsx +27 -27
- package/templates/nextblock-template/app/providers.tsx +96 -96
- package/templates/nextblock-template/app/robots.ts +123 -123
- package/templates/nextblock-template/app/thread/ThreadView.tsx +164 -164
- package/templates/nextblock-template/app/thread/[token]/route.ts +57 -57
- package/templates/nextblock-template/app/thread/layout.tsx +15 -15
- package/templates/nextblock-template/app/thread/page.tsx +98 -98
- package/templates/nextblock-template/app/ucp/v1/carts/[id]/cancel/route.ts +38 -38
- package/templates/nextblock-template/app/ucp/v1/carts/[id]/route.ts +68 -68
- package/templates/nextblock-template/app/ucp/v1/carts/route.ts +35 -35
- package/templates/nextblock-template/app/ucp/v1/catalog/lookup/route.ts +35 -35
- package/templates/nextblock-template/app/ucp/v1/catalog/product/route.ts +35 -35
- package/templates/nextblock-template/app/ucp/v1/catalog/search/route.ts +34 -34
- package/templates/nextblock-template/components/BlockRenderer.tsx +312 -312
- package/templates/nextblock-template/components/CartDrawerLoader.tsx +7 -7
- package/templates/nextblock-template/components/CartTranslator.tsx +210 -210
- package/templates/nextblock-template/components/ContactSellerSection.tsx +188 -188
- package/templates/nextblock-template/components/DeferredCartDrawer.tsx +23 -23
- package/templates/nextblock-template/components/DeferredCartTranslator.tsx +51 -51
- package/templates/nextblock-template/components/DeferredGlobalSearch.tsx +68 -68
- package/templates/nextblock-template/components/DeferredGoogleTagManager.tsx +70 -70
- package/templates/nextblock-template/components/DeferredSpeedInsights.tsx +69 -69
- package/templates/nextblock-template/components/FooterNavigation.tsx +32 -32
- package/templates/nextblock-template/components/GlobalSearch.tsx +557 -557
- package/templates/nextblock-template/components/Header.tsx +38 -38
- package/templates/nextblock-template/components/HtmlScriptExecutor.tsx +47 -47
- package/templates/nextblock-template/components/LanguageSwitcher.tsx +2 -2
- package/templates/nextblock-template/components/PostCommentsSection.tsx +378 -378
- package/templates/nextblock-template/components/ProductReviewsSection.tsx +426 -426
- package/templates/nextblock-template/components/SiteScripts.tsx +56 -56
- package/templates/nextblock-template/components/StaffReplies.tsx +102 -102
- package/templates/nextblock-template/components/blocks/PostCardSkeleton.tsx +12 -12
- package/templates/nextblock-template/components/blocks/PostsGridBlock.tsx +12 -12
- package/templates/nextblock-template/components/blocks/PostsGridClient.tsx +48 -48
- package/templates/nextblock-template/components/blocks/TestimonialBlock.tsx +9 -9
- package/templates/nextblock-template/components/blocks/ecommerceRendererLoaders.ts +23 -23
- package/templates/nextblock-template/components/blocks/publicRendererLoaders.ts +25 -25
- package/templates/nextblock-template/components/blocks/renderers/ButtonBlockRenderer.tsx +92 -92
- package/templates/nextblock-template/components/blocks/renderers/CartBlockRenderer.tsx +18 -18
- package/templates/nextblock-template/components/blocks/renderers/CheckoutBlockRenderer.tsx +20 -20
- package/templates/nextblock-template/components/blocks/renderers/FeaturedProductBlockRenderer.tsx +25 -25
- package/templates/nextblock-template/components/blocks/renderers/FormBlockRenderer.tsx +385 -385
- package/templates/nextblock-template/components/blocks/renderers/PostsGridBlockRenderer.tsx +24 -24
- package/templates/nextblock-template/components/blocks/renderers/ProductDetailsBlockRenderer.tsx +157 -157
- package/templates/nextblock-template/components/blocks/renderers/ProductGridBlockRenderer.tsx +34 -34
- package/templates/nextblock-template/components/blocks/renderers/SectionBlockRenderer.tsx +612 -612
- package/templates/nextblock-template/components/blocks/renderers/TestimonialBlockRenderer.tsx +57 -57
- package/templates/nextblock-template/components/blocks/renderers/inline/AlertWidgetRenderer.tsx +2 -2
- package/templates/nextblock-template/components/blocks/renderers/inline/CtaWidgetRenderer.tsx +2 -2
- package/templates/nextblock-template/components/blocks/types.ts +7 -7
- package/templates/nextblock-template/components/commerce/PaymentReadinessBoundary.tsx +32 -32
- package/templates/nextblock-template/components/env-var-warning.tsx +3 -3
- package/templates/nextblock-template/components/form-message.tsx +32 -32
- package/templates/nextblock-template/components/seo/GenerateMetaButton.tsx +137 -137
- package/templates/nextblock-template/components/seo/PageSeoAuditSection.tsx +244 -244
- package/templates/nextblock-template/components/seo/SeoAuditPanel.tsx +749 -749
- package/templates/nextblock-template/components/seo/SeoIssueList.tsx +195 -195
- package/templates/nextblock-template/components/seo/SeoScoreDial.tsx +144 -144
- package/templates/nextblock-template/components/seo/SocialPreview.tsx +243 -243
- package/templates/nextblock-template/components/seo/SocialPreviewDialog.tsx +110 -110
- package/templates/nextblock-template/components/submit-button.tsx +23 -23
- package/templates/nextblock-template/components/theme-icon.tsx +78 -78
- package/templates/nextblock-template/components/theme-switcher.tsx +85 -85
- package/templates/nextblock-template/context/AuthContext.tsx +23 -23
- package/templates/nextblock-template/context/ThemeCatalogContext.tsx +44 -44
- package/templates/nextblock-template/docs/01-PROJECT-OVERVIEW.md +94 -94
- package/templates/nextblock-template/docs/03-CMS-AND-EDITOR.md +77 -77
- package/templates/nextblock-template/docs/13-STAYING-UP-TO-DATE.md +372 -372
- package/templates/nextblock-template/docs/14-MESSAGES-INBOX.md +309 -309
- package/templates/nextblock-template/docs/README.md +42 -42
- package/templates/nextblock-template/docs/TECHNICAL_SPECIFICATION.md +12506 -12506
- package/templates/nextblock-template/hooks/use-hotkeys.ts +21 -21
- package/templates/nextblock-template/hooks/useGlobalSearch.ts +101 -101
- package/templates/nextblock-template/index.d.ts +7 -7
- package/templates/nextblock-template/lib/auth-redirects.ts +46 -46
- package/templates/nextblock-template/lib/blocks/blockColors.test.ts +134 -134
- package/templates/nextblock-template/lib/blocks/blockColors.ts +175 -175
- package/templates/nextblock-template/lib/blocks/blockRegistry.ts +761 -761
- package/templates/nextblock-template/lib/blocks/inlineScriptNonce.ts +20 -20
- package/templates/nextblock-template/lib/cms/contact-reminder.ts +64 -64
- package/templates/nextblock-template/lib/cms/payments-reminder.test.ts +135 -0
- package/templates/nextblock-template/lib/cms/payments-reminder.ts +30 -23
- package/templates/nextblock-template/lib/cms/unread-messages.ts +42 -42
- package/templates/nextblock-template/lib/commerce/seller-contact.ts +162 -162
- package/templates/nextblock-template/lib/config/email-settings.ts +323 -323
- package/templates/nextblock-template/lib/config/email-tls.test.ts +57 -57
- package/templates/nextblock-template/lib/cortex-ai/alt-text-request.ts +86 -86
- package/templates/nextblock-template/lib/cortex-ai/sandbox-headers.ts +60 -60
- package/templates/nextblock-template/lib/email/placeholder-address.test.ts +59 -59
- package/templates/nextblock-template/lib/email/placeholder-address.ts +39 -39
- package/templates/nextblock-template/lib/messages/thread-reference.test.ts +70 -70
- package/templates/nextblock-template/lib/messages/thread-token.test.ts +93 -93
- package/templates/nextblock-template/lib/messages/thread-token.ts +157 -157
- package/templates/nextblock-template/lib/messages/threads.ts +579 -579
- package/templates/nextblock-template/lib/posts/readTime.ts +60 -60
- package/templates/nextblock-template/lib/publishing/viewUrl.ts +26 -26
- package/templates/nextblock-template/lib/search/types.ts +27 -27
- package/templates/nextblock-template/lib/seo/alt-text-write-back.test.ts +154 -154
- package/templates/nextblock-template/lib/seo/alt-text-write-back.ts +109 -109
- package/templates/nextblock-template/lib/seo/block-content.ts +123 -123
- package/templates/nextblock-template/lib/seo/fix-prompts.test.ts +242 -242
- package/templates/nextblock-template/lib/seo/fix-prompts.ts +204 -204
- package/templates/nextblock-template/lib/seo/page-audit-context.tsx +140 -140
- package/templates/nextblock-template/lib/seo/page-document.test.ts +350 -350
- package/templates/nextblock-template/lib/seo/page-document.ts +412 -412
- package/templates/nextblock-template/lib/seo/redirect-store.test.ts +479 -479
- package/templates/nextblock-template/lib/seo/redirect-store.ts +466 -466
- package/templates/nextblock-template/lib/seo/robots-settings-signature.test.ts +102 -102
- package/templates/nextblock-template/lib/seo/robots-settings-signature.ts +41 -41
- package/templates/nextblock-template/lib/seo/robots-txt.test.ts +370 -370
- package/templates/nextblock-template/lib/seo/robots-txt.ts +510 -510
- package/templates/nextblock-template/lib/setup/migrations-bundle.ts +177 -177
- package/templates/nextblock-template/lib/site-scripts/revisions.ts +71 -71
- package/templates/nextblock-template/lib/site-scripts/types.ts +46 -46
- package/templates/nextblock-template/lib/site-url.test.ts +89 -89
- package/templates/nextblock-template/lib/site-url.ts +102 -102
- package/templates/nextblock-template/lib/themes/buildThemeCss.ts +124 -124
- package/templates/nextblock-template/lib/themes/tokenColor.ts +31 -31
- package/templates/nextblock-template/lib/themes/tokens.ts +143 -143
- package/templates/nextblock-template/lib/visual-editing/draft-content.test.ts +105 -105
- package/templates/nextblock-template/lib/visual-editing/draft-route.test.ts +42 -42
- package/templates/nextblock-template/lib/visual-editing/edit-info.test.ts +143 -143
- package/templates/nextblock-template/lib/visual-editing/edit-info.ts +94 -94
- package/templates/nextblock-template/lib/visual-editing/product-drafts.test.ts +81 -81
- package/templates/nextblock-template/lib/zod-config.ts +5 -5
- package/templates/nextblock-template/next-env.d.ts +1 -2
- package/templates/nextblock-template/package.json +1 -1
- package/templates/nextblock-template/postcss.config.js +6 -6
- package/templates/nextblock-template/scripts/backup.js +115 -115
- package/templates/nextblock-template/scripts/restore.js +385 -385
- package/templates/nextblock-template/scripts/validate-editor-block-schema.ts +112 -112
- package/templates/nextblock-template/tailwind.config.js +25 -25
- package/templates/nextblock-template/tools/build-migrate.mjs +102 -102
- package/templates/nextblock-template/tools/configure-supabase-auth.js +282 -282
- package/templates/nextblock-template/tools/deploy-supabase.js +159 -159
- package/templates/nextblock-template/tools/lib/migrate-core.mjs +569 -569
- package/templates/nextblock-template/tools/update.mjs +1303 -1303
- package/tsconfig.base.json +3 -3
|
@@ -1,502 +1,502 @@
|
|
|
1
|
-
import { getProductBySlug, getProducts, getProviderReadiness } from '@nextblock-cms/ecommerce/server';
|
|
2
|
-
import {
|
|
3
|
-
ProductProvider,
|
|
4
|
-
mapRawVariantRelations,
|
|
5
|
-
getVariantEffectivePriceRange,
|
|
6
|
-
normalizePriceMap,
|
|
7
|
-
normalizeSalePriceMap,
|
|
8
|
-
resolveTranslatedText,
|
|
9
|
-
} from '@nextblock-cms/ecommerce';
|
|
10
|
-
import { getSsgSupabaseClient, verifyPackageOnline } from '@nextblock-cms/db/server';
|
|
11
|
-
import { LIVE_STATUS, buildPublishedAtOrFilter, isPubliclyVisible } from '@nextblock-cms/utils';
|
|
12
|
-
import { notFound } from 'next/navigation';
|
|
13
|
-
import { Metadata } from 'next';
|
|
14
|
-
import { draftMode, cookies, headers } from 'next/headers';
|
|
15
|
-
import { getPageDataBySlug } from "../../[slug]/page.utils";
|
|
16
|
-
import BlockRenderer from "../../../components/BlockRenderer";
|
|
17
|
-
import { CurrentContentSetter } from "../../../components/CurrentContentSetter";
|
|
18
|
-
import {
|
|
19
|
-
applyProductDraftToProductRecord,
|
|
20
|
-
getProductDraft,
|
|
21
|
-
} from "../../../lib/visual-editing/product-drafts";
|
|
22
|
-
import { getRequestOrigin } from "../../../lib/visual-editing/edit-info";
|
|
23
|
-
import {
|
|
24
|
-
resolveMetaTitle,
|
|
25
|
-
resolveProductMetaDescription,
|
|
26
|
-
stringifyJsonLd,
|
|
27
|
-
buildSocialMetadata,
|
|
28
|
-
buildCanonicalUrl,
|
|
29
|
-
toOpenGraphLocale,
|
|
30
|
-
} from "../../lib/seo";
|
|
31
|
-
import { getSiteSettings } from "../../lib/site-settings";
|
|
32
|
-
// Ensure BlockType is imported or compatible with BlockRenderer props
|
|
33
|
-
import type { Database } from "@nextblock-cms/db";
|
|
34
|
-
type BlockType = Database['public']['Tables']['blocks']['Row'];
|
|
35
|
-
|
|
36
|
-
export const dynamicParams = true;
|
|
37
|
-
export const revalidate = 360;
|
|
38
|
-
export const dynamic = 'force-dynamic'; // keeps per-request locale; paired with short revalidate
|
|
39
|
-
export const fetchCache = 'force-no-store';
|
|
40
|
-
|
|
41
|
-
interface ProductPageProps {
|
|
42
|
-
params: Promise<{
|
|
43
|
-
slug: string;
|
|
44
|
-
}>;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function formatMinorUnitAmount(amount: unknown) {
|
|
48
|
-
const numericAmount = typeof amount === 'number' ? amount : Number(amount);
|
|
49
|
-
if (!Number.isFinite(numericAmount)) {
|
|
50
|
-
return undefined;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
return (numericAmount / 100).toFixed(2);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function resolveOfferAvailability(productRecord: any, canPurchase = true) {
|
|
57
|
-
// Don't advertise a buyable offer for something checkout will refuse: a rich result
|
|
58
|
-
// promising "InStock" that dead-ends at an enquiry form is worse than no offer badge.
|
|
59
|
-
if (!canPurchase) {
|
|
60
|
-
return 'https://schema.org/InStoreOnly';
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
if (productRecord.product_type === 'digital') {
|
|
64
|
-
return 'https://schema.org/InStock';
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
return typeof productRecord.stock === 'number' && productRecord.stock <= 0
|
|
68
|
-
? 'https://schema.org/OutOfStock'
|
|
69
|
-
: 'https://schema.org/InStock';
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function resolveCategoryName(category: any, languageCode?: string | null) {
|
|
73
|
-
if (!category || typeof category.name !== 'string') {
|
|
74
|
-
return null;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
return resolveTranslatedText(
|
|
78
|
-
category.name,
|
|
79
|
-
category.name_translations,
|
|
80
|
-
languageCode
|
|
81
|
-
);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
export async function generateStaticParams() {
|
|
85
|
-
// Unconfigured instance (pre-/setup): no DB to read product slugs from. Accept every
|
|
86
|
-
// Supabase key alias the Vercel integration may inject (incl. the new publishable key).
|
|
87
|
-
const hasSupabaseEnv =
|
|
88
|
-
(process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.SUPABASE_URL) &&
|
|
89
|
-
(process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||
|
|
90
|
-
process.env.SUPABASE_ANON_KEY ||
|
|
91
|
-
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY ||
|
|
92
|
-
process.env.SUPABASE_PUBLISHABLE_KEY);
|
|
93
|
-
if (!hasSupabaseEnv) {
|
|
94
|
-
return [];
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const supabase = getSsgSupabaseClient();
|
|
98
|
-
const { data: products } = await getProducts(supabase);
|
|
99
|
-
const productRows = ((products || []) as any[]).filter(
|
|
100
|
-
(product) => product.status === 'active'
|
|
101
|
-
);
|
|
102
|
-
if (productRows.length === 0) return [];
|
|
103
|
-
return productRows.map((product: any) => ({
|
|
104
|
-
slug: product.slug,
|
|
105
|
-
}));
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
export async function generateMetadata({ params }: ProductPageProps): Promise<Metadata> {
|
|
109
|
-
const { slug } = await params;
|
|
110
|
-
const supabase = getSsgSupabaseClient();
|
|
111
|
-
let preferredLocale: string | undefined;
|
|
112
|
-
try {
|
|
113
|
-
const store = await cookies();
|
|
114
|
-
preferredLocale = store.get("NEXT_USER_LOCALE")?.value || store.get("NEXT_LOCALE")?.value;
|
|
115
|
-
} catch {
|
|
116
|
-
preferredLocale = undefined;
|
|
117
|
-
}
|
|
118
|
-
if (!preferredLocale) {
|
|
119
|
-
try {
|
|
120
|
-
const hdrs = await headers();
|
|
121
|
-
// Proxy-detected locale first: it honors the CMS language-detection settings.
|
|
122
|
-
preferredLocale = hdrs.get("x-user-locale") || undefined;
|
|
123
|
-
if (!preferredLocale) {
|
|
124
|
-
const al = hdrs.get("accept-language");
|
|
125
|
-
if (al) preferredLocale = al.split(",")[0]?.split("-")[0];
|
|
126
|
-
}
|
|
127
|
-
} catch {
|
|
128
|
-
// ignore
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
const { data: product } = await getProductBySlug(supabase, slug, preferredLocale);
|
|
132
|
-
const productRecord = product as any;
|
|
133
|
-
|
|
134
|
-
if (
|
|
135
|
-
!productRecord ||
|
|
136
|
-
!isPubliclyVisible({
|
|
137
|
-
status: productRecord.status,
|
|
138
|
-
publishedAt: productRecord.published_at,
|
|
139
|
-
liveStatus: LIVE_STATUS.product,
|
|
140
|
-
})
|
|
141
|
-
) {
|
|
142
|
-
return { title: 'Product Not Found' };
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// Resolve image URL for OG Image
|
|
146
|
-
let imageUrl = undefined;
|
|
147
|
-
const mediaItem = productRecord.product_media?.[0]?.media;
|
|
148
|
-
if (mediaItem?.file_path) {
|
|
149
|
-
if (mediaItem.file_path.startsWith('http')) {
|
|
150
|
-
imageUrl = mediaItem.file_path;
|
|
151
|
-
} else if (process.env.NEXT_PUBLIC_R2_BASE_URL) {
|
|
152
|
-
imageUrl = `${process.env.NEXT_PUBLIC_R2_BASE_URL}/${mediaItem.file_path}`;
|
|
153
|
-
} else {
|
|
154
|
-
imageUrl = `${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public/media/${mediaItem.file_path}`;
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
const siteUrl = process.env.NEXT_PUBLIC_URL || "";
|
|
159
|
-
const [languagesResult, productTranslationsResult] = await Promise.all([
|
|
160
|
-
supabase.from('languages').select('id, code'),
|
|
161
|
-
supabase
|
|
162
|
-
.from('products')
|
|
163
|
-
.select('language_id, slug')
|
|
164
|
-
.eq('translation_group_id', productRecord.translation_group_id)
|
|
165
|
-
.eq('status', 'active')
|
|
166
|
-
// Never advertise a scheduled translation via hreflang.
|
|
167
|
-
.or(buildPublishedAtOrFilter())
|
|
168
|
-
]);
|
|
169
|
-
|
|
170
|
-
const { data: languages } = languagesResult;
|
|
171
|
-
const { data: productTranslations } = productTranslationsResult;
|
|
172
|
-
|
|
173
|
-
const alternates: { [key: string]: string } = {};
|
|
174
|
-
if (languages && productTranslations) {
|
|
175
|
-
productTranslations.forEach(pt => {
|
|
176
|
-
const langInfo = languages.find(l => l.id === pt.language_id);
|
|
177
|
-
if (langInfo) {
|
|
178
|
-
alternates[langInfo.code] = `${siteUrl}/product/${pt.slug}`;
|
|
179
|
-
}
|
|
180
|
-
});
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
const title = resolveMetaTitle(productRecord.meta_title, productRecord.title);
|
|
184
|
-
const description = resolveProductMetaDescription(
|
|
185
|
-
productRecord.meta_description,
|
|
186
|
-
productRecord.short_description
|
|
187
|
-
);
|
|
188
|
-
const { siteTitle } = await getSiteSettings();
|
|
189
|
-
// Self-referencing `<siteUrl>/product/<slug>` unless the product sets a manual custom_canonical override.
|
|
190
|
-
const canonicalUrl = buildCanonicalUrl(productRecord.custom_canonical, siteUrl, `/product/${slug}`);
|
|
191
|
-
|
|
192
|
-
return {
|
|
193
|
-
title,
|
|
194
|
-
description,
|
|
195
|
-
...buildSocialMetadata({
|
|
196
|
-
title,
|
|
197
|
-
description,
|
|
198
|
-
url: canonicalUrl,
|
|
199
|
-
siteTitle,
|
|
200
|
-
imageUrl,
|
|
201
|
-
type: 'website',
|
|
202
|
-
locale: toOpenGraphLocale(productRecord.language_code),
|
|
203
|
-
}),
|
|
204
|
-
alternates: {
|
|
205
|
-
canonical: canonicalUrl,
|
|
206
|
-
languages: Object.keys(alternates).length > 0 ? alternates : undefined,
|
|
207
|
-
},
|
|
208
|
-
};
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
export default async function ProductPage({ params }: ProductPageProps) {
|
|
212
|
-
const { slug } = await params;
|
|
213
|
-
const supabase = getSsgSupabaseClient();
|
|
214
|
-
|
|
215
|
-
// 0. Verify License
|
|
216
|
-
const isOnline = await verifyPackageOnline('ecommerce');
|
|
217
|
-
if (!isOnline) {
|
|
218
|
-
notFound();
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
let preferredLocale: string | undefined;
|
|
222
|
-
try {
|
|
223
|
-
const store = await cookies();
|
|
224
|
-
preferredLocale = store.get("NEXT_USER_LOCALE")?.value || store.get("NEXT_LOCALE")?.value;
|
|
225
|
-
} catch {
|
|
226
|
-
preferredLocale = undefined;
|
|
227
|
-
}
|
|
228
|
-
if (!preferredLocale) {
|
|
229
|
-
try {
|
|
230
|
-
const hdrs = await headers();
|
|
231
|
-
// Proxy-detected locale first: it honors the CMS language-detection settings.
|
|
232
|
-
preferredLocale = hdrs.get("x-user-locale") || undefined;
|
|
233
|
-
if (!preferredLocale) {
|
|
234
|
-
const al = hdrs.get("accept-language");
|
|
235
|
-
if (al) preferredLocale = al.split(",")[0]?.split("-")[0];
|
|
236
|
-
}
|
|
237
|
-
} catch {
|
|
238
|
-
// ignore
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
// 1. Fetch Product Data
|
|
243
|
-
const { data: product } = await getProductBySlug(supabase, slug, preferredLocale);
|
|
244
|
-
let productRecord = product as any;
|
|
245
|
-
|
|
246
|
-
const draft = await draftMode();
|
|
247
|
-
|
|
248
|
-
// Draft mode is how the CMS previews a product before it is public, so it must
|
|
249
|
-
// reach draft and scheduled rows — everyone else only sees active products whose
|
|
250
|
-
// go-live moment has passed.
|
|
251
|
-
if (
|
|
252
|
-
!productRecord ||
|
|
253
|
-
(!draft.isEnabled &&
|
|
254
|
-
!isPubliclyVisible({
|
|
255
|
-
status: productRecord.status,
|
|
256
|
-
publishedAt: productRecord.published_at,
|
|
257
|
-
liveStatus: LIVE_STATUS.product,
|
|
258
|
-
}))
|
|
259
|
-
) {
|
|
260
|
-
notFound();
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
const visualEditingEnabled =
|
|
264
|
-
draft.isEnabled || process.env.NEXTBLOCK_VISUAL_EDITING_ENABLED === 'true';
|
|
265
|
-
|
|
266
|
-
if (visualEditingEnabled) {
|
|
267
|
-
const productDraft = await getProductDraft(productRecord.id);
|
|
268
|
-
productRecord = applyProductDraftToProductRecord(productRecord, productDraft);
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
// 2. Fetch Template Page
|
|
272
|
-
const templatePage = await getPageDataBySlug('product-template');
|
|
273
|
-
|
|
274
|
-
// 3. Fallback or Use Template
|
|
275
|
-
let blocks: BlockType[] = [];
|
|
276
|
-
let languageId = 1; // Default to 1 if not found
|
|
277
|
-
|
|
278
|
-
if (templatePage) {
|
|
279
|
-
blocks = templatePage.blocks;
|
|
280
|
-
languageId = templatePage.language_id;
|
|
281
|
-
} else {
|
|
282
|
-
// Fallback Layout if no template exists
|
|
283
|
-
// We cast to any to avoid strict DB type matching for fallback mocks, specifically for UUID/Dates
|
|
284
|
-
blocks = [
|
|
285
|
-
{
|
|
286
|
-
id: 'fallback-product-details',
|
|
287
|
-
block_type: 'product_details',
|
|
288
|
-
content: {},
|
|
289
|
-
page_id: 'temp',
|
|
290
|
-
order: 0,
|
|
291
|
-
language_id: 1,
|
|
292
|
-
created_at: new Date().toISOString(),
|
|
293
|
-
updated_at: new Date().toISOString()
|
|
294
|
-
},
|
|
295
|
-
{
|
|
296
|
-
id: 'fallback-product-grid',
|
|
297
|
-
block_type: 'product_grid',
|
|
298
|
-
content: { type: 'latest', limit: 4, title: "You might also like" },
|
|
299
|
-
page_id: 'temp',
|
|
300
|
-
order: 1,
|
|
301
|
-
language_id: 1,
|
|
302
|
-
created_at: new Date().toISOString(),
|
|
303
|
-
updated_at: new Date().toISOString()
|
|
304
|
-
}
|
|
305
|
-
] as any as BlockType[];
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
const requestOrigin = await getRequestOrigin();
|
|
309
|
-
const productTemplateVisualEditing = templatePage
|
|
310
|
-
? {
|
|
311
|
-
enabled: visualEditingEnabled,
|
|
312
|
-
documentType: "page" as const,
|
|
313
|
-
documentId: templatePage.id,
|
|
314
|
-
slug: templatePage.slug,
|
|
315
|
-
languageId: templatePage.language_id,
|
|
316
|
-
draftId: templatePage.draft_id ?? null,
|
|
317
|
-
pageOrigin: requestOrigin,
|
|
318
|
-
}
|
|
319
|
-
: undefined;
|
|
320
|
-
|
|
321
|
-
// 4. Transform Product Data for Context
|
|
322
|
-
// Value Mapping
|
|
323
|
-
// Image URL resolution
|
|
324
|
-
let imageUrl: string | undefined = undefined;
|
|
325
|
-
const images: { url: string; alt?: string }[] = [];
|
|
326
|
-
|
|
327
|
-
if (productRecord.product_media && productRecord.product_media.length > 0) {
|
|
328
|
-
// Sort by sort_order
|
|
329
|
-
const sortedMedia = [...productRecord.product_media].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
|
|
330
|
-
|
|
331
|
-
sortedMedia.forEach(pm => {
|
|
332
|
-
if (pm.media?.file_path) {
|
|
333
|
-
let url = '';
|
|
334
|
-
if (pm.media.file_path.startsWith('http')) {
|
|
335
|
-
url = pm.media.file_path;
|
|
336
|
-
} else if (process.env.NEXT_PUBLIC_R2_BASE_URL) {
|
|
337
|
-
url = `${process.env.NEXT_PUBLIC_R2_BASE_URL}/${pm.media.file_path}`;
|
|
338
|
-
} else {
|
|
339
|
-
url = `${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public/media/${pm.media.file_path}`;
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
images.push({ url, alt: productRecord.title });
|
|
343
|
-
|
|
344
|
-
// Set primary image if it's the first one
|
|
345
|
-
if (!imageUrl) imageUrl = url;
|
|
346
|
-
}
|
|
347
|
-
});
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
const languageCode = Array.isArray(productRecord.languages)
|
|
351
|
-
? productRecord.languages[0]?.code
|
|
352
|
-
: productRecord.languages?.code;
|
|
353
|
-
const { attributes, variants } = mapRawVariantRelations(
|
|
354
|
-
productRecord.product_variants || [],
|
|
355
|
-
languageCode
|
|
356
|
-
);
|
|
357
|
-
const variantPriceRange = getVariantEffectivePriceRange(variants);
|
|
358
|
-
const productCategories = (productRecord.product_categories || [])
|
|
359
|
-
.map((pc: any) => pc.category)
|
|
360
|
-
.filter(Boolean);
|
|
361
|
-
const productCategoryNames = productCategories
|
|
362
|
-
.map((category: any) => resolveCategoryName(category, languageCode))
|
|
363
|
-
.filter((name: string | null): name is string => Boolean(name));
|
|
364
|
-
|
|
365
|
-
const contextProduct = {
|
|
366
|
-
id: productRecord.id,
|
|
367
|
-
title: productRecord.title,
|
|
368
|
-
slug: productRecord.slug,
|
|
369
|
-
sku: productRecord.sku,
|
|
370
|
-
average_rating: productRecord.average_rating,
|
|
371
|
-
total_reviews: productRecord.total_reviews,
|
|
372
|
-
upc: productRecord.upc || undefined,
|
|
373
|
-
price: productRecord.price,
|
|
374
|
-
prices: normalizePriceMap(productRecord.prices),
|
|
375
|
-
sale_price: productRecord.sale_price || null,
|
|
376
|
-
sale_prices: normalizeSalePriceMap(productRecord.sale_prices),
|
|
377
|
-
sale_start_at: productRecord.sale_start_at ?? null,
|
|
378
|
-
sale_end_at: productRecord.sale_end_at ?? null,
|
|
379
|
-
scheduled_price:
|
|
380
|
-
typeof productRecord.scheduled_price === 'number' ? productRecord.scheduled_price : undefined,
|
|
381
|
-
scheduled_prices: normalizePriceMap(productRecord.scheduled_prices),
|
|
382
|
-
scheduled_price_at: productRecord.scheduled_price_at ?? null,
|
|
383
|
-
is_taxable: productRecord.is_taxable ?? true,
|
|
384
|
-
product_type: productRecord.product_type ?? undefined,
|
|
385
|
-
payment_provider: productRecord.payment_provider ?? undefined,
|
|
386
|
-
price_range_min: variantPriceRange?.min ?? null,
|
|
387
|
-
price_range_max: variantPriceRange?.max ?? null,
|
|
388
|
-
image_url: imageUrl,
|
|
389
|
-
images: images,
|
|
390
|
-
short_description: productRecord.short_description || undefined,
|
|
391
|
-
description_json: productRecord.description_json,
|
|
392
|
-
stock:
|
|
393
|
-
productRecord.stock !== undefined && productRecord.stock !== null
|
|
394
|
-
? productRecord.stock
|
|
395
|
-
: undefined,
|
|
396
|
-
freemius_product_id: productRecord.freemius_product_id || undefined,
|
|
397
|
-
freemius_plan_id: productRecord.freemius_plan_id || undefined,
|
|
398
|
-
trial_period_days: productRecord.trial_period_days ?? 0,
|
|
399
|
-
trial_requires_payment_method: productRecord.trial_requires_payment_method ?? false,
|
|
400
|
-
freemius_plans: productRecord.freemius_plans,
|
|
401
|
-
language_id: productRecord.language_id,
|
|
402
|
-
translation_group_id: productRecord.translation_group_id || "",
|
|
403
|
-
has_variants: variants.length > 0,
|
|
404
|
-
attributes,
|
|
405
|
-
variants,
|
|
406
|
-
categories: productCategories,
|
|
407
|
-
product_variants: (productRecord.product_variants || []).map((variant: any) => ({
|
|
408
|
-
id: variant.id,
|
|
409
|
-
price: variant.price,
|
|
410
|
-
prices: normalizePriceMap(variant.prices),
|
|
411
|
-
sale_price: variant.sale_price,
|
|
412
|
-
sale_prices: normalizeSalePriceMap(variant.sale_prices),
|
|
413
|
-
sale_start_at: variant.sale_start_at ?? null,
|
|
414
|
-
sale_end_at: variant.sale_end_at ?? null,
|
|
415
|
-
scheduled_price: variant.scheduled_price ?? null,
|
|
416
|
-
scheduled_prices: normalizePriceMap(variant.scheduled_prices),
|
|
417
|
-
scheduled_price_at: variant.scheduled_price_at ?? null,
|
|
418
|
-
})),
|
|
419
|
-
};
|
|
420
|
-
const siteUrl = process.env.NEXT_PUBLIC_URL || "";
|
|
421
|
-
|
|
422
|
-
// Whether checkout would actually accept this product, so the structured data
|
|
423
|
-
// doesn't promise a purchase the store can't complete. Defaults to buyable on any
|
|
424
|
-
// failure — a working shop must never lose its offer markup to an unrelated error.
|
|
425
|
-
const canPurchase = await (async () => {
|
|
426
|
-
const provider = productRecord.payment_provider;
|
|
427
|
-
if (provider !== 'stripe' && provider !== 'freemius') return true;
|
|
428
|
-
try {
|
|
429
|
-
return (await getProviderReadiness(provider)).ready;
|
|
430
|
-
} catch {
|
|
431
|
-
return true;
|
|
432
|
-
}
|
|
433
|
-
})();
|
|
434
|
-
|
|
435
|
-
const nonce = (await headers()).get('x-nonce') || undefined;
|
|
436
|
-
const title = resolveMetaTitle(productRecord.meta_title, productRecord.title);
|
|
437
|
-
const description = resolveProductMetaDescription(
|
|
438
|
-
productRecord.meta_description,
|
|
439
|
-
productRecord.short_description
|
|
440
|
-
);
|
|
441
|
-
const { data: defaultCurrency } = await supabase
|
|
442
|
-
.from('currencies')
|
|
443
|
-
.select('code')
|
|
444
|
-
.eq('is_default', true)
|
|
445
|
-
.maybeSingle();
|
|
446
|
-
const price = formatMinorUnitAmount(productRecord.sale_price ?? productRecord.price);
|
|
447
|
-
const productJsonLd = {
|
|
448
|
-
'@context': 'https://schema.org',
|
|
449
|
-
'@type': 'Product',
|
|
450
|
-
name: title,
|
|
451
|
-
description,
|
|
452
|
-
image: images.map((image) => image.url),
|
|
453
|
-
category:
|
|
454
|
-
productCategoryNames.length > 1
|
|
455
|
-
? productCategoryNames
|
|
456
|
-
: productCategoryNames[0] ?? undefined,
|
|
457
|
-
sku: productRecord.sku || undefined,
|
|
458
|
-
gtin: productRecord.upc || undefined,
|
|
459
|
-
url: `${siteUrl}/product/${productRecord.slug}`,
|
|
460
|
-
offers: price
|
|
461
|
-
? {
|
|
462
|
-
'@type': 'Offer',
|
|
463
|
-
url: `${siteUrl}/product/${productRecord.slug}`,
|
|
464
|
-
price,
|
|
465
|
-
priceCurrency: defaultCurrency?.code || 'USD',
|
|
466
|
-
availability: resolveOfferAvailability(productRecord, canPurchase),
|
|
467
|
-
itemCondition: 'https://schema.org/NewCondition',
|
|
468
|
-
}
|
|
469
|
-
: undefined,
|
|
470
|
-
};
|
|
471
|
-
|
|
472
|
-
return (
|
|
473
|
-
<div className="min-h-screen bg-background pb-12">
|
|
474
|
-
<script
|
|
475
|
-
type="application/ld+json"
|
|
476
|
-
nonce={nonce}
|
|
477
|
-
suppressHydrationWarning
|
|
478
|
-
dangerouslySetInnerHTML={{ __html: stringifyJsonLd(productJsonLd) }}
|
|
479
|
-
/>
|
|
480
|
-
<ProductProvider product={contextProduct}>
|
|
481
|
-
{/*
|
|
482
|
-
BlockRenderer expects languageId property.
|
|
483
|
-
If templatePage is null, we defaulted languageId to 1.
|
|
484
|
-
*/}
|
|
485
|
-
<CurrentContentSetter
|
|
486
|
-
id={productRecord.id}
|
|
487
|
-
type="product"
|
|
488
|
-
slug={productRecord.slug}
|
|
489
|
-
translation_group_id={productRecord.translation_group_id}
|
|
490
|
-
/>
|
|
491
|
-
<BlockRenderer
|
|
492
|
-
blocks={blocks}
|
|
493
|
-
languageId={languageId}
|
|
494
|
-
excludeProductId={productRecord.id}
|
|
495
|
-
excludeTranslationGroupId={productRecord.translation_group_id}
|
|
496
|
-
visualEditing={productTemplateVisualEditing}
|
|
497
|
-
productVisualEditingEnabled={visualEditingEnabled}
|
|
498
|
-
/>
|
|
499
|
-
</ProductProvider>
|
|
500
|
-
</div>
|
|
501
|
-
);
|
|
502
|
-
}
|
|
1
|
+
import { getProductBySlug, getProducts, getProviderReadiness } from '@nextblock-cms/ecommerce/server';
|
|
2
|
+
import {
|
|
3
|
+
ProductProvider,
|
|
4
|
+
mapRawVariantRelations,
|
|
5
|
+
getVariantEffectivePriceRange,
|
|
6
|
+
normalizePriceMap,
|
|
7
|
+
normalizeSalePriceMap,
|
|
8
|
+
resolveTranslatedText,
|
|
9
|
+
} from '@nextblock-cms/ecommerce';
|
|
10
|
+
import { getSsgSupabaseClient, verifyPackageOnline } from '@nextblock-cms/db/server';
|
|
11
|
+
import { LIVE_STATUS, buildPublishedAtOrFilter, isPubliclyVisible } from '@nextblock-cms/utils';
|
|
12
|
+
import { notFound } from 'next/navigation';
|
|
13
|
+
import { Metadata } from 'next';
|
|
14
|
+
import { draftMode, cookies, headers } from 'next/headers';
|
|
15
|
+
import { getPageDataBySlug } from "../../[slug]/page.utils";
|
|
16
|
+
import BlockRenderer from "../../../components/BlockRenderer";
|
|
17
|
+
import { CurrentContentSetter } from "../../../components/CurrentContentSetter";
|
|
18
|
+
import {
|
|
19
|
+
applyProductDraftToProductRecord,
|
|
20
|
+
getProductDraft,
|
|
21
|
+
} from "../../../lib/visual-editing/product-drafts";
|
|
22
|
+
import { getRequestOrigin } from "../../../lib/visual-editing/edit-info";
|
|
23
|
+
import {
|
|
24
|
+
resolveMetaTitle,
|
|
25
|
+
resolveProductMetaDescription,
|
|
26
|
+
stringifyJsonLd,
|
|
27
|
+
buildSocialMetadata,
|
|
28
|
+
buildCanonicalUrl,
|
|
29
|
+
toOpenGraphLocale,
|
|
30
|
+
} from "../../lib/seo";
|
|
31
|
+
import { getSiteSettings } from "../../lib/site-settings";
|
|
32
|
+
// Ensure BlockType is imported or compatible with BlockRenderer props
|
|
33
|
+
import type { Database } from "@nextblock-cms/db";
|
|
34
|
+
type BlockType = Database['public']['Tables']['blocks']['Row'];
|
|
35
|
+
|
|
36
|
+
export const dynamicParams = true;
|
|
37
|
+
export const revalidate = 360;
|
|
38
|
+
export const dynamic = 'force-dynamic'; // keeps per-request locale; paired with short revalidate
|
|
39
|
+
export const fetchCache = 'force-no-store';
|
|
40
|
+
|
|
41
|
+
interface ProductPageProps {
|
|
42
|
+
params: Promise<{
|
|
43
|
+
slug: string;
|
|
44
|
+
}>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function formatMinorUnitAmount(amount: unknown) {
|
|
48
|
+
const numericAmount = typeof amount === 'number' ? amount : Number(amount);
|
|
49
|
+
if (!Number.isFinite(numericAmount)) {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return (numericAmount / 100).toFixed(2);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function resolveOfferAvailability(productRecord: any, canPurchase = true) {
|
|
57
|
+
// Don't advertise a buyable offer for something checkout will refuse: a rich result
|
|
58
|
+
// promising "InStock" that dead-ends at an enquiry form is worse than no offer badge.
|
|
59
|
+
if (!canPurchase) {
|
|
60
|
+
return 'https://schema.org/InStoreOnly';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (productRecord.product_type === 'digital') {
|
|
64
|
+
return 'https://schema.org/InStock';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return typeof productRecord.stock === 'number' && productRecord.stock <= 0
|
|
68
|
+
? 'https://schema.org/OutOfStock'
|
|
69
|
+
: 'https://schema.org/InStock';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function resolveCategoryName(category: any, languageCode?: string | null) {
|
|
73
|
+
if (!category || typeof category.name !== 'string') {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return resolveTranslatedText(
|
|
78
|
+
category.name,
|
|
79
|
+
category.name_translations,
|
|
80
|
+
languageCode
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function generateStaticParams() {
|
|
85
|
+
// Unconfigured instance (pre-/setup): no DB to read product slugs from. Accept every
|
|
86
|
+
// Supabase key alias the Vercel integration may inject (incl. the new publishable key).
|
|
87
|
+
const hasSupabaseEnv =
|
|
88
|
+
(process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.SUPABASE_URL) &&
|
|
89
|
+
(process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||
|
|
90
|
+
process.env.SUPABASE_ANON_KEY ||
|
|
91
|
+
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY ||
|
|
92
|
+
process.env.SUPABASE_PUBLISHABLE_KEY);
|
|
93
|
+
if (!hasSupabaseEnv) {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const supabase = getSsgSupabaseClient();
|
|
98
|
+
const { data: products } = await getProducts(supabase);
|
|
99
|
+
const productRows = ((products || []) as any[]).filter(
|
|
100
|
+
(product) => product.status === 'active'
|
|
101
|
+
);
|
|
102
|
+
if (productRows.length === 0) return [];
|
|
103
|
+
return productRows.map((product: any) => ({
|
|
104
|
+
slug: product.slug,
|
|
105
|
+
}));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function generateMetadata({ params }: ProductPageProps): Promise<Metadata> {
|
|
109
|
+
const { slug } = await params;
|
|
110
|
+
const supabase = getSsgSupabaseClient();
|
|
111
|
+
let preferredLocale: string | undefined;
|
|
112
|
+
try {
|
|
113
|
+
const store = await cookies();
|
|
114
|
+
preferredLocale = store.get("NEXT_USER_LOCALE")?.value || store.get("NEXT_LOCALE")?.value;
|
|
115
|
+
} catch {
|
|
116
|
+
preferredLocale = undefined;
|
|
117
|
+
}
|
|
118
|
+
if (!preferredLocale) {
|
|
119
|
+
try {
|
|
120
|
+
const hdrs = await headers();
|
|
121
|
+
// Proxy-detected locale first: it honors the CMS language-detection settings.
|
|
122
|
+
preferredLocale = hdrs.get("x-user-locale") || undefined;
|
|
123
|
+
if (!preferredLocale) {
|
|
124
|
+
const al = hdrs.get("accept-language");
|
|
125
|
+
if (al) preferredLocale = al.split(",")[0]?.split("-")[0];
|
|
126
|
+
}
|
|
127
|
+
} catch {
|
|
128
|
+
// ignore
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const { data: product } = await getProductBySlug(supabase, slug, preferredLocale);
|
|
132
|
+
const productRecord = product as any;
|
|
133
|
+
|
|
134
|
+
if (
|
|
135
|
+
!productRecord ||
|
|
136
|
+
!isPubliclyVisible({
|
|
137
|
+
status: productRecord.status,
|
|
138
|
+
publishedAt: productRecord.published_at,
|
|
139
|
+
liveStatus: LIVE_STATUS.product,
|
|
140
|
+
})
|
|
141
|
+
) {
|
|
142
|
+
return { title: 'Product Not Found' };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Resolve image URL for OG Image
|
|
146
|
+
let imageUrl = undefined;
|
|
147
|
+
const mediaItem = productRecord.product_media?.[0]?.media;
|
|
148
|
+
if (mediaItem?.file_path) {
|
|
149
|
+
if (mediaItem.file_path.startsWith('http')) {
|
|
150
|
+
imageUrl = mediaItem.file_path;
|
|
151
|
+
} else if (process.env.NEXT_PUBLIC_R2_BASE_URL) {
|
|
152
|
+
imageUrl = `${process.env.NEXT_PUBLIC_R2_BASE_URL}/${mediaItem.file_path}`;
|
|
153
|
+
} else {
|
|
154
|
+
imageUrl = `${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public/media/${mediaItem.file_path}`;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const siteUrl = process.env.NEXT_PUBLIC_URL || "";
|
|
159
|
+
const [languagesResult, productTranslationsResult] = await Promise.all([
|
|
160
|
+
supabase.from('languages').select('id, code'),
|
|
161
|
+
supabase
|
|
162
|
+
.from('products')
|
|
163
|
+
.select('language_id, slug')
|
|
164
|
+
.eq('translation_group_id', productRecord.translation_group_id)
|
|
165
|
+
.eq('status', 'active')
|
|
166
|
+
// Never advertise a scheduled translation via hreflang.
|
|
167
|
+
.or(buildPublishedAtOrFilter())
|
|
168
|
+
]);
|
|
169
|
+
|
|
170
|
+
const { data: languages } = languagesResult;
|
|
171
|
+
const { data: productTranslations } = productTranslationsResult;
|
|
172
|
+
|
|
173
|
+
const alternates: { [key: string]: string } = {};
|
|
174
|
+
if (languages && productTranslations) {
|
|
175
|
+
productTranslations.forEach(pt => {
|
|
176
|
+
const langInfo = languages.find(l => l.id === pt.language_id);
|
|
177
|
+
if (langInfo) {
|
|
178
|
+
alternates[langInfo.code] = `${siteUrl}/product/${pt.slug}`;
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const title = resolveMetaTitle(productRecord.meta_title, productRecord.title);
|
|
184
|
+
const description = resolveProductMetaDescription(
|
|
185
|
+
productRecord.meta_description,
|
|
186
|
+
productRecord.short_description
|
|
187
|
+
);
|
|
188
|
+
const { siteTitle } = await getSiteSettings();
|
|
189
|
+
// Self-referencing `<siteUrl>/product/<slug>` unless the product sets a manual custom_canonical override.
|
|
190
|
+
const canonicalUrl = buildCanonicalUrl(productRecord.custom_canonical, siteUrl, `/product/${slug}`);
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
title,
|
|
194
|
+
description,
|
|
195
|
+
...buildSocialMetadata({
|
|
196
|
+
title,
|
|
197
|
+
description,
|
|
198
|
+
url: canonicalUrl,
|
|
199
|
+
siteTitle,
|
|
200
|
+
imageUrl,
|
|
201
|
+
type: 'website',
|
|
202
|
+
locale: toOpenGraphLocale(productRecord.language_code),
|
|
203
|
+
}),
|
|
204
|
+
alternates: {
|
|
205
|
+
canonical: canonicalUrl,
|
|
206
|
+
languages: Object.keys(alternates).length > 0 ? alternates : undefined,
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export default async function ProductPage({ params }: ProductPageProps) {
|
|
212
|
+
const { slug } = await params;
|
|
213
|
+
const supabase = getSsgSupabaseClient();
|
|
214
|
+
|
|
215
|
+
// 0. Verify License
|
|
216
|
+
const isOnline = await verifyPackageOnline('ecommerce');
|
|
217
|
+
if (!isOnline) {
|
|
218
|
+
notFound();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
let preferredLocale: string | undefined;
|
|
222
|
+
try {
|
|
223
|
+
const store = await cookies();
|
|
224
|
+
preferredLocale = store.get("NEXT_USER_LOCALE")?.value || store.get("NEXT_LOCALE")?.value;
|
|
225
|
+
} catch {
|
|
226
|
+
preferredLocale = undefined;
|
|
227
|
+
}
|
|
228
|
+
if (!preferredLocale) {
|
|
229
|
+
try {
|
|
230
|
+
const hdrs = await headers();
|
|
231
|
+
// Proxy-detected locale first: it honors the CMS language-detection settings.
|
|
232
|
+
preferredLocale = hdrs.get("x-user-locale") || undefined;
|
|
233
|
+
if (!preferredLocale) {
|
|
234
|
+
const al = hdrs.get("accept-language");
|
|
235
|
+
if (al) preferredLocale = al.split(",")[0]?.split("-")[0];
|
|
236
|
+
}
|
|
237
|
+
} catch {
|
|
238
|
+
// ignore
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// 1. Fetch Product Data
|
|
243
|
+
const { data: product } = await getProductBySlug(supabase, slug, preferredLocale);
|
|
244
|
+
let productRecord = product as any;
|
|
245
|
+
|
|
246
|
+
const draft = await draftMode();
|
|
247
|
+
|
|
248
|
+
// Draft mode is how the CMS previews a product before it is public, so it must
|
|
249
|
+
// reach draft and scheduled rows — everyone else only sees active products whose
|
|
250
|
+
// go-live moment has passed.
|
|
251
|
+
if (
|
|
252
|
+
!productRecord ||
|
|
253
|
+
(!draft.isEnabled &&
|
|
254
|
+
!isPubliclyVisible({
|
|
255
|
+
status: productRecord.status,
|
|
256
|
+
publishedAt: productRecord.published_at,
|
|
257
|
+
liveStatus: LIVE_STATUS.product,
|
|
258
|
+
}))
|
|
259
|
+
) {
|
|
260
|
+
notFound();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const visualEditingEnabled =
|
|
264
|
+
draft.isEnabled || process.env.NEXTBLOCK_VISUAL_EDITING_ENABLED === 'true';
|
|
265
|
+
|
|
266
|
+
if (visualEditingEnabled) {
|
|
267
|
+
const productDraft = await getProductDraft(productRecord.id);
|
|
268
|
+
productRecord = applyProductDraftToProductRecord(productRecord, productDraft);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// 2. Fetch Template Page
|
|
272
|
+
const templatePage = await getPageDataBySlug('product-template');
|
|
273
|
+
|
|
274
|
+
// 3. Fallback or Use Template
|
|
275
|
+
let blocks: BlockType[] = [];
|
|
276
|
+
let languageId = 1; // Default to 1 if not found
|
|
277
|
+
|
|
278
|
+
if (templatePage) {
|
|
279
|
+
blocks = templatePage.blocks;
|
|
280
|
+
languageId = templatePage.language_id;
|
|
281
|
+
} else {
|
|
282
|
+
// Fallback Layout if no template exists
|
|
283
|
+
// We cast to any to avoid strict DB type matching for fallback mocks, specifically for UUID/Dates
|
|
284
|
+
blocks = [
|
|
285
|
+
{
|
|
286
|
+
id: 'fallback-product-details',
|
|
287
|
+
block_type: 'product_details',
|
|
288
|
+
content: {},
|
|
289
|
+
page_id: 'temp',
|
|
290
|
+
order: 0,
|
|
291
|
+
language_id: 1,
|
|
292
|
+
created_at: new Date().toISOString(),
|
|
293
|
+
updated_at: new Date().toISOString()
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
id: 'fallback-product-grid',
|
|
297
|
+
block_type: 'product_grid',
|
|
298
|
+
content: { type: 'latest', limit: 4, title: "You might also like" },
|
|
299
|
+
page_id: 'temp',
|
|
300
|
+
order: 1,
|
|
301
|
+
language_id: 1,
|
|
302
|
+
created_at: new Date().toISOString(),
|
|
303
|
+
updated_at: new Date().toISOString()
|
|
304
|
+
}
|
|
305
|
+
] as any as BlockType[];
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const requestOrigin = await getRequestOrigin();
|
|
309
|
+
const productTemplateVisualEditing = templatePage
|
|
310
|
+
? {
|
|
311
|
+
enabled: visualEditingEnabled,
|
|
312
|
+
documentType: "page" as const,
|
|
313
|
+
documentId: templatePage.id,
|
|
314
|
+
slug: templatePage.slug,
|
|
315
|
+
languageId: templatePage.language_id,
|
|
316
|
+
draftId: templatePage.draft_id ?? null,
|
|
317
|
+
pageOrigin: requestOrigin,
|
|
318
|
+
}
|
|
319
|
+
: undefined;
|
|
320
|
+
|
|
321
|
+
// 4. Transform Product Data for Context
|
|
322
|
+
// Value Mapping
|
|
323
|
+
// Image URL resolution
|
|
324
|
+
let imageUrl: string | undefined = undefined;
|
|
325
|
+
const images: { url: string; alt?: string }[] = [];
|
|
326
|
+
|
|
327
|
+
if (productRecord.product_media && productRecord.product_media.length > 0) {
|
|
328
|
+
// Sort by sort_order
|
|
329
|
+
const sortedMedia = [...productRecord.product_media].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
|
|
330
|
+
|
|
331
|
+
sortedMedia.forEach(pm => {
|
|
332
|
+
if (pm.media?.file_path) {
|
|
333
|
+
let url = '';
|
|
334
|
+
if (pm.media.file_path.startsWith('http')) {
|
|
335
|
+
url = pm.media.file_path;
|
|
336
|
+
} else if (process.env.NEXT_PUBLIC_R2_BASE_URL) {
|
|
337
|
+
url = `${process.env.NEXT_PUBLIC_R2_BASE_URL}/${pm.media.file_path}`;
|
|
338
|
+
} else {
|
|
339
|
+
url = `${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public/media/${pm.media.file_path}`;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
images.push({ url, alt: productRecord.title });
|
|
343
|
+
|
|
344
|
+
// Set primary image if it's the first one
|
|
345
|
+
if (!imageUrl) imageUrl = url;
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const languageCode = Array.isArray(productRecord.languages)
|
|
351
|
+
? productRecord.languages[0]?.code
|
|
352
|
+
: productRecord.languages?.code;
|
|
353
|
+
const { attributes, variants } = mapRawVariantRelations(
|
|
354
|
+
productRecord.product_variants || [],
|
|
355
|
+
languageCode
|
|
356
|
+
);
|
|
357
|
+
const variantPriceRange = getVariantEffectivePriceRange(variants);
|
|
358
|
+
const productCategories = (productRecord.product_categories || [])
|
|
359
|
+
.map((pc: any) => pc.category)
|
|
360
|
+
.filter(Boolean);
|
|
361
|
+
const productCategoryNames = productCategories
|
|
362
|
+
.map((category: any) => resolveCategoryName(category, languageCode))
|
|
363
|
+
.filter((name: string | null): name is string => Boolean(name));
|
|
364
|
+
|
|
365
|
+
const contextProduct = {
|
|
366
|
+
id: productRecord.id,
|
|
367
|
+
title: productRecord.title,
|
|
368
|
+
slug: productRecord.slug,
|
|
369
|
+
sku: productRecord.sku,
|
|
370
|
+
average_rating: productRecord.average_rating,
|
|
371
|
+
total_reviews: productRecord.total_reviews,
|
|
372
|
+
upc: productRecord.upc || undefined,
|
|
373
|
+
price: productRecord.price,
|
|
374
|
+
prices: normalizePriceMap(productRecord.prices),
|
|
375
|
+
sale_price: productRecord.sale_price || null,
|
|
376
|
+
sale_prices: normalizeSalePriceMap(productRecord.sale_prices),
|
|
377
|
+
sale_start_at: productRecord.sale_start_at ?? null,
|
|
378
|
+
sale_end_at: productRecord.sale_end_at ?? null,
|
|
379
|
+
scheduled_price:
|
|
380
|
+
typeof productRecord.scheduled_price === 'number' ? productRecord.scheduled_price : undefined,
|
|
381
|
+
scheduled_prices: normalizePriceMap(productRecord.scheduled_prices),
|
|
382
|
+
scheduled_price_at: productRecord.scheduled_price_at ?? null,
|
|
383
|
+
is_taxable: productRecord.is_taxable ?? true,
|
|
384
|
+
product_type: productRecord.product_type ?? undefined,
|
|
385
|
+
payment_provider: productRecord.payment_provider ?? undefined,
|
|
386
|
+
price_range_min: variantPriceRange?.min ?? null,
|
|
387
|
+
price_range_max: variantPriceRange?.max ?? null,
|
|
388
|
+
image_url: imageUrl,
|
|
389
|
+
images: images,
|
|
390
|
+
short_description: productRecord.short_description || undefined,
|
|
391
|
+
description_json: productRecord.description_json,
|
|
392
|
+
stock:
|
|
393
|
+
productRecord.stock !== undefined && productRecord.stock !== null
|
|
394
|
+
? productRecord.stock
|
|
395
|
+
: undefined,
|
|
396
|
+
freemius_product_id: productRecord.freemius_product_id || undefined,
|
|
397
|
+
freemius_plan_id: productRecord.freemius_plan_id || undefined,
|
|
398
|
+
trial_period_days: productRecord.trial_period_days ?? 0,
|
|
399
|
+
trial_requires_payment_method: productRecord.trial_requires_payment_method ?? false,
|
|
400
|
+
freemius_plans: productRecord.freemius_plans,
|
|
401
|
+
language_id: productRecord.language_id,
|
|
402
|
+
translation_group_id: productRecord.translation_group_id || "",
|
|
403
|
+
has_variants: variants.length > 0,
|
|
404
|
+
attributes,
|
|
405
|
+
variants,
|
|
406
|
+
categories: productCategories,
|
|
407
|
+
product_variants: (productRecord.product_variants || []).map((variant: any) => ({
|
|
408
|
+
id: variant.id,
|
|
409
|
+
price: variant.price,
|
|
410
|
+
prices: normalizePriceMap(variant.prices),
|
|
411
|
+
sale_price: variant.sale_price,
|
|
412
|
+
sale_prices: normalizeSalePriceMap(variant.sale_prices),
|
|
413
|
+
sale_start_at: variant.sale_start_at ?? null,
|
|
414
|
+
sale_end_at: variant.sale_end_at ?? null,
|
|
415
|
+
scheduled_price: variant.scheduled_price ?? null,
|
|
416
|
+
scheduled_prices: normalizePriceMap(variant.scheduled_prices),
|
|
417
|
+
scheduled_price_at: variant.scheduled_price_at ?? null,
|
|
418
|
+
})),
|
|
419
|
+
};
|
|
420
|
+
const siteUrl = process.env.NEXT_PUBLIC_URL || "";
|
|
421
|
+
|
|
422
|
+
// Whether checkout would actually accept this product, so the structured data
|
|
423
|
+
// doesn't promise a purchase the store can't complete. Defaults to buyable on any
|
|
424
|
+
// failure — a working shop must never lose its offer markup to an unrelated error.
|
|
425
|
+
const canPurchase = await (async () => {
|
|
426
|
+
const provider = productRecord.payment_provider;
|
|
427
|
+
if (provider !== 'stripe' && provider !== 'freemius') return true;
|
|
428
|
+
try {
|
|
429
|
+
return (await getProviderReadiness(provider)).ready;
|
|
430
|
+
} catch {
|
|
431
|
+
return true;
|
|
432
|
+
}
|
|
433
|
+
})();
|
|
434
|
+
|
|
435
|
+
const nonce = (await headers()).get('x-nonce') || undefined;
|
|
436
|
+
const title = resolveMetaTitle(productRecord.meta_title, productRecord.title);
|
|
437
|
+
const description = resolveProductMetaDescription(
|
|
438
|
+
productRecord.meta_description,
|
|
439
|
+
productRecord.short_description
|
|
440
|
+
);
|
|
441
|
+
const { data: defaultCurrency } = await supabase
|
|
442
|
+
.from('currencies')
|
|
443
|
+
.select('code')
|
|
444
|
+
.eq('is_default', true)
|
|
445
|
+
.maybeSingle();
|
|
446
|
+
const price = formatMinorUnitAmount(productRecord.sale_price ?? productRecord.price);
|
|
447
|
+
const productJsonLd = {
|
|
448
|
+
'@context': 'https://schema.org',
|
|
449
|
+
'@type': 'Product',
|
|
450
|
+
name: title,
|
|
451
|
+
description,
|
|
452
|
+
image: images.map((image) => image.url),
|
|
453
|
+
category:
|
|
454
|
+
productCategoryNames.length > 1
|
|
455
|
+
? productCategoryNames
|
|
456
|
+
: productCategoryNames[0] ?? undefined,
|
|
457
|
+
sku: productRecord.sku || undefined,
|
|
458
|
+
gtin: productRecord.upc || undefined,
|
|
459
|
+
url: `${siteUrl}/product/${productRecord.slug}`,
|
|
460
|
+
offers: price
|
|
461
|
+
? {
|
|
462
|
+
'@type': 'Offer',
|
|
463
|
+
url: `${siteUrl}/product/${productRecord.slug}`,
|
|
464
|
+
price,
|
|
465
|
+
priceCurrency: defaultCurrency?.code || 'USD',
|
|
466
|
+
availability: resolveOfferAvailability(productRecord, canPurchase),
|
|
467
|
+
itemCondition: 'https://schema.org/NewCondition',
|
|
468
|
+
}
|
|
469
|
+
: undefined,
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
return (
|
|
473
|
+
<div className="min-h-screen bg-background pb-12">
|
|
474
|
+
<script
|
|
475
|
+
type="application/ld+json"
|
|
476
|
+
nonce={nonce}
|
|
477
|
+
suppressHydrationWarning
|
|
478
|
+
dangerouslySetInnerHTML={{ __html: stringifyJsonLd(productJsonLd) }}
|
|
479
|
+
/>
|
|
480
|
+
<ProductProvider product={contextProduct}>
|
|
481
|
+
{/*
|
|
482
|
+
BlockRenderer expects languageId property.
|
|
483
|
+
If templatePage is null, we defaulted languageId to 1.
|
|
484
|
+
*/}
|
|
485
|
+
<CurrentContentSetter
|
|
486
|
+
id={productRecord.id}
|
|
487
|
+
type="product"
|
|
488
|
+
slug={productRecord.slug}
|
|
489
|
+
translation_group_id={productRecord.translation_group_id}
|
|
490
|
+
/>
|
|
491
|
+
<BlockRenderer
|
|
492
|
+
blocks={blocks}
|
|
493
|
+
languageId={languageId}
|
|
494
|
+
excludeProductId={productRecord.id}
|
|
495
|
+
excludeTranslationGroupId={productRecord.translation_group_id}
|
|
496
|
+
visualEditing={productTemplateVisualEditing}
|
|
497
|
+
productVisualEditingEnabled={visualEditingEnabled}
|
|
498
|
+
/>
|
|
499
|
+
</ProductProvider>
|
|
500
|
+
</div>
|
|
501
|
+
);
|
|
502
|
+
}
|