create-nextblock 0.11.2 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/package.json +1 -1
  2. package/templates/nextblock-template/app/(auth-pages)/sign-up/SignUpForm.tsx +141 -0
  3. package/templates/nextblock-template/app/(auth-pages)/sign-up/page.tsx +40 -122
  4. package/templates/nextblock-template/app/(auth-pages)/two-factor/components/TwoFactorForm.tsx +5 -0
  5. package/templates/nextblock-template/app/[slug]/page.tsx +5 -2
  6. package/templates/nextblock-template/app/[slug]/page.utils.ts +10 -9
  7. package/templates/nextblock-template/app/actions/email.ts +8 -1
  8. package/templates/nextblock-template/app/actions/feedback.ts +1 -0
  9. package/templates/nextblock-template/app/actions/formActions.ts +20 -97
  10. package/templates/nextblock-template/app/actions/interactions.ts +3 -2
  11. package/templates/nextblock-template/app/actions/twoFactorEmail.ts +3 -2
  12. package/templates/nextblock-template/app/actions/visualEditingActions.test.ts +1 -1
  13. package/templates/nextblock-template/app/actions/visualEditingActions.ts +2 -0
  14. package/templates/nextblock-template/app/actions.ts +14 -0
  15. package/templates/nextblock-template/app/api/brand/email-logo/route.ts +48 -0
  16. package/templates/nextblock-template/app/api/cron/reset-sandbox/route.ts +47 -1
  17. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +4970 -8566
  18. package/templates/nextblock-template/app/article/[slug]/page.tsx +5 -2
  19. package/templates/nextblock-template/app/article/[slug]/page.utils.ts +1 -0
  20. package/templates/nextblock-template/app/cms/pages/actions.ts +22 -18
  21. package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +20 -2
  22. package/templates/nextblock-template/app/cms/posts/actions.ts +5 -0
  23. package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +137 -133
  24. package/templates/nextblock-template/app/cms/settings/copyright/actions.ts +36 -0
  25. package/templates/nextblock-template/app/cms/settings/copyright/components/CopyrightForm.tsx +26 -1
  26. package/templates/nextblock-template/app/cms/settings/copyright/page.tsx +6 -2
  27. package/templates/nextblock-template/app/cms/settings/email/actions.ts +7 -3
  28. package/templates/nextblock-template/app/cms/settings/logos/actions.ts +29 -14
  29. package/templates/nextblock-template/app/cms/settings/logos/components/SetActiveLogoButton.tsx +42 -0
  30. package/templates/nextblock-template/app/cms/settings/logos/page.tsx +18 -5
  31. package/templates/nextblock-template/app/layout.tsx +29 -17
  32. package/templates/nextblock-template/app/lib/seo.test.ts +36 -0
  33. package/templates/nextblock-template/app/lib/seo.ts +32 -0
  34. package/templates/nextblock-template/app/product/[slug]/page.tsx +5 -2
  35. package/templates/nextblock-template/components/AppShell.tsx +16 -1
  36. package/templates/nextblock-template/components/auth/AuthBotProtection.tsx +182 -0
  37. package/templates/nextblock-template/docs/04-DATABASE-AND-AUTH.md +36 -42
  38. package/templates/nextblock-template/docs/13-STAYING-UP-TO-DATE.md +7 -0
  39. package/templates/nextblock-template/lib/botProtection/verify.ts +134 -0
  40. package/templates/nextblock-template/lib/email/branding-format.test.ts +133 -0
  41. package/templates/nextblock-template/lib/email/branding-format.ts +123 -0
  42. package/templates/nextblock-template/lib/email/branding.ts +76 -0
  43. package/templates/nextblock-template/lib/logos/active-logo.ts +53 -0
  44. package/templates/nextblock-template/lib/media/resolveMediaUrl.ts +1 -0
  45. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +8 -203
  46. package/templates/nextblock-template/lib/visual-editing/draft-content.ts +4 -2
  47. package/templates/nextblock-template/lib/visual-editing/mutations.ts +2 -2
  48. package/templates/nextblock-template/lib/visual-editing/product-drafts.ts +1 -0
  49. package/templates/nextblock-template/package.json +1 -1
  50. package/templates/nextblock-template/proxy.ts +16 -0
  51. package/templates/nextblock-template/public/images/nextblock-logo-button-tiny.png +0 -0
  52. package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
