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.
Files changed (42) hide show
  1. package/package.json +1 -1
  2. package/templates/nextblock-template/app/(auth-pages)/sign-in/page.tsx +0 -13
  3. package/templates/nextblock-template/app/(auth-pages)/sign-up/SignUpForm.tsx +0 -14
  4. package/templates/nextblock-template/app/[slug]/page.tsx +12 -4
  5. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +209 -101
  6. package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +2 -3
  7. package/templates/nextblock-template/app/cms/components/FeatureImageField.tsx +1 -0
  8. package/templates/nextblock-template/app/cms/interactions/InteractionsModerationClient.tsx +1 -1
  9. package/templates/nextblock-template/app/cms/interactions/page.tsx +1 -1
  10. package/templates/nextblock-template/app/cms/media/components/MediaPickerDialog.tsx +38 -35
  11. package/templates/nextblock-template/app/cms/media/components/MediaUploadForm.tsx +14 -11
  12. package/templates/nextblock-template/app/cms/products/ProductFormClientShell.tsx +62 -14
  13. package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +2 -2
  14. package/templates/nextblock-template/app/cms/revisions/RevisionHistoryButton.tsx +2 -2
  15. package/templates/nextblock-template/app/cms/revisions/actions.ts +5 -5
  16. package/templates/nextblock-template/app/cms/settings/languages/actions.ts +53 -1
  17. package/templates/nextblock-template/app/cms/settings/languages/components/LanguageDetectionPanel.tsx +188 -0
  18. package/templates/nextblock-template/app/cms/settings/languages/page.tsx +12 -1
  19. package/templates/nextblock-template/app/cms/users/actions.ts +0 -3
  20. package/templates/nextblock-template/app/cms/users/components/UserForm.tsx +0 -2
  21. package/templates/nextblock-template/app/cms/users/page.tsx +0 -2
  22. package/templates/nextblock-template/app/layout.tsx +42 -1
  23. package/templates/nextblock-template/app/product/[slug]/page.tsx +12 -4
  24. package/templates/nextblock-template/app/profile/ProfileAccountSidebar.tsx +1 -1
  25. package/templates/nextblock-template/app/profile/account-data.ts +1 -1
  26. package/templates/nextblock-template/app/profile/account-types.ts +0 -1
  27. package/templates/nextblock-template/app/profile/page.tsx +0 -1
  28. package/templates/nextblock-template/app/providers.tsx +2 -0
  29. package/templates/nextblock-template/components/PostCommentsSection.tsx +2 -2
  30. package/templates/nextblock-template/components/ProductReviewsSection.tsx +2 -2
  31. package/templates/nextblock-template/components/header-auth.tsx +1 -1
  32. package/templates/nextblock-template/context/LanguageContext.tsx +22 -7
  33. package/templates/nextblock-template/docs/TECHNICAL_SPECIFICATION.md +46 -43
  34. package/templates/nextblock-template/lib/custom-block-relation-registry.ts +3 -3
  35. package/templates/nextblock-template/lib/i18n/country-languages.ts +247 -0
  36. package/templates/nextblock-template/lib/i18n/detection.test.ts +197 -0
  37. package/templates/nextblock-template/lib/i18n/detection.ts +192 -0
  38. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +72 -37
  39. package/templates/nextblock-template/package.json +1 -1
  40. package/templates/nextblock-template/proxy.ts +141 -8
  41. package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
  42. 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
- const availableLanguages: Language[] = availableLanguagesResult;
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
- const al = hdrs.get("accept-language");
115
- if (al) preferredLocale = al.split(",")[0]?.split("-")[0];
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
- const al = hdrs.get("accept-language");
210
- if (al) preferredLocale = al.split(",")[0]?.split("-")[0];
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 || profile.github_username || user.email || 'User';
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, github_username')
21
+ .select('id, avatar_url, full_name')
22
22
  .eq('id', user.id)
23
23
  .maybeSingle();
24
24
 
@@ -7,5 +7,4 @@ export interface ProfileAccountSummary {
7
7
  id: string;
8
8
  avatar_url: string | null;
9
9
  full_name: string | null;
10
- github_username: string | null;
11
10
  }
@@ -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, github_username)")
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 || comment.profiles?.github_username || "Anonymous";
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, github_username)")
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 || review.profiles?.github_username || "Anonymous";
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 || profile?.github_username || user?.email || null;
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
- localStorage.setItem(LANGUAGE_STORAGE_KEY, effectiveLocale);
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
- Cookies.set(LANGUAGE_COOKIE_KEY, effectiveLocale, { path: '/', expires: 365, sameSite: 'Lax' });
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
- localStorage.setItem(LANGUAGE_STORAGE_KEY, localeToSet);
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') {