574 lines
18 KiB
JavaScript
574 lines
18 KiB
JavaScript
import { useState, useEffect, useRef } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { MdCheck, MdOutlineVisibility, MdOutlineVisibilityOff } from 'react-icons/md';
|
|
import logo from 'assets/images/doormile-logo.png';
|
|
|
|
import { useDispatch } from 'react-redux';
|
|
import { OpenToast } from 'components/third-party/OpenToast';
|
|
import { setLoginUser } from 'store/reducers/loginUserSlice';
|
|
import { markSessionStart } from 'utils/session';
|
|
import { loginAdmin } from 'pages/api/doormileApi';
|
|
|
|
// Astryx design system — see themes/astryx.js for the Doormile theme.
|
|
//
|
|
// Two things about how this page is built:
|
|
//
|
|
// 1. The brand panel and the password toggle are plain elements with a scoped
|
|
// stylesheet, not Astryx layout components. Astryx has no breakpoint props,
|
|
// no gradient/decoration surface and no end-slot on TextInput, so those
|
|
// three needs cannot be expressed through component props at all. The
|
|
// stylesheet below is the same escape hatch `themes/dt/primitives.js`
|
|
// (TableScroll) and `PageLayout.js` (PageShell) already use.
|
|
//
|
|
// 2. EVERY selector in that stylesheet is prefixed `.dm-login`. The brand red
|
|
// lives on this page only — the console's own palette (black accent, slate
|
|
// ink) is deliberately untouched, so nothing here leaks into the operator
|
|
// pages. Do not lift these colours into themes/astryx.js.
|
|
import { AppShell } from '@astryxdesign/core/AppShell';
|
|
import { Theme } from '@astryxdesign/core/theme';
|
|
import { VStack } from '@astryxdesign/core/VStack';
|
|
import { Card } from '@astryxdesign/core/Card';
|
|
import { TextInput } from '@astryxdesign/core/TextInput';
|
|
import { Button } from '@astryxdesign/core/Button';
|
|
import { Link } from '@astryxdesign/core/Link';
|
|
import { Divider } from '@astryxdesign/core/Divider';
|
|
import { doormileTheme } from 'themes/astryx';
|
|
|
|
// Doormile brand red. Stated once; every rule below reads the custom property
|
|
// so the two tones can never drift apart.
|
|
const BRAND = '#C8152D';
|
|
const BRAND_DEEP = '#A90F24';
|
|
|
|
const BULLETS = ['Real-time fleet visibility', 'AI-optimised dispatch routes', 'Tenant, pricing & invoice control'];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Scoped stylesheet.
|
|
//
|
|
// Astryx exposes stable class hooks (`astryx-card`, `astryx-text-input`,
|
|
// `astryx-button`, `astryx-field-label`, `astryx-link`) alongside its generated
|
|
// StyleX classes, and a two-class descendant selector out-specifies them —
|
|
// verified in the browser rather than assumed. That is what lets this restyle
|
|
// real Astryx components instead of re-implementing them.
|
|
// ---------------------------------------------------------------------------
|
|
const LOGIN_CSS = `
|
|
.dm-login {
|
|
--dm-brand: ${BRAND};
|
|
--dm-brand-deep: ${BRAND_DEEP};
|
|
--dm-ring: rgba(200, 21, 45, 0.10);
|
|
display: flex;
|
|
min-height: 100vh;
|
|
width: 100%;
|
|
}
|
|
|
|
/* ---- Left brand panel --------------------------------------------------- */
|
|
.dm-login__brand {
|
|
flex: 0 0 44%;
|
|
position: relative;
|
|
overflow: hidden;
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
padding: 56px;
|
|
color: #fff;
|
|
/* Depth without decoration: one light lift from the top-left, one shadow
|
|
into the bottom-right, over a restrained two-stop brand ramp. The panel
|
|
should read as lit, not as gradient-y. */
|
|
background:
|
|
radial-gradient(115% 85% at 10% 6%, rgba(255, 255, 255, 0.12) 0%, transparent 55%),
|
|
radial-gradient(95% 85% at 95% 100%, rgba(0, 0, 0, 0.22) 0%, transparent 62%),
|
|
linear-gradient(155deg, var(--dm-brand) 0%, #B31228 55%, var(--dm-brand-deep) 100%);
|
|
}
|
|
|
|
/* A single oversized arc, barely visible — the "logistics curve". One shape,
|
|
two hairlines, no pattern library. */
|
|
.dm-login__brand::before {
|
|
content: '';
|
|
position: absolute;
|
|
right: -240px;
|
|
bottom: -280px;
|
|
width: 640px;
|
|
height: 640px;
|
|
border-radius: 50%;
|
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
|
box-shadow: 0 0 0 72px rgba(255, 255, 255, 0.035);
|
|
pointer-events: none;
|
|
}
|
|
|
|
/* Faint route lines, masked so they fade out rather than tile edge to edge. */
|
|
.dm-login__brand::after {
|
|
content: '';
|
|
position: absolute;
|
|
inset: 0;
|
|
pointer-events: none;
|
|
background-image: repeating-linear-gradient(118deg, rgba(255, 255, 255, 0.055) 0 1px, transparent 1px 78px);
|
|
-webkit-mask-image: radial-gradient(130% 100% at 85% 12%, #000 0%, transparent 68%);
|
|
mask-image: radial-gradient(130% 100% at 85% 12%, #000 0%, transparent 68%);
|
|
}
|
|
|
|
.dm-login__logo {
|
|
position: absolute;
|
|
top: 56px;
|
|
left: 56px;
|
|
height: 46px;
|
|
width: auto;
|
|
z-index: 1;
|
|
}
|
|
|
|
.dm-login__copy {
|
|
position: relative;
|
|
z-index: 1;
|
|
max-width: 460px;
|
|
}
|
|
|
|
.dm-login__headline {
|
|
margin: 0;
|
|
font-family: var(--font-family-heading);
|
|
font-size: 34px;
|
|
line-height: 1.18;
|
|
letter-spacing: -0.02em;
|
|
font-weight: 700;
|
|
color: #fff;
|
|
}
|
|
|
|
.dm-login__lede {
|
|
margin: 18px 0 0;
|
|
font-size: 16px;
|
|
line-height: 1.65;
|
|
color: rgba(255, 255, 255, 0.82);
|
|
}
|
|
|
|
.dm-login__features {
|
|
margin: 34px 0 0;
|
|
padding: 0;
|
|
list-style: none;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
}
|
|
|
|
.dm-login__feature {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
font-size: 15px;
|
|
line-height: 1.4;
|
|
color: rgba(255, 255, 255, 0.94);
|
|
}
|
|
|
|
.dm-login__tick {
|
|
flex: 0 0 auto;
|
|
width: 22px;
|
|
height: 22px;
|
|
border-radius: 50%;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: rgba(255, 255, 255, 0.16);
|
|
border: 1px solid rgba(255, 255, 255, 0.22);
|
|
color: #fff;
|
|
}
|
|
|
|
/* ---- Right form panel --------------------------------------------------- */
|
|
.dm-login__form {
|
|
flex: 1 1 auto;
|
|
min-width: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: #f8f9fb;
|
|
padding: 48px 24px;
|
|
}
|
|
|
|
.dm-login__stack {
|
|
width: 100%;
|
|
max-width: 436px;
|
|
}
|
|
|
|
.dm-login .astryx-card {
|
|
background: #ffffff;
|
|
border: 1px solid #e5e7eb;
|
|
border-radius: 16px;
|
|
/* Elevated, not floating. */
|
|
box-shadow: 0 12px 35px rgba(15, 23, 42, 0.08);
|
|
}
|
|
|
|
.dm-login__title {
|
|
margin: 0;
|
|
font-family: var(--font-family-heading);
|
|
font-size: 26px;
|
|
line-height: 1.25;
|
|
letter-spacing: -0.01em;
|
|
font-weight: 700;
|
|
color: #101828;
|
|
}
|
|
|
|
.dm-login__subtitle {
|
|
margin: 6px 0 0;
|
|
font-size: 14px;
|
|
line-height: 1.5;
|
|
color: #667085;
|
|
}
|
|
|
|
/* ---- Fields ------------------------------------------------------------- */
|
|
.dm-login .astryx-field-label {
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
color: #344054;
|
|
}
|
|
|
|
.dm-login .astryx-text-input {
|
|
height: 48px;
|
|
border-radius: 10px;
|
|
border-color: #d1d5db;
|
|
background: #ffffff;
|
|
padding-inline: 14px;
|
|
transition: border-color 160ms ease, box-shadow 160ms ease;
|
|
}
|
|
|
|
.dm-login .astryx-text-input input {
|
|
font-size: 15px;
|
|
}
|
|
|
|
/* One focus treatment for the whole form. :focus-within so the ring follows
|
|
the field, not the bare <input> that sits inside Astryx's bordered wrapper.
|
|
|
|
Plain two-class specificity is enough: the resting rule above already
|
|
out-specifies Astryx's own StyleX rules on this element, and this selector
|
|
is strictly more specific than that one. If a future Astryx version starts
|
|
winning here, the escape hatch is StyleX's own repeated-:not(#id) idiom —
|
|
but write it with IDs that simply never exist rather than copying StyleX's
|
|
escaped-hash form, because a backslash inside THIS template literal is
|
|
consumed by JS before the CSS parser sees it and the rule is then dropped
|
|
silently. */
|
|
.dm-login .astryx-text-input:focus-within {
|
|
border-color: var(--dm-brand);
|
|
box-shadow: 0 0 0 3px var(--dm-ring);
|
|
}
|
|
|
|
/* Chrome paints autofilled inputs pale blue, which is the "browser-looking"
|
|
field the design brief calls out. An inset shadow the size of the box is the
|
|
only way to repaint it. */
|
|
.dm-login .astryx-text-input input:-webkit-autofill,
|
|
.dm-login .astryx-text-input input:-webkit-autofill:hover,
|
|
.dm-login .astryx-text-input input:-webkit-autofill:focus {
|
|
-webkit-box-shadow: 0 0 0 40px #ffffff inset;
|
|
box-shadow: 0 0 0 40px #ffffff inset;
|
|
-webkit-text-fill-color: #101828;
|
|
}
|
|
|
|
/* ---- Password visibility toggle ----------------------------------------- */
|
|
.dm-login__pw {
|
|
position: relative;
|
|
}
|
|
|
|
/* Keep typed characters clear of the toggle. */
|
|
.dm-login__pw .astryx-text-input {
|
|
padding-right: 46px;
|
|
}
|
|
|
|
.dm-login__pw-toggle {
|
|
position: absolute;
|
|
right: 4px;
|
|
bottom: 0;
|
|
height: 48px;
|
|
width: 42px;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border: 0;
|
|
padding: 0;
|
|
background: transparent;
|
|
color: #667085;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
transition: color 160ms ease, background-color 160ms ease;
|
|
}
|
|
|
|
.dm-login__pw-toggle:hover {
|
|
color: #344054;
|
|
background: #f2f4f7;
|
|
}
|
|
|
|
.dm-login__pw-toggle:focus-visible {
|
|
outline: none;
|
|
box-shadow: 0 0 0 3px var(--dm-ring);
|
|
color: var(--dm-brand);
|
|
}
|
|
|
|
/* ---- Primary action ----------------------------------------------------- */
|
|
.dm-login .astryx-button {
|
|
height: 48px;
|
|
border-radius: 10px;
|
|
background: var(--dm-brand);
|
|
border-color: var(--dm-brand);
|
|
color: #ffffff;
|
|
font-size: 15px;
|
|
font-weight: 600;
|
|
transition: background-color 160ms ease, transform 120ms ease, box-shadow 160ms ease;
|
|
}
|
|
|
|
.dm-login .astryx-button:hover:not([disabled]):not([aria-disabled='true']) {
|
|
background: var(--dm-brand-deep);
|
|
border-color: var(--dm-brand-deep);
|
|
}
|
|
|
|
.dm-login .astryx-button:active:not([disabled]):not([aria-disabled='true']) {
|
|
transform: translateY(1px);
|
|
background: #970d20;
|
|
}
|
|
|
|
.dm-login .astryx-button:focus-visible {
|
|
outline: none;
|
|
box-shadow: 0 0 0 3px var(--dm-ring);
|
|
}
|
|
|
|
.dm-login .astryx-button[disabled],
|
|
.dm-login .astryx-button[aria-disabled='true'] {
|
|
opacity: 0.7;
|
|
cursor: progress;
|
|
}
|
|
|
|
/* ---- Footer ------------------------------------------------------------- */
|
|
.dm-login__footer {
|
|
margin-top: 28px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-wrap: wrap;
|
|
gap: 8px 20px;
|
|
font-size: 13px;
|
|
color: #98a2b3;
|
|
}
|
|
|
|
.dm-login__footer .astryx-link {
|
|
font-size: 13px;
|
|
color: #667085;
|
|
}
|
|
|
|
.dm-login__footer .astryx-link:hover {
|
|
color: var(--dm-brand);
|
|
}
|
|
|
|
/* ---- Tablet ------------------------------------------------------------- */
|
|
@media (max-width: 1100px) {
|
|
.dm-login__brand { flex-basis: 42%; padding: 40px; }
|
|
.dm-login__logo { top: 40px; left: 40px; height: 40px; }
|
|
.dm-login__headline { font-size: 28px; }
|
|
.dm-login__lede { font-size: 15px; margin-top: 14px; }
|
|
.dm-login__features { margin-top: 26px; gap: 13px; }
|
|
}
|
|
|
|
/* ---- Mobile ------------------------------------------------------------- */
|
|
/* The brand panel COLLAPSES to a logo strip rather than being hidden and the
|
|
logo re-rendered above the card. That keeps exactly one <img> of the logo in
|
|
the DOM at every breakpoint — a duplicate that is merely display:none is
|
|
still a duplicate to a screen reader and to anyone reading the markup. */
|
|
@media (max-width: 860px) {
|
|
.dm-login { flex-direction: column; min-height: 100dvh; }
|
|
|
|
.dm-login__brand {
|
|
flex: 0 0 auto;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 22px 24px;
|
|
}
|
|
.dm-login__brand::before,
|
|
.dm-login__brand::after { display: none; }
|
|
|
|
.dm-login__logo { position: static; height: 34px; }
|
|
.dm-login__copy { display: none; }
|
|
|
|
.dm-login__form {
|
|
flex: 1 1 auto;
|
|
align-items: flex-start;
|
|
padding: 32px 20px 40px;
|
|
}
|
|
.dm-login__title { font-size: 23px; }
|
|
.dm-login__footer { margin-top: 22px; gap: 8px 16px; }
|
|
}
|
|
|
|
@media (max-width: 400px) {
|
|
.dm-login__form { padding-inline: 16px; }
|
|
}
|
|
`;
|
|
|
|
const Login = () => {
|
|
const dispatch = useDispatch();
|
|
const navigate = useNavigate();
|
|
const [loading, setLoading] = useState(false);
|
|
const [email, setEmail] = useState('');
|
|
const [password, setPassword] = useState('');
|
|
const [showPassword, setShowPassword] = useState(false);
|
|
|
|
const emailRef = useRef(null);
|
|
const passwordRef = useRef(null);
|
|
|
|
useEffect(() => {
|
|
// 'authname' is the AUTH_PRESENCE_KEY (utils/session.js) — App.js and the
|
|
// inactivity/cross-tab logout machinery all key off it, unrelated to which
|
|
// backend actually authenticated the session.
|
|
if (localStorage.getItem('authname')) {
|
|
navigate('/doormile/dispatch');
|
|
}
|
|
}, []);
|
|
|
|
// Astryx's TextInput has no `autoComplete` prop and BaseProps declares only
|
|
// `xstyle`, so an unknown prop would be dropped rather than forwarded. It
|
|
// does forward `ref` to the real <input>, which is the supported way to
|
|
// reach the element — password managers need these attributes to offer a
|
|
// saved credential at all. Re-applied when the toggle swaps `type`.
|
|
useEffect(() => {
|
|
emailRef.current?.setAttribute('autocomplete', 'email');
|
|
passwordRef.current?.setAttribute('autocomplete', 'current-password');
|
|
}, [showPassword]);
|
|
|
|
const opentoast = (message, variant = 'error') => OpenToast(message, variant, 2000);
|
|
|
|
const handleSubmit = async (e) => {
|
|
e.preventDefault();
|
|
// Guard against a second submit while the first is still in flight —
|
|
// the button is disabled while loading, but Enter in a field is not.
|
|
if (loading) return;
|
|
|
|
if (!email || !password) {
|
|
opentoast('Enter your email and password');
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
try {
|
|
const res = await loginAdmin(email, password);
|
|
if (res.success) {
|
|
const user = res.user || {};
|
|
// Compatibility keys: existing pages/api.js read userid/roleid/tenantid
|
|
// straight from localStorage rather than through a context. 'authname'
|
|
// stays the AUTH_PRESENCE_KEY so the rest of the session contract
|
|
// (useInactivityLogout, cross-tab logout, App.js gate) needs no changes.
|
|
localStorage.setItem('authname', user.email || user.name || email);
|
|
localStorage.setItem('firstname', user.name || '');
|
|
localStorage.setItem('userid', user.id ?? '');
|
|
localStorage.setItem('roleid', user.role ?? '');
|
|
localStorage.setItem('tenantid', user.tenantid ?? '');
|
|
dispatch(setLoginUser(user));
|
|
markSessionStart();
|
|
opentoast('Login successful', 'success');
|
|
navigate('/doormile/dispatch');
|
|
} else {
|
|
opentoast(res.message || 'Login failed');
|
|
}
|
|
} catch (err) {
|
|
opentoast(err?.message || 'Login failed');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Theme theme={doormileTheme} mode="light">
|
|
<AppShell contentPadding={0}>
|
|
<style>{LOGIN_CSS}</style>
|
|
|
|
<div className="dm-login">
|
|
{/* ---- Brand panel. The ONLY Doormile logo on this page. ---- */}
|
|
<aside className="dm-login__brand">
|
|
<img className="dm-login__logo" src={logo} alt="Doormile" />
|
|
|
|
<div className="dm-login__copy">
|
|
<h1 className="dm-login__headline">Operate your dispatch, end to end.</h1>
|
|
<p className="dm-login__lede">
|
|
Orders, AI route optimisation, live rider tracking and billing — all in the Doormile Express operator console.
|
|
</p>
|
|
<ul className="dm-login__features">
|
|
{BULLETS.map((t) => (
|
|
<li key={t} className="dm-login__feature">
|
|
<span className="dm-login__tick" aria-hidden="true">
|
|
<MdCheck size={14} />
|
|
</span>
|
|
{t}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
</aside>
|
|
|
|
{/* ---- Form panel ---- */}
|
|
<main className="dm-login__form">
|
|
<div className="dm-login__stack">
|
|
<Card padding={8} elevation="none">
|
|
<VStack gap={5} padding={0}>
|
|
<div>
|
|
<h2 className="dm-login__title">Welcome back</h2>
|
|
<p className="dm-login__subtitle">Sign in to the Doormile Express console</p>
|
|
</div>
|
|
|
|
<Divider />
|
|
|
|
<form noValidate onSubmit={handleSubmit}>
|
|
<VStack gap={4} padding={0}>
|
|
<TextInput
|
|
ref={emailRef}
|
|
hasAutoFocus
|
|
label="Email address"
|
|
type="email"
|
|
size="lg"
|
|
placeholder="you@company.com"
|
|
value={email}
|
|
isDisabled={loading}
|
|
onChange={(value) => setEmail(value.toLocaleLowerCase())}
|
|
/>
|
|
|
|
<div className="dm-login__pw">
|
|
<TextInput
|
|
ref={passwordRef}
|
|
label="Password"
|
|
type={showPassword ? 'text' : 'password'}
|
|
size="lg"
|
|
placeholder="Enter your password"
|
|
value={password}
|
|
isDisabled={loading}
|
|
onChange={(value) => setPassword(value)}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="dm-login__pw-toggle"
|
|
onClick={() => setShowPassword((v) => !v)}
|
|
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
|
aria-pressed={showPassword}
|
|
>
|
|
{showPassword ? <MdOutlineVisibilityOff size={19} /> : <MdOutlineVisibility size={19} />}
|
|
</button>
|
|
</div>
|
|
|
|
<Button
|
|
label={loading ? 'Signing in…' : 'Sign in'}
|
|
type="submit"
|
|
variant="primary"
|
|
size="lg"
|
|
width="100%"
|
|
isLoading={loading}
|
|
isDisabled={loading}
|
|
/>
|
|
</VStack>
|
|
</form>
|
|
</VStack>
|
|
</Card>
|
|
|
|
<div className="dm-login__footer">
|
|
<span>© {new Date().getFullYear()} Doormile</span>
|
|
<Link href="https://doormile.com/terms-of-service" target="_blank" isExternalLink type="supporting">
|
|
Terms & Conditions
|
|
</Link>
|
|
<Link href="https://doormile.com/privacy-policy" target="_blank" isExternalLink type="supporting">
|
|
Privacy Policy
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
</AppShell>
|
|
</Theme>
|
|
);
|
|
};
|
|
|
|
export default Login;
|