@@ -16,12 +16,14 @@ import { useHotkeys } from '../../../../../hooks/use-hotkeys';
16
16
  interface CopyrightFormProps {
17
17
  languages: Language[];
18
18
  initialSettings: CopyrightSettings;
19
+ initialAttributionEnabled: boolean;
19
20
  }
20
21
 
21
- export default function CopyrightForm({ languages, initialSettings }: CopyrightFormProps) {
22
+ export default function CopyrightForm({ languages, initialSettings, initialAttributionEnabled }: CopyrightFormProps) {
22
23
  const [isPending, startTransition] = useTransition();
23
24
  const [message, setMessage] = useState<Message | null>(null);
24
25
  const [settings, setSettings] = useState<CopyrightSettings>(initialSettings);
26
+ const [attributionEnabled, setAttributionEnabled] = useState(initialAttributionEnabled);
25
27
 
26
28
  const handleInputChange = (langCode: string, value: string) => {
27
29
  setSettings(prev => ({ ...prev, [langCode]: value }));
@@ -36,6 +38,8 @@ export default function CopyrightForm({ languages, initialSettings }: CopyrightF
36
38
  const value = settings[lang.code] || '';
37
39
  formData.append(`copyright_${lang.code}`, value);
38
40
  }
41
+ // Always submit an explicit value so unchecking is captured (not just omitted).
42
+ formData.append('footer_show_attribution', attributionEnabled ? 'true' : 'false');
39
43
 
40
44
  startTransition(async () => {
41
45
  try {
@@ -73,6 +77,27 @@ export default function CopyrightForm({ languages, initialSettings }: CopyrightF
73
77
  ))}
74
78
  </div>
75
79
 
80
+ <div className="space-y-2 border-t pt-4">
81
+ <div className="flex items-start gap-3">
82
+ <input
83
+ id="footer_show_attribution"
84
+ name="footer_show_attribution"
85
+ type="checkbox"
86
+ checked={attributionEnabled}
87
+ onChange={(e) => setAttributionEnabled(e.target.checked)}
88
+ className="mt-0.5 h-4 w-4 rounded border-input"
89
+ />
90
+ <div className="space-y-1">
91
+ <Label htmlFor="footer_show_attribution" className="cursor-pointer">
92
+ Show &ldquo;Published with NextBlock&trade; CMS&rdquo; link in the footer
93
+ </Label>
94
+ <p className="text-xs text-muted-foreground">
95
+ Adds a small credit link to nextblock.dev next to the copyright. Enabled by default.
96
+ </p>
97
+ </div>
98
+ </div>
99
+ </div>
100
+
76
101
  <div className="flex items-center gap-4">
77
102
  <Button type="submit" disabled={isPending}>
78
103
  {isPending ? (
@@ -1,12 +1,15 @@
1
1
  // app/cms/settings/copyright/page.tsx
2
2
  import { getActiveLanguagesServerSide } from '../languages/actions';
3
- import { getCopyrightSettings } from './actions';
3
+ import { getCopyrightSettings, getFooterAttributionEnabled } from './actions';
4
4
  import CopyrightForm from './components/CopyrightForm';
5
5
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@nextblock-cms/ui';
6
6
 
7
7
  export default async function CopyrightSettingsPage() {
8
8
  const languages = await getActiveLanguagesServerSide();
9
- const copyrightSettings = await getCopyrightSettings();
9
+ const [copyrightSettings, footerAttributionEnabled] = await Promise.all([
10
+ getCopyrightSettings(),
11
+ getFooterAttributionEnabled(),
12
+ ]);
10
13
 
11
14
  const year = new Date().getFullYear();
12
15
 
@@ -24,6 +27,7 @@ export default async function CopyrightSettingsPage() {
24
27
  <CopyrightForm
25
28
  languages={languages}
26
29
  initialSettings={copyrightSettings}
30
+ initialAttributionEnabled={footerAttributionEnabled}
27
31
  />
28
32
  </CardContent>
29
33
  </Card>
@@ -51,9 +51,13 @@ export async function sendTestEmail(formData: FormData) {
51
51
 
52
52
  await sendEmail({
53
53
  to,
54
- subject: 'NextBlock test email',
55
- text: 'This is a test email from your NextBlock CMS. SMTP is configured correctly.',
56
- html: '<p>This is a test email from your NextBlock CMS. SMTP is configured correctly. 🎉</p>',
54
+ subject: 'Test email',
55
+ text: 'This is a test email from your CMS. SMTP is configured correctly.',
56
+ html:
57
+ '<div style="font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;max-width:480px;margin:0 auto;padding:24px;">' +
58
+ '{{brand_header}}' +
59
+ '<p>This is a test email from your CMS. SMTP is configured correctly. 🎉</p>' +
60
+ '</div>',
57
61
  });
58
62
 
59
63
  return { success: true as const, message: `Test email sent to ${to}.` };
@@ -6,6 +6,11 @@ import { revalidatePath, updateTag } from 'next/cache'
6
6
  import { redirect } from 'next/navigation'
7
7
  import type { Logo } from './types'
8
8
  import { SITE_SETTINGS_CACHE_TAG } from '../../../lib/site-settings'
9
+ import {
10
+ ACTIVE_LOGO_SETTING_KEY,
11
+ resolveActiveLogo,
12
+ resolveActiveLogoId,
13
+ } from '../../../../lib/logos/active-logo'
9
14
 
10
15
  const PUBLIC_LAYOUT_LOGO_CACHE_TAG = 'public-layout-logo'
11
16
 
@@ -100,25 +105,35 @@ export async function getLogoById(id: string) {
100
105
 
101
106
  export async function getActiveLogo(): Promise<Logo | null> {
102
107
  const supabase = createClient()
108
+ return (await resolveActiveLogo(supabase)) as Logo | null
109
+ }
103
110
 
104
- const { data, error } = await supabase
105
- .from('logos')
106
- .select(
107
- `
108
- *,
109
- media:media_id (*)
110
- `
111
- )
112
- .order('created_at', { ascending: false })
113
- .limit(1)
114
- .maybeSingle()
111
+ /** The admin-pinned active logo id (site_settings.active_logo_id), or null when unset. */
112
+ export async function getActiveLogoId(): Promise<string | null> {
113
+ const supabase = createClient()
114
+ return resolveActiveLogoId(supabase)
115
+ }
116
+
117
+ /**
118
+ * Pin which logo is active across the storefront, invoices, and transactional emails.
119
+ * Persists the choice as site_settings.active_logo_id (RLS restricts writes to ADMIN/WRITER)
120
+ * and busts the public header + CMS caches so the change shows immediately.
121
+ */
122
+ export async function setActiveLogo(
123
+ logoId: string
124
+ ): Promise<{ success: boolean; error?: string }> {
125
+ const supabase = createClient()
126
+ const { error } = await supabase
127
+ .from('site_settings')
128
+ .upsert({ key: ACTIVE_LOGO_SETTING_KEY, value: logoId })
115
129
 
116
130
  if (error) {
117
- console.error('Error fetching active logo:', error.message)
118
- throw new Error(`Failed to fetch active logo: ${error.message}`)
131
+ console.error('Error setting active logo:', error.message)
132
+ return { success: false, error: error.message }
119
133
  }
120
134
 
121
- return data as Logo | null
135
+ revalidateLogoViews()
136
+ return { success: true }
122
137
  }
123
138
 
124
139
  export async function saveInvoiceSettings(payload: InvoiceSettings) {
@@ -0,0 +1,42 @@
1
+ "use client";
2
+
3
+ import { DropdownMenuItem } from "@nextblock-cms/ui";
4
+ import { CheckCircle2 } from "lucide-react";
5
+ import { setActiveLogo } from "../actions";
6
+ import { useTransition } from "react";
7
+ import { toast } from "react-hot-toast";
8
+ import { useRouter } from "next/navigation";
9
+
10
+ interface SetActiveLogoButtonProps {
11
+ logoId: string;
12
+ }
13
+
14
+ export default function SetActiveLogoButton({ logoId }: SetActiveLogoButtonProps) {
15
+ const router = useRouter();
16
+ const [isPending, startTransition] = useTransition();
17
+
18
+ const handleSetActive = () => {
19
+ startTransition(async () => {
20
+ const result = await setActiveLogo(logoId);
21
+ if (result?.error) {
22
+ toast.error(`Error: ${result.error}`);
23
+ } else {
24
+ toast.success("Active logo updated");
25
+ router.refresh();
26
+ }
27
+ });
28
+ };
29
+
30
+ return (
31
+ <DropdownMenuItem
32
+ className="cursor-pointer"
33
+ // Keep the menu from closing before the transition starts.
34
+ onSelect={(e) => e.preventDefault()}
35
+ onClick={() => !isPending && handleSetActive()}
36
+ disabled={isPending}
37
+ >
38
+ <CheckCircle2 className="mr-2 h-4 w-4" />
39
+ {isPending ? "Setting active..." : "Set as active"}
40
+ </DropdownMenuItem>
41
+ );
42
+ }
@@ -17,11 +17,12 @@ import {
17
17
  DropdownMenuTrigger,
18
18
  DropdownMenuSeparator,
19
19
  } from '@nextblock-cms/ui'
20
- import { getLogos, getSiteSeoSettings } from './actions'
20
+ import { getActiveLogoId, getLogos, getSiteSeoSettings } from './actions'
21
21
  import BrandingSettingsForm from './components/BrandingSettingsForm'
22
22
  import SiteSeoSettingsForm from './components/SiteSeoSettingsForm'
23
23
  import MediaImage from '../../media/components/MediaImage'
24
24
  import DeleteLogoButton from './components/DeleteLogoButton'
25
+ import SetActiveLogoButton from './components/SetActiveLogoButton'
25
26
  import { resolveMediaUrl } from '../../../../lib/media/resolveMediaUrl'
26
27
 
27
28
  function resolveLogoSrc(objectKey?: string | null) {
@@ -29,12 +30,17 @@ function resolveLogoSrc(objectKey?: string | null) {
29
30
  }
30
31
 
31
32
  export default async function CmsLogosListPage() {
32
- const [logos, branding, seoSettings] = await Promise.all([
33
+ const [logos, branding, seoSettings, pinnedActiveLogoId] = await Promise.all([
33
34
  getLogos(),
34
35
  getInvoiceBrandingData(),
35
36
  getSiteSeoSettings(),
37
+ getActiveLogoId(),
36
38
  ])
37
39
 
40
+ // The effective active logo: the admin-pinned one, else the most recent (logos come back
41
+ // newest-first). This is what the storefront, invoices, and emails resolve to.
42
+ const activeLogoId = pinnedActiveLogoId ?? logos[0]?.id ?? null
43
+
38
44
  return (
39
45
  <div className="w-full space-y-8">
40
46
  <div className="mb-6">
@@ -55,7 +61,8 @@ export default async function CmsLogosListPage() {
55
61
  <div>
56
62
  <h2 className="text-xl font-semibold">Logos</h2>
57
63
  <p className="text-sm text-muted-foreground">
58
- The most recently created logo is used on the storefront and invoices.
64
+ Choose which logo is active on the storefront, invoices, and emails. New logos
65
+ become active automatically until you pick one.
59
66
  </p>
60
67
  </div>
61
68
  <Button variant="default" asChild>
@@ -92,7 +99,7 @@ export default async function CmsLogosListPage() {
92
99
  </TableRow>
93
100
  </TableHeader>
94
101
  <TableBody>
95
- {logos.map((logo, index) => (
102
+ {logos.map((logo) => (
96
103
  <TableRow key={logo.id}>
97
104
  <TableCell>
98
105
  {logo.media ? (
@@ -112,7 +119,7 @@ export default async function CmsLogosListPage() {
112
119
  <TableCell className="font-medium">
113
120
  <div className="flex items-center gap-2">
114
121
  <span>{logo.name}</span>
115
- {index === 0 ? (
122
+ {logo.id === activeLogoId ? (
116
123
  <span className="rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary">
117
124
  Active
118
125
  </span>
@@ -130,6 +137,12 @@ export default async function CmsLogosListPage() {
130
137
  </Button>
131
138
  </DropdownMenuTrigger>
132
139
  <DropdownMenuContent align="end">
140
+ {logo.id !== activeLogoId ? (
141
+ <>
142
+ <SetActiveLogoButton logoId={logo.id} />
143
+ <DropdownMenuSeparator />
144
+ </>
145
+ ) : null}
133
146
  <DropdownMenuItem asChild>
134
147
  <Link href={`/cms/settings/logos/${logo.id}/edit`}>
135
148
  <Edit3 className="mr-2 h-4 w-4" />
@@ -26,6 +26,7 @@ import { verifyPackageOnline } from '@nextblock-cms/db/server';
26
26
  import { unstable_cache } from 'next/cache';
27
27
  import { createStaticSupabaseClient, getSiteSettings } from './lib/site-settings';
28
28
  import { DEFAULT_OG_IMAGE } from './lib/seo';
29
+ import { resolveActiveLogo } from '../lib/logos/active-logo';
29
30
  import {
30
31
  isSupabaseConfigured,
31
32
  resolveSupabaseAnonKey,
@@ -102,6 +103,22 @@ const getCachedCopyrightSettings = unstable_cache(
102
103
  { revalidate: PUBLIC_LAYOUT_REVALIDATE_SECONDS }
103
104
  );
104
105
 
106
+ const getCachedFooterAttribution = unstable_cache(
107
+ async (): Promise<boolean> => {
108
+ const supabase = createStaticSupabaseClient();
109
+ const { data } = await supabase
110
+ .from('site_settings')
111
+ .select('value')
112
+ .eq('key', 'footer_show_attribution')
113
+ .maybeSingle();
114
+
115
+ // Absent row = enabled (default); only an explicit `false` disables it.
116
+ return data ? data.value !== false : true;
117
+ },
118
+ ['public-layout-footer-attribution'],
119
+ { revalidate: PUBLIC_LAYOUT_REVALIDATE_SECONDS }
120
+ );
121
+
105
122
  const getCachedGlobalCss = unstable_cache(
106
123
  async (): Promise<string> => {
107
124
  const supabase = createStaticSupabaseClient();
@@ -212,25 +229,15 @@ const getCachedNavigationMenu = unstable_cache(
212
229
 
213
230
  const getCachedActiveLogo = unstable_cache(
214
231
  async (): Promise<HeaderLogo | null> => {
215
- const supabase = createStaticSupabaseClient();
216
- const { data, error } = await supabase
217
- .from('logos')
218
- .select(
219
- `
220
- *,
221
- media:media_id (*)
222
- `
223
- )
224
- .order('created_at', { ascending: false })
225
- .limit(1)
226
- .maybeSingle();
227
-
228
- if (error) {
229
- console.error('Error fetching cached active logo:', error.message);
232
+ try {
233
+ const supabase = createStaticSupabaseClient();
234
+ // Honor the admin-pinned active logo (site_settings.active_logo_id), else newest.
235
+ const logo = await resolveActiveLogo(supabase);
236
+ return (logo as HeaderLogo | null) ?? null;
237
+ } catch (error) {
238
+ console.error('Error fetching cached active logo:', error);
230
239
  return null;
231
240
  }
232
-
233
- return data as HeaderLogo | null;
234
241
  },
235
242
  ['public-layout-logo'],
236
243
  { revalidate: PUBLIC_LAYOUT_REVALIDATE_SECONDS, tags: [PUBLIC_LAYOUT_LOGO_CACHE_TAG] }
@@ -266,6 +273,7 @@ async function loadLayoutData() {
266
273
  isEcommerceActive: false,
267
274
  globalCss: '',
268
275
  privacySettings: DEFAULT_PRIVACY_SETTINGS,
276
+ footerAttributionEnabled: true,
269
277
  };
270
278
  }
271
279
 
@@ -337,6 +345,7 @@ async function loadLayoutData() {
337
345
  const role = profile?.role ?? null;
338
346
  const canAccessCms = role === 'ADMIN' || role === 'WRITER';
339
347
  const { siteTitle } = await getSiteSettings();
348
+ const footerAttributionEnabled = await getCachedFooterAttribution().catch(() => true);
340
349
 
341
350
  return {
342
351
  user,
@@ -358,6 +367,7 @@ async function loadLayoutData() {
358
367
  isEcommerceActive,
359
368
  globalCss,
360
369
  privacySettings,
370
+ footerAttributionEnabled,
361
371
  };
362
372
  }
363
373
 
@@ -438,6 +448,7 @@ export default async function RootLayout({
438
448
  isEcommerceActive,
439
449
  globalCss,
440
450
  privacySettings,
451
+ footerAttributionEnabled,
441
452
  } = await loadLayoutData();
442
453
  const draft = await draftMode();
443
454
  // GTM container id comes solely from the privacy settings row (site_settings).
@@ -509,6 +520,7 @@ export default async function RootLayout({
509
520
  isDraftModeEnabled={draft.isEnabled}
510
521
  isEcommerceActive={isEcommerceActive}
511
522
  logo={logo}
523
+ showFooterAttribution={footerAttributionEnabled}
512
524
  siteTitle={siteTitle}
513
525
  >
514
526
  {children}
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import {
3
+ buildCanonicalUrl,
3
4
  extractIntroExcerptFromBlocks,
4
5
  resolveMetaTitle,
5
6
  resolvePageMetaDescription,
@@ -50,3 +51,38 @@ describe("seo helpers", () => {
50
51
  expect(stringifyJsonLd({ name: "</script>" })).toContain("\\u003c/script>");
51
52
  });
52
53
  });
54
+
55
+ describe("buildCanonicalUrl", () => {
56
+ const siteUrl = "https://example.com";
57
+
58
+ it("self-references when there is no override", () => {
59
+ expect(buildCanonicalUrl(null, siteUrl, "/about")).toBe("https://example.com/about");
60
+ expect(buildCanonicalUrl("", siteUrl, "/about")).toBe("https://example.com/about");
61
+ expect(buildCanonicalUrl(" ", siteUrl, "/about")).toBe("https://example.com/about");
62
+ });
63
+
64
+ it("uses an absolute override verbatim (cross-domain allowed)", () => {
65
+ expect(buildCanonicalUrl("https://other.com/x", siteUrl, "/about")).toBe("https://other.com/x");
66
+ expect(buildCanonicalUrl(" http://other.com/y ", siteUrl, "/about")).toBe("http://other.com/y");
67
+ });
68
+
69
+ it("resolves relative overrides against the site URL", () => {
70
+ expect(buildCanonicalUrl("/canonical-path", siteUrl, "/about")).toBe(
71
+ "https://example.com/canonical-path"
72
+ );
73
+ expect(buildCanonicalUrl("canonical-path", siteUrl, "/about")).toBe(
74
+ "https://example.com/canonical-path"
75
+ );
76
+ });
77
+
78
+ it("normalizes the path and a trailing slash on the site URL", () => {
79
+ expect(buildCanonicalUrl(null, "https://example.com/", "about")).toBe(
80
+ "https://example.com/about"
81
+ );
82
+ });
83
+
84
+ it("returns a relative fallback when the site URL is unset (resolved by metadataBase)", () => {
85
+ expect(buildCanonicalUrl(null, "", "/about")).toBe("/about");
86
+ expect(buildCanonicalUrl("/override", "", "/about")).toBe("/override");
87
+ });
88
+ });
@@ -205,6 +205,38 @@ export function composeTitleWithSite(
205
205
  : `${cleanTitle}${suffix}`;
206
206
  }
207
207
 
208
+ /**
209
+ * Resolves the canonical URL for a public page/post/product.
210
+ *
211
+ * By default this is the self-referencing `<siteUrl><path>`. When the content row
212
+ * sets a manual `custom_canonical` override, that wins:
213
+ * - absolute values (`https://…`) are used verbatim,
214
+ * - root-relative (`/foo`) and bare (`foo`) values are resolved against `siteUrl`.
215
+ * A null/blank override falls back to the self-referencing default, so existing
216
+ * content is unaffected. `siteUrl` may be empty (pre-config / no NEXT_PUBLIC_URL),
217
+ * in which case a relative path is returned and resolved by `metadataBase`.
218
+ */
219
+ export function buildCanonicalUrl(
220
+ customCanonical: string | null | undefined,
221
+ siteUrl: string,
222
+ path: string
223
+ ): string {
224
+ const base = (siteUrl || '').replace(/\/+$/, '');
225
+ const normalizedPath = path.startsWith('/') ? path : `/${path}`;
226
+ const fallback = `${base}${normalizedPath}`;
227
+
228
+ const custom = customCanonical?.trim();
229
+ if (!custom) {
230
+ return fallback;
231
+ }
232
+
233
+ if (/^https?:\/\//i.test(custom)) {
234
+ return custom;
235
+ }
236
+
237
+ return custom.startsWith('/') ? `${base}${custom}` : `${base}/${custom}`;
238
+ }
239
+
208
240
  /** Maps a language code (e.g. `fr`, `en-US`) to an Open Graph locale (`fr_FR`). */
209
241
  export function toOpenGraphLocale(languageCode?: string | null): string {
210
242
  const code = (languageCode ?? '').toLowerCase().split('-')[0];
@@ -24,6 +24,7 @@ import {
24
24
  resolveProductMetaDescription,
25
25
  stringifyJsonLd,
26
26
  buildSocialMetadata,
27
+ buildCanonicalUrl,
27
28
  toOpenGraphLocale,
28
29
  } from "../../lib/seo";
29
30
  import { getSiteSettings } from "../../lib/site-settings";
@@ -163,6 +164,8 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
163
164
  productRecord.short_description
164
165
  );
165
166
  const { siteTitle } = await getSiteSettings();
167
+ // Self-referencing `<siteUrl>/product/<slug>` unless the product sets a manual custom_canonical override.
168
+ const canonicalUrl = buildCanonicalUrl(productRecord.custom_canonical, siteUrl, `/product/${slug}`);
166
169
 
167
170
  return {
168
171
  title,
@@ -170,14 +173,14 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
170
173
  ...buildSocialMetadata({
171
174
  title,
172
175
  description,
173
- url: `${siteUrl}/product/${slug}`,
176
+ url: canonicalUrl,
174
177
  siteTitle,
175
178
  imageUrl,
176
179
  type: 'website',
177
180
  locale: toOpenGraphLocale(productRecord.language_code),
178
181
  }),
179
182
  alternates: {
180
- canonical: `${siteUrl}/product/${slug}`,
183
+ canonical: canonicalUrl,
181
184
  languages: Object.keys(alternates).length > 0 ? alternates : undefined,
182
185
  },
183
186
  };
@@ -47,6 +47,7 @@ type AppShellProps = {
47
47
  isDraftModeEnabled: boolean;
48
48
  isEcommerceActive: boolean;
49
49
  logo: Logo | null;
50
+ showFooterAttribution?: boolean;
50
51
  siteTitle: string;
51
52
  };
52
53
 
@@ -61,6 +62,7 @@ export function AppShell({
61
62
  isDraftModeEnabled,
62
63
  isEcommerceActive,
63
64
  logo,
65
+ showFooterAttribution = true,
64
66
  siteTitle,
65
67
  }: AppShellProps) {
66
68
  const pathname = usePathname() || '';
@@ -145,8 +147,21 @@ export function AppShell({
145
147
  )}
146
148
  </p>
147
149
  )}
148
- <div className="flex flex-row items-center gap-4">
150
+ <div className="flex flex-row flex-wrap items-center justify-center gap-x-4 gap-y-2">
149
151
  <p className="text-muted-foreground">{copyrightText}</p>
152
+ {showFooterAttribution && (
153
+ <p className="text-muted-foreground">
154
+ Published with{' '}
155
+ <a
156
+ href="https://nextblock.dev"
157
+ target="_blank"
158
+ rel="noopener"
159
+ className="font-medium hover:underline"
160
+ >
161
+ NextBlock&trade; CMS
162
+ </a>
163
+ </p>
164
+ )}
150
165
  <ThemeSwitcher />
151
166
  </div>
152
167
  </div>