@lastbrain/app 2.0.24 → 2.0.35

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 (81) hide show
  1. package/dist/analytics/registry.d.ts +7 -0
  2. package/dist/analytics/registry.d.ts.map +1 -0
  3. package/dist/analytics/registry.js +11 -0
  4. package/dist/auth/useAuthSession.d.ts.map +1 -1
  5. package/dist/auth/useAuthSession.js +85 -1
  6. package/dist/cli.js +19 -3
  7. package/dist/components/LanguageSwitcher.d.ts +3 -1
  8. package/dist/components/LanguageSwitcher.d.ts.map +1 -1
  9. package/dist/components/LanguageSwitcher.js +134 -21
  10. package/dist/config/version.d.ts.map +1 -1
  11. package/dist/config/version.js +30 -19
  12. package/dist/i18n/server-lang.d.ts +1 -1
  13. package/dist/i18n/server-lang.d.ts.map +1 -1
  14. package/dist/i18n/types.d.ts +1 -1
  15. package/dist/i18n/types.d.ts.map +1 -1
  16. package/dist/i18n/useLink.d.ts.map +1 -1
  17. package/dist/i18n/useLink.js +15 -0
  18. package/dist/index.d.ts +3 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +4 -0
  21. package/dist/layouts/AdminLayoutWithSidebar.d.ts +3 -1
  22. package/dist/layouts/AdminLayoutWithSidebar.d.ts.map +1 -1
  23. package/dist/layouts/AdminLayoutWithSidebar.js +2 -2
  24. package/dist/layouts/AppProviders.d.ts +9 -1
  25. package/dist/layouts/AppProviders.d.ts.map +1 -1
  26. package/dist/layouts/AppProviders.js +24 -3
  27. package/dist/layouts/AuthLayout.js +1 -1
  28. package/dist/layouts/PublicLayout.js +1 -1
  29. package/dist/layouts/RootLayout.d.ts.map +1 -1
  30. package/dist/scripts/init-app.d.ts.map +1 -1
  31. package/dist/scripts/init-app.js +343 -138
  32. package/dist/scripts/module-build.d.ts.map +1 -1
  33. package/dist/scripts/module-build.js +784 -59
  34. package/dist/scripts/module-create.d.ts.map +1 -1
  35. package/dist/scripts/module-create.js +227 -10
  36. package/dist/scripts/sitemap-flat-generator.d.ts +39 -0
  37. package/dist/scripts/sitemap-flat-generator.d.ts.map +1 -0
  38. package/dist/scripts/sitemap-flat-generator.js +231 -0
  39. package/dist/scripts/sitemap-manifest-generator.d.ts +59 -0
  40. package/dist/scripts/sitemap-manifest-generator.d.ts.map +1 -0
  41. package/dist/scripts/sitemap-manifest-generator.js +290 -0
  42. package/dist/sitemap/manifest.d.ts +8 -0
  43. package/dist/sitemap/manifest.d.ts.map +1 -0
  44. package/dist/sitemap/manifest.js +6 -0
  45. package/dist/styles.css +2 -2
  46. package/dist/templates/AuthGuidePage.js +2 -0
  47. package/dist/templates/DefaultDoc.d.ts.map +1 -1
  48. package/dist/templates/DefaultDoc.js +9 -5
  49. package/dist/templates/DocPage.d.ts.map +1 -1
  50. package/dist/templates/DocPage.js +40 -0
  51. package/dist/templates/MigrationsGuidePage.js +2 -0
  52. package/dist/templates/ModuleGuidePage.d.ts.map +1 -1
  53. package/dist/templates/ModuleGuidePage.js +4 -1
  54. package/dist/templates/SimpleHomePage.js +2 -0
  55. package/package.json +31 -26
  56. package/src/analytics/registry.ts +14 -0
  57. package/src/auth/useAuthSession.ts +91 -1
  58. package/src/cli.ts +19 -3
  59. package/src/components/LanguageSwitcher.tsx +183 -60
  60. package/src/config/version.ts +30 -19
  61. package/src/i18n/server-lang.ts +2 -1
  62. package/src/i18n/types.ts +2 -1
  63. package/src/i18n/useLink.ts +15 -0
  64. package/src/index.ts +17 -0
  65. package/src/layouts/AdminLayoutWithSidebar.tsx +4 -0
  66. package/src/layouts/AppProviders.tsx +74 -9
  67. package/src/layouts/AuthLayout.tsx +1 -1
  68. package/src/layouts/PublicLayout.tsx +1 -1
  69. package/src/layouts/RootLayout.tsx +0 -1
  70. package/src/scripts/init-app.ts +418 -149
  71. package/src/scripts/module-build.ts +923 -63
  72. package/src/scripts/module-create.ts +260 -10
  73. package/src/scripts/sitemap-flat-generator.ts +313 -0
  74. package/src/scripts/sitemap-manifest-generator.ts +476 -0
  75. package/src/sitemap/manifest.ts +17 -0
  76. package/src/templates/AuthGuidePage.tsx +1 -1
  77. package/src/templates/DefaultDoc.tsx +397 -6
  78. package/src/templates/DocPage.tsx +40 -0
  79. package/src/templates/MigrationsGuidePage.tsx +1 -1
  80. package/src/templates/ModuleGuidePage.tsx +3 -2
  81. package/src/templates/SimpleHomePage.tsx +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lastbrain/app",
