create-nextblock 0.12.13 → 0.12.15
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-in/page.tsx +0 -13
- package/templates/nextblock-template/app/(auth-pages)/sign-up/SignUpForm.tsx +0 -14
- package/templates/nextblock-template/app/[slug]/page.tsx +12 -4
- package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +209 -101
- package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +2 -3
- package/templates/nextblock-template/app/cms/components/FeatureImageField.tsx +1 -0
- package/templates/nextblock-template/app/cms/interactions/InteractionsModerationClient.tsx +1 -1
- package/templates/nextblock-template/app/cms/interactions/page.tsx +1 -1
- 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/revisions/RevisionHistoryButton.tsx +2 -2
- package/templates/nextblock-template/app/cms/revisions/actions.ts +5 -5
- 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/cms/users/actions.ts +0 -3
- package/templates/nextblock-template/app/cms/users/components/UserForm.tsx +0 -2
- package/templates/nextblock-template/app/cms/users/page.tsx +0 -2
- 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/profile/ProfileAccountSidebar.tsx +1 -1
- package/templates/nextblock-template/app/profile/account-data.ts +1 -1
- package/templates/nextblock-template/app/profile/account-types.ts +0 -1
- package/templates/nextblock-template/app/profile/page.tsx +0 -1
- package/templates/nextblock-template/app/providers.tsx +2 -0
- package/templates/nextblock-template/components/PostCommentsSection.tsx +2 -2
- package/templates/nextblock-template/components/ProductReviewsSection.tsx +2 -2
- package/templates/nextblock-template/components/header-auth.tsx +1 -1
- package/templates/nextblock-template/context/LanguageContext.tsx +22 -7
- package/templates/nextblock-template/docs/TECHNICAL_SPECIFICATION.md +46 -43
- package/templates/nextblock-template/lib/custom-block-relation-registry.ts +3 -3
- 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/lib/setup/migrations-bundle.ts +72 -37
- 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/templates/nextblock-template/components/GitHubLoginButton.tsx +0 -36
|
@@ -33,6 +33,13 @@ import {
|
|
|
33
33
|
resolveSupabaseUrl,
|
|
34
34
|
} from '../lib/setup/env-status';
|
|
35
35
|
import { resolveMediaBaseUrl } from '../lib/storage/provider';
|
|
36
|
+
import {
|
|
37
|
+
LANGUAGE_DETECTION_SETTING_KEY,
|
|
38
|
+
LANGUAGE_DETECTION_CACHE_TAG,
|
|
39
|
+
DEFAULT_LANGUAGE_DETECTION_SETTINGS,
|
|
40
|
+
normalizeLanguageDetectionSettings,
|
|
41
|
+
type LanguageDetectionSettings,
|
|
42
|
+
} from '../lib/i18n/detection';
|
|
36
43
|
|
|
37
44
|
const defaultUrl = process.env.NEXT_PUBLIC_URL || 'http://localhost:3000';
|
|
38
45
|
|
|
@@ -83,6 +90,25 @@ const getCachedLanguages = unstable_cache(
|
|
|
83
90
|
{ revalidate: PUBLIC_LAYOUT_REVALIDATE_SECONDS }
|
|
84
91
|
);
|
|
85
92
|
|
|
93
|
+
const getCachedLanguageDetectionSettings = unstable_cache(
|
|
94
|
+
async (): Promise<LanguageDetectionSettings> => {
|
|
95
|
+
const supabase = createStaticSupabaseClient();
|
|
96
|
+
const { data, error } = await supabase
|
|
97
|
+
.from('site_settings')
|
|
98
|
+
.select('value')
|
|
99
|
+
.eq('key', LANGUAGE_DETECTION_SETTING_KEY)
|
|
100
|
+
.maybeSingle();
|
|
101
|
+
|
|
102
|
+
// Absent row or read error = defaults (browser detection, remembered choice).
|
|
103
|
+
if (error) {
|
|
104
|
+
return { ...DEFAULT_LANGUAGE_DETECTION_SETTINGS };
|
|
105
|
+
}
|
|
106
|
+
return normalizeLanguageDetectionSettings(data?.value);
|
|
107
|
+
},
|
|
108
|
+
['public-language-detection'],
|
|
109
|
+
{ revalidate: PUBLIC_LAYOUT_REVALIDATE_SECONDS, tags: [LANGUAGE_DETECTION_CACHE_TAG] }
|
|
110
|
+
);
|
|
111
|
+
|
|
86
112
|
const getCachedCopyrightSettings = unstable_cache(
|
|
87
113
|
async (): Promise<Record<string, string>> => {
|
|
88
114
|
const supabase = createStaticSupabaseClient();
|
|
@@ -274,6 +300,7 @@ async function loadLayoutData() {
|
|
|
274
300
|
globalCss: '',
|
|
275
301
|
privacySettings: DEFAULT_PRIVACY_SETTINGS,
|
|
276
302
|
footerAttributionEnabled: true,
|
|
303
|
+
rememberVisitorChoice: DEFAULT_LANGUAGE_DETECTION_SETTINGS.rememberVisitorChoice,
|
|
277
304
|
};
|
|
278
305
|
}
|
|
279
306
|
|
|
@@ -300,6 +327,7 @@ async function loadLayoutData() {
|
|
|
300
327
|
translationsResult,
|
|
301
328
|
isEcommerceActive,
|
|
302
329
|
privacySettings,
|
|
330
|
+
languageDetectionSettings,
|
|
303
331
|
] = await Promise.all([
|
|
304
332
|
supabase.auth.getUser(),
|
|
305
333
|
getCachedLanguages().catch(() => getActiveLanguagesServerSide().catch(() => [])),
|
|
@@ -311,9 +339,19 @@ async function loadLayoutData() {
|
|
|
311
339
|
getCachedTranslations().catch(() => []),
|
|
312
340
|
verifyPackageOnline('ecommerce').catch(() => false),
|
|
313
341
|
getPrivacySettings().catch(() => DEFAULT_PRIVACY_SETTINGS),
|
|
342
|
+
getCachedLanguageDetectionSettings().catch(() => ({
|
|
343
|
+
...DEFAULT_LANGUAGE_DETECTION_SETTINGS,
|
|
344
|
+
})),
|
|
314
345
|
]);
|
|
315
346
|
|
|
316
|
-
|
|
347
|
+
// Serve only active languages, matching the proxy's detection set (is_active
|
|
348
|
+
// null counts as active). getCachedLanguages / getActiveLanguagesServerSide
|
|
349
|
+
// both return every row, so filtering here keeps the public switcher and
|
|
350
|
+
// LanguageProvider in step with the locales the proxy will actually honor —
|
|
351
|
+
// otherwise picking a deactivated language would ping-pong against the proxy.
|
|
352
|
+
const availableLanguages: Language[] = availableLanguagesResult.filter(
|
|
353
|
+
(lang) => lang.is_active !== false,
|
|
354
|
+
);
|
|
317
355
|
const availableCurrencies: StoreCurrency[] = currenciesResult;
|
|
318
356
|
const defaultLanguage: Language | null =
|
|
319
357
|
availableLanguages.find((lang) => lang.is_default) ?? availableLanguages[0] ?? null;
|
|
@@ -368,6 +406,7 @@ async function loadLayoutData() {
|
|
|
368
406
|
globalCss,
|
|
369
407
|
privacySettings,
|
|
370
408
|
footerAttributionEnabled,
|
|
409
|
+
rememberVisitorChoice: languageDetectionSettings.rememberVisitorChoice,
|
|
371
410
|
};
|
|
372
411
|
}
|
|
373
412
|
|
|
@@ -449,6 +488,7 @@ export default async function RootLayout({
|
|
|
449
488
|
globalCss,
|
|
450
489
|
privacySettings,
|
|
451
490
|
footerAttributionEnabled,
|
|
491
|
+
rememberVisitorChoice,
|
|
452
492
|
} = await loadLayoutData();
|
|
453
493
|
const draft = await draftMode();
|
|
454
494
|
// GTM container id comes solely from the privacy settings row (site_settings).
|
|
@@ -502,6 +542,7 @@ export default async function RootLayout({
|
|
|
502
542
|
initialCurrencyCode={serverCurrencyCode}
|
|
503
543
|
initialAvailableLanguages={availableLanguages}
|
|
504
544
|
initialDefaultLanguage={defaultLanguage}
|
|
545
|
+
rememberVisitorChoice={rememberVisitorChoice}
|
|
505
546
|
translations={translations}
|
|
506
547
|
nonce={nonce}
|
|
507
548
|
>
|
|
@@ -111,8 +111,12 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
|
|
|
111
111
|
if (!preferredLocale) {
|
|
112
112
|
try {
|
|
113
113
|
const hdrs = await headers();
|
|
114
|
-
|
|
115
|
-
|
|
114
|
+
// Proxy-detected locale first: it honors the CMS language-detection settings.
|
|
115
|
+
preferredLocale = hdrs.get("x-user-locale") || undefined;
|
|
116
|
+
if (!preferredLocale) {
|
|
117
|
+
const al = hdrs.get("accept-language");
|
|
118
|
+
if (al) preferredLocale = al.split(",")[0]?.split("-")[0];
|
|
119
|
+
}
|
|
116
120
|
} catch {
|
|
117
121
|
// ignore
|
|
118
122
|
}
|
|
@@ -206,8 +210,12 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|
|
206
210
|
if (!preferredLocale) {
|
|
207
211
|
try {
|
|
208
212
|
const hdrs = await headers();
|
|
209
|
-
|
|
210
|
-
|
|
213
|
+
// Proxy-detected locale first: it honors the CMS language-detection settings.
|
|
214
|
+
preferredLocale = hdrs.get("x-user-locale") || undefined;
|
|
215
|
+
if (!preferredLocale) {
|
|
216
|
+
const al = hdrs.get("accept-language");
|
|
217
|
+
if (al) preferredLocale = al.split(",")[0]?.split("-")[0];
|
|
218
|
+
}
|
|
211
219
|
} catch {
|
|
212
220
|
// ignore
|
|
213
221
|
}
|
|
@@ -27,7 +27,7 @@ export function ProfileAccountSidebar({
|
|
|
27
27
|
}: ProfileAccountSidebarProps) {
|
|
28
28
|
const { t } = useTranslations();
|
|
29
29
|
const displayName =
|
|
30
|
-
profile.full_name ||
|
|
30
|
+
profile.full_name || user.email || 'User';
|
|
31
31
|
|
|
32
32
|
return (
|
|
33
33
|
<Card className="h-fit">
|
|
@@ -18,7 +18,7 @@ export async function requireProfileAccountContext(redirectTo: string) {
|
|
|
18
18
|
|
|
19
19
|
const { data: profile } = await supabase
|
|
20
20
|
.from('profiles')
|
|
21
|
-
.select('id, avatar_url, full_name
|
|
21
|
+
.select('id, avatar_url, full_name')
|
|
22
22
|
.eq('id', user.id)
|
|
23
23
|
.maybeSingle();
|
|
24
24
|
|
|
@@ -36,7 +36,6 @@ export default async function ProfilePage() {
|
|
|
36
36
|
full_name: profile.full_name || '',
|
|
37
37
|
avatar_url: profile.avatar_url || '',
|
|
38
38
|
website: profile.website || '',
|
|
39
|
-
github_username: profile.github_username || '',
|
|
40
39
|
phone: profile.phone || '',
|
|
41
40
|
billing_address: billingAddress,
|
|
42
41
|
shipping_address: shippingAddress,
|
|
@@ -44,6 +44,7 @@ export function Providers({ children, ...props }: { children: React.ReactNode;[k
|
|
|
44
44
|
initialCurrencyCode,
|
|
45
45
|
initialAvailableLanguages,
|
|
46
46
|
initialDefaultLanguage,
|
|
47
|
+
rememberVisitorChoice,
|
|
47
48
|
translations,
|
|
48
49
|
nonce
|
|
49
50
|
} = props;
|
|
@@ -54,6 +55,7 @@ export function Providers({ children, ...props }: { children: React.ReactNode;[k
|
|
|
54
55
|
serverLocale={serverLocale}
|
|
55
56
|
initialAvailableLanguages={initialAvailableLanguages}
|
|
56
57
|
initialDefaultLanguage={initialDefaultLanguage}
|
|
58
|
+
rememberVisitorChoice={rememberVisitorChoice}
|
|
57
59
|
>
|
|
58
60
|
<CurrencyProvider
|
|
59
61
|
initialCurrencies={initialCurrencies}
|
|
@@ -77,7 +77,7 @@ export default function PostCommentsSection({ postId }: PostCommentsSectionProps
|
|
|
77
77
|
try {
|
|
78
78
|
const { data, error: dbError } = await supabase
|
|
79
79
|
.from("cms_interactions" as any)
|
|
80
|
-
.select("*, profiles(full_name, avatar_url
|
|
80
|
+
.select("*, profiles(full_name, avatar_url)")
|
|
81
81
|
.eq("post_id", postId)
|
|
82
82
|
.eq("type", "comment")
|
|
83
83
|
.eq("status", "approved")
|
|
@@ -280,7 +280,7 @@ export default function PostCommentsSection({ postId }: PostCommentsSectionProps
|
|
|
280
280
|
optimisticComments.map((comment) => {
|
|
281
281
|
const hasLiked = likedIds.includes(comment.id) || comment.tempHasReacted;
|
|
282
282
|
const likeCount = (comment.reactions as Record<string, number>)?.likes || 0;
|
|
283
|
-
const commenterName = comment.profiles?.full_name ||
|
|
283
|
+
const commenterName = comment.profiles?.full_name || "Anonymous";
|
|
284
284
|
const dateStr = new Date(comment.created_at).toLocaleDateString(lang, {
|
|
285
285
|
year: "numeric",
|
|
286
286
|
month: "long",
|
|
@@ -79,7 +79,7 @@ export default function ProductReviewsSection({ productId }: ProductReviewsSecti
|
|
|
79
79
|
try {
|
|
80
80
|
const { data, error: dbError } = await supabase
|
|
81
81
|
.from("cms_interactions" as any)
|
|
82
|
-
.select("*, profiles(full_name, avatar_url
|
|
82
|
+
.select("*, profiles(full_name, avatar_url)")
|
|
83
83
|
.eq("product_id", productId)
|
|
84
84
|
.eq("type", "review")
|
|
85
85
|
.eq("status", "approved")
|
|
@@ -317,7 +317,7 @@ export default function ProductReviewsSection({ productId }: ProductReviewsSecti
|
|
|
317
317
|
optimisticReviews.map((review) => {
|
|
318
318
|
const hasLiked = likedIds.includes(review.id) || review.tempHasReacted;
|
|
319
319
|
const likeCount = (review.reactions as Record<string, number>)?.likes || 0;
|
|
320
|
-
const reviewerName = review.profiles?.full_name ||
|
|
320
|
+
const reviewerName = review.profiles?.full_name || "Anonymous";
|
|
321
321
|
const dateStr = new Date(review.created_at).toLocaleDateString(lang, {
|
|
322
322
|
year: "numeric",
|
|
323
323
|
month: "long",
|
|
@@ -20,7 +20,7 @@ import { User, LogOut, LayoutDashboard } from "lucide-react";
|
|
|
20
20
|
export default function AuthButton() {
|
|
21
21
|
const { user, profile, isAdmin, isWriter } = useAuth();
|
|
22
22
|
const { t } = useTranslations();
|
|
23
|
-
const displayName = profile?.full_name ||
|
|
23
|
+
const displayName = profile?.full_name || user?.email || null;
|
|
24
24
|
const showAdminLink = isAdmin || isWriter;
|
|
25
25
|
|
|
26
26
|
const handleSignOut = async () => {
|
|
@@ -27,13 +27,15 @@ interface LanguageProviderProps {
|
|
|
27
27
|
serverLocale?: string; // Locale determined on the server (from X-User-Locale header)
|
|
28
28
|
initialAvailableLanguages?: Language[]; // Languages fetched on the server
|
|
29
29
|
initialDefaultLanguage?: Language | null; // Default language determined on the server
|
|
30
|
+
rememberVisitorChoice?: boolean; // CMS language-detection setting: persist locale for a year vs session-only
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
export const LanguageProvider = ({
|
|
33
34
|
children,
|
|
34
35
|
serverLocale,
|
|
35
36
|
initialAvailableLanguages,
|
|
36
|
-
initialDefaultLanguage
|
|
37
|
+
initialDefaultLanguage,
|
|
38
|
+
rememberVisitorChoice = true
|
|
37
39
|
}: LanguageProviderProps) => {
|
|
38
40
|
|
|
39
41
|
const [currentLocale, _setCurrentLocale] = useState<string>(() => {
|
|
@@ -124,10 +126,18 @@ export const LanguageProvider = ({
|
|
|
124
126
|
if (isMounted) { // Check mount status before final state updates
|
|
125
127
|
_setCurrentLocale(effectiveLocale);
|
|
126
128
|
if (typeof window !== 'undefined') {
|
|
127
|
-
|
|
129
|
+
// Mirror the cookie's persistence: when "remember" is off, keep no
|
|
130
|
+
// durable client-side record (and purge any left from a prior session)
|
|
131
|
+
// so the setting's "session only" promise holds on the client too.
|
|
132
|
+
if (rememberVisitorChoice) {
|
|
133
|
+
localStorage.setItem(LANGUAGE_STORAGE_KEY, effectiveLocale);
|
|
134
|
+
} else {
|
|
135
|
+
localStorage.removeItem(LANGUAGE_STORAGE_KEY);
|
|
136
|
+
}
|
|
128
137
|
document.documentElement.lang = effectiveLocale;
|
|
129
138
|
}
|
|
130
|
-
|
|
139
|
+
// Omitting `expires` makes it a session cookie, so detection re-runs next session.
|
|
140
|
+
Cookies.set(LANGUAGE_COOKIE_KEY, effectiveLocale, { path: '/', ...(rememberVisitorChoice ? { expires: 365 } : {}), sameSite: 'Lax' });
|
|
131
141
|
setIsLoadingLanguages(false); // Done loading/initializing
|
|
132
142
|
}
|
|
133
143
|
};
|
|
@@ -137,7 +147,7 @@ export const LanguageProvider = ({
|
|
|
137
147
|
return () => {
|
|
138
148
|
isMounted = false; // Cleanup function to set isMounted to false
|
|
139
149
|
};
|
|
140
|
-
}, [serverLocale, initialAvailableLanguages, initialDefaultLanguage, clientSelectedLocale]);
|
|
150
|
+
}, [serverLocale, initialAvailableLanguages, initialDefaultLanguage, clientSelectedLocale, rememberVisitorChoice]);
|
|
141
151
|
|
|
142
152
|
const setCurrentLocaleCallback = useCallback(async (localeCode: string) => { // Add async here
|
|
143
153
|
let localeToSet = DEFAULT_FALLBACK_LOCALE;
|
|
@@ -159,10 +169,15 @@ export const LanguageProvider = ({
|
|
|
159
169
|
|
|
160
170
|
_setCurrentLocale(localeToSet);
|
|
161
171
|
if (typeof window !== 'undefined') {
|
|
162
|
-
|
|
172
|
+
// Match the cookie's persistence (see the init effect): no durable record when off.
|
|
173
|
+
if (rememberVisitorChoice) {
|
|
174
|
+
localStorage.setItem(LANGUAGE_STORAGE_KEY, localeToSet);
|
|
175
|
+
} else {
|
|
176
|
+
localStorage.removeItem(LANGUAGE_STORAGE_KEY);
|
|
177
|
+
}
|
|
163
178
|
document.documentElement.lang = localeToSet;
|
|
164
179
|
}
|
|
165
|
-
Cookies.set(LANGUAGE_COOKIE_KEY, localeToSet, { path: '/', expires: 365, sameSite: 'Lax' });
|
|
180
|
+
Cookies.set(LANGUAGE_COOKIE_KEY, localeToSet, { path: '/', ...(rememberVisitorChoice ? { expires: 365 } : {}), sameSite: 'Lax' });
|
|
166
181
|
|
|
167
182
|
// The LanguageSwitcher component is responsible for navigation (push or refresh).
|
|
168
183
|
// We only set the clientSelectedLocale here to ensure the main useEffect
|
|
@@ -172,7 +187,7 @@ export const LanguageProvider = ({
|
|
|
172
187
|
setClientSelectedLocale(localeToSet);
|
|
173
188
|
}
|
|
174
189
|
// router.refresh(); // REMOVED: LanguageSwitcher will handle navigation/refresh.
|
|
175
|
-
}, [availableLanguages]);
|
|
190
|
+
}, [availableLanguages, rememberVisitorChoice]);
|
|
176
191
|
|
|
177
192
|
useEffect(() => {
|
|
178
193
|
if (currentLocale && typeof window !== 'undefined') {
|