@majapisoftwares/ui 6.1.3 → 6.2.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/docs/STANDARDS.md +2 -0
- package/docs/validation/preference-titles.md +22 -22
- package/package.json +1 -1
- package/src/patterns/preferences.tsx +180 -180
- package/src/templates/login-form.tsx +140 -129
package/docs/STANDARDS.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# Padrão v1 de código e UI/UX
|
|
2
2
|
|
|
3
|
+
`LoginForm` aceita `mode="providers"` com `alternative` obrigatório para autenticação exclusiva por OAuth/SSO, sem campos de senha nem callback de credenciais. O modo padrão continua `credentials`. Catálogo e starter demonstram em `/login/provider`. A identidade autenticada usa `UserMenu` no slot `footer` do `AppShell`; tema, idioma e saída permanecem no padrão compartilhado.
|
|
4
|
+
|
|
3
5
|
Base atual: shadcn base-luma com @base-ui/react. Os componentes interativos expõem fronteiras use client para uso no Next.js App Router. A migração da API Radix está documentada em MIGRATION-0.2.md.
|
|
4
6
|
|
|
5
7
|
## Arquitetura e código
|
|
@@ -1,22 +1,22 @@
|
|
|
1
|
-
# Saved-language titles
|
|
2
|
-
|
|
3
|
-
The initial preference state matched server HTML in English. Child title effects
|
|
4
|
-
ran before browser preferences were loaded, briefly writing the English title.
|
|
5
|
-
The pre-paint visibility mask also expired after two seconds, independently of
|
|
6
|
-
whether hydration had completed.
|
|
7
|
-
|
|
8
|
-
`Preferences` now exposes internal readiness to `useDocumentTitle`. Titles are
|
|
9
|
-
written only after the stored/browser language is resolved. The pre-paint mask
|
|
10
|
-
is removed only when React has committed that language. The public API remains
|
|
11
|
-
compatible, including `useDocumentTitle` outside Preferences.
|
|
12
|
-
|
|
13
|
-
The catalog and generated starter consume the shared implementation. Cloud and
|
|
14
|
-
the starter pin the corrected public Git source archive by its full commit SHA;
|
|
15
|
-
no registry authentication or mutable dependency ref is required.
|
|
16
|
-
|
|
17
|
-
Validation: Portuguese and English hydration tests record every document-title
|
|
18
|
-
write and verify that all writes use the selected language. Library typecheck,
|
|
19
|
-
lint, standards and build pass. Generated-starter lint, typecheck, UI standards
|
|
20
|
-
and build pass. Local Chrome refresh shows `Padrões · Majapi UI` and
|
|
21
|
-
`Repositórios · Majapi Cloud`, with visible Portuguese content and the button
|
|
22
|
-
`Conectar repositórios`. No production deployment was performed.
|
|
1
|
+
# Saved-language titles
|
|
2
|
+
|
|
3
|
+
The initial preference state matched server HTML in English. Child title effects
|
|
4
|
+
ran before browser preferences were loaded, briefly writing the English title.
|
|
5
|
+
The pre-paint visibility mask also expired after two seconds, independently of
|
|
6
|
+
whether hydration had completed.
|
|
7
|
+
|
|
8
|
+
`Preferences` now exposes internal readiness to `useDocumentTitle`. Titles are
|
|
9
|
+
written only after the stored/browser language is resolved. The pre-paint mask
|
|
10
|
+
is removed only when React has committed that language. The public API remains
|
|
11
|
+
compatible, including `useDocumentTitle` outside Preferences.
|
|
12
|
+
|
|
13
|
+
The catalog and generated starter consume the shared implementation. Cloud and
|
|
14
|
+
the starter pin the corrected public Git source archive by its full commit SHA;
|
|
15
|
+
no registry authentication or mutable dependency ref is required.
|
|
16
|
+
|
|
17
|
+
Validation: Portuguese and English hydration tests record every document-title
|
|
18
|
+
write and verify that all writes use the selected language. Library typecheck,
|
|
19
|
+
lint, standards and build pass. Generated-starter lint, typecheck, UI standards
|
|
20
|
+
and build pass. Local Chrome refresh shows `Padrões · Majapi UI` and
|
|
21
|
+
`Repositórios · Majapi Cloud`, with visible Portuguese content and the button
|
|
22
|
+
`Conectar repositórios`. No production deployment was performed.
|
package/package.json
CHANGED
|
@@ -1,180 +1,180 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
createContext,
|
|
5
|
-
useContext,
|
|
6
|
-
useEffect,
|
|
7
|
-
useState,
|
|
8
|
-
type ReactNode,
|
|
9
|
-
} from "react";
|
|
10
|
-
import { LanguagesIcon, MoonIcon, SunIcon } from "lucide-react";
|
|
11
|
-
import {
|
|
12
|
-
LocaleProvider,
|
|
13
|
-
locales,
|
|
14
|
-
matchLocale,
|
|
15
|
-
useUiMessages,
|
|
16
|
-
type Locale,
|
|
17
|
-
} from "../lib/i18n";
|
|
18
|
-
import { Button } from "../components/ui/button";
|
|
19
|
-
import { Toaster } from "../components/ui/sonner";
|
|
20
|
-
import {
|
|
21
|
-
DropdownMenu,
|
|
22
|
-
DropdownMenuContent,
|
|
23
|
-
DropdownMenuRadioGroup,
|
|
24
|
-
DropdownMenuRadioItem,
|
|
25
|
-
DropdownMenuTrigger,
|
|
26
|
-
} from "../components/ui/dropdown-menu";
|
|
27
|
-
|
|
28
|
-
type Theme = "light" | "dark";
|
|
29
|
-
const PreferencesContext = createContext<{
|
|
30
|
-
locale: Locale;
|
|
31
|
-
setLocale: (locale: Locale) => void;
|
|
32
|
-
theme: Theme;
|
|
33
|
-
setTheme: (theme: Theme) => void;
|
|
34
|
-
} | null>(null);
|
|
35
|
-
const PreferencesReadyContext = createContext(true);
|
|
36
|
-
|
|
37
|
-
function read(key: string) {
|
|
38
|
-
try {
|
|
39
|
-
return localStorage.getItem(key);
|
|
40
|
-
} catch {
|
|
41
|
-
return null;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
function write(key: string, value: string) {
|
|
45
|
-
try {
|
|
46
|
-
localStorage.setItem(key, value);
|
|
47
|
-
} catch {
|
|
48
|
-
// Storage can be unavailable (private mode); preferences just won't persist.
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Mirrors matchLocale; keep both in sync.
|
|
54
|
-
* Inline this in `<head>` (e.g. `<script dangerouslySetInnerHTML>`) so the
|
|
55
|
-
* stored theme applies before paint.
|
|
56
|
-
*/
|
|
57
|
-
export const preferencesScript = `try{var d=document.documentElement;if(localStorage.getItem("theme")==="dark")d.classList.add("dark");var l=localStorage.getItem("locale");if(l!=="en"&&l!=="pt-BR"){l="en";var a=navigator.languages||[];for(var i=0;i<a.length;i++){if(/^pt/i.test(a[i])){l="pt-BR";break}if(/^en/i.test(a[i]))break}}if(l!==d.lang){d.lang=l;d.setAttribute("data-locale-pending",l)}}catch(e){}`;
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Locale + theme for the whole app. Renders `defaultLocale` first (matches
|
|
61
|
-
* static/server HTML), then applies the stored or browser locale on mount.
|
|
62
|
-
*/
|
|
63
|
-
export function Preferences({
|
|
64
|
-
defaultLocale = "en",
|
|
65
|
-
children,
|
|
66
|
-
}: {
|
|
67
|
-
defaultLocale?: Locale;
|
|
68
|
-
children: ReactNode;
|
|
69
|
-
}) {
|
|
70
|
-
const [locale, setLocale] = useState<Locale>(defaultLocale);
|
|
71
|
-
const [theme, setTheme] = useState<Theme>("light");
|
|
72
|
-
const [ready, setReady] = useState(false);
|
|
73
|
-
useEffect(() => {
|
|
74
|
-
const stored = read("locale");
|
|
75
|
-
// eslint-disable-next-line react-hooks/set-state-in-effect -- sync from browser-only storage after hydration
|
|
76
|
-
setLocale(
|
|
77
|
-
locales.includes(stored as Locale)
|
|
78
|
-
? (stored as Locale)
|
|
79
|
-
: matchLocale(navigator.languages),
|
|
80
|
-
);
|
|
81
|
-
setTheme(read("theme") === "dark" ? "dark" : "light");
|
|
82
|
-
setReady(true);
|
|
83
|
-
}, []);
|
|
84
|
-
useEffect(() => {
|
|
85
|
-
if (!ready) return;
|
|
86
|
-
const root = document.documentElement;
|
|
87
|
-
root.lang = locale;
|
|
88
|
-
// The pre-paint script hides the page until the saved locale is rendered.
|
|
89
|
-
if (root.dataset.localePending === locale)
|
|
90
|
-
delete root.dataset.localePending;
|
|
91
|
-
}, [locale, ready]);
|
|
92
|
-
useEffect(() => {
|
|
93
|
-
document.documentElement.classList.toggle("dark", theme === "dark");
|
|
94
|
-
}, [theme]);
|
|
95
|
-
return (
|
|
96
|
-
<PreferencesContext.Provider
|
|
97
|
-
value={{
|
|
98
|
-
locale,
|
|
99
|
-
setLocale: (next) => {
|
|
100
|
-
setLocale(next);
|
|
101
|
-
write("locale", next);
|
|
102
|
-
},
|
|
103
|
-
theme,
|
|
104
|
-
setTheme: (next) => {
|
|
105
|
-
setTheme(next);
|
|
106
|
-
write("theme", next);
|
|
107
|
-
},
|
|
108
|
-
}}
|
|
109
|
-
>
|
|
110
|
-
<PreferencesReadyContext.Provider value={ready}>
|
|
111
|
-
<LocaleProvider locale={locale}>
|
|
112
|
-
{children}
|
|
113
|
-
<Toaster theme={theme} />
|
|
114
|
-
</LocaleProvider>
|
|
115
|
-
</PreferencesReadyContext.Provider>
|
|
116
|
-
</PreferencesContext.Provider>
|
|
117
|
-
);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* Owns `document.title` so it follows the active language. Leave `title` out
|
|
122
|
-
* of Next.js metadata, or Next will overwrite it after hydration.
|
|
123
|
-
*/
|
|
124
|
-
export function useDocumentTitle(title: string) {
|
|
125
|
-
const ready = useContext(PreferencesReadyContext);
|
|
126
|
-
useEffect(() => {
|
|
127
|
-
if (!ready) return;
|
|
128
|
-
document.title = title;
|
|
129
|
-
}, [title, ready]);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
export function usePreferences() {
|
|
133
|
-
const context = useContext(PreferencesContext);
|
|
134
|
-
if (!context)
|
|
135
|
-
throw new Error("usePreferences must be used within Preferences.");
|
|
136
|
-
return context;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
const localeNames: Record<Locale, string> = {
|
|
140
|
-
en: "English",
|
|
141
|
-
"pt-BR": "Português",
|
|
142
|
-
};
|
|
143
|
-
|
|
144
|
-
/** Language menu and theme toggle, e.g. for `AppShell` actions. */
|
|
145
|
-
export function PreferenceControls() {
|
|
146
|
-
const { locale, setLocale, theme, setTheme } = usePreferences();
|
|
147
|
-
const t = useUiMessages();
|
|
148
|
-
return (
|
|
149
|
-
<>
|
|
150
|
-
<DropdownMenu>
|
|
151
|
-
<DropdownMenuTrigger
|
|
152
|
-
render={<Button variant="ghost" size="sm" aria-label={t.language} />}
|
|
153
|
-
>
|
|
154
|
-
<LanguagesIcon />
|
|
155
|
-
{locale === "en" ? "EN" : "PT"}
|
|
156
|
-
</DropdownMenuTrigger>
|
|
157
|
-
<DropdownMenuContent align="end">
|
|
158
|
-
<DropdownMenuRadioGroup
|
|
159
|
-
value={locale}
|
|
160
|
-
onValueChange={(value) => setLocale(value as Locale)}
|
|
161
|
-
>
|
|
162
|
-
{locales.map((value) => (
|
|
163
|
-
<DropdownMenuRadioItem key={value} value={value}>
|
|
164
|
-
{localeNames[value]}
|
|
165
|
-
</DropdownMenuRadioItem>
|
|
166
|
-
))}
|
|
167
|
-
</DropdownMenuRadioGroup>
|
|
168
|
-
</DropdownMenuContent>
|
|
169
|
-
</DropdownMenu>
|
|
170
|
-
<Button
|
|
171
|
-
variant="ghost"
|
|
172
|
-
size="icon-sm"
|
|
173
|
-
aria-label={t.toggleTheme}
|
|
174
|
-
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
|
175
|
-
>
|
|
176
|
-
{theme === "dark" ? <SunIcon /> : <MoonIcon />}
|
|
177
|
-
</Button>
|
|
178
|
-
</>
|
|
179
|
-
);
|
|
180
|
-
}
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
useContext,
|
|
6
|
+
useEffect,
|
|
7
|
+
useState,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
} from "react";
|
|
10
|
+
import { LanguagesIcon, MoonIcon, SunIcon } from "lucide-react";
|
|
11
|
+
import {
|
|
12
|
+
LocaleProvider,
|
|
13
|
+
locales,
|
|
14
|
+
matchLocale,
|
|
15
|
+
useUiMessages,
|
|
16
|
+
type Locale,
|
|
17
|
+
} from "../lib/i18n";
|
|
18
|
+
import { Button } from "../components/ui/button";
|
|
19
|
+
import { Toaster } from "../components/ui/sonner";
|
|
20
|
+
import {
|
|
21
|
+
DropdownMenu,
|
|
22
|
+
DropdownMenuContent,
|
|
23
|
+
DropdownMenuRadioGroup,
|
|
24
|
+
DropdownMenuRadioItem,
|
|
25
|
+
DropdownMenuTrigger,
|
|
26
|
+
} from "../components/ui/dropdown-menu";
|
|
27
|
+
|
|
28
|
+
type Theme = "light" | "dark";
|
|
29
|
+
const PreferencesContext = createContext<{
|
|
30
|
+
locale: Locale;
|
|
31
|
+
setLocale: (locale: Locale) => void;
|
|
32
|
+
theme: Theme;
|
|
33
|
+
setTheme: (theme: Theme) => void;
|
|
34
|
+
} | null>(null);
|
|
35
|
+
const PreferencesReadyContext = createContext(true);
|
|
36
|
+
|
|
37
|
+
function read(key: string) {
|
|
38
|
+
try {
|
|
39
|
+
return localStorage.getItem(key);
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function write(key: string, value: string) {
|
|
45
|
+
try {
|
|
46
|
+
localStorage.setItem(key, value);
|
|
47
|
+
} catch {
|
|
48
|
+
// Storage can be unavailable (private mode); preferences just won't persist.
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Mirrors matchLocale; keep both in sync.
|
|
54
|
+
* Inline this in `<head>` (e.g. `<script dangerouslySetInnerHTML>`) so the
|
|
55
|
+
* stored theme applies before paint.
|
|
56
|
+
*/
|
|
57
|
+
export const preferencesScript = `try{var d=document.documentElement;if(localStorage.getItem("theme")==="dark")d.classList.add("dark");var l=localStorage.getItem("locale");if(l!=="en"&&l!=="pt-BR"){l="en";var a=navigator.languages||[];for(var i=0;i<a.length;i++){if(/^pt/i.test(a[i])){l="pt-BR";break}if(/^en/i.test(a[i]))break}}if(l!==d.lang){d.lang=l;d.setAttribute("data-locale-pending",l)}}catch(e){}`;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Locale + theme for the whole app. Renders `defaultLocale` first (matches
|
|
61
|
+
* static/server HTML), then applies the stored or browser locale on mount.
|
|
62
|
+
*/
|
|
63
|
+
export function Preferences({
|
|
64
|
+
defaultLocale = "en",
|
|
65
|
+
children,
|
|
66
|
+
}: {
|
|
67
|
+
defaultLocale?: Locale;
|
|
68
|
+
children: ReactNode;
|
|
69
|
+
}) {
|
|
70
|
+
const [locale, setLocale] = useState<Locale>(defaultLocale);
|
|
71
|
+
const [theme, setTheme] = useState<Theme>("light");
|
|
72
|
+
const [ready, setReady] = useState(false);
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
const stored = read("locale");
|
|
75
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect -- sync from browser-only storage after hydration
|
|
76
|
+
setLocale(
|
|
77
|
+
locales.includes(stored as Locale)
|
|
78
|
+
? (stored as Locale)
|
|
79
|
+
: matchLocale(navigator.languages),
|
|
80
|
+
);
|
|
81
|
+
setTheme(read("theme") === "dark" ? "dark" : "light");
|
|
82
|
+
setReady(true);
|
|
83
|
+
}, []);
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (!ready) return;
|
|
86
|
+
const root = document.documentElement;
|
|
87
|
+
root.lang = locale;
|
|
88
|
+
// The pre-paint script hides the page until the saved locale is rendered.
|
|
89
|
+
if (root.dataset.localePending === locale)
|
|
90
|
+
delete root.dataset.localePending;
|
|
91
|
+
}, [locale, ready]);
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
document.documentElement.classList.toggle("dark", theme === "dark");
|
|
94
|
+
}, [theme]);
|
|
95
|
+
return (
|
|
96
|
+
<PreferencesContext.Provider
|
|
97
|
+
value={{
|
|
98
|
+
locale,
|
|
99
|
+
setLocale: (next) => {
|
|
100
|
+
setLocale(next);
|
|
101
|
+
write("locale", next);
|
|
102
|
+
},
|
|
103
|
+
theme,
|
|
104
|
+
setTheme: (next) => {
|
|
105
|
+
setTheme(next);
|
|
106
|
+
write("theme", next);
|
|
107
|
+
},
|
|
108
|
+
}}
|
|
109
|
+
>
|
|
110
|
+
<PreferencesReadyContext.Provider value={ready}>
|
|
111
|
+
<LocaleProvider locale={locale}>
|
|
112
|
+
{children}
|
|
113
|
+
<Toaster theme={theme} />
|
|
114
|
+
</LocaleProvider>
|
|
115
|
+
</PreferencesReadyContext.Provider>
|
|
116
|
+
</PreferencesContext.Provider>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Owns `document.title` so it follows the active language. Leave `title` out
|
|
122
|
+
* of Next.js metadata, or Next will overwrite it after hydration.
|
|
123
|
+
*/
|
|
124
|
+
export function useDocumentTitle(title: string) {
|
|
125
|
+
const ready = useContext(PreferencesReadyContext);
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
if (!ready) return;
|
|
128
|
+
document.title = title;
|
|
129
|
+
}, [title, ready]);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function usePreferences() {
|
|
133
|
+
const context = useContext(PreferencesContext);
|
|
134
|
+
if (!context)
|
|
135
|
+
throw new Error("usePreferences must be used within Preferences.");
|
|
136
|
+
return context;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const localeNames: Record<Locale, string> = {
|
|
140
|
+
en: "English",
|
|
141
|
+
"pt-BR": "Português",
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/** Language menu and theme toggle, e.g. for `AppShell` actions. */
|
|
145
|
+
export function PreferenceControls() {
|
|
146
|
+
const { locale, setLocale, theme, setTheme } = usePreferences();
|
|
147
|
+
const t = useUiMessages();
|
|
148
|
+
return (
|
|
149
|
+
<>
|
|
150
|
+
<DropdownMenu>
|
|
151
|
+
<DropdownMenuTrigger
|
|
152
|
+
render={<Button variant="ghost" size="sm" aria-label={t.language} />}
|
|
153
|
+
>
|
|
154
|
+
<LanguagesIcon />
|
|
155
|
+
{locale === "en" ? "EN" : "PT"}
|
|
156
|
+
</DropdownMenuTrigger>
|
|
157
|
+
<DropdownMenuContent align="end">
|
|
158
|
+
<DropdownMenuRadioGroup
|
|
159
|
+
value={locale}
|
|
160
|
+
onValueChange={(value) => setLocale(value as Locale)}
|
|
161
|
+
>
|
|
162
|
+
{locales.map((value) => (
|
|
163
|
+
<DropdownMenuRadioItem key={value} value={value}>
|
|
164
|
+
{localeNames[value]}
|
|
165
|
+
</DropdownMenuRadioItem>
|
|
166
|
+
))}
|
|
167
|
+
</DropdownMenuRadioGroup>
|
|
168
|
+
</DropdownMenuContent>
|
|
169
|
+
</DropdownMenu>
|
|
170
|
+
<Button
|
|
171
|
+
variant="ghost"
|
|
172
|
+
size="icon-sm"
|
|
173
|
+
aria-label={t.toggleTheme}
|
|
174
|
+
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
|
175
|
+
>
|
|
176
|
+
{theme === "dark" ? <SunIcon /> : <MoonIcon />}
|
|
177
|
+
</Button>
|
|
178
|
+
</>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
@@ -1,129 +1,140 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { useId, type ReactNode } from "react";
|
|
4
|
-
import { Button } from "../components/ui/button";
|
|
5
|
-
import { Input } from "../components/ui/input";
|
|
6
|
-
import { Field, FieldGroup, FieldLabel } from "../components/ui/field";
|
|
7
|
-
import { Alert, AlertDescription } from "../components/ui/alert";
|
|
8
|
-
import {
|
|
9
|
-
Card,
|
|
10
|
-
CardContent,
|
|
11
|
-
CardDescription,
|
|
12
|
-
CardHeader,
|
|
13
|
-
CardTitle,
|
|
14
|
-
} from "../components/ui/card";
|
|
15
|
-
import { defineMessages, useMessages } from "../lib/i18n";
|
|
16
|
-
|
|
17
|
-
const messages = defineMessages({
|
|
18
|
-
en: {
|
|
19
|
-
title: "Welcome back",
|
|
20
|
-
email: "Email",
|
|
21
|
-
password: "Password",
|
|
22
|
-
submit: "Log in",
|
|
23
|
-
pending: "Logging in…",
|
|
24
|
-
},
|
|
25
|
-
"pt-BR": {
|
|
26
|
-
title: "Bem-vindo de volta",
|
|
27
|
-
email: "E-mail",
|
|
28
|
-
password: "Senha",
|
|
29
|
-
submit: "Entrar",
|
|
30
|
-
pending: "Entrando…",
|
|
31
|
-
},
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
export interface LoginValues {
|
|
35
|
-
email: string;
|
|
36
|
-
password: string;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
<
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
}
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useId, type ReactNode } from "react";
|
|
4
|
+
import { Button } from "../components/ui/button";
|
|
5
|
+
import { Input } from "../components/ui/input";
|
|
6
|
+
import { Field, FieldGroup, FieldLabel } from "../components/ui/field";
|
|
7
|
+
import { Alert, AlertDescription } from "../components/ui/alert";
|
|
8
|
+
import {
|
|
9
|
+
Card,
|
|
10
|
+
CardContent,
|
|
11
|
+
CardDescription,
|
|
12
|
+
CardHeader,
|
|
13
|
+
CardTitle,
|
|
14
|
+
} from "../components/ui/card";
|
|
15
|
+
import { defineMessages, useMessages } from "../lib/i18n";
|
|
16
|
+
|
|
17
|
+
const messages = defineMessages({
|
|
18
|
+
en: {
|
|
19
|
+
title: "Welcome back",
|
|
20
|
+
email: "Email",
|
|
21
|
+
password: "Password",
|
|
22
|
+
submit: "Log in",
|
|
23
|
+
pending: "Logging in…",
|
|
24
|
+
},
|
|
25
|
+
"pt-BR": {
|
|
26
|
+
title: "Bem-vindo de volta",
|
|
27
|
+
email: "E-mail",
|
|
28
|
+
password: "Senha",
|
|
29
|
+
submit: "Entrar",
|
|
30
|
+
pending: "Entrando…",
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export interface LoginValues {
|
|
35
|
+
email: string;
|
|
36
|
+
password: string;
|
|
37
|
+
}
|
|
38
|
+
interface LoginFormOptions {
|
|
39
|
+
pending?: boolean;
|
|
40
|
+
error?: string;
|
|
41
|
+
title?: string;
|
|
42
|
+
description?: string;
|
|
43
|
+
submitLabel?: string;
|
|
44
|
+
/** Shown next to the password label, e.g. a "Forgot password?" link. */
|
|
45
|
+
recovery?: ReactNode;
|
|
46
|
+
/** Shown above the fields, e.g. OAuth buttons. */
|
|
47
|
+
alternative?: ReactNode;
|
|
48
|
+
footer?: ReactNode;
|
|
49
|
+
}
|
|
50
|
+
export type LoginFormProps = LoginFormOptions &
|
|
51
|
+
(
|
|
52
|
+
| { mode?: "credentials"; onSubmit: (values: LoginValues) => void }
|
|
53
|
+
| { mode: "providers"; onSubmit?: never; alternative: ReactNode }
|
|
54
|
+
);
|
|
55
|
+
export function LoginForm({
|
|
56
|
+
mode = "credentials",
|
|
57
|
+
onSubmit,
|
|
58
|
+
pending = false,
|
|
59
|
+
error,
|
|
60
|
+
title,
|
|
61
|
+
description,
|
|
62
|
+
submitLabel,
|
|
63
|
+
recovery,
|
|
64
|
+
alternative,
|
|
65
|
+
footer,
|
|
66
|
+
}: LoginFormProps) {
|
|
67
|
+
const id = useId();
|
|
68
|
+
const t = useMessages(messages);
|
|
69
|
+
return (
|
|
70
|
+
<Card>
|
|
71
|
+
<CardHeader className="text-center">
|
|
72
|
+
<CardTitle className="text-xl">{title ?? t.title}</CardTitle>
|
|
73
|
+
{description ? <CardDescription>{description}</CardDescription> : null}
|
|
74
|
+
</CardHeader>
|
|
75
|
+
<CardContent>
|
|
76
|
+
<form
|
|
77
|
+
aria-busy={pending}
|
|
78
|
+
onSubmit={(event) => {
|
|
79
|
+
event.preventDefault();
|
|
80
|
+
const data = new FormData(event.currentTarget);
|
|
81
|
+
onSubmit?.({
|
|
82
|
+
email: String(data.get("email") ?? ""),
|
|
83
|
+
password: String(data.get("password") ?? ""),
|
|
84
|
+
});
|
|
85
|
+
}}
|
|
86
|
+
>
|
|
87
|
+
<FieldGroup>
|
|
88
|
+
{alternative}
|
|
89
|
+
{error ? (
|
|
90
|
+
<Alert variant="destructive">
|
|
91
|
+
<AlertDescription>{error}</AlertDescription>
|
|
92
|
+
</Alert>
|
|
93
|
+
) : null}
|
|
94
|
+
{mode === "credentials" ? (
|
|
95
|
+
<>
|
|
96
|
+
<Field>
|
|
97
|
+
<FieldLabel htmlFor={`${id}-email`}>{t.email}</FieldLabel>
|
|
98
|
+
<Input
|
|
99
|
+
id={`${id}-email`}
|
|
100
|
+
name="email"
|
|
101
|
+
type="email"
|
|
102
|
+
autoComplete="username"
|
|
103
|
+
required
|
|
104
|
+
disabled={pending}
|
|
105
|
+
/>
|
|
106
|
+
</Field>
|
|
107
|
+
<Field>
|
|
108
|
+
<div className="flex items-center">
|
|
109
|
+
<FieldLabel htmlFor={`${id}-password`}>
|
|
110
|
+
{t.password}
|
|
111
|
+
</FieldLabel>
|
|
112
|
+
{recovery ? (
|
|
113
|
+
<div className="ml-auto text-sm">{recovery}</div>
|
|
114
|
+
) : null}
|
|
115
|
+
</div>
|
|
116
|
+
<Input
|
|
117
|
+
id={`${id}-password`}
|
|
118
|
+
name="password"
|
|
119
|
+
type="password"
|
|
120
|
+
autoComplete="current-password"
|
|
121
|
+
required
|
|
122
|
+
disabled={pending}
|
|
123
|
+
/>
|
|
124
|
+
</Field>
|
|
125
|
+
<Button type="submit" disabled={pending}>
|
|
126
|
+
{pending ? t.pending : (submitLabel ?? t.submit)}
|
|
127
|
+
</Button>
|
|
128
|
+
</>
|
|
129
|
+
) : null}
|
|
130
|
+
{footer ? (
|
|
131
|
+
<div className="text-center text-sm text-muted-foreground">
|
|
132
|
+
{footer}
|
|
133
|
+
</div>
|
|
134
|
+
) : null}
|
|
135
|
+
</FieldGroup>
|
|
136
|
+
</form>
|
|
137
|
+
</CardContent>
|
|
138
|
+
</Card>
|
|
139
|
+
);
|
|
140
|
+
}
|