3
- "version": "2.0.24",
3
+ "version": "2.0.35",
4
4
  "description": "Framework modulaire Next.js avec CLI et système de modules",
5
5
  "private": false,
6
6
  "type": "module",
@@ -24,8 +24,7 @@
24
24
  "directory": "packages/app"
25
25
  },
26
26
  "publishConfig": {
27
- "access": "public",
28
- "registry": "https://registry.npmjs.org"
27
+ "access": "public"
29
28
  },
30
29
  "bin": {
31
30
  "lastbrain": "./dist/cli.js"
@@ -49,30 +48,15 @@
49
48
  "types": "./dist/i18n/server-lang.d.ts",
50
49
  "import": "./dist/i18n/server-lang.js",
51
50
  "default": "./dist/i18n/server-lang.js"
51
+ },
52
+ "./sitemap/*": {
53
+ "types": "./dist/sitemap/*.d.ts",
54
+ "default": "./dist/sitemap/*.js"
52
55
  }
53
56
  },
54
- "scripts": {
55
- "dev": "tsc -p tsconfig.json --watch",
56
- "build": "tsc -p tsconfig.json && npm run build:css && npm run copy:templates && chmod +x dist/cli.js 2>/dev/null || true",
57
- "build:css": "NODE_ENV=production pnpm exec postcss ./src/styles.css -o ./dist/styles.css",
58
- "copy:templates": "mkdir -p dist/templates/migrations dist/templates/env.example dist/templates/gitignore && (cp src/templates/migrations/* dist/templates/migrations/ 2>/dev/null || true) && (cp src/templates/env.example/.env.example dist/templates/env.example/ 2>/dev/null || true) && (cp src/templates/gitignore/.gitignore dist/templates/gitignore/ 2>/dev/null || true)",
59
- "lint": "eslint .",
60
- "prepublishOnly": "if [ -z \"$CI\" ]; then node ../../scripts/pre-publish.js && npm run build; fi",
61
- "module:build": "node dist/scripts/module-build.js",
62
- "module:create": "node dist/scripts/module-create.js",
63
- "module:add": "node dist/scripts/module-add.js",
64
- "module:remove": "node dist/scripts/module-remove.js",
65
- "module:list": "node dist/scripts/module-list.js",
66
- "build:modules": "node dist/scripts/module-build.js",
67
- "db:migrations:sync": "node dist/scripts/db-migrations-sync.js",
68
- "db:init": "node dist/scripts/db-init.js",
69
- "readme:create": "node dist/scripts/readme-build.js",
70
- "dev:shell": "next dev ./src/app-shell -p 3001",
71
- "dev:sync": "node dist/scripts/dev-sync.js"
72
- },
73
57
  "dependencies": {
74
- "@lastbrain/core": "^2.0.20",
75
- "@lastbrain/ui": "^2.0.20",
58
+ "@lastbrain/core": "^2.0.31",
59
+ "@lastbrain/ui": "^2.0.31",
76
60
  "@supabase/supabase-js": "^2.84.0",
77
61
  "chalk": "^5.3.0",
78
62
  "commander": "^14.0.2",
@@ -80,7 +64,7 @@
80
64
  "glob": "^11.0.0",
81
65
  "inquirer": "^13.0.1",
82
66
  "lucide-react": "^0.554.0",
83
- "next": "^15.5.7",
67
+ "next": "^15.1.6",
84
68
  "next-themes": "^0.4.6",
85
69
  "react": "^19.2.1",
86
70
  "react-dom": "^19.2.1"
@@ -88,6 +72,9 @@
88
72
  "peerDependencies": {
89
73
  "next": ">=15.0.0"
90
74
  },
75
+ "peerDependenciesOptional": {
76
+ "@lastbrain-labs/module-billing-pro": "workspace:*"
77
+ },
91
78
  "devDependencies": {
92
79
  "@heroui/theme": "^2.4.23",
93
80
  "@tailwindcss/postcss": "^4.1.17",
@@ -101,5 +88,23 @@
101
88
  "postcss-cli": "^11.0.0",
102
89
  "tailwindcss": "^4.1.17",
103
90
  "typescript": "^5.4.0"
91
+ },
92
+ "scripts": {
93
+ "dev": "tsc -p tsconfig.json --watch",
94
+ "build": "tsc -p tsconfig.json && npm run build:css && npm run copy:templates && chmod +x dist/cli.js 2>/dev/null || true",
95
+ "build:css": "NODE_ENV=production pnpm exec postcss ./src/styles.css -o ./dist/styles.css",
96
+ "copy:templates": "mkdir -p dist/templates/migrations dist/templates/env.example dist/templates/gitignore && (cp src/templates/migrations/* dist/templates/migrations/ 2>/dev/null || true) && (cp src/templates/env.example/.env.example dist/templates/env.example/ 2>/dev/null || true) && (cp src/templates/gitignore/.gitignore dist/templates/gitignore/ 2>/dev/null || true)",
97
+ "lint": "eslint .",
98
+ "module:build": "node dist/scripts/module-build.js",
99
+ "module:create": "node dist/scripts/module-create.js",
100
+ "module:add": "node dist/scripts/module-add.js",
101
+ "module:remove": "node dist/scripts/module-remove.js",
102
+ "module:list": "node dist/scripts/module-list.js",
103
+ "build:modules": "node dist/scripts/module-build.js",
104
+ "db:migrations:sync": "node dist/scripts/db-migrations-sync.js",
105
+ "db:init": "node dist/scripts/db-init.js",
106
+ "readme:create": "node dist/scripts/readme-build.js",
107
+ "dev:shell": "next dev ./src/app-shell -p 3001",
108
+ "dev:sync": "node dist/scripts/dev-sync.js"
104
109
  }
105
- }
110
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Global Analytics Registry
3
+ * Permet aux apps d'enregistrer un composant analytics global
4
+ */
5
+
6
+ let globalAnalyticsComponent: React.ComponentType | null = null;
7
+
8
+ export function registerAnalyticsComponent(component: React.ComponentType) {
9
+ globalAnalyticsComponent = component;
10
+ }
11
+
12
+ export function getAnalyticsComponent(): React.ComponentType | null {
13
+ return globalAnalyticsComponent;
14
+ }
@@ -1,9 +1,13 @@
1
1
  "use client";
