create-nextblock 0.12.14 → 0.12.16
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)/two-factor/components/TwoFactorForm.tsx +10 -3
- package/templates/nextblock-template/app/[slug]/page.tsx +12 -4
- package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +26 -1
- package/templates/nextblock-template/app/cms/components/FeatureImageField.tsx +1 -0
- package/templates/nextblock-template/app/cms/media/components/MediaPickerDialog.tsx +38 -35
- package/templates/nextblock-template/app/cms/media/components/MediaUploadForm.tsx +14 -11
- package/templates/nextblock-template/app/cms/products/ProductFormClientShell.tsx +62 -14
- package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +2 -2
- package/templates/nextblock-template/app/cms/settings/languages/actions.ts +53 -1
- package/templates/nextblock-template/app/cms/settings/languages/components/LanguageDetectionPanel.tsx +188 -0
- package/templates/nextblock-template/app/cms/settings/languages/page.tsx +12 -1
- package/templates/nextblock-template/app/layout.tsx +42 -1
- package/templates/nextblock-template/app/product/[slug]/page.tsx +12 -4
- package/templates/nextblock-template/app/providers.tsx +2 -0
- package/templates/nextblock-template/context/LanguageContext.tsx +22 -7
- package/templates/nextblock-template/docs/TECHNICAL_SPECIFICATION.md +16 -11
- package/templates/nextblock-template/lib/i18n/country-languages.ts +247 -0
- package/templates/nextblock-template/lib/i18n/detection.test.ts +197 -0
- package/templates/nextblock-template/lib/i18n/detection.ts +192 -0
- package/templates/nextblock-template/package.json +1 -1
- package/templates/nextblock-template/proxy.ts +141 -8
- package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
package/package.json
CHANGED
package/templates/nextblock-template/app/(auth-pages)/two-factor/components/TwoFactorForm.tsx
CHANGED
|
@@ -25,10 +25,11 @@ export default function TwoFactorForm({
|
|
|
25
25
|
type === 'email' && pendingEmailCode ? `Enter the code we sent to ${email}.` : null,
|
|
26
26
|
);
|
|
27
27
|
|
|
28
|
-
const submit = () => {
|
|
28
|
+
const submit = (codeToSubmit: string = code) => {
|
|
29
|
+
if (codeToSubmit.length !== 6) return;
|
|
29
30
|
setError(null);
|
|
30
31
|
const formData = new FormData();
|
|
31
|
-
formData.append('code',
|
|
32
|
+
formData.append('code', codeToSubmit);
|
|
32
33
|
formData.append('redirect_to', redirectTo);
|
|
33
34
|
startTransition(async () => {
|
|
34
35
|
try {
|
|
@@ -85,7 +86,13 @@ export default function TwoFactorForm({
|
|
|
85
86
|
autoFocus
|
|
86
87
|
maxLength={6}
|
|
87
88
|
value={code}
|
|
88
|
-
onChange={(e) =>
|
|
89
|
+
onChange={(e) => {
|
|
90
|
+
const next = e.target.value.replace(/\D/g, '').slice(0, 6);
|
|
91
|
+
setCode(next);
|
|
92
|
+
// Auto-submit the moment a full 6-digit code is entered (typed, pasted,
|
|
93
|
+
// or filled by the OS one-time-code autofill) — no button press needed.
|
|
94
|
+
if (next.length === 6 && !isPending) submit(next);
|
|
95
|
+
}}
|
|
89
96
|
placeholder="000000"
|
|
90
97
|
className="tracking-[0.5em] text-center text-lg"
|
|
91
98
|
/>
|
|
@@ -78,8 +78,12 @@ export async function generateMetadata(
|
|
|
78
78
|
if (!preferredLocale) {
|
|
79
79
|
try {
|
|
80
80
|
const hdrs = await headers();
|
|
81
|
-
|
|
82
|
-
|
|
81
|
+
// Proxy-detected locale first: it honors the CMS language-detection settings.
|
|
82
|
+
preferredLocale = hdrs.get("x-user-locale") || undefined;
|
|
83
|
+
if (!preferredLocale) {
|
|
84
|
+
const al = hdrs.get("accept-language");
|
|
85
|
+
if (al) preferredLocale = al.split(",")[0]?.split("-")[0];
|
|
86
|
+
}
|
|
83
87
|
} catch {
|
|
84
88
|
// ignore header lookup errors
|
|
85
89
|
}
|
|
@@ -153,8 +157,12 @@ export default async function DynamicPage({ params: paramsPromise }: PageProps)
|
|
|
153
157
|
if (!preferredLocale) {
|
|
154
158
|
try {
|
|
155
159
|
const hdrs = await headers();
|
|
156
|
-
|
|
157
|
-
|
|
160
|
+
// Proxy-detected locale first: it honors the CMS language-detection settings.
|
|
161
|
+
preferredLocale = hdrs.get("x-user-locale") || undefined;
|
|
162
|
+
if (!preferredLocale) {
|
|
163
|
+
const al = hdrs.get("accept-language");
|
|
164
|
+
if (al) preferredLocale = al.split(",")[0]?.split("-")[0];
|
|
165
|
+
}
|
|
158
166
|
} catch {
|
|
159
167
|
// ignore header lookup errors
|
|
160
168
|
}
|
|
@@ -5706,6 +5706,30 @@ WHERE key IN (
|
|
|
5706
5706
|
);
|
|
5707
5707
|
|
|
5708
5708
|
|
|
5709
|
+
-- >>> FROM: 00000000000011_language_detection_admin_only.sql <<<
|
|
5710
|
+
-- Restrict writes to the language-detection settings row to ADMIN only.
|
|
5711
|
+
--
|
|
5712
|
+
-- \`site_settings.language_detection_settings\` is a non-sensitive, anon-READABLE
|
|
5713
|
+
-- key (the request proxy reads it with the anon client to pick a visitor's first
|
|
5714
|
+
-- language). The CMS surfaces it under /cms/settings (ADMIN-only) and the server
|
|
5715
|
+
-- action guards with an ADMIN check, but the baseline write policies let ADMIN
|
|
5716
|
+
-- *or* WRITER write any non-sensitive key — so a WRITER could change site-wide
|
|
5717
|
+
-- detection directly via PostgREST. This migration adds the key to the ADMIN-only
|
|
5718
|
+
-- write group (INSERT/UPDATE/DELETE) to match the UI boundary, while leaving the
|
|
5719
|
+
-- SELECT policy untouched so the proxy's anon read keeps working.
|
|
5720
|
+
--
|
|
5721
|
+
-- Forward-only; recreates the three write policies idempotently.
|
|
5722
|
+
|
|
5723
|
+
DROP POLICY IF EXISTS site_settings_insert_policy ON public.site_settings;
|
|
5724
|
+
CREATE POLICY site_settings_insert_policy ON public.site_settings FOR INSERT TO authenticated WITH CHECK ((((key <> ALL (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role]))) OR ((key = ANY (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role))));
|
|
5725
|
+
|
|
5726
|
+
DROP POLICY IF EXISTS site_settings_update_policy ON public.site_settings;
|
|
5727
|
+
CREATE POLICY site_settings_update_policy ON public.site_settings FOR UPDATE TO authenticated USING ((((key <> ALL (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role]))) OR ((key = ANY (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role)))) WITH CHECK ((((key <> ALL (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role]))) OR ((key = ANY (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role))));
|
|
5728
|
+
|
|
5729
|
+
DROP POLICY IF EXISTS site_settings_delete_policy ON public.site_settings;
|
|
5730
|
+
CREATE POLICY site_settings_delete_policy ON public.site_settings FOR DELETE TO authenticated USING ((((key <> ALL (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role]))) OR ((key = ANY (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role))));
|
|
5731
|
+
|
|
5732
|
+
|
|
5709
5733
|
-- Step D: Record the applied migrations in history (truncated in Step B) so
|
|
5710
5734
|
-- \`npm run db:migrate:check\` reports up to date instead of listing every file as pending.
|
|
5711
5735
|
INSERT INTO supabase_migrations.schema_migrations (version, name) VALUES
|
|
@@ -5719,7 +5743,8 @@ WHERE key IN (
|
|
|
5719
5743
|
('00000000000007', 'home_live_demo_promo_copy_fix'),
|
|
5720
5744
|
('00000000000008', 'setup_article_reorder'),
|
|
5721
5745
|
('00000000000009', 'home_live_demo_promo_contrast'),
|
|
5722
|
-
('00000000000010', 'drop_github_username')
|
|
5746
|
+
('00000000000010', 'drop_github_username'),
|
|
5747
|
+
('00000000000011', 'language_detection_admin_only')
|
|
5723
5748
|
ON CONFLICT (version) DO NOTHING;
|
|
5724
5749
|
|
|
5725
5750
|
-- Step E: Anchor preserved profiles
|
|
@@ -158,12 +158,13 @@ export default function MediaPickerDialog({
|
|
|
158
158
|
</DialogTrigger>
|
|
159
159
|
)}
|
|
160
160
|
<DialogContent className="sm:max-w-[650px] md:max-w-[800px] lg:max-w-[1000px] max-h-[90vh] flex flex-col">
|
|
161
|
-
<DialogHeader>
|
|
161
|
+
<DialogHeader className="shrink-0 pb-1">
|
|
162
162
|
<DialogTitle>{title}</DialogTitle>
|
|
163
163
|
</DialogHeader>
|
|
164
164
|
|
|
165
|
-
<div className="
|
|
165
|
+
<div className="flex-1 min-h-0 overflow-y-auto pr-1">
|
|
166
166
|
<MediaUploadForm
|
|
167
|
+
compact
|
|
167
168
|
returnJustData={true}
|
|
168
169
|
defaultFolder={defaultFolder}
|
|
169
170
|
onUploadSuccess={(newMedia) => {
|
|
@@ -171,39 +172,40 @@ export default function MediaPickerDialog({
|
|
|
171
172
|
handleSelect(newMedia);
|
|
172
173
|
}}
|
|
173
174
|
/>
|
|
174
|
-
</div>
|
|
175
175
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
<div className="flex-grow flex items-center justify-center">
|
|
192
|
-
<p>Loading media...</p>
|
|
193
|
-
</div>
|
|
194
|
-
) : loadError && filtered.length === 0 ? (
|
|
195
|
-
<div className="flex-grow flex flex-col items-center justify-center gap-3 text-center">
|
|
196
|
-
<p className="max-w-sm text-sm text-muted-foreground">{loadError}</p>
|
|
197
|
-
<Button type="button" variant="outline" size="sm" onClick={() => void fetchLibrary()}>
|
|
198
|
-
Retry
|
|
199
|
-
</Button>
|
|
200
|
-
</div>
|
|
201
|
-
) : filtered.length === 0 ? (
|
|
202
|
-
<div className="flex-grow flex items-center justify-center">
|
|
203
|
-
<p>No media found.</p>
|
|
176
|
+
<Separator className="my-3" />
|
|
177
|
+
|
|
178
|
+
<div className="flex flex-col">
|
|
179
|
+
<h3 className="text-base font-medium mb-2 text-center">Or Select from Library</h3>
|
|
180
|
+
<div className="sticky top-0 z-10 -mx-1 mb-2 bg-background px-1 pb-1">
|
|
181
|
+
<div className="relative">
|
|
182
|
+
<Input
|
|
183
|
+
type="search"
|
|
184
|
+
placeholder="Search library..."
|
|
185
|
+
value={searchTerm}
|
|
186
|
+
onChange={(e) => setSearchTerm(e.target.value)}
|
|
187
|
+
className="pl-10"
|
|
188
|
+
/>
|
|
189
|
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
190
|
+
</div>
|
|
204
191
|
</div>
|
|
205
|
-
|
|
206
|
-
|
|
192
|
+
{isLoading && filtered.length === 0 ? (
|
|
193
|
+
<div className="flex items-center justify-center py-10">
|
|
194
|
+
<p>Loading media...</p>
|
|
195
|
+
</div>
|
|
196
|
+
) : loadError && filtered.length === 0 ? (
|
|
197
|
+
<div className="flex flex-col items-center justify-center gap-3 py-10 text-center">
|
|
198
|
+
<p className="max-w-sm text-sm text-muted-foreground">{loadError}</p>
|
|
199
|
+
<Button type="button" variant="outline" size="sm" onClick={() => void fetchLibrary()}>
|
|
200
|
+
Retry
|
|
201
|
+
</Button>
|
|
202
|
+
</div>
|
|
203
|
+
) : filtered.length === 0 ? (
|
|
204
|
+
<div className="flex items-center justify-center py-10">
|
|
205
|
+
<p>No media found.</p>
|
|
206
|
+
</div>
|
|
207
|
+
) : (
|
|
208
|
+
<div className="flex flex-wrap gap-3 pb-2">
|
|
207
209
|
{filtered.map((media: Media) => {
|
|
208
210
|
const previewPath = resolveMediaPreviewPath(media);
|
|
209
211
|
const previewSrc = previewPath ? resolveMediaPreviewSrc(previewPath) : null;
|
|
@@ -241,8 +243,9 @@ export default function MediaPickerDialog({
|
|
|
241
243
|
</button>
|
|
242
244
|
);
|
|
243
245
|
})}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
+
</div>
|
|
247
|
+
)}
|
|
248
|
+
</div>
|
|
246
249
|
</div>
|
|
247
250
|
</DialogContent>
|
|
248
251
|
</Dialog>
|
|
@@ -21,11 +21,13 @@ interface MediaUploadFormProps {
|
|
|
21
21
|
// And will use onUploadSuccess instead of router.refresh().
|
|
22
22
|
returnJustData?: boolean;
|
|
23
23
|
defaultFolder?: string; // Optional pre-populated folder
|
|
24
|
+
// If true, tightens spacing/heights so the form fits inside a modal on shorter screens.
|
|
25
|
+
compact?: boolean;
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
import { useUploadFolder } from "../UploadFolderContext";
|
|
27
29
|
|
|
28
|
-
export default function MediaUploadForm({ onUploadSuccess, returnJustData, defaultFolder }: MediaUploadFormProps) {
|
|
30
|
+
export default function MediaUploadForm({ onUploadSuccess, returnJustData, defaultFolder, compact }: MediaUploadFormProps) {
|
|
29
31
|
const router = useRouter();
|
|
30
32
|
const [isPending, startTransition] = useTransition();
|
|
31
33
|
const [file, setFile] = useState<File | null>(null);
|
|
@@ -292,26 +294,27 @@ export default function MediaUploadForm({ onUploadSuccess, returnJustData, defau
|
|
|
292
294
|
};
|
|
293
295
|
|
|
294
296
|
return (
|
|
295
|
-
<div className=
|
|
296
|
-
<div role="group" aria-label="Upload new media" className="space-y-4">
|
|
297
|
+
<div className={`border rounded-lg shadow-sm bg-card ${compact ? "p-4" : "p-6 mb-6"}`}>
|
|
298
|
+
<div role="group" aria-label="Upload new media" className={compact ? "space-y-3" : "space-y-4"}>
|
|
297
299
|
<div>
|
|
298
|
-
<
|
|
299
|
-
|
|
300
|
-
<div>
|
|
301
|
-
<Label htmlFor="media-folder" className="text-sm">Folder
|
|
300
|
+
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2">
|
|
301
|
+
<Label htmlFor="media-file" className="text-base font-medium">Upload New Media</Label>
|
|
302
|
+
<div className="flex items-center gap-2">
|
|
303
|
+
<Label htmlFor="media-folder" className="whitespace-nowrap text-sm text-muted-foreground">Upload Folder:</Label>
|
|
302
304
|
<Input
|
|
303
305
|
id="media-folder"
|
|
304
306
|
placeholder="uploads/"
|
|
305
307
|
value={folder}
|
|
306
308
|
onChange={(e) => setFolder(e.target.value)}
|
|
307
309
|
onKeyDown={handleFolderKeyDown}
|
|
310
|
+
className="h-9 w-44 sm:w-56"
|
|
308
311
|
/>
|
|
309
312
|
</div>
|
|
310
313
|
</div>
|
|
311
314
|
<div className="mt-2 flex items-center justify-center w-full">
|
|
312
315
|
<label
|
|
313
316
|
htmlFor="media-file-input"
|
|
314
|
-
className={`flex flex-col items-center justify-center w-full h-40 border-2 border-dashed rounded-lg cursor-pointer bg-muted/30 hover:bg-muted/50 transition-colors ${
|
|
317
|
+
className={`flex flex-col items-center justify-center w-full ${compact ? "h-28" : "h-40"} border-2 border-dashed rounded-lg cursor-pointer bg-muted/30 hover:bg-muted/50 transition-colors ${
|
|
315
318
|
isDraggingOver ? "border-primary bg-primary-foreground/20" : "border-input"
|
|
316
319
|
}`}
|
|
317
320
|
onDrop={handleDrop}
|
|
@@ -319,8 +322,8 @@ export default function MediaUploadForm({ onUploadSuccess, returnJustData, defau
|
|
|
319
322
|
onDragEnter={handleDragEnter}
|
|
320
323
|
onDragLeave={handleDragLeave}
|
|
321
324
|
>
|
|
322
|
-
<div className=
|
|
323
|
-
<UploadCloud className="w-10 h-10 mb-3
|
|
325
|
+
<div className={`flex flex-col items-center justify-center pointer-events-none ${compact ? "pt-3 pb-4" : "pt-5 pb-6"}`}> {/* pointer-events-none for children */}
|
|
326
|
+
<UploadCloud className={`text-muted-foreground ${compact ? "w-8 h-8 mb-2" : "w-10 h-10 mb-3"}`} />
|
|
324
327
|
<p className="mb-2 text-sm text-muted-foreground">
|
|
325
328
|
<span className="font-semibold">Click to upload</span> or drag and drop
|
|
326
329
|
</p>
|
|
@@ -332,7 +335,7 @@ export default function MediaUploadForm({ onUploadSuccess, returnJustData, defau
|
|
|
332
335
|
{previewUrl && file && file.type.startsWith("image/") && (
|
|
333
336
|
<div className="mt-4">
|
|
334
337
|
<Label>Preview:</Label>
|
|
335
|
-
<Image src={previewUrl} alt="Preview" width={300} height={192} className=
|
|
338
|
+
<Image src={previewUrl} alt="Preview" width={300} height={192} className={`mt-2 rounded-md w-auto object-contain border ${compact ? "max-h-32" : "max-h-48"}`} />
|
|
336
339
|
</div>
|
|
337
340
|
)}
|
|
338
341
|
{file && <p className="text-sm mt-2 text-muted-foreground">Selected: {file.name} ({(file.size / 1024 / 1024).toFixed(2)} MB)</p>}
|
|
@@ -3,32 +3,80 @@
|
|
|
3
3
|
import React from 'react';
|
|
4
4
|
import { ProductForm } from '@nextblock-cms/ecommerce';
|
|
5
5
|
import MediaPickerDialog from '../media/components/MediaPickerDialog';
|
|
6
|
-
|
|
6
|
+
import DraftStatusActions from '../components/DraftStatusActions';
|
|
7
|
+
|
|
8
|
+
type ProductFormProps = React.ComponentProps<typeof ProductForm>;
|
|
9
|
+
type ProductUpdateAction = NonNullable<ProductFormProps['updateAction']>;
|
|
10
|
+
|
|
11
|
+
type ProductFormClientShellProps = ProductFormProps & {
|
|
12
|
+
/** Present only in edit mode; drives the "Unpublished Draft" toolbar. */
|
|
13
|
+
productId?: string;
|
|
14
|
+
/** Draft existence as computed on the server for the current render. */
|
|
15
|
+
serverHasDraft?: boolean;
|
|
16
|
+
};
|
|
7
17
|
|
|
8
18
|
const productFormSkeletonRows = ['details', 'description', 'media', 'inventory'];
|
|
9
19
|
|
|
10
|
-
export default function ProductFormClientShell(
|
|
20
|
+
export default function ProductFormClientShell({
|
|
21
|
+
productId,
|
|
22
|
+
serverHasDraft = false,
|
|
23
|
+
updateAction,
|
|
24
|
+
...props
|
|
25
|
+
}: ProductFormClientShellProps) {
|
|
11
26
|
const [isMounted, setIsMounted] = React.useState(false);
|
|
27
|
+
// The draft toolbar is gated on the server-computed `hasDraft`, but the form
|
|
28
|
+
// autosave writes a draft WITHOUT revalidating the route (revalidating would
|
|
29
|
+
// re-init the form and loop the autosave — see updateProductAction). Track
|
|
30
|
+
// draft existence on the client so the toolbar can appear the moment an
|
|
31
|
+
// autosave persists a draft, without a server refetch.
|
|
32
|
+
const [hasDraft, setHasDraft] = React.useState(serverHasDraft);
|
|
12
33
|
|
|
13
34
|
React.useEffect(() => {
|
|
14
35
|
setIsMounted(true);
|
|
15
36
|
}, []);
|
|
16
37
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
38
|
+
// Keep in sync with the server whenever it reports a draft (e.g. block edits
|
|
39
|
+
// in BlockEditorArea call router.refresh(), which re-runs the page). Only ever
|
|
40
|
+
// latch ON here — publish/discard reload the whole page, which resets state.
|
|
41
|
+
React.useEffect(() => {
|
|
42
|
+
if (serverHasDraft) {
|
|
43
|
+
setHasDraft(true);
|
|
44
|
+
}
|
|
45
|
+
}, [serverHasDraft]);
|
|
46
|
+
|
|
47
|
+
// Reveal the toolbar as soon as a form autosave succeeds. The autosave already
|
|
48
|
+
// upserted a product_drafts row, so a draft now exists.
|
|
49
|
+
const wrappedUpdateAction = React.useCallback(
|
|
50
|
+
async (data: Parameters<ProductUpdateAction>[0]) => {
|
|
51
|
+
const result = await updateAction!(data);
|
|
52
|
+
setHasDraft(true);
|
|
53
|
+
return result;
|
|
54
|
+
},
|
|
55
|
+
[updateAction]
|
|
56
|
+
);
|
|
20
57
|
|
|
21
58
|
return (
|
|
22
|
-
|
|
23
|
-
{
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
59
|
+
<>
|
|
60
|
+
{productId ? (
|
|
61
|
+
<DraftStatusActions parentId={productId} parentType="product" hasDraft={hasDraft} />
|
|
62
|
+
) : null}
|
|
63
|
+
{isMounted ? (
|
|
64
|
+
<ProductForm
|
|
65
|
+
{...props}
|
|
66
|
+
hasOpenDraft={hasDraft}
|
|
67
|
+
updateAction={updateAction ? wrappedUpdateAction : undefined}
|
|
68
|
+
mediaPickerNode={
|
|
69
|
+
<MediaPickerDialog
|
|
70
|
+
triggerLabel="+ Add Image"
|
|
71
|
+
triggerVariant="outline"
|
|
72
|
+
defaultFolder="uploads/products/"
|
|
73
|
+
/>
|
|
74
|
+
}
|
|
29
75
|
/>
|
|
30
|
-
|
|
31
|
-
|
|
76
|
+
) : (
|
|
77
|
+
<ProductFormShellSkeleton />
|
|
78
|
+
)}
|
|
79
|
+
</>
|
|
32
80
|
);
|
|
33
81
|
}
|
|
34
82
|
|
|
@@ -13,7 +13,6 @@ import {
|
|
|
13
13
|
DropdownMenuTrigger,
|
|
14
14
|
} from '@nextblock-cms/ui';
|
|
15
15
|
import ProductFormClientShell from '../../ProductFormClientShell';
|
|
16
|
-
import DraftStatusActions from '../../../components/DraftStatusActions';
|
|
17
16
|
import {
|
|
18
17
|
getCmsProduct,
|
|
19
18
|
getEnabledPaymentProviders,
|
|
@@ -191,7 +190,6 @@ export default async function EditProductPage({
|
|
|
191
190
|
|
|
192
191
|
return (
|
|
193
192
|
<div className="space-y-8 w-full max-w-[1400px] mx-auto px-6 py-8">
|
|
194
|
-
<DraftStatusActions parentId={product.id} parentType="product" hasDraft={hasDraft} />
|
|
195
193
|
<CortexAiPageContextRegistrar
|
|
196
194
|
context={{
|
|
197
195
|
contentType: 'product',
|
|
@@ -279,6 +277,8 @@ export default async function EditProductPage({
|
|
|
279
277
|
</div>
|
|
280
278
|
|
|
281
279
|
<ProductFormClientShell
|
|
280
|
+
productId={product.id}
|
|
281
|
+
serverHasDraft={hasDraft}
|
|
282
282
|
initialData={normalizedInitialData}
|
|
283
283
|
isEdit
|
|
284
284
|
availableLanguagesProp={languages}
|
|
@@ -2,9 +2,15 @@
|
|
|
2
2
|
"use server";
|
|
3
3
|
|
|
4
4
|
import { createClient } from "@nextblock-cms/db/server";
|
|
5
|
-
import { revalidatePath } from "next/cache";
|
|
5
|
+
import { revalidatePath, updateTag } from "next/cache";
|
|
6
6
|
import { redirect } from "next/navigation";
|
|
7
7
|
import type { Database } from "@nextblock-cms/db";
|
|
8
|
+
import {
|
|
9
|
+
LANGUAGE_DETECTION_SETTING_KEY,
|
|
10
|
+
LANGUAGE_DETECTION_CACHE_TAG,
|
|
11
|
+
normalizeLanguageDetectionSettings,
|
|
12
|
+
type LanguageDetectionSettings,
|
|
13
|
+
} from "../../../../lib/i18n/detection";
|
|
8
14
|
|
|
9
15
|
type Language = Database["public"]["Tables"]["languages"]["Row"];
|
|
10
16
|
|
|
@@ -260,3 +266,49 @@ export async function deleteLanguage(languageId: number) {
|
|
|
260
266
|
revalidatePath("/");
|
|
261
267
|
redirect("/cms/settings/languages?success=Language deleted successfully. All associated content has also been removed.");
|
|
262
268
|
}
|
|
269
|
+
|
|
270
|
+
// --- Language detection settings (site_settings.language_detection_settings) ---
|
|
271
|
+
|
|
272
|
+
export async function getLanguageDetectionSettings(): Promise<LanguageDetectionSettings> {
|
|
273
|
+
const supabase = createClient();
|
|
274
|
+
const { data, error } = await supabase
|
|
275
|
+
.from("site_settings")
|
|
276
|
+
.select("value")
|
|
277
|
+
.eq("key", LANGUAGE_DETECTION_SETTING_KEY)
|
|
278
|
+
.maybeSingle();
|
|
279
|
+
|
|
280
|
+
if (error) {
|
|
281
|
+
console.error("Error fetching language detection settings:", error);
|
|
282
|
+
}
|
|
283
|
+
// Absent row or error = defaults (browser detection, remembered choice).
|
|
284
|
+
return normalizeLanguageDetectionSettings(error ? null : data?.value);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export async function updateLanguageDetectionSettings(
|
|
288
|
+
input: LanguageDetectionSettings,
|
|
289
|
+
): Promise<{ success?: string; error?: string }> {
|
|
290
|
+
const supabase = createClient();
|
|
291
|
+
|
|
292
|
+
if (!(await verifyAdmin(supabase))) {
|
|
293
|
+
return { error: "Unauthorized: Admin role required." };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Never trust the client payload shape — coerce to a valid settings object.
|
|
297
|
+
const settings = normalizeLanguageDetectionSettings(input);
|
|
298
|
+
|
|
299
|
+
const { error } = await supabase
|
|
300
|
+
.from("site_settings")
|
|
301
|
+
.upsert({ key: LANGUAGE_DETECTION_SETTING_KEY, value: settings });
|
|
302
|
+
|
|
303
|
+
if (error) {
|
|
304
|
+
console.error("Error saving language detection settings:", error);
|
|
305
|
+
return { error: `Failed to save detection settings: ${error.message}` };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
updateTag(LANGUAGE_DETECTION_CACHE_TAG);
|
|
309
|
+
revalidatePath("/cms/settings/languages");
|
|
310
|
+
revalidatePath("/", "layout");
|
|
311
|
+
// The proxy caches detection config in-memory for up to a minute per worker,
|
|
312
|
+
// so the change isn't instant for new visitors — set that expectation here.
|
|
313
|
+
return { success: "Language detection settings saved. Changes reach new visitors within about a minute." };
|
|
314
|
+
}
|