Compare commits

...

2 Commits

Author SHA1 Message Date
ee57184fa2 console: rebuild the look in the assured-inspired language (approved direction)
Full styling reset to the approved mockup — from scratch, no functional change.

- PALETTE: clean white ground, warm near-black ink, ONE confident magenta-pink
  accent (#e5227f) reserved for action and active state. The accent keeps the
  --zk-blue-* token names it always had; only the values changed. Teal is the
  customer, amber a person; pink is "act / here", nothing else. Neutrals warmed,
  shadows softened to a plum-ink low alpha.
- TYPE: Plus Jakarta Sans — geometric-humanist, bold on headings (page title
  800, panel titles and figures 700+), the modern-product register assured has.
- SHAPE: the flat single-canvas is gone; back to clean rounded cards that float
  a little on the off-white — panels, metric tiles (money in the accent tint),
  the stage rail, the facts rail. Status ribbon in teal, the primary action a
  pink pill with an accent glow.

Same components, same data, same behaviour — only the surface.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 12:24:44 +05:30
fda1247ee9 fix(console): blank lead page — statusStrip const used blocked/stalled before they existed
The lifted status strip (e21e394) was defined right after `holds`, but its JSX
references `blocked` and `stalled`, which are declared ~80 lines lower. A const
that reads a const declared below it throws "Cannot access before
initialization" at render — a temporal dead zone the bundler does not catch, so
the build passed and the lead page rendered blank.

Moved the statusStrip definition to just after `stalled`, so every value it
reads exists first. The lifted-above-the-metrics position is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 12:13:33 +05:30
5 changed files with 181 additions and 159 deletions

View File

@ -18,7 +18,7 @@
kept in the fallback stack, and Geist Mono carries refs and figures. --> kept in the fallback stack, and Geist Mono carries refs and figures. -->
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500&display=swap" /> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=Geist+Mono:wght@400;500&display=swap" />
<!-- Written next to the artifact at placement; carries this environment's <!-- Written next to the artifact at placement; carries this environment's
VITE_ZINO_API_URL, which src/runtimeConfig.js reads off VITE_ZINO_API_URL, which src/runtimeConfig.js reads off
window.__RUNTIME_CONFIG__. Without it requireConfigValue throws and the window.__RUNTIME_CONFIG__. Without it requireConfigValue throws and the

View File

@ -5,11 +5,12 @@
align-items: center; align-items: center;
gap: var(--sp-6); gap: var(--sp-6);
flex-wrap: wrap; flex-wrap: wrap;
margin: var(--sp-6) 0; margin: 0 0 var(--sp-5);
padding: var(--sp-2) 0 var(--sp-5); padding: var(--sp-6) var(--sp-7);
border: 0; border: 1px solid var(--zk-line-soft);
border-bottom: 1px solid var(--zk-line); border-radius: var(--r-md);
background: transparent; box-shadow: var(--sh-sm);
background: var(--zk-white);
} }
.rail ol { .rail ol {

View File

@ -22,64 +22,69 @@
} }
:root { :root {
/* Brand palette lifted from zurichkotak.com */ /* PALETTE assured-inspired
--zk-blue: #2167ae; A clean white ground, warm near-black ink, and ONE confident accent:
--zk-blue-dark: #1a5490; a magenta-pink, reserved for action and active state. The token names
--zk-blue-deep: #1a528b; keep their history (the accent still lives under the --zk-blue-* names
--zk-navy: #23366f; the whole app references), but the values are the new scheme. Teal is
--zk-blue-mid: #5495cf; the customer, amber a person; the pink is "act / here", nothing else. */
--zk-blue-light: #91bfe3; --zk-blue: #e5227f; /* THE ACCENT (was brand blue) — pink */
--zk-cyan: #c7ebf9; --zk-blue-dark: #c30f68;
--zk-teal: #0e7c74; --zk-blue-deep: #c30f68;
--zk-teal-ink: #0b5f59; --zk-navy: #2a1d3d; /* a deep plum-ink for the logo / darkest text */
--zk-teal-tint: #e3f4f2; --zk-blue-mid: #ee5fa0;
--zk-teal-line: #a5d8d3; --zk-blue-light: #f6cfe3; /* pink hairline / ring */
--zk-good: #1f9d63; --zk-cyan: #fdebf4; /* pink-soft */
--zk-teal: #0e9e8f;
--zk-teal-ink: #0a6b60;
--zk-teal-tint: #e2f5f2;
--zk-teal-line: #bfe6e1;
--zk-good: #18a558;
--zk-ink: #2b2e31; --zk-ink: #1c1626; /* warm near-black */
--zk-muted: #5d6162; --zk-muted: #645d72;
--zk-grey: #a6adaf; --zk-grey: #a49dae;
--zk-line: #dde4e3; --zk-line: #e3e1ea;
--zk-line-soft: #eceeef; --zk-line-soft: #ececf1;
--zk-tint: #f4f9fc; --zk-tint: #faf9fb; /* off-white section ground */
--zk-tint-blue: #ebf3fb; --zk-sunk: #f5f3f8; /* a touch deeper — hover / inset */
--zk-tint-blue: #fdebf4; /* pink-soft (was tint-blue) */
--zk-white: #fff; --zk-white: #fff;
--zk-danger: #d32f2f; --zk-danger: #d64545;
/* Amber and red accents already used for people / lapsed renewals, named so --zk-amber: #c77d1a;
they stop being magic hex literals scattered across four stylesheets. */ --zk-amber-ink: #9a5d10;
--zk-amber: #b5761f; --zk-amber-tint: #fbf0dd;
--zk-amber-ink: #93500f; --zk-amber-line: #ecd3a3;
--zk-amber-tint: #fdf1e7; --zk-danger-ink: #b23636;
--zk-amber-line: #f0c69a; --zk-danger-tint: #fbeaea;
--zk-danger-ink: #a3261f; --zk-danger-line: #f0c9c9;
--zk-danger-tint: #fdeceb;
--zk-danger-line: #f0c2bd;
/* Shape, depth, motion /* Shape, depth, motion
One scale, used everywhere. The palette above is untouched; every shadow Rounder than before and softer shadows the assured cards read as
below is the brand navy at low alpha, so depth reads as part of the brand objects that float a little, on a warm plum-ink low alpha rather than
rather than as generic grey haze. */ generic grey haze. */
--r-xs: 6px; --r-xs: 8px;
--r-sm: 8px; --r-sm: 10px;
--r-md: 12px; --r-md: 14px;
--r-lg: 16px; --r-lg: 18px;
--r-xl: 20px; --r-xl: 22px;
--r-pill: 999px; --r-pill: 999px;
--sh-xs: 0 1px 2px rgba(35, 54, 111, 0.05); --sh-xs: 0 1px 2px rgba(28, 22, 38, 0.04);
--sh-sm: 0 1px 2px rgba(35, 54, 111, 0.05), 0 2px 6px -2px rgba(35, 54, 111, 0.07); --sh-sm: 0 1px 2px rgba(28, 22, 38, 0.04), 0 4px 14px -8px rgba(28, 22, 38, 0.10);
--sh-md: 0 1px 2px rgba(35, 54, 111, 0.05), 0 10px 24px -8px rgba(35, 54, 111, 0.14); --sh-md: 0 1px 2px rgba(28, 22, 38, 0.04), 0 8px 24px -10px rgba(28, 22, 38, 0.12);
--sh-lg: 0 2px 6px rgba(35, 54, 111, 0.06), 0 20px 42px -14px rgba(35, 54, 111, 0.22); --sh-lg: 0 2px 6px rgba(28, 22, 38, 0.06), 0 24px 48px -16px rgba(28, 22, 38, 0.22);
--sh-accent: 0 6px 20px -6px rgba(229, 34, 127, 0.42);
--ring: 0 0 0 3px rgba(33, 103, 174, 0.16); --ring: 0 0 0 3px rgba(229, 34, 127, 0.16);
--ease: cubic-bezier(0.4, 0, 0.2, 1); --ease: cubic-bezier(0.4, 0, 0.2, 1);
--t-fast: 0.14s var(--ease); --t-fast: 0.14s var(--ease);
--t: 0.2s var(--ease); --t: 0.2s var(--ease);
--grad-blue: linear-gradient(135deg, var(--zk-blue) 0%, var(--zk-blue-deep) 100%); --grad-blue: linear-gradient(135deg, var(--zk-blue) 0%, var(--zk-blue-dark) 100%);
--grad-blue-hover: linear-gradient(135deg, var(--zk-blue-dark) 0%, var(--zk-navy) 100%); --grad-blue-hover: linear-gradient(135deg, var(--zk-blue-dark) 0%, #a80c59 100%);
/* TYPE SCALE /* TYPE SCALE
The app had FIFTY-FOUR distinct font sizes, and forty of them sat The app had FIFTY-FOUR distinct font sizes, and forty of them sat
@ -114,6 +119,8 @@
--fw-normal: 400; --fw-normal: 400;
--fw-medium: 500; --fw-medium: 500;
--fw-semi: 600; --fw-semi: 600;
--fw-bold: 700;
--fw-x: 800;
/* SPACING /* SPACING
One rhythm. Panels breathe at 24-28px rather than 14-18px, which is One rhythm. Panels breathe at 24-28px rather than 14-18px, which is
@ -131,8 +138,8 @@
/* Display and body are the SAME brand face Zurich Sans has no bold, /* Display and body are the SAME brand face Zurich Sans has no bold,
so headings and figures lean on weight 500 and tighter tracking, set per so headings and figures lean on weight 500 and tighter tracking, set per
use, not on a second family. */ use, not on a second family. */
--font-display: 'Geist', 'Zurich Sans', system-ui, sans-serif; --font-display: 'Plus Jakarta Sans', 'Geist', system-ui, sans-serif;
--font-zurich: 'Geist', 'Zurich Sans', system-ui, -apple-system, sans-serif; --font-zurich: 'Plus Jakarta Sans', 'Geist', system-ui, -apple-system, sans-serif;
--font-mono: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, monospace; --font-mono: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
font-family: var(--font-zurich); font-family: var(--font-zurich);

View File

@ -301,81 +301,6 @@ export default function Lead() {
const stage = phaseOf(STAGES.find((s) => s.name === stateName), row) const stage = phaseOf(STAGES.find((s) => s.name === stateName), row)
const holds = holdsOf(stage) const holds = holdsOf(stage)
/* WHAT IS HAPPENING NOW, lifted above the numbers. An operator's first
question about a lead is "what is going on / what do I do", not "what is
the premium" so the status strip (an alert when blocked or stalled, the
live state otherwise) sits directly under the header, before the metrics.
Rendered full-width there rather than inside the narrow work column. */
const statusStrip = (
blocked ? (
<div className="doing doing--blocked">
<span className="doing__stop" aria-hidden="true" />
<div>
<strong>Stopped {blocked.what}</strong>
<span>
{blocked.fix} The agents will pick the lead up again on their own once it
is there; nothing else is running in the meantime.
</span>
<button
type="button" className="doing__fix"
onClick={() => setOpen(blocked.via)}
>
Open Collect Documents
</button>
</div>
</div>
) : stalled ? (
<div className="doing doing--stalled">
<span className="doing__stop" aria-hidden="true" />
<div>
<strong>{stage.doing} nothing for {stalled.mins} minutes</strong>
<span>
{stage.by} has not come back. That is usually the model provider being
slow or dropping a request, not a problem with this lead the work so far
is safe and nothing has been lost.
{stalled.nudge
? ` Running ${stalled.nudge.label.toLowerCase()} wakes ${stalled.nudge.by} to try again.`
: ' There is no step to re-run from here; the actions below are the way on.'}
</span>
{stalled.nudge ? (
<button
type="button" className="doing__fix"
onClick={() => setOpen(stalled.nudge.uid)}
>
{stalled.nudge.label}
</button>
) : null}
</div>
</div>
) : stage?.kind === 'customer' && stage.doing ? (
/* Not an agent working, and not a task of yours either the lead
is with someone outside the business. Without this the screen
went blank at exactly the stage where an operator is most likely
to wonder whether something has broken. */
<div className="doing doing--waiting">
<span className="doing__wait" aria-hidden="true" />
<div>
<strong>{stage.doing}</strong>
<span>
Their answer comes back to this lead on its own Engage reads it and
records the acceptance. Nothing is waiting on you.
</span>
</div>
</div>
) : stage?.kind === 'auto' ? (
<div className="doing">
<span className="doing__pulse" aria-hidden="true" />
<div>
<strong>{stage.doing}</strong>
<span>
Handled by {stage.by}.
{stage.after ? ` The lead stays in ${stateName} until a quote exists — nothing is waiting on you.` : ''}
{' '}Typically completes within two minutes; this view refreshes automatically.
</span>
</div>
</div>
) : null
)
// Only what THIS user may run. The workflow refuses the rest server-side // Only what THIS user may run. The workflow refuses the rest server-side
// anyway; showing them a row of buttons that all 403 reads as a broken app // anyway; showing them a row of buttons that all 403 reads as a broken app
// rather than as a control. // rather than as a control.
@ -454,6 +379,83 @@ blocked ? (
// the way out rather than going on promising two minutes. // the way out rather than going on promising two minutes.
const stillFor = row.updated_at ? now - Date.parse(row.updated_at) : 0 const stillFor = row.updated_at ? now - Date.parse(row.updated_at) : 0
const stalled = !blocked && stage?.kind === 'auto' && stillFor > STALL_AFTER_MS const stalled = !blocked && stage?.kind === 'auto' && stillFor > STALL_AFTER_MS
/* WHAT IS HAPPENING NOW, lifted above the numbers. An operator's first
question about a lead is "what is going on / what do I do", not "what is
the premium" so the status strip (an alert when blocked or stalled, the
live state otherwise) sits directly under the header, before the metrics.
Rendered full-width there rather than inside the narrow work column. */
const statusStrip = (
blocked ? (
<div className="doing doing--blocked">
<span className="doing__stop" aria-hidden="true" />
<div>
<strong>Stopped {blocked.what}</strong>
<span>
{blocked.fix} The agents will pick the lead up again on their own once it
is there; nothing else is running in the meantime.
</span>
<button
type="button" className="doing__fix"
onClick={() => setOpen(blocked.via)}
>
Open Collect Documents
</button>
</div>
</div>
) : stalled ? (
<div className="doing doing--stalled">
<span className="doing__stop" aria-hidden="true" />
<div>
<strong>{stage.doing} nothing for {stalled.mins} minutes</strong>
<span>
{stage.by} has not come back. That is usually the model provider being
slow or dropping a request, not a problem with this lead the work so far
is safe and nothing has been lost.
{stalled.nudge
? ` Running ${stalled.nudge.label.toLowerCase()} wakes ${stalled.nudge.by} to try again.`
: ' There is no step to re-run from here; the actions below are the way on.'}
</span>
{stalled.nudge ? (
<button
type="button" className="doing__fix"
onClick={() => setOpen(stalled.nudge.uid)}
>
{stalled.nudge.label}
</button>
) : null}
</div>
</div>
) : stage?.kind === 'customer' && stage.doing ? (
/* Not an agent working, and not a task of yours either the lead
is with someone outside the business. Without this the screen
went blank at exactly the stage where an operator is most likely
to wonder whether something has broken. */
<div className="doing doing--waiting">
<span className="doing__wait" aria-hidden="true" />
<div>
<strong>{stage.doing}</strong>
<span>
Their answer comes back to this lead on its own Engage reads it and
records the acceptance. Nothing is waiting on you.
</span>
</div>
</div>
) : stage?.kind === 'auto' ? (
<div className="doing">
<span className="doing__pulse" aria-hidden="true" />
<div>
<strong>{stage.doing}</strong>
<span>
Handled by {stage.by}.
{stage.after ? ` The lead stays in ${stateName} until a quote exists — nothing is waiting on you.` : ''}
{' '}Typically completes within two minutes; this view refreshes automatically.
</span>
</div>
</div>
) : null
)
? { mins: Math.round(stillFor / 60000), nudge: nudgeFor(stage, row) } ? { mins: Math.round(stillFor / 60000), nudge: nudgeFor(stage, row) }
: null : null

View File

@ -10,7 +10,7 @@
.page__title { .page__title {
margin: 0; margin: 0;
font-size: var(--fs-2xl); font-size: var(--fs-2xl);
font-weight: var(--fw-semi); font-weight: var(--fw-x);
letter-spacing: -0.022em; letter-spacing: -0.022em;
line-height: 1.2; line-height: 1.2;
color: var(--zk-ink); color: var(--zk-ink);
@ -309,14 +309,14 @@
/* ---- panel ---- */ /* ---- panel ---- */
.panel { .panel {
/* FLAT. Was a shadowed white card; now a plain section on the white /* CARD. The assured look is clean white cards that float a little on the
canvas, set off by its heading and by space, not by a box. */ off-white ground rounded, soft-shadowed, bounded. */
background: transparent; background: var(--zk-white);
border: 0; border: 1px solid var(--zk-line-soft);
border-radius: 0; border-radius: var(--r-lg);
box-shadow: none; box-shadow: var(--sh-sm);
padding: 0; padding: var(--sp-6) var(--sp-7);
margin-bottom: var(--sp-8); margin-bottom: var(--sp-5);
} }
.panel__head { .panel__head {
@ -330,7 +330,7 @@
.panel__title { .panel__title {
margin: 0; margin: 0;
font-size: var(--fs-lg); font-size: var(--fs-lg);
font-weight: var(--fw-semi); font-weight: var(--fw-bold);
letter-spacing: -0.018em; letter-spacing: -0.018em;
line-height: var(--lh-tight); line-height: var(--lh-tight);
} }
@ -404,12 +404,14 @@
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 14px; gap: 14px;
margin-bottom: 22px; margin-bottom: var(--sp-5);
padding: 16px 20px; padding: var(--sp-4) var(--sp-5);
border-radius: var(--r-lg); border-radius: var(--r-md);
border: 1px solid var(--zk-blue-light); border: 1px solid var(--zk-teal-line);
background: var(--zk-tint-blue); background: var(--zk-teal-tint);
color: var(--zk-teal-ink);
} }
.doing strong { color: var(--zk-teal-ink); }
.doing div { .doing div {
display: flex; display: flex;
@ -618,9 +620,9 @@
/* Rail panels are tighter than main panels a 340px measure cannot afford /* Rail panels are tighter than main panels a 340px measure cannot afford
32px of padding on each side. */ 32px of padding on each side. */
.lead__side .panel { padding: 0; margin-bottom: var(--sp-6); } .lead__side .panel { padding: var(--sp-5) var(--sp-6); margin-bottom: var(--sp-4); }
.lead__side .panel > .panel__head { padding: 0; margin-bottom: var(--sp-4); } .lead__side .panel > .panel__head { padding: 0; margin-bottom: var(--sp-4); }
.lead__side .panel--crew { padding: 0 0 var(--sp-6); border-bottom: 1px solid var(--zk-line); } .lead__side .panel--crew { border-bottom: 0; }
/* rail headings sit at label size, not the big panel-title size the rail is /* rail headings sit at label size, not the big panel-title size the rail is
reference, and a 18px "Customer" heading would shout over the facts. */ reference, and a 18px "Customer" heading would shout over the facts. */
.lead__side .panel__title { .lead__side .panel__title {
@ -1107,16 +1109,26 @@
22px against 12px labels. Premium and commission the two numbers 22px against 12px labels. Premium and commission the two numbers
anyone actually came for are bigger again. */ anyone actually came for are bigger again. */
.lmetrics { .lmetrics {
display: flex; display: grid;
flex-wrap: wrap; grid-template-columns: repeat(4, 1fr);
gap: var(--sp-6) var(--sp-8); gap: var(--sp-4);
padding: var(--sp-6) 0; margin-bottom: var(--sp-5);
border-top: 1px solid var(--zk-line);
border-bottom: 1px solid var(--zk-line);
margin-bottom: 0;
} }
.lm { min-width: 0; } .lm {
min-width: 0;
background: var(--zk-white);
border: 1px solid var(--zk-line-soft);
border-radius: var(--r-md);
box-shadow: var(--sh-sm);
padding: var(--sp-5);
}
/* Money leads: the two figures a renewal is about sit in the accent tint. */
.lm--hero { background: var(--zk-tint-blue); border-color: var(--zk-blue-light); }
.lm--hero .lm__v { color: var(--zk-blue-dark); }
.lm--hero .lm__l { color: var(--zk-blue-dark); }
@media (max-width: 1040px) { .lmetrics { grid-template-columns: 1fr 1fr; } }
.lm__l { .lm__l {
display: block; display: block;
@ -1132,7 +1144,7 @@
display: block; display: block;
margin-bottom: var(--sp-1); margin-bottom: var(--sp-1);
font-size: var(--fs-xl); font-size: var(--fs-xl);
font-weight: var(--fw-semi); font-weight: var(--fw-x);
line-height: var(--lh-tight); line-height: var(--lh-tight);
color: var(--zk-navy); color: var(--zk-navy);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
@ -1821,17 +1833,17 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: var(--sp-3); gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4) var(--sp-3) var(--sp-5); padding: var(--sp-3) var(--sp-5) var(--sp-3) var(--sp-5);
border: 0; border: 0;
border-radius: var(--r-md); border-radius: var(--r-md);
background: var(--zk-amber); background: var(--zk-blue);
color: #fff; color: #fff;
cursor: pointer; cursor: pointer;
box-shadow: var(--sh-sm); box-shadow: var(--sh-accent);
transition: background var(--t-fast), box-shadow var(--t-fast), transform var(--t-fast); transition: background var(--t-fast), box-shadow var(--t-fast), transform var(--t-fast);
} }
.cta:hover { background: var(--zk-amber-ink); box-shadow: var(--sh-md); } .cta:hover { background: var(--zk-blue-dark); transform: translateY(-1px); }
.cta:focus-visible { outline: 2px solid var(--zk-amber-ink); outline-offset: 2px; } .cta:focus-visible { outline: 2px solid var(--zk-blue-dark); outline-offset: 2px; }
.cta__txt { display: flex; flex-direction: column; align-items: flex-start; line-height: 1.2; } .cta__txt { display: flex; flex-direction: column; align-items: flex-start; line-height: 1.2; }
.cta__txt small { .cta__txt small {
font-size: var(--fs-3xs); font-size: var(--fs-3xs);