2
2
 
3
3
  import { useEffect, useState } from "react";
4
- import { supabaseBrowserClient } from "@lastbrain/core";
4
+ import { supabaseBrowserClient, useRealtimeListener } from "@lastbrain/core";
5
5
  import type { User } from "@supabase/supabase-js";
6
6
 
7
+ // Note: this hook also listens to `user_profile_updated` realtime broadcasts
8
+ // and centralises cookie + redirect logic so the app reacts to profile language
9
+ // changes in a single place.
10
+
7
11
  export function useAuthSession() {
8
12
  const [user, setUser] = useState<User | null>(null);
9
13
  const [loading, setLoading] = useState(true);
@@ -54,5 +58,91 @@ export function useAuthSession() {
54
58
  return () => subscription.unsubscribe();
55
59
  }, []);
56
60
 
61
+ // Realtime listener for user_profile updates (broadcast from RealtimeProvider)
62
+ // This keeps NEXT_LOCALE in sync and performs a single, centralised redirect
63
+ // when the user's language changes remotely.
64
+
65
+ useRealtimeListener({
66
+ table: "user_profil",
67
+ // broadcast event produced by RealtimeProvider is `user_profile_updated`
68
+ eventName: "user_profile_updated",
69
+ onUpdate(payload) {
70
+ try {
71
+ // Recherche robuste d'un objet contenant la propriété `language` ou `locale`
72
+ const findLangRecord = (obj: any) => {
73
+ if (!obj || typeof obj !== "object") return null;
74
+ const seen = new WeakSet();
75
+ const queue: any[] = [obj];
76
+ while (queue.length) {
77
+ const cur = queue.shift();
78
+ if (!cur || typeof cur !== "object" || seen.has(cur)) continue;
79
+ seen.add(cur);
80
+ if ("language" in cur || "locale" in cur) return cur;
81
+ for (const k of Object.keys(cur)) {
82
+ const v = cur[k];
83
+ if (v && typeof v === "object") queue.push(v);
84
+ }
85
+ }
86
+ return null;
87
+ };
88
+
89
+ const candidate =
90
+ findLangRecord(payload) ||
91
+ payload?.new ||
92
+ payload?.record?.new ||
93
+ payload?.payload?.new ||
94
+ payload?.after ||
95
+ payload?.data ||
96
+ null;
97
+
98
+ const newLang = candidate?.language || candidate?.locale || null;
99
+
100
+ if (!newLang) {
101
+ console.debug(
102
+ "useAuthSession: realtime payload did not contain language",
103
+ payload
104
+ );
105
+ return;
106
+ }
107
+
108
+ // Read current cookie
109
+ const cookie = document.cookie
110
+ .split("; ")
111
+ .find((c) => c.startsWith("NEXT_LOCALE="));
112
+ const cookieLang = cookie ? cookie.split("=")[1] : null;
113
+ if (cookieLang === newLang) return;
114
+
115
+ // Set cookie and navigate to the language-prefixed URL
116
+ try {
117
+ const maxAge = 365 * 24 * 60 * 60;
118
+ document.cookie = `NEXT_LOCALE=${newLang}; Path=/; Max-Age=${maxAge}; SameSite=Lax`;
119
+ } catch (_e) {
120
+ // ignore
121
+ }
122
+
123
+ try {
124
+ sessionStorage.setItem("locale-change-ts", Date.now().toString());
125
+ } catch (_e) {
126
+ /* ignore */
127
+ }
128
+
129
+ try {
130
+ const pathname = window.location.pathname;
131
+ const pathWithoutLang =
132
+ pathname.replace(/^\/[a-z]{2}(?=\/|$)/, "") || "/";
133
+ const safePath = pathWithoutLang.startsWith("/api")
134
+ ? "/"
135
+ : pathWithoutLang;
136
+ const search = window.location.search || "";
137
+ window.location.href = `/${newLang}${safePath}${search}`;
138
+ } catch (_e) {
139
+ // ignore navigation errors
140
+ }
141
+ } catch (e) {
142
+ console.debug("useAuthSession: error handling realtime payload", e);
143
+ }
144
+ },
145
+ });
146
+
57
147
  return { user, loading, isSuperAdmin };
