774 lines
24 KiB
TypeScript
774 lines
24 KiB
TypeScript
import { useState, type FormEvent, type ReactNode } from 'react';
|
|
import { Navigate, useNavigate } from 'react-router-dom';
|
|
import {
|
|
AlertCircle,
|
|
ArrowRight,
|
|
Eye,
|
|
EyeOff,
|
|
Loader2,
|
|
Lock,
|
|
Mail,
|
|
ShieldCheck,
|
|
Sparkles,
|
|
} from 'lucide-react';
|
|
import { useAuth } from '@/auth/AuthContext';
|
|
import { HOME_ROUTE } from '@/auth/roles';
|
|
import {
|
|
MIN_PASSWORD_LENGTH,
|
|
PasswordSetupRequiredError,
|
|
setInitialPassword,
|
|
} from '@/auth/session';
|
|
|
|
/**
|
|
* Sign-in — KROW's full-bleed auth archetype.
|
|
*
|
|
* A single 1024px card at 28px radius over the ambient canvas, split two-up:
|
|
* a tinted brand panel on the left carrying an inset product image, and the
|
|
* form on the right at a 448px measure. Rhythm is 24px between regions, 16px
|
|
* between fields, 6px inside a field.
|
|
*
|
|
* One form for all three roles. The old console had three separate login paths
|
|
* because the backend compares a client-declared roleid against the stored one;
|
|
* here the role is read off the login response, so a person signs in once and
|
|
* lands where their account says they belong.
|
|
*/
|
|
export function LoginPage() {
|
|
const { user, signIn } = useAuth();
|
|
const navigate = useNavigate();
|
|
|
|
const [email, setEmail] = useState('');
|
|
const [password, setPassword] = useState('');
|
|
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [isBusy, setIsBusy] = useState(false);
|
|
|
|
/**
|
|
* The userid `applogin` returned for an account with no password, or null.
|
|
*
|
|
* Non-null flips this page into its second state. It is deliberately not a
|
|
* route: the userid only exists because a sign-in attempt just produced it,
|
|
* and a `/set-password` URL that could be opened cold would be a way to set
|
|
* any account's password from nothing.
|
|
*/
|
|
const [setupUserid, setSetupUserid] = useState<number | null>(null);
|
|
const [newPassword, setNewPassword] = useState('');
|
|
const [confirmPassword, setConfirmPassword] = useState('');
|
|
|
|
if (user) return <Navigate to={HOME_ROUTE[user.role]} replace />;
|
|
|
|
async function handleSubmit(event: FormEvent) {
|
|
event.preventDefault();
|
|
setError(null);
|
|
setIsBusy(true);
|
|
try {
|
|
const session = await signIn(email, password);
|
|
navigate(HOME_ROUTE[session.role], { replace: true });
|
|
} catch (cause) {
|
|
// Not a failure — the first step. The account exists and has no password,
|
|
// and the backend handed back the userid to set one against.
|
|
if (cause instanceof PasswordSetupRequiredError) {
|
|
setSetupUserid(cause.userid);
|
|
setNewPassword('');
|
|
setConfirmPassword('');
|
|
} else {
|
|
setError(cause instanceof Error ? cause.message : 'Sign-in failed');
|
|
}
|
|
} finally {
|
|
setIsBusy(false);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set the password, then sign in with it.
|
|
*
|
|
* Signing in afterwards rather than sending the person back to the form: they
|
|
* have just typed the password twice, and `applogin` is the only proof the
|
|
* write actually took.
|
|
*/
|
|
async function handleSetup(event: FormEvent) {
|
|
event.preventDefault();
|
|
if (setupUserid === null) return;
|
|
setError(null);
|
|
|
|
if (newPassword !== confirmPassword) {
|
|
setError('Those two passwords do not match.');
|
|
return;
|
|
}
|
|
|
|
setIsBusy(true);
|
|
try {
|
|
await setInitialPassword(setupUserid, newPassword);
|
|
const session = await signIn(email, newPassword);
|
|
navigate(HOME_ROUTE[session.role], { replace: true });
|
|
} catch (cause) {
|
|
setError(cause instanceof Error ? cause.message : 'Could not set the password');
|
|
} finally {
|
|
setIsBusy(false);
|
|
}
|
|
}
|
|
|
|
const canSubmit = email.trim() !== '' && password !== '' && !isBusy;
|
|
const canSetup =
|
|
newPassword.length >= MIN_PASSWORD_LENGTH && confirmPassword !== '' && !isBusy;
|
|
|
|
return (
|
|
<div
|
|
className="login-shell"
|
|
style={{ minHeight: '100dvh', display: 'grid', placeItems: 'center' }}
|
|
>
|
|
<div
|
|
className="login-split"
|
|
style={{
|
|
width: '100%',
|
|
maxWidth: 1024,
|
|
background: 'var(--color-surface)',
|
|
border: '1px solid var(--color-line)',
|
|
borderRadius: 28,
|
|
boxShadow: '0 24px 56px -12px rgb(15 23 42 / .10), 0 8px 20px -8px rgb(15 23 42 / .05)',
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
<BrandPanel />
|
|
{setupUserid === null ? (
|
|
<FormPanel
|
|
email={email}
|
|
password={password}
|
|
isPasswordVisible={isPasswordVisible}
|
|
error={error}
|
|
isBusy={isBusy}
|
|
canSubmit={canSubmit}
|
|
onEmail={setEmail}
|
|
onPassword={setPassword}
|
|
onToggleVisible={() => setIsPasswordVisible((visible) => !visible)}
|
|
onSubmit={handleSubmit}
|
|
/>
|
|
) : (
|
|
<SetupPanel
|
|
email={email}
|
|
newPassword={newPassword}
|
|
confirmPassword={confirmPassword}
|
|
isPasswordVisible={isPasswordVisible}
|
|
error={error}
|
|
isBusy={isBusy}
|
|
canSubmit={canSetup}
|
|
onNewPassword={setNewPassword}
|
|
onConfirmPassword={setConfirmPassword}
|
|
onToggleVisible={() => setIsPasswordVisible((visible) => !visible)}
|
|
onSubmit={handleSetup}
|
|
onBack={() => {
|
|
setSetupUserid(null);
|
|
setError(null);
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ────────────────────────────────────────────────────────────────────────────
|
|
Left — the brand panel
|
|
──────────────────────────────────────────────────────────────────────────── */
|
|
|
|
/**
|
|
* A tint, not a slab.
|
|
*
|
|
* The reference panel is a soft wash rather than a saturated block, so the card
|
|
* reads as one surface with a warm side rather than two glued halves. The
|
|
* gradient runs from the ambient canvas's violet stop into its warm stop, which
|
|
* is what ties the card to the page behind it.
|
|
*/
|
|
function BrandPanel() {
|
|
return (
|
|
<div
|
|
className="login-brand"
|
|
style={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
justifyContent: 'space-between',
|
|
background: 'linear-gradient(155deg, #F4EEF8 0%, #F7F3F9 45%, #FBF8F2 100%)',
|
|
}}
|
|
>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
<img
|
|
src="/logo-wordmark.png"
|
|
alt="Nearle"
|
|
style={{ height: 28, width: 'auto', display: 'block' }}
|
|
/>
|
|
<span
|
|
style={{
|
|
background: 'var(--color-brand)',
|
|
color: '#fff',
|
|
borderRadius: 999,
|
|
padding: '3px 10px',
|
|
fontSize: 10.5,
|
|
fontWeight: 700,
|
|
letterSpacing: '0.1em',
|
|
}}
|
|
>
|
|
CONSOLE
|
|
</span>
|
|
</div>
|
|
|
|
{/* The inset image card: white, 8px inset, hairline border, soft shadow.
|
|
Hidden on a phone, where it would push the form below the fold. */}
|
|
<div
|
|
className="login-hero"
|
|
style={{
|
|
alignSelf: 'center',
|
|
maxWidth: 320,
|
|
width: '100%',
|
|
background: '#fff',
|
|
border: '1px solid var(--color-line)',
|
|
borderRadius: 20,
|
|
padding: 8,
|
|
boxShadow: '0 4px 16px -2px rgb(16 24 32 / .06), 0 2px 6px -3px rgb(16 24 32 / .04)',
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
borderRadius: 14,
|
|
overflow: 'hidden',
|
|
background: 'var(--color-brand)',
|
|
display: 'grid',
|
|
placeItems: 'center',
|
|
aspectRatio: '1',
|
|
}}
|
|
>
|
|
<img
|
|
src="/logo-512.png"
|
|
alt=""
|
|
width={280}
|
|
height={280}
|
|
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 7,
|
|
color: 'var(--color-brand)',
|
|
fontSize: 11,
|
|
fontWeight: 700,
|
|
letterSpacing: '0.11em',
|
|
textTransform: 'uppercase',
|
|
marginBottom: 10,
|
|
}}
|
|
>
|
|
<Sparkles size={13} />
|
|
Retail operations platform
|
|
</div>
|
|
<h2
|
|
style={{
|
|
margin: 0,
|
|
fontFamily: 'var(--font-display)',
|
|
fontSize: 24,
|
|
lineHeight: 1.28,
|
|
fontWeight: 600,
|
|
letterSpacing: '-0.015em',
|
|
color: 'var(--color-ink-1)',
|
|
}}
|
|
>
|
|
Every store, every till, one console.
|
|
</h2>
|
|
<p
|
|
style={{
|
|
margin: '10px 0 0',
|
|
fontSize: 13.5,
|
|
lineHeight: 1.65,
|
|
color: 'var(--color-ink-3)',
|
|
maxWidth: 380,
|
|
}}
|
|
>
|
|
Onboard tenants and branches, publish the catalogue, and watch online orders and counter
|
|
sales land side by side.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ────────────────────────────────────────────────────────────────────────────
|
|
Right — the form
|
|
──────────────────────────────────────────────────────────────────────────── */
|
|
|
|
interface FormPanelProps {
|
|
email: string;
|
|
password: string;
|
|
isPasswordVisible: boolean;
|
|
error: string | null;
|
|
isBusy: boolean;
|
|
canSubmit: boolean;
|
|
onEmail: (value: string) => void;
|
|
onPassword: (value: string) => void;
|
|
onToggleVisible: () => void;
|
|
onSubmit: (event: FormEvent) => void;
|
|
}
|
|
|
|
function FormPanel({
|
|
email,
|
|
password,
|
|
isPasswordVisible,
|
|
error,
|
|
isBusy,
|
|
canSubmit,
|
|
onEmail,
|
|
onPassword,
|
|
onToggleVisible,
|
|
onSubmit,
|
|
}: FormPanelProps) {
|
|
return (
|
|
<div className="login-form" style={{ display: 'grid', placeItems: 'center' }}>
|
|
<div style={{ width: '100%', maxWidth: 448, display: 'flex', flexDirection: 'column', gap: 24 }}>
|
|
<div>
|
|
<h1
|
|
style={{
|
|
margin: 0,
|
|
fontFamily: 'var(--font-display)',
|
|
fontSize: 26,
|
|
lineHeight: 1.2,
|
|
fontWeight: 700,
|
|
letterSpacing: '-0.02em',
|
|
color: 'var(--color-ink-1)',
|
|
}}
|
|
>
|
|
Welcome back
|
|
</h1>
|
|
<p style={{ margin: '6px 0 0', fontSize: 13.5, lineHeight: 1.6, color: 'var(--color-ink-3)' }}>
|
|
Sign in to manage tenants, branches, catalogue and counter sales.
|
|
</p>
|
|
</div>
|
|
|
|
<form onSubmit={onSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
|
<Field label="Work email" htmlFor="email" icon={<Mail size={15} />}>
|
|
<input
|
|
id="email"
|
|
type="email"
|
|
value={email}
|
|
onChange={(event) => onEmail(event.target.value)}
|
|
placeholder="you@company.com"
|
|
autoComplete="username"
|
|
autoFocus
|
|
required
|
|
style={inputStyle}
|
|
/>
|
|
</Field>
|
|
|
|
<Field
|
|
label="Password"
|
|
htmlFor="password"
|
|
icon={<Lock size={15} />}
|
|
action={
|
|
<button
|
|
type="button"
|
|
onClick={onToggleVisible}
|
|
aria-label={isPasswordVisible ? 'Hide password' : 'Show password'}
|
|
style={eyeButtonStyle}
|
|
>
|
|
{isPasswordVisible ? <EyeOff size={15} /> : <Eye size={15} />}
|
|
</button>
|
|
}
|
|
>
|
|
<input
|
|
id="password"
|
|
type={isPasswordVisible ? 'text' : 'password'}
|
|
value={password}
|
|
onChange={(event) => onPassword(event.target.value)}
|
|
placeholder="••••••••"
|
|
autoComplete="current-password"
|
|
required
|
|
style={{ ...inputStyle, paddingRight: 40 }}
|
|
/>
|
|
</Field>
|
|
|
|
{/* Neither "Keep me signed in" nor "Forgot password?" is here any more.
|
|
|
|
The checkbox was initialised true, toggled, rendered — and never
|
|
read: `handleSubmit` and `signIn(email, password)` never received
|
|
it, so ticking or clearing it changed nothing about how long the
|
|
session lasted. The link had no handler at all, and there is no
|
|
password-reset endpoint in Fiesta to give it one. Both promised
|
|
something the system does not do. */}
|
|
|
|
<ErrorNote message={error} />
|
|
|
|
<SubmitButton
|
|
canSubmit={canSubmit}
|
|
isBusy={isBusy}
|
|
busyLabel="Signing in…"
|
|
label="Continue"
|
|
/>
|
|
</form>
|
|
|
|
<p
|
|
style={{
|
|
margin: 0,
|
|
fontSize: 12,
|
|
lineHeight: 1.6,
|
|
color: 'var(--color-ink-4)',
|
|
textAlign: 'center',
|
|
}}
|
|
>
|
|
Use the account your administrator set up for you. Your role decides which workspace
|
|
opens — you do not pick one.
|
|
</p>
|
|
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ────────────────────────────────────────────────────────────────────────────
|
|
Right — first sign-in, setting the password
|
|
──────────────────────────────────────────────────────────────────────────── */
|
|
|
|
interface SetupPanelProps {
|
|
email: string;
|
|
newPassword: string;
|
|
confirmPassword: string;
|
|
isPasswordVisible: boolean;
|
|
error: string | null;
|
|
isBusy: boolean;
|
|
canSubmit: boolean;
|
|
onNewPassword: (value: string) => void;
|
|
onConfirmPassword: (value: string) => void;
|
|
onToggleVisible: () => void;
|
|
onSubmit: (event: FormEvent) => void;
|
|
onBack: () => void;
|
|
}
|
|
|
|
/**
|
|
* The second state of this page, not a second page.
|
|
*
|
|
* An account created by `createtenantuser` or `createtenantlocation` is spawned
|
|
* with an empty password, so its owner's first sign-in cannot succeed and there
|
|
* is no reset email to fall back on. Before this existed the page detected the
|
|
* condition and then told the person to go and find an administrator — for an
|
|
* account that was working as designed.
|
|
*/
|
|
function SetupPanel({
|
|
email,
|
|
newPassword,
|
|
confirmPassword,
|
|
isPasswordVisible,
|
|
error,
|
|
isBusy,
|
|
canSubmit,
|
|
onNewPassword,
|
|
onConfirmPassword,
|
|
onToggleVisible,
|
|
onSubmit,
|
|
onBack,
|
|
}: SetupPanelProps) {
|
|
const isTooShort = newPassword !== '' && newPassword.length < MIN_PASSWORD_LENGTH;
|
|
const isMismatched = confirmPassword !== '' && newPassword !== confirmPassword;
|
|
|
|
return (
|
|
<div className="login-form" style={{ display: 'grid', placeItems: 'center' }}>
|
|
<div style={{ width: '100%', maxWidth: 448, display: 'flex', flexDirection: 'column', gap: 24 }}>
|
|
<div>
|
|
<div
|
|
style={{
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
gap: 7,
|
|
marginBottom: 12,
|
|
padding: '4px 10px',
|
|
borderRadius: 999,
|
|
background: 'var(--color-surface-subtle)',
|
|
border: '1px solid var(--color-line)',
|
|
fontSize: 11,
|
|
fontWeight: 700,
|
|
letterSpacing: '0.09em',
|
|
textTransform: 'uppercase',
|
|
color: 'var(--color-brand)',
|
|
}}
|
|
>
|
|
<ShieldCheck size={13} />
|
|
First sign-in
|
|
</div>
|
|
<h1
|
|
style={{
|
|
margin: 0,
|
|
fontFamily: 'var(--font-display)',
|
|
fontSize: 26,
|
|
lineHeight: 1.2,
|
|
fontWeight: 700,
|
|
letterSpacing: '-0.02em',
|
|
color: 'var(--color-ink-1)',
|
|
}}
|
|
>
|
|
Choose a password
|
|
</h1>
|
|
<p style={{ margin: '6px 0 0', fontSize: 13.5, lineHeight: 1.6, color: 'var(--color-ink-3)' }}>
|
|
{email} has no password yet. Set one now and we will sign you straight in.
|
|
</p>
|
|
</div>
|
|
|
|
<form onSubmit={onSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
|
<Field
|
|
label="New password"
|
|
htmlFor="new-password"
|
|
icon={<Lock size={15} />}
|
|
action={
|
|
<button
|
|
type="button"
|
|
onClick={onToggleVisible}
|
|
aria-label={isPasswordVisible ? 'Hide password' : 'Show password'}
|
|
style={eyeButtonStyle}
|
|
>
|
|
{isPasswordVisible ? <EyeOff size={15} /> : <Eye size={15} />}
|
|
</button>
|
|
}
|
|
>
|
|
<input
|
|
id="new-password"
|
|
type={isPasswordVisible ? 'text' : 'password'}
|
|
value={newPassword}
|
|
onChange={(event) => onNewPassword(event.target.value)}
|
|
placeholder={`At least ${MIN_PASSWORD_LENGTH} characters`}
|
|
autoComplete="new-password"
|
|
autoFocus
|
|
required
|
|
aria-invalid={isTooShort}
|
|
style={{ ...inputStyle, paddingRight: 40 }}
|
|
/>
|
|
</Field>
|
|
|
|
<Field label="Confirm password" htmlFor="confirm-password" icon={<Lock size={15} />}>
|
|
<input
|
|
id="confirm-password"
|
|
type={isPasswordVisible ? 'text' : 'password'}
|
|
value={confirmPassword}
|
|
onChange={(event) => onConfirmPassword(event.target.value)}
|
|
placeholder="Type it again"
|
|
autoComplete="new-password"
|
|
required
|
|
aria-invalid={isMismatched}
|
|
style={{
|
|
...inputStyle,
|
|
borderColor: isMismatched ? 'rgba(214,69,69,.45)' : 'var(--color-line)',
|
|
}}
|
|
/>
|
|
</Field>
|
|
|
|
<Hint>
|
|
{isTooShort
|
|
? `A few more characters — ${MIN_PASSWORD_LENGTH} is the minimum.`
|
|
: isMismatched
|
|
? 'Those two do not match yet.'
|
|
: 'Passwords on this backend are stored as typed. Do not reuse one from elsewhere.'}
|
|
</Hint>
|
|
|
|
<ErrorNote message={error} />
|
|
|
|
<SubmitButton
|
|
canSubmit={canSubmit}
|
|
isBusy={isBusy}
|
|
busyLabel="Setting it…"
|
|
label="Set password and sign in"
|
|
/>
|
|
</form>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={onBack}
|
|
style={{
|
|
alignSelf: 'center',
|
|
border: 0,
|
|
background: 'transparent',
|
|
padding: 0,
|
|
fontSize: 12.5,
|
|
fontFamily: 'inherit',
|
|
color: 'var(--color-ink-3)',
|
|
cursor: 'pointer',
|
|
textDecoration: 'underline',
|
|
textUnderlineOffset: 3,
|
|
}}
|
|
>
|
|
Use a different account
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** A quiet line under the fields — advisory, never an error. */
|
|
function Hint({ children }: { children: ReactNode }) {
|
|
return (
|
|
<p style={{ margin: 0, fontSize: 12.5, lineHeight: 1.55, color: 'var(--color-ink-4)' }}>
|
|
{children}
|
|
</p>
|
|
);
|
|
}
|
|
|
|
/* ────────────────────────────────────────────────────────────────────────────
|
|
Shared form furniture
|
|
──────────────────────────────────────────────────────────────────────────── */
|
|
|
|
function ErrorNote({ message }: { message: string | null }) {
|
|
if (!message) return null;
|
|
return (
|
|
<div
|
|
role="alert"
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'flex-start',
|
|
gap: 9,
|
|
padding: '11px 13px',
|
|
borderRadius: 12,
|
|
background: 'var(--color-error-muted, #FCEEEE)',
|
|
border: '1px solid rgba(214,69,69,.22)',
|
|
color: '#B03434',
|
|
fontSize: 13,
|
|
lineHeight: 1.55,
|
|
}}
|
|
>
|
|
<AlertCircle size={16} style={{ flex: 'none', marginTop: 1 }} />
|
|
{message}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SubmitButton({
|
|
canSubmit,
|
|
isBusy,
|
|
label,
|
|
busyLabel,
|
|
}: {
|
|
canSubmit: boolean;
|
|
isBusy: boolean;
|
|
label: string;
|
|
busyLabel: string;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="submit"
|
|
disabled={!canSubmit}
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
gap: 9,
|
|
height: 46,
|
|
width: '100%',
|
|
borderRadius: 14,
|
|
border: 0,
|
|
background: canSubmit
|
|
? 'var(--color-brand)'
|
|
: 'color-mix(in oklab, var(--color-brand) 45%, #fff)',
|
|
color: '#fff',
|
|
fontSize: 14.5,
|
|
fontWeight: 600,
|
|
cursor: canSubmit ? 'pointer' : 'default',
|
|
boxShadow: canSubmit ? '0 10px 30px -6px rgb(102 37 130 / .28)' : 'none',
|
|
transition: 'background .2s cubic-bezier(.16,1,.3,1), box-shadow .2s',
|
|
}}
|
|
>
|
|
{isBusy ? (
|
|
<>
|
|
<Loader2 size={16} style={{ animation: 'spin 1s linear infinite' }} />
|
|
{busyLabel}
|
|
</>
|
|
) : (
|
|
<>
|
|
{label}
|
|
<ArrowRight size={16} />
|
|
</>
|
|
)}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
const eyeButtonStyle: React.CSSProperties = {
|
|
position: 'absolute',
|
|
right: 8,
|
|
top: '50%',
|
|
transform: 'translateY(-50%)',
|
|
width: 28,
|
|
height: 28,
|
|
display: 'grid',
|
|
placeItems: 'center',
|
|
borderRadius: 8,
|
|
border: 0,
|
|
background: 'transparent',
|
|
color: 'var(--color-ink-4)',
|
|
cursor: 'pointer',
|
|
};
|
|
|
|
/* ────────────────────────────────────────────────────────────────────────────
|
|
Field
|
|
──────────────────────────────────────────────────────────────────────────── */
|
|
|
|
const inputStyle: React.CSSProperties = {
|
|
height: 44,
|
|
width: '100%',
|
|
borderRadius: 12,
|
|
border: '1px solid var(--color-line)',
|
|
background: 'var(--color-surface-subtle)',
|
|
padding: '0 12px 0 38px',
|
|
fontSize: 14,
|
|
fontFamily: 'inherit',
|
|
color: 'var(--color-ink-1)',
|
|
outline: 'none',
|
|
transition: 'border-color .2s, background .2s, box-shadow .2s',
|
|
};
|
|
|
|
/**
|
|
* A labelled field.
|
|
*
|
|
* The label is a small-caps overline rather than sentence case — the auth page
|
|
* is the one screen with only two inputs on it, and the extra weight there
|
|
* reads as deliberate rather than shouty.
|
|
*/
|
|
function Field({
|
|
label,
|
|
htmlFor,
|
|
icon,
|
|
action,
|
|
children,
|
|
}: {
|
|
label: string;
|
|
htmlFor: string;
|
|
icon: ReactNode;
|
|
action?: ReactNode;
|
|
children: ReactNode;
|
|
}) {
|
|
return (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
<label
|
|
htmlFor={htmlFor}
|
|
style={{
|
|
fontSize: 11,
|
|
fontWeight: 700,
|
|
letterSpacing: '0.09em',
|
|
textTransform: 'uppercase',
|
|
color: 'var(--color-ink-3)',
|
|
}}
|
|
>
|
|
{label}
|
|
</label>
|
|
<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
|
|
<span
|
|
style={{
|
|
position: 'absolute',
|
|
left: 13,
|
|
display: 'flex',
|
|
color: 'var(--color-ink-4)',
|
|
pointerEvents: 'none',
|
|
}}
|
|
>
|
|
{icon}
|
|
</span>
|
|
{children}
|
|
{action}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|