Initial commit

This commit is contained in:
2026-08-24 20:35:18 +05:30
commit 1dc582ba07
85 changed files with 21081 additions and 0 deletions

View File

@@ -0,0 +1,539 @@
import { lazy, Suspense, useState, type FormEvent, type ReactNode } from 'react';
import { Navigate, useNavigate } from 'react-router-dom';
import { AlertCircle, ArrowRight, Eye, EyeOff, Loader2, Lock, Mail, Sparkles } from 'lucide-react';
import { useAuth } from '@/auth/AuthContext';
import { HOME_ROUTE } from '@/auth/roles';
import { PasswordSetupRequiredError } from '@/auth/session';
import type { ConsoleRole, SessionUser } from '@/auth/roles';
/**
* The demo panel is loaded lazily behind the build-time DEV constant, so in a
* production build this expression is `null`, the import is dead code, and
* neither the panel nor the accounts behind it are emitted at all.
*/
const DemoPanel = import.meta.env.DEV
? lazy(() => import('@/demo/DemoPanel').then((m) => ({ default: m.DemoPanel })))
: null;
/**
* 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, signInAsDemo } = useAuth();
const navigate = useNavigate();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const [isRemembered, setIsRemembered] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isBusy, setIsBusy] = useState(false);
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) {
setError(
cause instanceof PasswordSetupRequiredError
? 'This account has no password set yet. An administrator needs to set one before you can sign in.'
: cause instanceof Error
? cause.message
: 'Sign-in failed',
);
} finally {
setIsBusy(false);
}
}
const canSubmit = email.trim() !== '' && password !== '' && !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 />
<FormPanel
email={email}
password={password}
isPasswordVisible={isPasswordVisible}
isRemembered={isRemembered}
error={error}
isBusy={isBusy}
canSubmit={canSubmit}
onEmail={setEmail}
onPassword={setPassword}
onToggleVisible={() => setIsPasswordVisible((visible) => !visible)}
onToggleRemember={() => setIsRemembered((remembered) => !remembered)}
onSubmit={handleSubmit}
onDemo={(role, session) => {
signInAsDemo(session);
navigate(HOME_ROUTE[role], { replace: true });
}}
/>
</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;
isRemembered: boolean;
error: string | null;
isBusy: boolean;
canSubmit: boolean;
onEmail: (value: string) => void;
onPassword: (value: string) => void;
onToggleVisible: () => void;
onToggleRemember: () => void;
onSubmit: (event: FormEvent) => void;
onDemo: (role: ConsoleRole, session: SessionUser) => void;
}
function FormPanel({
email,
password,
isPasswordVisible,
isRemembered,
error,
isBusy,
canSubmit,
onEmail,
onPassword,
onToggleVisible,
onToggleRemember,
onSubmit,
onDemo,
}: 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={{
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',
}}
>
{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>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
flexWrap: 'wrap',
}}
>
<label
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 8,
fontSize: 13,
color: 'var(--color-ink-2)',
cursor: 'pointer',
}}
>
<input
type="checkbox"
checked={isRemembered}
onChange={onToggleRemember}
style={{ width: 15, height: 15, accentColor: 'var(--color-brand)', cursor: 'pointer' }}
/>
Keep me signed in
</label>
<button
type="button"
style={{
border: 0,
background: 'transparent',
padding: 0,
fontSize: 13,
fontWeight: 600,
color: 'var(--color-brand)',
cursor: 'pointer',
}}
>
Forgot password?
</button>
</div>
{error ? (
<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 }} />
{error}
</div>
) : null}
<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' }} />
Signing in…
</>
) : (
<>
Continue
<ArrowRight size={16} />
</>
)}
</button>
</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>
{DemoPanel ? (
<Suspense fallback={null}>
<DemoPanel onDemo={onDemo} />
</Suspense>
) : null}
</div>
</div>
);
}
/* ────────────────────────────────────────────────────────────────────────────
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>
);
}