58
148
  }
package/src/cli.ts CHANGED
@@ -27,9 +27,25 @@ program
27
27
  )
28
28
  .action(async (directory: string | undefined, options) => {
29
29
  try {
30
- const targetDir = directory
31
- ? path.resolve(process.cwd(), directory)
32
- : process.cwd();
30
+ let targetDir: string;
31
+
32
+ if (directory) {
33
+ // Si on est dans un monorepo (détecté par pnpm-workspace.yaml à la racine)
34
+ const fs = await import("fs-extra");
35
+ const cwd = process.cwd();
36
+ const workspaceFile = path.join(cwd, "pnpm-workspace.yaml");
37
+ const isMonorepo = await fs.pathExists(workspaceFile);
38
+
39
+ if (isMonorepo) {
40
+ // Dans un monorepo, créer dans apps/
41
+ targetDir = path.resolve(cwd, "apps", directory);
42
+ } else {
43
+ // Hors monorepo, créer où demandé
44
+ targetDir = path.resolve(cwd, directory);
45
+ }
46
+ } else {
47
+ targetDir = process.cwd();
48
+ }
33
49
 
34
50
  await initApp({
35
51
  force: options.force || false,
@@ -1,7 +1,10 @@
1
1
  "use client";
2
2
 
3
3
  import { useEffect, useState } from "react";
4
+ import { useRouter } from "next/navigation";
4
5
  import { useLanguage } from "../i18n/LanguageProvider";
6
+ import { eventBus } from "@lastbrain/core";
7
+ import { useAuth } from "@lastbrain/core";
5
8
  import {
6
9
  Button,
7
10
  Dropdown,
@@ -14,17 +17,19 @@ import {
14
17
  interface LanguageSwitcherProps {
15
18
  variant?: "default" | "minimal";
16
19
  className?: string;
20
+ availableLanguages?: string[];
21
+ localeMap?: Record<string, string>;
17
22
  }
18
23
 
19
24
  export function LanguageSwitcher({
20
25
  variant = "default",
21
26
  className = "",
27
+ availableLanguages = ["fr", "en"],
28
+ localeMap = { fr: "fr_FR", en: "en_US" },
22
29
  }: LanguageSwitcherProps) {
23
- const { lang, setLang } = useLanguage();
24
- const [flagUrls, setFlagUrls] = useState<Record<"fr" | "en", string>>({
25
- fr: "",
26
- en: "",
27
- });
30
+ const { lang, setLang: setLangFromContext } = useLanguage();
31
+ const router = useRouter();
32
+ const [flagUrls, setFlagUrls] = useState<Record<string, string>>({});
28
33
  const [loading, setLoading] = useState(false);
29
34
  const [isHydrated, setIsHydrated] = useState(false);
30
35
 
@@ -33,6 +38,100 @@ export function LanguageSwitcher({
33
38
  setIsHydrated(true);
34
39
  }, []);
35
40
 
41
+ // Fonction pour changer la langue avec redirection correcte
42
+ const { user } = useAuth();
43
+
44
+ const handleLanguageChange = async (newLang: string) => {
45
+ const currentPath = window.location.pathname;
46
+ // Extraire les segments du chemin
47
+ const segments = currentPath.split("/").filter(Boolean);
48
+
49
+ // Nettoyer TOUS les codes langue (2 lettres) du début du path
50
+ // Ça corrige les paths corrompus comme /en/es/recipes
51
+ while (segments.length > 0 && /^[a-z]{2}$/.test(segments[0])) {
52
+ segments.shift(); // Enlever le premier segment s'il est une langue
53
+ }
54
+
55
+ // Reconstruire le path avec la nouvelle langue
56
+ const pathWithoutLang = segments.length > 0 ? "/" + segments.join("/") : "";
57
+ const newPath = `/${newLang}${pathWithoutLang}`;
58
+
59
+ // Si l'utilisateur est connecté, mettre à jour sa préférence en BDD
60
+ if (user) {
61
+ try {
62
+ const resp = await fetch("/api/auth/profile", {
63
+ method: "PATCH",
64
+ headers: { "Content-Type": "application/json" },
65
+ credentials: "include",
66
+ body: JSON.stringify({ language: newLang }),
67
+ });
68
+
69
+ // attendre la confirmation du serveur que la locale a bien été mise à jour
70
+ // afin d'éviter un reload avant que le cookie soit appliqué côté serveur.
71
+ try {
72
+ const json = await resp.json();
73
+ if (json && json.data) {
74
+ // émettre localement l'événement realtime pour propager immédiatement
75
+ try {
76
+ eventBus.emit("user_profile_updated", { new: json.data });
77
+ } catch (e) {
78
+ /* ignore */
79
+ }
80
+ } else {
81
+ // si serveur n'a pas renvoyé de données attendues, on continue
82
+ }
83
+ } catch (e) {
84
+ // ignore json parse errors
85
+ }
86
+ } catch (e) {
87
+ console.debug("language switch: failed to update profile language", e);
88
+ }
89
+ }
90
+
91
+ // Mettre à jour le contexte local immédiatement (UX local)
92
+ try {
93
+ setLangFromContext(newLang);
94
+ } catch (_e) {
95
+ // ignore
96
+ }
97
+
98
+ // Si l'utilisateur n'est pas authentifié, écrire immédiatement
99
+ // le cookie NEXT_LOCALE et forcer un refresh pour que Next.js
100
+ // prenne en compte la nouvelle locale côté serveur.
101
+ if (!user) {
102
+ try {
103
+ // 1 an
104
+ document.cookie = `NEXT_LOCALE=${newLang}; Path=/; Max-Age=${31536000}; SameSite=Lax`;
105
+ } catch (e) {
106
+ // ignore
107
+ }
108
+
109
+ try {
110
+ // Navigate to the same path prefixed with the new locale,
111
+ // preserving search and hash.
112
+ const target = `${newPath}${window.location.search || ""}${
113
+ window.location.hash || ""
114
+ }`;
115
+ router.push(target);
116
+ } catch (e) {
117
+ // fallback: navigate to the same path but with the new locale
118
+ try {
119
+ window.location.assign(
120
+ `${newPath}${window.location.search || ""}${
121
+ window.location.hash || ""
122
+ }`
123
+ );
124
+ } catch (_err) {
125
+ /* ignore */
126
+ }
127
+ }
128
+ return;
129
+ }
130
+
131
+ // Pour utilisateurs authentifiés, la redirection et l'écriture
132
+ // du cookie NEXT_LOCALE sont gérées par le listener realtime centralisé.
133
+ };
134
+
36
135
  useEffect(() => {
37
136
  let canceled = false;
38
137
  const objectUrls: string[] = [];
@@ -41,28 +140,36 @@ export function LanguageSwitcher({
41
140
  setLoading(true);
42
141
  try {
43
142
  const entries = await Promise.all(
44
- (["fr", "en"] as const).map(async (code) => {
45
- const apiCode = code === "fr" ? "fr" : "gb";
46
- const response = await fetch(`https://flagcdn.com/${apiCode}.svg`);
47
- if (!response.ok) throw new Error(`Flag fetch failed: ${code}`);
48
- const blob = await response.blob();
49
- const url = URL.createObjectURL(blob);
50
- objectUrls.push(url);
51
- return [code, url] as const;
143
+ availableLanguages.map(async (code) => {
144
+ const apiCode = code === "fr" ? "fr" : code === "en" ? "gb" : code;
145
+ try {
146
+ const response = await fetch(
147
+ `https://flagcdn.com/${apiCode}.svg`
148
+ );
149
+ if (!response.ok) throw new Error(`Flag fetch failed: ${code}`);
150
+ const blob = await response.blob();
151
+ const url = URL.createObjectURL(blob);
152
+ objectUrls.push(url);
153
+ return [code, url] as const;
154
+ } catch {
155
+ // Fallback to direct URL if fetch fails
156
+ return [code, `https://flagcdn.com/${apiCode}.svg`] as const;
157
+ }
52
158
  })
53
159
  );
54
160
 
55
161
  if (!canceled) {
56
- setFlagUrls(
57
- Object.fromEntries(entries) as Record<"fr" | "en", string>
58
- );
162
+ setFlagUrls(Object.fromEntries(entries));
59
163
  }
60
164
  } catch (_err) {
61
165
  if (!canceled) {
62
- setFlagUrls({
63
- fr: "https://flagcdn.com/fr.svg",
64
- en: "https://flagcdn.com/gb.svg",
166
+ // Fallback: create a map with direct URLs
167
+ const fallbackUrls: Record<string, string> = {};
168
+ availableLanguages.forEach((code) => {
169
+ const apiCode = code === "fr" ? "fr" : code === "en" ? "gb" : code;
170
+ fallbackUrls[code] = `https://flagcdn.com/${apiCode}.svg`;
65
171
  });
172
+ setFlagUrls(fallbackUrls);
66
173
  }
67
174
  } finally {
68
175
  if (!canceled) setLoading(false);
@@ -75,9 +182,9 @@ export function LanguageSwitcher({
75
182
  canceled = true;
76
183
  objectUrls.forEach((url) => URL.revokeObjectURL(url));
77
184
  };
78
- }, []);
185
+ }, [availableLanguages]);
79
186
 
80
- const renderFlag = (code: "fr" | "en") => (
187
+ const renderFlag = (code: string) => (
81
188
  <span className="inline-flex items-center gap-2">
82
189
  {flagUrls[code] ? (
83
190
  <img
@@ -90,45 +197,58 @@ export function LanguageSwitcher({
90
197
  {code.toUpperCase()}
91
198
  </span>
92
199
  ) : null}
93
- {/* <span>{code === "fr" ? "Français" : "English"}</span> */}
94
200
  </span>
95
201
  );
96
202
 
203
+ // SSR placeholder - ne pas rendre le Dropdown avant l'hydration
204
+ // pour éviter les mismatches d'ID React Aria
205
+ if (!isHydrated) {
206
+ return (
207
+ <span className="inline-flex h-4 w-6 items-center justify-center rounded-sm bg-slate-200 text-[10px] font-semibold text-slate-600">
208
+ {lang.toUpperCase()}
209
+ </span>
210
+ );
211
+ }
212
+
97
213
  if (variant === "minimal") {
98
214
  return (
99
- <Dropdown size="sm" className="px-0 m-0">
100
- <DropdownTrigger className="px-0 m-0">
101
- {renderFlag(lang)}
102
- </DropdownTrigger>
103
- <DropdownMenu>
104
- <DropdownItem
105
- key="en"
106
- onPress={() => setLang("en")}
107
- startContent={renderFlag("en")}
108
- className={lang === "en" ? "bg-primary/10" : ""}
109
- >
110
- English {isHydrated && lang === "en" && ""}
111
- </DropdownItem>
112
- <DropdownItem
113
- key="fr"
114
- onPress={() => setLang("fr")}
115
- startContent={renderFlag("fr")}
116
- className={lang === "fr" ? "bg-primary/10" : ""}
117
- >
118
- Français {isHydrated && lang === "fr" && "✓"}
119
- </DropdownItem>
120
- </DropdownMenu>
121
- </Dropdown>
215
+ <div className="">
216
+ <Dropdown size="sm" className="px-0 m-0">
217
+ <DropdownTrigger className="px-0 m-0">
218
+ {renderFlag(lang)}
219
+ </DropdownTrigger>
220
+ <DropdownMenu>
221
+ {availableLanguages.map((code) => (
222
+ <DropdownItem
223
+ key={code}
224
+ onPress={() => handleLanguageChange(code)}
225
+ startContent={renderFlag(code)}
226
+ className={lang === code ? "bg-primary/10" : ""}
227
+ >
228
+ {code === "fr"
229
+ ? "Français"
230
+ : code === "en"
231
+ ? "English"
232
+ : code === "es"
233
+ ? "Spanish"
234
+ : code}
235
+ {isHydrated && lang === code && " ✓"}
236
+ </DropdownItem>
237
+ ))}
238
+ </DropdownMenu>
239
+ </Dropdown>
240
+ </div>
122
241
  );
123
242
  }
124
243
 
125
244
  return (
126
- <Dropdown size="sm" className="px-0">
245
+ <Dropdown size="sm" className="px-0 ">
127
246
  <DropdownTrigger className="px-0 m-0">
128
247
  <Button
129
248
  variant="light"
130
249
  size="sm"
131
250
  className="px-0 m-0"
251
+ aria-label={`Switch language (current: ${lang})`}
132
252
  startContent={renderFlag(lang)}
133
253
  endContent={loading ? <Spinner size="sm" /> : null}
134
254
  >
@@ -136,20 +256,23 @@ export function LanguageSwitcher({
136
256
  </Button>
137
257
  </DropdownTrigger>
138
258
  <DropdownMenu>
139
- <DropdownItem
140
- key="en"
141
- onPress={() => setLang("en")}
142
- className={lang === "en" ? "bg-primary/10" : ""}
143
- >
144
- {renderFlag("en")} English {isHydrated && lang === "en" && "✓"}
145
- </DropdownItem>
146
- <DropdownItem
147
- key="fr"
148
- onPress={() => setLang("fr")}
149
- className={lang === "fr" ? "bg-primary/10" : ""}
150
- >
151
- {renderFlag("fr")} Français {isHydrated && lang === "fr" && "✓"}
152
- </DropdownItem>
259
+ {availableLanguages.map((code) => (
260
+ <DropdownItem
261
+ key={code}
262
+ onPress={() => handleLanguageChange(code)}
263
+ className={lang === code ? "bg-primary/10" : ""}
264
+ >
265
+ {renderFlag(code)}{" "}
266
+ {code === "fr"
267
+ ? "Français"
268
+ : code === "en"
269
+ ? "English"
270
+ : code === "es"
271
+ ? "Spanish"
272
+ : code}
273
+ {isHydrated && lang === code && " ✓"}
274
+ </DropdownItem>
275
+ ))}
153
276
  </DropdownMenu>
154
277
  </Dropdown>
155
278
  );
@@ -5,23 +5,34 @@
5
5
  */
6
6
 
7
7
  export const PACKAGE_VERSIONS: Record<string, string> = {
8
- "@lastbrain-labs/module-billing-pro": "^2.0.18",
9
- "@lastbrain-labs/module-cj-analyzer-pro": "^0.1.10",
10
- "@lastbrain-labs/module-core-cart-pro": "^2.0.18",
11
- "@lastbrain-labs/module-core-commerce-pro": "^2.0.18",
12
- "@lastbrain-labs/module-core-order-pro": "^2.0.18",
13
- "@lastbrain-labs/module-core-payment-pro": "^2.0.18",
14
- "@lastbrain-labs/module-core-product-pro": "^2.0.18",
15
- "@lastbrain-labs/module-recipes-pro": "^2.0.18",
16
- "@lastbrain-labs/module-shop-pro": "^0.1.10",
17
- "@lastbrain/app": "^2.0.21",
18
- "@lastbrain/core": "^2.0.19",
19
- "@lastbrain/module-ai": "^2.0.18",
20
- "@lastbrain/module-auth": "^2.0.19",
21
- "@lastbrain/module-legal": "^2.0.18",
22
- "@lastbrain/module-project-board": "^2.0.18",
23
- "@lastbrain/module-tasks": "^2.0.18",
24
- "@lastbrain/ui": "^2.0.19",
25
- "apps/recipe": "^2.0.11",
26
- lastbrain: "^2.0.11",
8
+ "@lastbrain-labs/metrics-ui": "^1.0.2",
9
+ "@lastbrain-labs/module-billing-pro": "^2.0.29",
10
+ "@lastbrain-labs/module-cj-analyzer-pro": "^0.1.21",
11
+ "@lastbrain-labs/module-contact-pro": "^0.1.2",
12
+ "@lastbrain-labs/module-core-cart-pro": "^2.0.29",
13
+ "@lastbrain-labs/module-core-commerce-pro": "^2.0.29",
14
+ "@lastbrain-labs/module-core-order-pro": "^2.0.29",
15
+ "@lastbrain-labs/module-core-payment-pro": "^2.0.29",
16
+ "@lastbrain-labs/module-core-product-pro": "^2.0.29",
17
+ "@lastbrain-labs/module-metrics-pro": "^0.1.2",
18
+ "@lastbrain-labs/module-recipes-pro": "^2.0.31",
19
+ "@lastbrain-labs/module-shop-pro": "^0.1.21",
20
+ "@lastbrain/ai-ui-core": "^1.0.2",
21
+ "@lastbrain/ai-ui-react": "^1.0.2",
22
+ "@lastbrain/ai-ui-theme-heroui": "^1.0.2",
23
+ "@lastbrain/app": "^2.0.34",
24
+ "@lastbrain/core": "^2.0.30",
25
+ "@lastbrain/module-ai": "^2.0.29",
26
+ "@lastbrain/module-audit-pro": "^0.1.2",
27
+ "@lastbrain/module-auth": "^2.0.30",
28
+ "@lastbrain/module-blog": "^0.1.2",
29
+ "@lastbrain/module-legal": "^2.0.29",
30
+ "@lastbrain/module-project-board": "^2.0.29",
31
+ "@lastbrain/module-tasks": "^2.0.29",
32
+ "@lastbrain/module-tools": "^0.1.2",
33
+ "@lastbrain/ui": "^2.0.30",
34
+ "audit": "^2.0.14",
35
+ "prompt": "^0.1.1",
36
+ "recipe": "^2.0.14",
37
+ "tools": "^2.0.14",
27
38
  };
@@ -2,7 +2,8 @@ import { cookies } from "next/headers";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
 
5
- export type Language = "fr" | "en";
5
+ // Language peut être n'importe quel code langue à 2 lettres (fr, en, es, de, etc.)
6
+ export type Language = string;
6
7
 
7
8
  /**
8
9
  * Récupère la langue depuis les cookies côté serveur
package/src/i18n/types.ts CHANGED
@@ -2,7 +2,8 @@
2
2
  * Types pour le système i18n
3
3
  */
4
4
 
5
- export type Language = "fr" | "en";
5
+ // Language peut être n'importe quel code langue à 2 lettres (fr, en, es, de, etc.)
6
+ export type Language = string;
6
7
 
7
8
  export type TranslationKey = string;
8
9