create-nextblock 0.11.3 → 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.
- package/package.json +1 -1
- package/templates/nextblock-template/app/(auth-pages)/sign-up/SignUpForm.tsx +141 -0
- package/templates/nextblock-template/app/(auth-pages)/sign-up/page.tsx +40 -122
- package/templates/nextblock-template/app/(auth-pages)/two-factor/components/TwoFactorForm.tsx +5 -0
- package/templates/nextblock-template/app/[slug]/page.tsx +5 -2
- package/templates/nextblock-template/app/[slug]/page.utils.ts +10 -9
- package/templates/nextblock-template/app/actions/email.ts +8 -1
- package/templates/nextblock-template/app/actions/feedback.ts +1 -0
- package/templates/nextblock-template/app/actions/formActions.ts +20 -97
- package/templates/nextblock-template/app/actions/interactions.ts +3 -2
- package/templates/nextblock-template/app/actions/twoFactorEmail.ts +3 -2
- package/templates/nextblock-template/app/actions/visualEditingActions.test.ts +1 -1
- package/templates/nextblock-template/app/actions/visualEditingActions.ts +2 -0
- package/templates/nextblock-template/app/actions.ts +14 -0
- package/templates/nextblock-template/app/api/brand/email-logo/route.ts +48 -0
- package/templates/nextblock-template/app/api/cron/reset-sandbox/route.ts +47 -1
- package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +4967 -8677
- package/templates/nextblock-template/app/article/[slug]/page.tsx +5 -2
- package/templates/nextblock-template/app/article/[slug]/page.utils.ts +1 -0
- package/templates/nextblock-template/app/cms/pages/actions.ts +22 -18
- package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +20 -2
- package/templates/nextblock-template/app/cms/posts/actions.ts +5 -0
- package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +137 -133
- package/templates/nextblock-template/app/cms/settings/copyright/actions.ts +36 -0
- package/templates/nextblock-template/app/cms/settings/copyright/components/CopyrightForm.tsx +26 -1
- package/templates/nextblock-template/app/cms/settings/copyright/page.tsx +6 -2
- package/templates/nextblock-template/app/cms/settings/email/actions.ts +7 -3
- package/templates/nextblock-template/app/cms/settings/logos/actions.ts +29 -14
- package/templates/nextblock-template/app/cms/settings/logos/components/SetActiveLogoButton.tsx +42 -0
- package/templates/nextblock-template/app/cms/settings/logos/page.tsx +18 -5
- package/templates/nextblock-template/app/layout.tsx +29 -17
- package/templates/nextblock-template/app/lib/seo.test.ts +36 -0
- package/templates/nextblock-template/app/lib/seo.ts +32 -0
- package/templates/nextblock-template/app/product/[slug]/page.tsx +5 -2
- package/templates/nextblock-template/components/AppShell.tsx +16 -1
- package/templates/nextblock-template/components/auth/AuthBotProtection.tsx +182 -0
- package/templates/nextblock-template/docs/04-DATABASE-AND-AUTH.md +36 -42
- package/templates/nextblock-template/lib/botProtection/verify.ts +134 -0
- package/templates/nextblock-template/lib/email/branding-format.test.ts +133 -0
- package/templates/nextblock-template/lib/email/branding-format.ts +123 -0
- package/templates/nextblock-template/lib/email/branding.ts +76 -0
- package/templates/nextblock-template/lib/logos/active-logo.ts +53 -0
- package/templates/nextblock-template/lib/media/resolveMediaUrl.ts +1 -0
- package/templates/nextblock-template/lib/setup/migrations-bundle.ts +8 -213
- package/templates/nextblock-template/lib/visual-editing/draft-content.ts +4 -2
- package/templates/nextblock-template/lib/visual-editing/mutations.ts +2 -2
- package/templates/nextblock-template/lib/visual-editing/product-drafts.ts +1 -0
- package/templates/nextblock-template/package.json +1 -1
- package/templates/nextblock-template/proxy.ts +16 -0
- package/templates/nextblock-template/public/images/nextblock-logo-button-tiny.png +0 -0
- package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
package/templates/nextblock-template/app/cms/settings/copyright/components/CopyrightForm.tsx
CHANGED
|
@@ -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 “Published with NextBlock™ CMS” 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
|
|
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: '
|
|
55
|
-
text: 'This is a test email from your
|
|
56
|
-
html:
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
118
|
-
|
|
131
|
+
console.error('Error setting active logo:', error.message)
|
|
132
|
+
return { success: false, error: error.message }
|
|
119
133
|
}
|
|
120
134
|
|
|
121
|
-
|
|
135
|
+
revalidateLogoViews()
|
|
136
|
+
return { success: true }
|
|
122
137
|
}
|
|
123
138
|
|
|
124
139
|
export async function saveInvoiceSettings(payload: InvoiceSettings) {
|
package/templates/nextblock-template/app/cms/settings/logos/components/SetActiveLogoButton.tsx
ADDED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
{
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
.
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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:
|
|
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:
|
|
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™ CMS
|
|
162
|
+
</a>
|
|
163
|
+
</p>
|
|
164
|
+
)}
|
|
150
165
|
<ThemeSwitcher />
|
|
151
166
|
</div>
|
|
152
167
|
</div>
|