first commit
This commit is contained in:
175
src/features/auth/components/JoinForm.tsx
Normal file
175
src/features/auth/components/JoinForm.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import {FIELD_BASE} from './LoginCredentialsForm';
|
||||
import {MIN_PASSWORD_LENGTH, useJoinForm} from '@/features/auth/hooks/useJoinForm';
|
||||
|
||||
/**
|
||||
* Redeem an invitation code: check it, then choose a name and a password.
|
||||
*
|
||||
* Styled as the sign-in form is, from the same field string, because the two
|
||||
* sit in the same frame and are the only two ways into the console. The email
|
||||
* and role are SHOWN, never editable: they come from the invitation, and the
|
||||
* platform refuses a registration that names either.
|
||||
*/
|
||||
const LABEL = 'block text-[13px] font-medium text-[#3F3D36] mb-2';
|
||||
|
||||
const ROLE_LABEL: Record<string, string> = {
|
||||
staff: 'Staff',
|
||||
manager: 'Manager',
|
||||
owner: 'Owner',
|
||||
};
|
||||
|
||||
export function JoinForm() {
|
||||
const {
|
||||
code,
|
||||
preview,
|
||||
fullName,
|
||||
password,
|
||||
confirm,
|
||||
error,
|
||||
isSubmitting,
|
||||
setCode,
|
||||
setFullName,
|
||||
setPassword,
|
||||
setConfirm,
|
||||
submit,
|
||||
reset,
|
||||
} = useJoinForm();
|
||||
|
||||
const border = error ? 'border-[#DC2626]' : 'border-[#E4E1D6]';
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} noValidate className="space-y-[18px]">
|
||||
{preview ? (
|
||||
<>
|
||||
<div className="rounded-xl border border-[#EDEAE0] bg-[#FAF9F4] px-4 py-3">
|
||||
<p className="text-[15px] font-semibold text-[#14161F]">
|
||||
Join {preview.companyName}
|
||||
</p>
|
||||
<p className="mt-1 text-[13px] text-[#6B6960]">
|
||||
{preview.email} · {ROLE_LABEL[preview.role] ?? preview.role}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="join-name" className={LABEL}>
|
||||
Your name
|
||||
</label>
|
||||
<input
|
||||
id="join-name"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
value={fullName}
|
||||
onChange={(e) => setFullName(e.target.value)}
|
||||
placeholder="How colleagues will see you"
|
||||
disabled={isSubmitting}
|
||||
className={`${FIELD_BASE} px-4 border-[#E4E1D6]`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="join-password" className={LABEL}>
|
||||
Choose a password
|
||||
</label>
|
||||
<input
|
||||
id="join-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={`At least ${MIN_PASSWORD_LENGTH} characters`}
|
||||
aria-invalid={!!error}
|
||||
disabled={isSubmitting}
|
||||
className={`${FIELD_BASE} px-4 ${border}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="join-confirm" className={LABEL}>
|
||||
Type it again
|
||||
</label>
|
||||
<input
|
||||
id="join-confirm"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
aria-invalid={!!error}
|
||||
disabled={isSubmitting}
|
||||
className={`${FIELD_BASE} px-4 ${border}`}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
<label htmlFor="join-code" className={LABEL}>
|
||||
Invitation code
|
||||
</label>
|
||||
<input
|
||||
id="join-code"
|
||||
type="text"
|
||||
autoComplete="one-time-code"
|
||||
autoCapitalize="characters"
|
||||
spellCheck={false}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder="XXXXXX-XXXXXX-XXXXXX-XXXXXX"
|
||||
aria-invalid={!!error}
|
||||
disabled={isSubmitting}
|
||||
className={`${FIELD_BASE} px-4 font-mono tracking-wide ${border}`}
|
||||
/>
|
||||
<p className="mt-1.5 text-xs text-[#8A8577]">
|
||||
Your manager gave you this code. It works once.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error ? (
|
||||
<p role="alert" className="text-xs text-[#DC2626]">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full h-11 rounded-xl bg-[#F4C430] text-[#14161F] font-semibold text-[15px] hover:bg-[#E8B81C] active:scale-[0.995] transition-all shadow-[0_8px_20px_-8px_rgba(244,196,48,0.85)] flex items-center justify-center gap-2 outline-none focus-visible:ring-4 focus-visible:ring-[#F4C430]/35 disabled:opacity-70 disabled:cursor-not-allowed disabled:shadow-none"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<span className="inline-block w-4 h-4 border-2 border-[#14161F]/25 border-t-[#14161F] rounded-full animate-spin" />
|
||||
<span className="sr-only">{preview ? 'Creating your account' : 'Checking the code'}</span>
|
||||
</>
|
||||
) : preview ? (
|
||||
'Create account and sign in'
|
||||
) : (
|
||||
'Continue'
|
||||
)}
|
||||
</button>
|
||||
|
||||
<p className="text-[13px] text-[#6B6960]">
|
||||
{preview ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
disabled={isSubmitting}
|
||||
className="font-medium text-[#14161F] underline underline-offset-2 hover:text-[#8A6300] transition-colors"
|
||||
>
|
||||
Use a different code
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
Already have an account?{' '}
|
||||
<Link
|
||||
href="/login"
|
||||
className="font-medium text-[#14161F] underline underline-offset-2 hover:text-[#8A6300] transition-colors"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
236
src/features/auth/components/LoginCredentialsForm.tsx
Normal file
236
src/features/auth/components/LoginCredentialsForm.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {LOGIN_ENDPOINT, useLoginForm} from '@/features/auth/hooks/useLoginForm';
|
||||
|
||||
/**
|
||||
* The sign-in form itself: two fields, the remember-me control and the submit
|
||||
* button.
|
||||
*
|
||||
* All state and every rule come from useLoginForm — this file decides only how
|
||||
* things look. That separation is what lets the error copy, the redirect
|
||||
* target and the validation change without touching markup, and vice versa.
|
||||
*
|
||||
* ── On the hand-rolled Tailwind here ─────────────────────────────────────
|
||||
* The rest of the app is built from Astryx components, and this screen
|
||||
* deliberately is not: it predates the workspace, it is the only page a
|
||||
* merchant sees before the product, and it renders outside the shell on a
|
||||
* surface pinned to the light palette. See the note in LoginSplit for why the
|
||||
* colour values are literal.
|
||||
*/
|
||||
|
||||
/** One string, both inputs — the two fields must not drift apart. */
|
||||
export const FIELD_BASE =
|
||||
'w-full h-11 rounded-xl bg-white border text-[15px] text-[#14161F] placeholder:text-[#AEA99B] transition-all outline-none focus:border-[#F4C430] focus:ring-4 focus:ring-[#F4C430]/20 disabled:opacity-60 disabled:cursor-not-allowed';
|
||||
|
||||
export function LoginCredentialsForm() {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const {
|
||||
email,
|
||||
password,
|
||||
rememberMe,
|
||||
errors,
|
||||
isSubmitting,
|
||||
next,
|
||||
setEmail,
|
||||
setPassword,
|
||||
setRememberMe,
|
||||
submit,
|
||||
} = useLoginForm();
|
||||
|
||||
return (
|
||||
/**
|
||||
* `method="post"` and `action` are the no-JavaScript safety net, and they
|
||||
* are load-bearing rather than decorative.
|
||||
*
|
||||
* `submit` calls preventDefault, so on a hydrated page these attributes
|
||||
* are never exercised. But hydration is not instantaneous: a form with
|
||||
* neither attribute submits GET to its own URL, so a Continue pressed
|
||||
* before React attached — or on a page whose bundle failed outright —
|
||||
* navigated to `/login?email=…&password=…`, writing the password into the
|
||||
* address bar, the history and every log along the way.
|
||||
*
|
||||
* With these set, that same early submit is an ordinary POST with the
|
||||
* credentials in the body, and the endpoint answers it with a redirect.
|
||||
* Sign-in therefore works with JavaScript disabled entirely.
|
||||
*/
|
||||
<form
|
||||
onSubmit={submit}
|
||||
method="post"
|
||||
action={LOGIN_ENDPOINT}
|
||||
noValidate
|
||||
className="space-y-[18px]"
|
||||
>
|
||||
{/*
|
||||
The deep link the proxy preserved, carried through the native path —
|
||||
the hydrated path reads it from the URL instead. Without it, a no-JS
|
||||
sign-in from /login?next=/settings/billing would land on the dashboard.
|
||||
*/}
|
||||
{next && <input type="hidden" name="next" value={next} />}
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="login-email"
|
||||
className="block text-[13px] font-medium text-[#3F3D36] mb-2"
|
||||
>
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="login-email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="name@company.com"
|
||||
// aria-invalid + aria-describedby so the message is announced, not
|
||||
// just coloured — a red border alone tells a screen reader nothing.
|
||||
aria-invalid={!!errors.email || !!errors.form}
|
||||
aria-describedby={errors.email ? 'login-email-error' : undefined}
|
||||
disabled={isSubmitting}
|
||||
className={`${FIELD_BASE} px-4 ${
|
||||
errors.email || errors.form ? 'border-[#DC2626]' : 'border-[#E4E1D6]'
|
||||
}`}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p id="login-email-error" className="text-xs text-[#DC2626] mt-1.5">
|
||||
{errors.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="login-password"
|
||||
className="block text-[13px] font-medium text-[#3F3D36] mb-2"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="login-password"
|
||||
name="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
aria-invalid={!!errors.password || !!errors.form}
|
||||
aria-describedby={
|
||||
errors.password ? 'login-password-error' : undefined
|
||||
}
|
||||
disabled={isSubmitting}
|
||||
className={`${FIELD_BASE} pl-4 pr-12 ${
|
||||
errors.password || errors.form
|
||||
? 'border-[#DC2626]'
|
||||
: 'border-[#E4E1D6]'
|
||||
}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((prev) => !prev)}
|
||||
disabled={isSubmitting}
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 rounded-md text-[#8A8577] hover:text-[#14161F] transition-colors outline-none focus-visible:ring-2 focus-visible:ring-[#F4C430]/40 disabled:opacity-50"
|
||||
>
|
||||
{showPassword ? (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24" />
|
||||
<path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68" />
|
||||
<path d="M6.61 6.61A13.52 13.52 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61" />
|
||||
<line x1="2" x2="22" y1="2" y2="22" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p id="login-password-error" className="text-xs text-[#DC2626] mt-1.5">
|
||||
{errors.password}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Anything the server could not attribute to a field — network, 5xx. */}
|
||||
{errors.form && (
|
||||
<p role="alert" className="text-xs text-[#DC2626]">
|
||||
{errors.form}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="rememberMe"
|
||||
checked={rememberMe}
|
||||
onChange={(e) => setRememberMe(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-[#D6D2C4] cursor-pointer accent-[#F4C430]"
|
||||
/>
|
||||
<span className="text-[13px] text-[#3F3D36]">Remember me</span>
|
||||
</label>
|
||||
|
||||
<a
|
||||
href="#"
|
||||
className="text-[13px] font-medium text-[#6B6960] hover:text-[#8A6300] transition-colors"
|
||||
>
|
||||
Forgot password?
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full h-11 rounded-xl bg-[#F4C430] text-[#14161F] font-semibold text-[15px] hover:bg-[#E8B81C] active:scale-[0.995] transition-all shadow-[0_8px_20px_-8px_rgba(244,196,48,0.85)] flex items-center justify-center gap-2 outline-none focus-visible:ring-4 focus-visible:ring-[#F4C430]/35 disabled:opacity-70 disabled:cursor-not-allowed disabled:shadow-none"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<span className="inline-block w-4 h-4 border-2 border-[#14161F]/25 border-t-[#14161F] rounded-full animate-spin" />
|
||||
<span className="sr-only">Signing in</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Continue
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M5 12h14M12 5l7 7-7 7" />
|
||||
</svg>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
157
src/features/auth/components/LoginHeroPanel.tsx
Normal file
157
src/features/auth/components/LoginHeroPanel.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
'use client';
|
||||
|
||||
import {useEffect, useState} from 'react';
|
||||
import Image from 'next/image';
|
||||
|
||||
/**
|
||||
* The left half of the sign-in screen: a two-slide auto-advancing carousel,
|
||||
* the marketing line and the pagination dots.
|
||||
*
|
||||
* Split from LoginSplit purely so that neither file has to be read to change
|
||||
* the other — this one is entirely decorative and holds the only state on the
|
||||
* screen that has nothing to do with signing in (which slide is showing),
|
||||
* while the form half holds all the behaviour.
|
||||
*
|
||||
* Hidden below `md`, where the panel would push the form off the fold.
|
||||
*
|
||||
* ── Full-bleed, and why that is safe here ────────────────────────────────
|
||||
* The artwork covers the whole panel, edge to edge inside the rounded corner.
|
||||
*
|
||||
* That used to cost a lot of picture: against a short, wide panel the 1122×1402
|
||||
* posters lost roughly a quarter of their height, and a different quarter as
|
||||
* the card grew or shrank. It no longer does. The panel is now ~530×650, a
|
||||
* 0.82 ratio against the posters' 0.80, so `cover` scales to width and trims
|
||||
* only a few percent off the top and bottom — the mascot, the logo and the
|
||||
* plinth all survive. If this panel is ever made materially wider or shorter
|
||||
* than the artwork, that stops being true and the crop comes back.
|
||||
*
|
||||
* Slide 1 (mascot with bag) anchors to the BOTTOM so the white bag is lifted
|
||||
* clear of the white caption headline at the base of the card, while the plain
|
||||
* yellow background at the top absorbs any vertical crop.
|
||||
*
|
||||
* Slide 2 (selfie booth) anchors to the TOP so the booth's curved arch, logo
|
||||
* and "Loyaly.ai" title have comfortable breathing room and are never cropped
|
||||
* by the container's top edge or rounded corners.
|
||||
*
|
||||
* The caption sits over the lower band on an explicit-stop scrim rather than
|
||||
* Tailwind's from/via/to, which puts `via` at the midpoint and left the
|
||||
* headline in the thin end of the fade — white type over the near-white
|
||||
* shopping bag, around 1.5:1. Headline, supporting line and dots all share the
|
||||
* left padding edge: a centred dot row under left-aligned type was one of the
|
||||
* things that made the panel read as unresolved.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Shipped brand artwork rather than stock photography: these are the only two
|
||||
* images on the screen and they are the mascot lockups the brand already owns,
|
||||
* so the panel cannot break when a third-party image host does.
|
||||
*/
|
||||
const SLIDES = [
|
||||
{
|
||||
src: '/brand/login-hero-1.jpeg',
|
||||
alt: 'The Loyaly.ai mascot holding a Loyaly.ai shopping bag',
|
||||
position: 'object-bottom',
|
||||
},
|
||||
{
|
||||
src: '/brand/login-hero-2.jpeg',
|
||||
alt: 'The Loyaly.ai selfie booth activation, with SNAP, SMILE and SHARE rewards',
|
||||
position: 'object-top',
|
||||
},
|
||||
] as const;
|
||||
|
||||
/** Long enough to read the caption under the slide, short enough to notice. */
|
||||
const SLIDE_MS = 5000;
|
||||
|
||||
export function LoginHeroPanel() {
|
||||
const [active, setActive] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
// An auto-rotating carousel is exactly the motion the reduced-motion
|
||||
// preference is about, so honour it by holding on the first slide rather
|
||||
// than cross-fading forever behind someone's back.
|
||||
const reduced =
|
||||
typeof window !== 'undefined' &&
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
if (reduced) return;
|
||||
|
||||
const timer = window.setInterval(
|
||||
() => setActive((prev) => (prev + 1) % SLIDES.length),
|
||||
SLIDE_MS,
|
||||
);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="hidden md:block md:w-1/2 p-3">
|
||||
<div className="relative h-full min-h-[600px] overflow-hidden rounded-[22px] bg-[#F7F3E4]">
|
||||
{/*
|
||||
Both slides are always mounted and stacked; only opacity moves. A
|
||||
crossfade needs the outgoing frame to still be painted, and swapping
|
||||
a single <Image src> would flash the panel background between them.
|
||||
*/}
|
||||
{SLIDES.map((slide, index) => (
|
||||
<Image
|
||||
key={slide.src}
|
||||
src={slide.src}
|
||||
alt={index === active ? slide.alt : ''}
|
||||
fill
|
||||
// Only the first slide is above the fold on arrival; the second
|
||||
// still preloads eagerly so the first transition never pops.
|
||||
priority={index === 0}
|
||||
// The panel is display:none below md, but a hidden <img> is still
|
||||
// fetched, so this only ever describes the desktop box: half the
|
||||
// viewport, which at 2x picks the full 1122px source.
|
||||
sizes="50vw"
|
||||
className={`object-cover ${slide.position} transition-opacity duration-1000 ease-in-out ${
|
||||
index === active ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
aria-hidden={index !== active}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Readability scrim — see the note above on the explicit stops. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 h-3/5"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(to top, rgba(0,0,0,0.88) 0%, rgba(0,0,0,0.74) 30%,' +
|
||||
'rgba(0,0,0,0.5) 60%, rgba(0,0,0,0.2) 83%, transparent 100%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 px-8 pb-8 lg:px-10 lg:pb-9">
|
||||
<h2 className="max-w-[15ch] text-[26px] lg:text-[30px] font-bold leading-[1.2] tracking-[-0.015em] text-white">
|
||||
Smarter rewards for stronger brands
|
||||
</h2>
|
||||
<p className="mt-3 max-w-[40ch] text-sm lg:text-[15px] leading-relaxed text-white/85">
|
||||
Empower your customers. Grow your business. All in one place.
|
||||
</p>
|
||||
|
||||
{/* Left-aligned with the type above it, not centred under it. */}
|
||||
<div
|
||||
className="mt-7 flex items-center gap-2"
|
||||
role="tablist"
|
||||
aria-label="Highlights"
|
||||
>
|
||||
{SLIDES.map((slide, index) => (
|
||||
<button
|
||||
key={slide.src}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={index === active}
|
||||
aria-label={`Show highlight ${index + 1} of ${SLIDES.length}`}
|
||||
onClick={() => setActive(index)}
|
||||
className={`h-1.5 rounded-full transition-all duration-500 ${
|
||||
index === active
|
||||
? 'w-7 bg-[#F4C430]'
|
||||
: 'w-1.5 bg-white/45 hover:bg-white/75'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
135
src/features/auth/components/LoginSplit.tsx
Normal file
135
src/features/auth/components/LoginSplit.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import {BrandLogo} from '@/shared/components/brand/BrandLogo';
|
||||
import {LoginCredentialsForm} from './LoginCredentialsForm';
|
||||
import {LoginHeroPanel} from './LoginHeroPanel';
|
||||
|
||||
/**
|
||||
* The sign-in screen's frame: the ivory canvas, the ambient yellow shapes, the
|
||||
* split card, and the two halves that fill it.
|
||||
*
|
||||
* This file used to be 314 lines holding the photograph, the form, the fake
|
||||
* submit and the page chrome at once. It is now the composition only —
|
||||
* behaviour lives in useLoginForm, the form markup in LoginCredentialsForm,
|
||||
* the carousel in LoginHeroPanel — which is what makes each of them separately
|
||||
* readable and separately changeable.
|
||||
*
|
||||
* ── On the literal colour values here ────────────────────────────────────
|
||||
* The workspace is monochrome and token-driven; this screen deliberately is
|
||||
* not. It predates the workspace, it is the only page a merchant sees before
|
||||
* the product, it renders outside the Astryx shell, and it is pinned to the
|
||||
* light palette whatever the theme cookie says (see `.login-surface` in
|
||||
* globals.css). The one hue used is #F4C430 — the same Loyaly yellow as
|
||||
* `--color-brand-warm`, kept literal because no Astryx token is in scope here.
|
||||
*/
|
||||
/**
|
||||
* `/join` renders in the same frame: redeeming an invitation is the other way
|
||||
* into the console, and a second design for it would be a second thing to
|
||||
* keep in step. Called with no props, this is exactly the sign-in screen.
|
||||
*/
|
||||
export function LoginSplit({
|
||||
title = 'Welcome back',
|
||||
description = 'Access your Merchant Operating System to manage stores, rewards, staff and AI insights.',
|
||||
children,
|
||||
}: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
children?: React.ReactNode;
|
||||
} = {}) {
|
||||
return (
|
||||
<div className="login-surface min-h-screen w-full relative flex flex-col items-center justify-center gap-4 p-4 sm:p-6 lg:px-10 lg:py-8 bg-[#FAF9F4] overflow-hidden selection:bg-[#F4C430] selection:text-[#14161F]">
|
||||
{/*
|
||||
The ambient layer: a warm wash at the outer edges, then three large
|
||||
curved shapes cropped by the viewport so only an arc of each is ever
|
||||
visible. The brief is "felt, not seen" — nothing here should read as a
|
||||
distinct object behind the card.
|
||||
|
||||
Painted with radial gradients rather than blurred elements, and that is
|
||||
a performance fix rather than a style choice. Three `blur-[110px]`
|
||||
divs are three compositing layers the size of the viewport, and the
|
||||
carousel crossfading on top of them invalidated all three every frame
|
||||
for a full second every five seconds — enough to lock the renderer.
|
||||
A gradient is soft for free: no filter, no layer, no repaint.
|
||||
*/}
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
background:
|
||||
// Top-right arc.
|
||||
'radial-gradient(60rem 46rem at 104% -14%, rgba(244,196,48,0.30), rgba(244,196,48,0.10) 46%, transparent 72%),' +
|
||||
// Bottom-left arc.
|
||||
'radial-gradient(58rem 44rem at -8% 112%, rgba(244,196,48,0.26), rgba(244,196,48,0.09) 46%, transparent 72%),' +
|
||||
// Left-edge sliver — the tallest and faintest of the three.
|
||||
'radial-gradient(30rem 40rem at -12% 42%, rgba(244,196,48,0.20), transparent 68%),' +
|
||||
// The wash that keeps the four corners off flat white.
|
||||
'radial-gradient(ellipse 80% 70% at 50% 50%, transparent 45%, rgba(244,196,48,0.05) 100%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative z-10 w-full max-w-[1120px] rounded-[28px] bg-white border border-[#EDEAE0] shadow-[0_28px_70px_-30px_rgba(31,29,20,0.22),0_2px_8px_-2px_rgba(31,29,20,0.06)] flex flex-col md:flex-row overflow-hidden">
|
||||
<LoginHeroPanel />
|
||||
|
||||
<div className="w-full md:w-1/2 p-8 sm:p-10 lg:px-12 lg:py-10 flex flex-col justify-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<BrandLogo height={34} priority />
|
||||
<div className="h-4 w-px bg-[#E2DFD4]" />
|
||||
<span className="text-[11px] font-semibold tracking-[0.14em] text-[#8A8577] uppercase">
|
||||
Merchant OS
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-9 lg:mt-10">
|
||||
<h1 className="text-[30px] sm:text-[36px] font-bold tracking-[-0.02em] leading-[1.1] text-[#14161F]">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="mt-3 text-[15px] leading-relaxed text-[#6B6960] max-w-[46ch]">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-7">
|
||||
{children ?? (
|
||||
<>
|
||||
<LoginCredentialsForm />
|
||||
<p className="mt-6 text-[13px] text-[#6B6960]">
|
||||
Got an invitation code?{' '}
|
||||
<Link
|
||||
href="/join"
|
||||
className="font-medium text-[#14161F] underline underline-offset-2 hover:text-[#8A6300] transition-colors"
|
||||
>
|
||||
Join your team
|
||||
</Link>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
In normal flow rather than pinned to the viewport bottom: the card grows
|
||||
with the form's error messages, and an absolutely positioned footer sat
|
||||
on top of it as soon as it did.
|
||||
*/}
|
||||
<p className="relative z-10 text-[11px] text-[#9C978A] text-center">
|
||||
By continuing, you agree to Loyaly's{' '}
|
||||
<a
|
||||
href="#"
|
||||
className="underline underline-offset-2 hover:text-[#6B6960] transition-colors"
|
||||
>
|
||||
Terms of Service
|
||||
</a>{' '}
|
||||
and{' '}
|
||||
<a
|
||||
href="#"
|
||||
className="underline underline-offset-2 hover:text-[#6B6960] transition-colors"
|
||||
>
|
||||
Privacy Policy
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
src/features/auth/guards/AuthGuard.tsx
Normal file
48
src/features/auth/guards/AuthGuard.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import {useEffect} from 'react';
|
||||
import {usePathname, useRouter} from 'next/navigation';
|
||||
import {Center} from '@astryxdesign/core/Center';
|
||||
import {Spinner} from '@astryxdesign/core/Spinner';
|
||||
import {useSession} from '@/features/auth/providers/SessionProvider';
|
||||
|
||||
/**
|
||||
* Client-side half of route protection. The server half is src/proxy.ts.
|
||||
*
|
||||
* ── Why both ──────────────────────────────────────────────────────────────
|
||||
* The proxy is the one that MATTERS: it refuses to serve the route at all, so
|
||||
* there is nothing to defeat by disabling JavaScript. But it only runs on
|
||||
* navigations the server sees. This guard covers what happens afterwards — a
|
||||
* session that expires while a tab sits open, a logout in another tab, a
|
||||
* client-side route change within the already-loaded app. Without it, the
|
||||
* workspace would keep rendering against an identity that no longer exists
|
||||
* until something happened to hit the network.
|
||||
*
|
||||
* Treat this as a correctness guard, never as the security boundary. Anything
|
||||
* that must not leak is fetched through a route handler, and route handlers
|
||||
* check the session themselves.
|
||||
*/
|
||||
export function AuthGuard({children}: {children: React.ReactNode}) {
|
||||
const {status} = useSession();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== 'unauthenticated') return;
|
||||
const next = pathname && pathname !== '/dashboard' ? `?next=${encodeURIComponent(pathname)}` : '';
|
||||
router.replace(`/login${next}`);
|
||||
}, [status, router, pathname]);
|
||||
|
||||
// 'loading' means the bootstrap request is still in flight. Rendering the
|
||||
// workspace here would paint an empty-looking shell and then swap it, and
|
||||
// rendering the redirect would fire it for users who ARE signed in.
|
||||
if (status !== 'authenticated') {
|
||||
return (
|
||||
<Center height="100vh" role="status" aria-label="Checking your session">
|
||||
<Spinner size="lg" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
123
src/features/auth/hooks/useJoinForm.ts
Normal file
123
src/features/auth/hooks/useJoinForm.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
'use client';
|
||||
|
||||
import {useCallback, useState} from 'react';
|
||||
import {useRouter, useSearchParams} from 'next/navigation';
|
||||
import {useSession} from '@/features/auth/providers/SessionProvider';
|
||||
import {authRepository} from '@/features/auth/repositories/authRepository';
|
||||
import {destinationForUser} from '@/features/auth/services/roleDestination';
|
||||
import type {InvitationPreview} from '@/features/auth/types/join';
|
||||
|
||||
/** The platform's own floor, checked here too so the common mistake costs no round trip. */
|
||||
export const MIN_PASSWORD_LENGTH = 8;
|
||||
|
||||
/**
|
||||
* Redeeming an invitation, in two steps.
|
||||
*
|
||||
* 1. The code is checked BEFORE anybody chooses a password, so the screen
|
||||
* can say what they are joining and a mistyped code is caught first.
|
||||
* 2. Name and password are sent with the code; the answer is a full session
|
||||
* and the person goes straight into the console — never to a login form.
|
||||
*
|
||||
* A code in the link (`/join?code=…`) is prefilled, so a code sent over
|
||||
* WhatsApp is one tap. Dashes and case in a code do not matter upstream; it is
|
||||
* sent exactly as typed.
|
||||
*/
|
||||
export function useJoinForm() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const {refresh} = useSession();
|
||||
|
||||
const [code, setCode] = useState(() => searchParams.get('code') ?? '');
|
||||
const [preview, setPreview] = useState<InvitationPreview | null>(null);
|
||||
const [fullName, setFullName] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const check = useCallback(
|
||||
async (value: string) => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
setError('Enter the invitation code you were given.');
|
||||
return;
|
||||
}
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
const res = await authRepository.invitation(trimmed);
|
||||
setIsSubmitting(false);
|
||||
if (!res.ok || !res.data) {
|
||||
setError(res.message ?? 'That invitation code is not valid. Ask for a new one.');
|
||||
return;
|
||||
}
|
||||
setPreview(res.data);
|
||||
// Prefilled from the invitation, and still editable: it is how they
|
||||
// appear to colleagues, and whoever invited them may have guessed.
|
||||
setFullName((prev) => prev || res.data!.fullName);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const register = useCallback(async () => {
|
||||
if (password.length < MIN_PASSWORD_LENGTH) {
|
||||
setError(`Choose a password of at least ${MIN_PASSWORD_LENGTH} characters.`);
|
||||
return;
|
||||
}
|
||||
if (password !== confirm) {
|
||||
setError('The two passwords do not match.');
|
||||
return;
|
||||
}
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
const res = await authRepository.register({
|
||||
code: code.trim(),
|
||||
fullName: fullName.trim(),
|
||||
password,
|
||||
});
|
||||
if (!res.ok || !res.data) {
|
||||
setIsSubmitting(false);
|
||||
setError(res.message ?? 'Could not create your account. Please try again.');
|
||||
return;
|
||||
}
|
||||
// The cookies are set; let the provider read them before navigating, so
|
||||
// the workspace does not paint its signed-out state first. isSubmitting
|
||||
// stays true until the navigation commits, as on the sign-in form.
|
||||
await refresh();
|
||||
router.replace(destinationForUser(res.data.user));
|
||||
router.refresh();
|
||||
}, [code, fullName, password, confirm, refresh, router]);
|
||||
|
||||
const submit = useCallback(
|
||||
(event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (isSubmitting) return;
|
||||
void (preview ? register() : check(code));
|
||||
},
|
||||
[isSubmitting, preview, register, check, code],
|
||||
);
|
||||
|
||||
/** Back to step one, for somebody holding the wrong code. */
|
||||
const reset = useCallback(() => {
|
||||
setPreview(null);
|
||||
setPassword('');
|
||||
setConfirm('');
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
code,
|
||||
preview,
|
||||
fullName,
|
||||
password,
|
||||
confirm,
|
||||
error,
|
||||
isSubmitting,
|
||||
setCode,
|
||||
setFullName,
|
||||
setPassword,
|
||||
setConfirm,
|
||||
check,
|
||||
submit,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
162
src/features/auth/hooks/useLoginForm.ts
Normal file
162
src/features/auth/hooks/useLoginForm.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
'use client';
|
||||
|
||||
import {useCallback, useState} from 'react';
|
||||
import {useRouter, useSearchParams} from 'next/navigation';
|
||||
import {useToast} from '@astryxdesign/core/Toast';
|
||||
import {useSession} from '@/features/auth/providers/SessionProvider';
|
||||
import {
|
||||
LOGIN_ERROR_PARAM,
|
||||
loginErrorFromCode,
|
||||
} from '@/features/auth/services/loginErrorCodes';
|
||||
import {resolveRedirectTargetFor} from '@/features/auth/services/redirectTarget';
|
||||
import {destinationForUser} from '@/features/auth/services/roleDestination';
|
||||
import type {LoginError} from '@/features/auth/types/auth';
|
||||
|
||||
/**
|
||||
* Where credentials are POSTed. Exported because the form element itself needs
|
||||
* it for `action` — the no-JavaScript path submits straight here, bypassing
|
||||
* this hook, authService and the repository entirely.
|
||||
*/
|
||||
export const LOGIN_ENDPOINT = '/api/auth/login';
|
||||
|
||||
/**
|
||||
* Every behaviour of the sign-in form, with no markup attached.
|
||||
*
|
||||
* Pulled out of the component for the reason the brief asks for: the form was
|
||||
* a 314-line file where a fake `setTimeout` login sat in the middle of layout
|
||||
* markup. Now the view renders state and the rules live here — which also
|
||||
* makes the flow testable without mounting a page, and means a redesign of the
|
||||
* screen cannot silently change what "signed in" means.
|
||||
*
|
||||
* The chain below this hook is: SessionProvider → authService (validation and
|
||||
* domain rules) → authRepository (transport) → POST /api/auth/login. This hook
|
||||
* knows about none of it beyond the first link.
|
||||
*/
|
||||
|
||||
export interface LoginFormState {
|
||||
email: string;
|
||||
password: string;
|
||||
rememberMe: boolean;
|
||||
/** Field-addressed, so a message renders under the input that caused it. */
|
||||
errors: Partial<Record<LoginError['field'], string>>;
|
||||
isSubmitting: boolean;
|
||||
}
|
||||
|
||||
export function useLoginForm() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const toast = useToast();
|
||||
const {login} = useSession();
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [rememberMe, setRememberMe] = useState(false);
|
||||
/**
|
||||
* Seeded from `?error=` so a sign-in that failed on the no-JavaScript path
|
||||
* still shows its message once the page hydrates. The code is looked up in a
|
||||
* fixed table — an unrecognised one yields nothing, so the URL cannot be
|
||||
* used to write arbitrary text onto the sign-in screen.
|
||||
*
|
||||
* Seeding in useState rather than an effect keeps the message present in the
|
||||
* first render, server and client alike, so there is no flash and no
|
||||
* hydration mismatch to patch up.
|
||||
*/
|
||||
const [errors, setErrors] = useState<LoginFormState['errors']>(() => {
|
||||
const seeded = loginErrorFromCode(searchParams.get(LOGIN_ERROR_PARAM));
|
||||
return seeded ? {[seeded.field]: seeded.message} : {};
|
||||
});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Raw, for the hidden field the native form path posts back. Unvalidated
|
||||
// here on purpose: the server validates it through resolveRedirectTarget,
|
||||
// which is the only side that can be trusted to.
|
||||
const next = searchParams.get('next');
|
||||
|
||||
// Consumed at submit time through resolveRedirectTargetFor, which is shared
|
||||
// with the no-JavaScript path in the login route so the two cannot land in
|
||||
// different places — and which drops a `next` belonging to the other console.
|
||||
// See redirectTarget.ts.
|
||||
|
||||
const submit = useCallback(
|
||||
async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (isSubmitting) return;
|
||||
|
||||
setErrors({});
|
||||
setIsSubmitting(true);
|
||||
|
||||
const result = await login({email, password, rememberMe});
|
||||
|
||||
if (!result.ok) {
|
||||
setErrors({[result.error.field]: result.error.message});
|
||||
toast({
|
||||
type: 'error',
|
||||
body: result.error.message,
|
||||
isAutoHide: true,
|
||||
autoHideDuration: 4000,
|
||||
uniqueID: 'login-error',
|
||||
collisionBehavior: 'overwrite',
|
||||
});
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Deliberately NOT clearing isSubmitting on success: the button stays in
|
||||
// its loading state until the navigation commits, so the form cannot be
|
||||
// submitted twice while the route transition is in flight.
|
||||
// An explicit `next` wins: somebody sent here from a deep link gets the
|
||||
// page they asked for. Otherwise the destination comes from what the
|
||||
// BACKEND just returned — never from which of the three login pages they
|
||||
// happened to open. `destinationForUser` reads `isPlatformAdmin` before
|
||||
// the role, so an operator lands on /admin and a tenant-scoped `admin`
|
||||
// still lands where merchants do.
|
||||
router.replace(
|
||||
resolveRedirectTargetFor(
|
||||
next,
|
||||
result.session.user.isPlatformAdmin,
|
||||
destinationForUser(result.session.user),
|
||||
),
|
||||
);
|
||||
// The session cookie changed, so any server-rendered layout above this
|
||||
// route is stale. Without this, the workspace can paint its signed-out
|
||||
// seed until something else happens to refetch.
|
||||
router.refresh();
|
||||
},
|
||||
[
|
||||
isSubmitting,
|
||||
login,
|
||||
email,
|
||||
password,
|
||||
rememberMe,
|
||||
toast,
|
||||
router,
|
||||
next,
|
||||
],
|
||||
);
|
||||
|
||||
/** Clearing on edit: an error that outlives the typo it describes is noise. */
|
||||
const updateEmail = useCallback((value: string) => {
|
||||
setEmail(value);
|
||||
setErrors((prev) => (prev.email ? {...prev, email: undefined} : prev));
|
||||
}, []);
|
||||
|
||||
const updatePassword = useCallback((value: string) => {
|
||||
setPassword(value);
|
||||
setErrors((prev) =>
|
||||
prev.password ? {...prev, password: undefined} : prev,
|
||||
);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
email,
|
||||
password,
|
||||
rememberMe,
|
||||
errors,
|
||||
isSubmitting,
|
||||
next,
|
||||
setEmail: updateEmail,
|
||||
setPassword: updatePassword,
|
||||
setRememberMe,
|
||||
submit,
|
||||
};
|
||||
}
|
||||
313
src/features/auth/providers/SessionProvider.tsx
Normal file
313
src/features/auth/providers/SessionProvider.tsx
Normal file
@@ -0,0 +1,313 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from 'react';
|
||||
import {useRouter} from 'next/navigation';
|
||||
import {authService} from '@/features/auth/services/authService';
|
||||
import {TAB_ID_STORAGE_KEY} from '@/features/auth/services/tabScope';
|
||||
import {FOREIGN_SEED_ATTR} from '@/features/auth/services/tabSession';
|
||||
import {SIDENAV_COLLAPSED_KEY} from '@/shared/layouts/workspace/sidebarStorage';
|
||||
import type {
|
||||
AuthSession,
|
||||
AuthUser,
|
||||
LoginCredentials,
|
||||
LoginResult,
|
||||
} from '@/features/auth/types/auth';
|
||||
|
||||
/**
|
||||
* Who is signed in, according to the SERVER.
|
||||
*
|
||||
* ── What changed, and why it matters ──────────────────────────────────────
|
||||
* This used to read a `loyaly.session` object out of localStorage, which meant
|
||||
* the client both stored and validated its own identity — anything with a
|
||||
* devtools console could grant itself a session. Now the credential is an
|
||||
* httpOnly cookie the client cannot read, and this provider's only job is to
|
||||
* ASK (`GET /api/auth/session`) and cache the answer for rendering.
|
||||
*
|
||||
* The consequence to keep in mind: nothing here is a security boundary. It
|
||||
* decides what to draw, never what to permit. Permission is decided in
|
||||
* src/proxy.ts before the route renders, and again in every route handler.
|
||||
*
|
||||
* ── The three states ──────────────────────────────────────────────────────
|
||||
* 'loading' the bootstrap request is in flight; render nothing
|
||||
* identity-shaped or the UI flashes "signed out"
|
||||
* 'authenticated' user present
|
||||
* 'unauthenticated' server says no session
|
||||
*
|
||||
* `loading` is a first-class state rather than `user === null`, because those
|
||||
* two mean opposite things to a guard: one should wait, the other redirect.
|
||||
*/
|
||||
|
||||
export type SessionStatus = 'loading' | 'authenticated' | 'unauthenticated';
|
||||
|
||||
interface SessionValue {
|
||||
status: SessionStatus;
|
||||
session: AuthSession | null;
|
||||
user: AuthUser | null;
|
||||
isAuthenticated: boolean;
|
||||
login: (credentials: LoginCredentials) => Promise<LoginResult>;
|
||||
logout: () => Promise<void>;
|
||||
/** Re-ask the server. Used after anything that could change identity. */
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
const SessionContext = createContext<SessionValue | null>(null);
|
||||
|
||||
export function SessionProvider({
|
||||
children,
|
||||
/**
|
||||
* The server-resolved session, from the signed cookie in the root layout.
|
||||
*
|
||||
* REQUIRED, not optional. Making it optional would mean a mount path where
|
||||
* the client has to ask who it is before it can draw anything, and that
|
||||
* costs a guard spinner on every full page load for an answer the server
|
||||
* already had in the same request.
|
||||
*/
|
||||
initialSession,
|
||||
/**
|
||||
* The tab the seed above was resolved from, or null when the server could
|
||||
* not tell. Compared against this tab's own id — see the effect below.
|
||||
*/
|
||||
initialTabId,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
initialSession: AuthSession | null;
|
||||
initialTabId: string | null;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
/**
|
||||
* `seedIsOurs` decides whether the server's answer is about THIS tab.
|
||||
*
|
||||
* Read through `useSyncExternalStore` with a server snapshot of `true`, and
|
||||
* that split is load-bearing. The server cannot see `sessionStorage`, so it
|
||||
* always renders the seed as ours; hydration has to render the same thing or
|
||||
* React throws a hydration mismatch and regenerates the tree. This used to be
|
||||
* a `useState` initializer that read storage during the hydration render —
|
||||
* which disagreed with the server in every newly opened tab. Now hydration
|
||||
* uses the server's answer and React re-renders with the real one straight
|
||||
* after.
|
||||
*
|
||||
* The server HTML for a foreign seed still contains another person's shell,
|
||||
* so it must not be SEEN: the inline script marks <html> with
|
||||
* FOREIGN_SEED_ATTR before first paint (same comparison as here), CSS hides
|
||||
* the body while it is set, and the layout effect below clears it once this
|
||||
* provider has rendered the client's answer — the guard spinner.
|
||||
*
|
||||
* ── Why a seed can belong to another tab ─────────────────────────────────
|
||||
* A document navigation cannot carry `X-Tab-Id`, so the server resolves the
|
||||
* session through the `loyaly_tab` pointer cookie, which names whichever tab
|
||||
* was last focused. A newly opened tab has not written it yet when its first
|
||||
* document is rendered, so it is handed the previous tab's identity. That is
|
||||
* a HINT (see tabScopeRequest.ts), and it was being treated as an answer:
|
||||
* measured, a second tab opened on /admin rendered the first tab's operator
|
||||
* in the chrome and then failed every data call with 401, because the cookies
|
||||
* those calls need are keyed to a tab id it does not have.
|
||||
*
|
||||
* ── Why comparing ids is enough ──────────────────────────────────────────
|
||||
* The inline script in the root layout writes this tab's id into
|
||||
* `sessionStorage` before any of this parses, so by the time React renders,
|
||||
* the id is there. If it matches the one the seed came from, the seed IS this
|
||||
* tab's and nothing needs re-asking — which is the common case, and it keeps
|
||||
* the seed doing the job it exists for. If it differs, the only honest state
|
||||
* is 'loading' until `GET /api/auth/session` answers with the header, which
|
||||
* addresses the right cookie.
|
||||
*
|
||||
* Storage that throws (private mode) reads as `null` and the seed is trusted,
|
||||
* which is the same fail-open the script and `tabHeaders()` take: a browser
|
||||
* with no `sessionStorage` has one shared session, and that is the documented
|
||||
* degraded mode, not a reason to refuse to render.
|
||||
*/
|
||||
const seedIsOurs = useSyncExternalStore(
|
||||
subscribeNever,
|
||||
() => readSeedIsOurs(initialTabId),
|
||||
() => true,
|
||||
);
|
||||
|
||||
const [session, setSession] = useState<AuthSession | null>(initialSession);
|
||||
const [status, setStatus] = useState<SessionStatus>(
|
||||
initialSession ? 'authenticated' : 'unauthenticated',
|
||||
);
|
||||
/**
|
||||
* Whether the client has had its OWN answer (a fetch, a login, a logout).
|
||||
* Until it has, a foreign seed reads as 'loading' with no session — derived
|
||||
* at render time rather than written into state, so the correction lands in
|
||||
* the same render that learns the seed is not ours.
|
||||
*/
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const awaitingOwnAnswer = !seedIsOurs && !confirmed;
|
||||
|
||||
const settle = useCallback((next: AuthSession | null) => {
|
||||
setSession(next);
|
||||
setStatus(next ? 'authenticated' : 'unauthenticated');
|
||||
setConfirmed(true);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Un-hide the page once what is rendered is the client's own answer.
|
||||
*
|
||||
* Keyed on agreement, not on `seedIsOurs` alone: on the hydration commit the
|
||||
* rendered value is still the server's `true`, and if the client disagrees a
|
||||
* re-render is already on its way — clearing then would flash the foreign
|
||||
* shell. Whenever the two agree, clear, so a disagreement between the script
|
||||
* and this reader can never leave the page hidden for good.
|
||||
*/
|
||||
useLayoutEffect(() => {
|
||||
if (seedIsOurs !== readSeedIsOurs(initialTabId)) return;
|
||||
document.documentElement.removeAttribute(FOREIGN_SEED_ATTR);
|
||||
}, [seedIsOurs, initialTabId]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
settle(await authService.currentSession());
|
||||
}, [settle]);
|
||||
|
||||
/**
|
||||
* Ask for real when the seed was not ours.
|
||||
*
|
||||
* Runs once, only in the tab that detected the mismatch, and it is the whole
|
||||
* reason `seedIsOurs` exists: `refresh()` goes through `httpClient`, which
|
||||
* attaches `X-Tab-Id`, so the server reads THIS tab's cookies and answers
|
||||
* about this tab. A tab with no session of its own lands on 'unauthenticated'
|
||||
* here, and AuthGuard sends it to /login — which now always renders, so that
|
||||
* is where it stops.
|
||||
*
|
||||
* `seedIsOurs` only moves once — from the hydration snapshot to the client's
|
||||
* — and nothing this writes feeds back into it, so it cannot re-fire.
|
||||
* Every later re-check is the visibilitychange listener below.
|
||||
*
|
||||
* The request is spelled out rather than delegated to `refresh()` so the
|
||||
* state updates sit in the async continuation, where they can be dropped if
|
||||
* the provider unmounts first. A late answer landing on a torn-down tree is
|
||||
* the one way a one-shot fetch like this can misbehave.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (seedIsOurs) return;
|
||||
|
||||
let cancelled = false;
|
||||
void authService.currentSession().then((next) => {
|
||||
if (!cancelled) settle(next);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [seedIsOurs, settle]);
|
||||
|
||||
/**
|
||||
* Re-check on focus.
|
||||
*
|
||||
* A workspace tab can sit open for hours. In that time the session can
|
||||
* expire, or the merchant can sign out in another tab — and without this the
|
||||
* shell keeps rendering an identity that no longer exists until something
|
||||
* happens to hit the network. Re-asking when the tab is looked at again is
|
||||
* cheap (one request, only on a real focus change) and it is what makes
|
||||
* AuthGuard's redirect fire at the moment the user comes back.
|
||||
*
|
||||
* This is the subscribe-to-an-external-system shape an effect is for: the
|
||||
* setState happens in the event callback, not in the effect body.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const onFocus = () => {
|
||||
if (document.visibilityState === 'visible') void refresh();
|
||||
};
|
||||
window.addEventListener('visibilitychange', onFocus);
|
||||
return () => window.removeEventListener('visibilitychange', onFocus);
|
||||
}, [refresh]);
|
||||
|
||||
const login = useCallback(
|
||||
async (credentials: LoginCredentials): Promise<LoginResult> => {
|
||||
const result = await authService.login(credentials);
|
||||
if (result.ok) settle(result.session);
|
||||
return result;
|
||||
},
|
||||
[settle],
|
||||
);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await authService.logout();
|
||||
settle(null);
|
||||
|
||||
/**
|
||||
* Clear this tab's preferences — and ONLY this tab's.
|
||||
*
|
||||
* This used to be `localStorage.clear()` + `sessionStorage.clear()`.
|
||||
* `localStorage` is shared by the whole browser, so signing out of one tab
|
||||
* wiped the theme and sidebar state of every other signed-in tab; and
|
||||
* clearing `sessionStorage` wholesale would now also throw away this tab's
|
||||
* id, orphaning the cookie the logout route still has to find.
|
||||
*
|
||||
* Named keys instead. The session itself is already gone server-side, which
|
||||
* is what actually ends it — this is only about not handing the next person
|
||||
* at a shared machine a workspace shaped like the last one.
|
||||
*
|
||||
* Wrapped because storage throws in private mode, and a failed cleanup must
|
||||
* not strand someone in a session they asked to leave.
|
||||
*/
|
||||
try {
|
||||
window.localStorage.removeItem(SIDENAV_COLLAPSED_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
// replace, not push: Back must not return to a workspace the session no
|
||||
// longer authorises. The proxy would bounce it anyway; this keeps the
|
||||
// history clean rather than relying on that.
|
||||
router.replace('/login');
|
||||
// Drop the client router cache too, or a Back gesture can repaint the
|
||||
// previous authenticated render from memory before the proxy is consulted.
|
||||
router.refresh();
|
||||
}, [router, settle]);
|
||||
|
||||
const value = useMemo<SessionValue>(
|
||||
() => {
|
||||
const shownStatus = awaitingOwnAnswer ? 'loading' : status;
|
||||
const shownSession = awaitingOwnAnswer ? null : session;
|
||||
return {
|
||||
status: shownStatus,
|
||||
session: shownSession,
|
||||
user: shownSession?.user ?? null,
|
||||
isAuthenticated: shownStatus === 'authenticated',
|
||||
login,
|
||||
logout,
|
||||
refresh,
|
||||
};
|
||||
},
|
||||
[awaitingOwnAnswer, status, session, login, logout, refresh],
|
||||
);
|
||||
|
||||
return <SessionContext value={value}>{children}</SessionContext>;
|
||||
}
|
||||
|
||||
/** This tab's id never changes after the inline script sets it: nothing to subscribe to. */
|
||||
function subscribeNever(): () => void {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
/**
|
||||
* The same comparison the inline script makes (see tabSession.ts) — keep the
|
||||
* two in step, or the page is hidden by one and never un-hidden by the other.
|
||||
* Storage that throws reads as ours: the documented fail-open.
|
||||
*/
|
||||
function readSeedIsOurs(seedTabId: string | null): boolean {
|
||||
let own: string | null;
|
||||
try {
|
||||
own = window.sessionStorage.getItem(TAB_ID_STORAGE_KEY);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
return own === null || own === seedTabId;
|
||||
}
|
||||
|
||||
export function useSession(): SessionValue {
|
||||
const ctx = useContext(SessionContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useSession must be used inside <SessionProvider>');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
55
src/features/auth/repositories/authRepository.ts
Normal file
55
src/features/auth/repositories/authRepository.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import {postJson, getJson} from '@/shared/services/httpClient';
|
||||
import type {
|
||||
AuthSession,
|
||||
LoginCredentials,
|
||||
LoginError,
|
||||
} from '@/features/auth/types/auth';
|
||||
import type {InvitationPreview} from '@/features/auth/types/join';
|
||||
|
||||
/**
|
||||
* TRANSPORT ONLY.
|
||||
*
|
||||
* This is the layer that knows URLs, verbs and status codes, and it is the
|
||||
* only one. Swapping the mock route handlers for `https://api.loyaly.ai` means
|
||||
* editing the three strings below — the service above it, the hook above that
|
||||
* and the form above that never learn where the data came from.
|
||||
*
|
||||
* It deliberately does NOT interpret results: mapping an HTTP failure onto a
|
||||
* field-addressed form error is a domain decision and belongs in the service.
|
||||
*/
|
||||
|
||||
export interface AuthTransportResult<T> {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
data?: T;
|
||||
message?: string;
|
||||
/** Present when the server attributed a failure to one input. */
|
||||
field?: LoginError['field'];
|
||||
}
|
||||
|
||||
export const authRepository = {
|
||||
login(credentials: LoginCredentials) {
|
||||
return postJson<AuthSession>('/api/auth/login', credentials);
|
||||
},
|
||||
|
||||
logout() {
|
||||
return postJson<{ok: boolean}>('/api/auth/logout', {});
|
||||
},
|
||||
|
||||
/** What a code is for. No session needed — the holder has no account yet. */
|
||||
invitation(code: string) {
|
||||
return getJson<InvitationPreview>(
|
||||
`/api/auth/invitation?code=${encodeURIComponent(code)}`,
|
||||
);
|
||||
},
|
||||
|
||||
/** Redeem a code. Success sets the session cookies, like login does. */
|
||||
register(body: {code: string; fullName: string; password: string}) {
|
||||
return postJson<AuthSession>('/api/auth/register', body);
|
||||
},
|
||||
|
||||
/** Null data means "no session" — a normal answer, not a failure. */
|
||||
currentSession() {
|
||||
return getJson<AuthSession | null>('/api/auth/session');
|
||||
},
|
||||
};
|
||||
99
src/features/auth/services/authService.ts
Normal file
99
src/features/auth/services/authService.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import {authRepository} from '@/features/auth/repositories/authRepository';
|
||||
import type {
|
||||
AuthSession,
|
||||
LoginCredentials,
|
||||
LoginError,
|
||||
LoginResult,
|
||||
} from '@/features/auth/types/auth';
|
||||
|
||||
/**
|
||||
* The domain layer for authentication.
|
||||
*
|
||||
* It owns the RULES — what counts as valid input, what an HTTP status means in
|
||||
* user terms, what "signed out" looks like — and it owns them independently of
|
||||
* both the transport below it and the React tree above it. Nothing here is
|
||||
* async-framework-specific, so it is directly unit-testable and it survives a
|
||||
* backend swap untouched.
|
||||
*/
|
||||
|
||||
const EMAIL_PATTERN = /^\S+@\S+\.\S+$/;
|
||||
|
||||
/**
|
||||
* Client-side validation, mirroring the server's.
|
||||
*
|
||||
* Duplicated on purpose, and the duplication is the point: this copy exists
|
||||
* for latency (no round trip to be told a field is blank), the server's copy
|
||||
* exists for correctness (a POST can arrive without ever passing through this
|
||||
* form). Neither is redundant, because they answer to different threats.
|
||||
*/
|
||||
export function validateCredentials(
|
||||
input: Pick<LoginCredentials, 'email' | 'password'>,
|
||||
): LoginError | null {
|
||||
if (!input.email.trim()) {
|
||||
return {field: 'email', message: 'Enter your email address.'};
|
||||
}
|
||||
if (!EMAIL_PATTERN.test(input.email.trim())) {
|
||||
return {field: 'email', message: 'Enter a valid email address.'};
|
||||
}
|
||||
if (!input.password) {
|
||||
return {field: 'password', message: 'Enter your password.'};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Narrow the server's free-form `field` back onto the union. */
|
||||
function toErrorField(field: string | undefined): LoginError['field'] {
|
||||
return field === 'email' || field === 'password' ? field : 'form';
|
||||
}
|
||||
|
||||
export const authService = {
|
||||
/**
|
||||
* Validate, then attempt. Returns a discriminated result — callers cannot
|
||||
* accidentally treat a failure as a success, and nothing throws, because a
|
||||
* wrong password is an expected outcome of logging in, not an exception.
|
||||
*/
|
||||
async login(credentials: LoginCredentials): Promise<LoginResult> {
|
||||
const invalid = validateCredentials(credentials);
|
||||
if (invalid) return {ok: false, error: invalid};
|
||||
|
||||
const res = await authRepository.login(credentials);
|
||||
|
||||
if (!res.ok || !res.data) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
field: toErrorField(res.field),
|
||||
message:
|
||||
res.message ??
|
||||
(res.status === 0
|
||||
? 'Could not reach the server. Check your connection.'
|
||||
: 'Sign-in failed. Please try again.'),
|
||||
},
|
||||
};
|
||||
}
|
||||
return {ok: true, session: res.data};
|
||||
},
|
||||
|
||||
/**
|
||||
* Ends the server session. Resolves even when the request fails — a user who
|
||||
* pressed Log out must never be left looking at a workspace because the
|
||||
* network blipped; the client clears its own state and redirects regardless,
|
||||
* and the cookie's own expiry is the backstop.
|
||||
*/
|
||||
async logout(): Promise<void> {
|
||||
try {
|
||||
await authRepository.logout();
|
||||
} catch {
|
||||
/* deliberately ignored — see above */
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Who the server says we are. `null` is a valid answer (signed out), so it
|
||||
* is returned as data rather than raised as an error.
|
||||
*/
|
||||
async currentSession(): Promise<AuthSession | null> {
|
||||
const res = await authRepository.currentSession();
|
||||
return res.ok ? (res.data ?? null) : null;
|
||||
},
|
||||
};
|
||||
72
src/features/auth/services/loginErrorCodes.ts
Normal file
72
src/features/auth/services/loginErrorCodes.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type {LoginError} from '@/features/auth/types/auth';
|
||||
|
||||
/**
|
||||
* The no-JavaScript sign-in path's error vocabulary.
|
||||
*
|
||||
* When the form posts natively (see LoginCredentialsForm), the server cannot
|
||||
* hand the failure back in a JSON body — it has to redirect, and the only
|
||||
* channel a redirect has is the URL. So failures travel as a short CODE that
|
||||
* is looked up here, never as the message itself.
|
||||
*
|
||||
* That indirection is the point. A `?message=` parameter is attacker-authored
|
||||
* text rendered inside the sign-in screen: enough to forge "Your account was
|
||||
* suspended — call this number." A code that is not in this table renders
|
||||
* nothing at all, so the worst an attacker can put on the page is silence.
|
||||
*
|
||||
* Note what is absent: the email, the password, and any echo of user input.
|
||||
* The redirect carries the fact of a failure and nothing else.
|
||||
*/
|
||||
|
||||
export const LOGIN_ERROR_PARAM = 'error';
|
||||
|
||||
/** Code → the field it belongs to and what the user reads. */
|
||||
const LOGIN_ERRORS: Record<string, LoginError> = {
|
||||
email_required: {field: 'email', message: 'Invalid email or password.'},
|
||||
email_invalid: {field: 'email', message: 'Invalid email or password.'},
|
||||
password_required: {field: 'password', message: 'Invalid email or password.'},
|
||||
invalid_credentials: {field: 'form', message: 'Invalid email or password.'},
|
||||
malformed: {field: 'form', message: 'Sign-in failed. Please try again.'},
|
||||
// The platform throttles at 10 failures per account and 60 per IP in 15
|
||||
// minutes, cleared by a success. Distinct from bad credentials because the
|
||||
// remedy is different: waiting, not retyping.
|
||||
too_many_attempts: {
|
||||
field: 'form',
|
||||
message: 'Too many sign-in attempts. Wait a few minutes and try again.',
|
||||
},
|
||||
platform_unreachable: {
|
||||
field: 'form',
|
||||
message: 'Could not reach Loyaly. Check your connection and try again.',
|
||||
},
|
||||
/**
|
||||
* The SERVER is misconfigured — LOYALY_API_BASE is missing or points
|
||||
* somewhere that is not the platform. Separate from platform_unreachable
|
||||
* because the remedies have nothing in common: "check your connection" is
|
||||
* actively wrong advice for a fault that no user can do anything about, and
|
||||
* it had people checking their wifi while an env var sat unset.
|
||||
*
|
||||
* Says nothing about which variable. The operator detail goes to the server
|
||||
* log; this is what the person at the form reads.
|
||||
*/
|
||||
misconfigured: {
|
||||
field: 'form',
|
||||
message: 'Sign-in is unavailable right now. Please contact support.',
|
||||
},
|
||||
/**
|
||||
* Correct credentials for a platform admin. This console has no platform
|
||||
* console of its own, so the session is refused and they are pointed at the
|
||||
* one that does.
|
||||
*/
|
||||
platform_account: {
|
||||
field: 'form',
|
||||
message:
|
||||
'This is a platform account. Sign in to the platform console instead.',
|
||||
},
|
||||
};
|
||||
|
||||
export type LoginErrorCode = keyof typeof LOGIN_ERRORS;
|
||||
|
||||
/** Unknown or absent codes resolve to null — render nothing, never guess. */
|
||||
export function loginErrorFromCode(code: string | null): LoginError | null {
|
||||
if (!code) return null;
|
||||
return LOGIN_ERRORS[code] ?? null;
|
||||
}
|
||||
64
src/features/auth/services/redirectTarget.ts
Normal file
64
src/features/auth/services/redirectTarget.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Where a signed-in user should land, from the proxy's `?next=` hint.
|
||||
*
|
||||
* ── Why this is shared rather than inlined ───────────────────────────────
|
||||
* Two paths redirect after a successful sign-in — the hydrated form and the
|
||||
* native form POST handled in the login route — and they must agree. Measured
|
||||
* back when they did not: signing in from `/login?next=/settings/billing`
|
||||
* landed on /dashboard because one side carried a hardcoded destination. Both
|
||||
* now resolve through `resolveRedirectTargetFor` below, so the answer does not
|
||||
* depend on which path the browser took.
|
||||
*
|
||||
* (A third redirecting authority used to exist — `GuestGuard`, which reacted to
|
||||
* the session becoming authenticated — and racing it was the original reason
|
||||
* this module is shared. It is gone: see shared/layouts/PublicLayout.tsx.)
|
||||
*
|
||||
* ── Why the validation matters ───────────────────────────────────────────
|
||||
* `next` is attacker-controllable — it is a query parameter on a public page.
|
||||
* Anything that is not a same-origin absolute path is discarded, which closes
|
||||
* the open-redirect that `router.replace(next)` would otherwise be:
|
||||
*
|
||||
* /settings/billing → allowed
|
||||
* https://evil.test → rejected (absolute URL)
|
||||
* //evil.test → rejected (protocol-relative — still leaves the site)
|
||||
* settings/billing → rejected (relative; resolves against the current path)
|
||||
*/
|
||||
|
||||
export const DEFAULT_DESTINATION = '/dashboard';
|
||||
|
||||
/**
|
||||
* `next`, but only when it belongs to the person who just signed in.
|
||||
*
|
||||
* ── Why the plain version is not enough after sign-in ────────────────────
|
||||
* `next` is written by the proxy when a request for a protected path has no
|
||||
* session — someone's cookie lapsed while they were working. Resuming there is
|
||||
* the point, and that still happens.
|
||||
*
|
||||
* What must NOT happen is a stale `next` outliving the identity it was written
|
||||
* for. The sign-in page is shared: a staff member's session lapses on /customers,
|
||||
* the proxy writes `?next=/customers`, and the next person to use that screen is
|
||||
* handed a path chosen by the last one. Across consoles that is worse than
|
||||
* untidy — a platform operator would be dropped into a tenant screen that
|
||||
* answers 500 to every call, and a merchant into /admin, which the platform
|
||||
* answers 404 to. Neither is a page either of them can use.
|
||||
*
|
||||
* So the rule is: honour `next` only when it is on the same side of the
|
||||
* admin/merchant line as the user the platform just returned. Anything else
|
||||
* falls back to that user's own home, which is always somewhere they can work.
|
||||
*
|
||||
* Deliberately NOT a per-role allowlist. /customers is a legitimate destination
|
||||
* for a manager as well as for staff, and refusing it would break the ordinary
|
||||
* resume-where-you-were case to chase a tidier-looking rule.
|
||||
*/
|
||||
export function resolveRedirectTargetFor(
|
||||
next: string | null,
|
||||
isPlatformAdmin: boolean,
|
||||
fallback: string,
|
||||
): string {
|
||||
if (!next || !next.startsWith('/') || next.startsWith('//')) return fallback;
|
||||
|
||||
// No admin console here: a platform admin has nowhere to resume to.
|
||||
if (isPlatformAdmin) return fallback;
|
||||
|
||||
return next;
|
||||
}
|
||||
54
src/features/auth/services/roleDestination.ts
Normal file
54
src/features/auth/services/roleDestination.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import {DEFAULT_DESTINATION} from './redirectTarget';
|
||||
import type {AuthUser, UserRole} from '@/features/auth/types/auth';
|
||||
|
||||
/**
|
||||
* Where a signed-in user lands, decided by what the BACKEND returned.
|
||||
*
|
||||
* ── Never from the page or the URL ───────────────────────────────────────
|
||||
* There is one sign-in page. `POST /api/auth/login` takes an address, a
|
||||
* password and a device label, and nothing else — no role, no user_type, no
|
||||
* per-audience endpoint — so nothing about who somebody is can be known until
|
||||
* the platform has said so. This is read only AFTER that answer arrives.
|
||||
*
|
||||
* That also preserves the form's no-enumeration property: a destination chosen
|
||||
* before authentication would tell an unauthenticated stranger which role an
|
||||
* address holds, the same leak the identical wrong-password / no-such-account
|
||||
* answer exists to close.
|
||||
*
|
||||
* One module, read by BOTH sign-in paths — the hydrated fetch and the native
|
||||
* form POST — so a browser with JavaScript disabled cannot land somewhere else.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Tenant roles only, and `admin` is deliberately absent.
|
||||
*
|
||||
* That is not an oversight: `admin` is a role a TENANT can hold too — an
|
||||
* account with `role === 'admin'` and a non-empty `client_id` is an ordinary
|
||||
* merchant user who belongs on /dashboard. Keying the platform console off the
|
||||
* role alone would send that person to a console with nothing in it for them,
|
||||
* which is exactly the mistake `isPlatformAdmin` exists to prevent.
|
||||
*
|
||||
* The distinction is decided server-side from the role AND an empty
|
||||
* `client_id` together — see userMapper.
|
||||
*/
|
||||
export const ROLE_DESTINATIONS: Record<Exclude<UserRole, 'admin'>, string> = {
|
||||
staff: DEFAULT_DESTINATION,
|
||||
manager: DEFAULT_DESTINATION,
|
||||
owner: DEFAULT_DESTINATION,
|
||||
};
|
||||
|
||||
/**
|
||||
* The landing route for a user. Platform admins never get a session in this
|
||||
* console (the login route refuses them), so only the role decides.
|
||||
*/
|
||||
export function destinationForUser(user: AuthUser): string {
|
||||
return destinationForRole(user.role);
|
||||
}
|
||||
|
||||
/** Falls back to the default for a role this console does not know. */
|
||||
export function destinationForRole(role: string | undefined): string {
|
||||
if (!role) return DEFAULT_DESTINATION;
|
||||
return (
|
||||
ROLE_DESTINATIONS[role as Exclude<UserRole, 'admin'>] ?? DEFAULT_DESTINATION
|
||||
);
|
||||
}
|
||||
15
src/features/auth/services/roles.ts
Normal file
15
src/features/auth/services/roles.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type {UserRole} from '@/features/auth/types/auth';
|
||||
|
||||
/**
|
||||
* Whether a role may be OFFERED an action — never whether it may perform it.
|
||||
*
|
||||
* The platform decides that, on every request, and answers 403 with a message
|
||||
* saying who can. This only keeps buttons off the screen for people who would
|
||||
* be refused, so a staff account is not shown an Erase it cannot use. Roles are
|
||||
* strictly nested: each can do everything the one below can.
|
||||
*/
|
||||
const RANK: Record<UserRole, number> = {staff: 0, manager: 1, owner: 2, admin: 3};
|
||||
|
||||
export function hasRole(role: UserRole | undefined, minimum: UserRole): boolean {
|
||||
return role !== undefined && RANK[role] >= RANK[minimum];
|
||||
}
|
||||
80
src/features/auth/services/serverSession.ts
Normal file
80
src/features/auth/services/serverSession.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import 'server-only';
|
||||
import {cookies} from 'next/headers';
|
||||
import {verifySessionToken} from '@/features/auth/services/sessionToken';
|
||||
import {sessionCookieFor} from '@/features/auth/services/tabScope';
|
||||
import {resolveTabId} from '@/features/auth/services/tabScopeRequest';
|
||||
import type {AuthSession, UserRole} from '@/features/auth/types/auth';
|
||||
|
||||
/**
|
||||
* Who the request claims to be, from the signed cookie.
|
||||
*
|
||||
* ── Identity here, AUTHORITY upstream ────────────────────────────────────
|
||||
* This resolves identity for RENDERING — seeding the shell so a page load
|
||||
* paints the signed-in header instead of flashing a spinner, and refusing
|
||||
* anonymous route handlers early. It is intentionally cheap: verifying an HMAC
|
||||
* costs microseconds, and this runs on every request.
|
||||
*
|
||||
* It is NOT the authority on whether the session is still good. That lives on
|
||||
* the platform, and it answers on two paths that cannot be skipped:
|
||||
*
|
||||
* • /api/auth/session calls GET /api/auth/me on every page load, so a
|
||||
* deactivated user loses the shell on their next navigation
|
||||
* • every data route carries a platform token, so a revoked session gets a
|
||||
* 401 from the platform itself the moment it asks for anything real
|
||||
*
|
||||
* The previous version re-read a fixture directory here to catch role changes.
|
||||
* There is no local directory any more — the platform is the directory — so
|
||||
* the check moved to where the platform actually answers.
|
||||
*/
|
||||
/**
|
||||
* ── Which tab's session ──────────────────────────────────────────────────
|
||||
* Resolved through `resolveTabId`, so a server render seeds the shell with the
|
||||
* identity of the tab making the request rather than whichever tab signed in
|
||||
* most recently. During a Server Component render there is no `X-Tab-Id`
|
||||
* header, so this falls back to the `loyaly_tab` pointer cookie — a hint, and
|
||||
* deliberately only a hint. `SessionProvider` re-resolves on the client with
|
||||
* the header, so a stale pointer costs one corrected fetch and never renders
|
||||
* one tab's data inside another.
|
||||
*/
|
||||
export async function getServerSession(): Promise<AuthSession | null> {
|
||||
const tabId = await resolveTabId();
|
||||
if (!tabId) return null;
|
||||
|
||||
const store = await cookies();
|
||||
const payload = verifySessionToken(store.get(sessionCookieFor(tabId))?.value);
|
||||
if (!payload) return null;
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: payload.sub,
|
||||
email: payload.email,
|
||||
name: payload.name,
|
||||
role: payload.role as UserRole,
|
||||
organisation: payload.organisation,
|
||||
// `=== true` rather than a cast: a cookie minted before this field
|
||||
// existed has it `undefined`, and a merchant must read as false.
|
||||
isPlatformAdmin: payload.isPlatformAdmin === true,
|
||||
},
|
||||
expiresAt: new Date(payload.exp * 1000).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard for route handlers that must not answer an anonymous request.
|
||||
*
|
||||
* const auth = await requireSession();
|
||||
* if ('response' in auth) return auth.response;
|
||||
*/
|
||||
export async function requireSession(): Promise<
|
||||
{session: AuthSession} | {response: Response}
|
||||
> {
|
||||
const session = await getServerSession();
|
||||
if (session) return {session};
|
||||
|
||||
return {
|
||||
response: Response.json(
|
||||
{error: {code: 'unauthorized', message: 'Sign in to continue.'}},
|
||||
{status: 401, headers: {'cache-control': 'no-store'}},
|
||||
),
|
||||
};
|
||||
}
|
||||
149
src/features/auth/services/sessionToken.ts
Normal file
149
src/features/auth/services/sessionToken.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import {createHmac, timingSafeEqual} from 'node:crypto';
|
||||
import {authSecret} from '@/shared/config/authSecret';
|
||||
|
||||
/**
|
||||
* The session cookie format, and the only place that knows how to mint or
|
||||
* verify one. SERVER ONLY — imported by the auth route handlers and by
|
||||
* src/proxy.ts, never by a component.
|
||||
*
|
||||
* ── Why a signed cookie rather than localStorage ─────────────────────────
|
||||
* The session this replaces lived in localStorage, which means (a) any script
|
||||
* on the origin could read it, (b) the "session" was whatever the client said
|
||||
* it was, and (c) the server had no way to gate a request. An httpOnly cookie
|
||||
* is invisible to JS, travels automatically, and can be checked in the proxy
|
||||
* before a protected page is ever rendered. That is the difference between a
|
||||
* login screen and authentication.
|
||||
*
|
||||
* ── Format ────────────────────────────────────────────────────────────────
|
||||
* base64url(JSON payload) + "." + base64url(HMAC-SHA256(payload, secret))
|
||||
*
|
||||
* Self-contained and stateless, like a JWT without the algorithm-confusion
|
||||
* surface: there is no `alg` field to downgrade, because there is exactly one
|
||||
* algorithm. If a real backend issues JWTs instead, only `verifySessionToken`
|
||||
* changes — its callers already treat the result as an opaque payload.
|
||||
*
|
||||
* NOT encrypted, only signed: the payload is readable by anyone holding the
|
||||
* cookie (which is the user themselves), so it carries an id and a display
|
||||
* name, never anything secret.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The signing key comes from shared/config/authSecret and nowhere else.
|
||||
*
|
||||
* It used to be read here from process.env directly, with tokenStore.ts doing
|
||||
* the same a second time under slightly different rules — one accepted a
|
||||
* whitespace-only value that the other rejected. Signing and encryption now
|
||||
* resolve through one function, so the identity cookie and the token bundle
|
||||
* cannot disagree about whether this deployment has a secret.
|
||||
*/
|
||||
|
||||
export const SESSION_COOKIE = 'loyaly_session';
|
||||
|
||||
/** Cookie lifetimes. `remember me` is the difference between the two. */
|
||||
export const REMEMBERED_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; // 30 days
|
||||
export const SESSION_MAX_AGE_SECONDS = 60 * 60 * 12; // 12 hours
|
||||
|
||||
export interface SessionPayload {
|
||||
/** User id. The name is display-only; authorisation resolves from this. */
|
||||
sub: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: string;
|
||||
organisation: string;
|
||||
/**
|
||||
* A platform operator, not a merchant. Computed SERVER-SIDE from
|
||||
* `role === 'admin' && client_id === ''` (userMapper.isPlatformAdmin) and
|
||||
* signed into this payload, because the browser never sees `client_id` and
|
||||
* must not infer the answer from `organisation` — `client_name` is
|
||||
* COALESCEd to '' upstream, so a tenant whose company name is blank would
|
||||
* read as an operator.
|
||||
*
|
||||
* Routing only. Authorisation stays upstream: the platform answers 404 to
|
||||
* `/api/admin/*` for anyone who is not one of these, whatever this says.
|
||||
*/
|
||||
isPlatformAdmin: boolean;
|
||||
/** Issued-at and expiry, epoch seconds. */
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
function b64url(input: Buffer | string): string {
|
||||
return Buffer.from(input).toString('base64url');
|
||||
}
|
||||
|
||||
function sign(data: string): string {
|
||||
return createHmac('sha256', authSecret()).update(data).digest('base64url');
|
||||
}
|
||||
|
||||
export function createSessionToken(
|
||||
payload: Omit<SessionPayload, 'iat' | 'exp'>,
|
||||
maxAgeSeconds: number,
|
||||
): string {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const full: SessionPayload = {
|
||||
...payload,
|
||||
iat: now,
|
||||
exp: now + maxAgeSeconds,
|
||||
};
|
||||
const body = b64url(JSON.stringify(full));
|
||||
return `${body}.${sign(body)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the payload only when the signature verifies AND the token is
|
||||
* unexpired. Every failure mode — malformed, tampered, expired — returns null,
|
||||
* because callers must treat all of them identically: no session.
|
||||
*
|
||||
* The comparison is timing-safe. A `===` here leaks how many leading bytes of
|
||||
* a forged signature were correct, which is enough to forge one byte at a time.
|
||||
*/
|
||||
export function verifySessionToken(
|
||||
token: string | undefined,
|
||||
): SessionPayload | null {
|
||||
if (!token) return null;
|
||||
|
||||
const dot = token.lastIndexOf('.');
|
||||
if (dot <= 0) return null;
|
||||
|
||||
const body = token.slice(0, dot);
|
||||
const provided = Buffer.from(token.slice(dot + 1));
|
||||
const expected = Buffer.from(sign(body));
|
||||
|
||||
if (
|
||||
provided.length !== expected.length ||
|
||||
!timingSafeEqual(provided, expected)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let payload: SessionPayload;
|
||||
try {
|
||||
payload = JSON.parse(
|
||||
Buffer.from(body, 'base64url').toString('utf8'),
|
||||
) as SessionPayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof payload.exp !== 'number' || payload.exp * 1000 <= Date.now()) {
|
||||
return null;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cookie attributes, in one place so the login route and the logout route
|
||||
* cannot disagree about scope — a delete that misses on `path` leaves a live
|
||||
* session behind.
|
||||
*/
|
||||
export function sessionCookieOptions(maxAgeSeconds?: number) {
|
||||
return {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax' as const,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
path: '/',
|
||||
// Omitted for a browser-session cookie: the browser drops it on close,
|
||||
// which is exactly what "don't remember me" should mean.
|
||||
...(maxAgeSeconds !== undefined ? {maxAge: maxAgeSeconds} : {}),
|
||||
};
|
||||
}
|
||||
88
src/features/auth/services/tabScope.ts
Normal file
88
src/features/auth/services/tabScope.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Which browser TAB a session belongs to — the naming rules, and nothing else.
|
||||
*
|
||||
* ── The problem ──────────────────────────────────────────────────────────
|
||||
* A cookie jar belongs to the browser profile, not the tab. One pair of cookies
|
||||
* for the whole origin meant one identity for the whole browser: signing in as
|
||||
* a manager in a second tab replaced the admin in the first, and merely
|
||||
* switching back to the first tab repainted it as the manager, because the
|
||||
* session provider refetches on `visibilitychange`. No cookie attribute scopes
|
||||
* a cookie to a tab; this is not something React state can fix.
|
||||
*
|
||||
* ── The fix ──────────────────────────────────────────────────────────────
|
||||
* Every tab mints a random id into `sessionStorage` — the only per-tab lifetime
|
||||
* browsers give us — and each tab's session lives in its OWN pair of cookies:
|
||||
*
|
||||
* loyaly_session_<tabId> signed identity
|
||||
* loyaly_tokens_<tabId> AES-sealed access + refresh
|
||||
*
|
||||
* Nothing about the security model changes. Both cookies are still httpOnly, so
|
||||
* JavaScript still cannot read a token. The id is NOT a credential: it names
|
||||
* which cookie to open, and a forged one selects a cookie the attacker's own
|
||||
* browser already had — or, far more likely, none at all.
|
||||
*
|
||||
* ── Why this file is pure ────────────────────────────────────────────────
|
||||
* `src/proxy.ts` needs `isSessionCookieName` and `sessionCookieFor`, and the
|
||||
* proxy cannot import `server-only` — that package throws on import outside a
|
||||
* react-server condition, the same trap documented in platformApi.ts. So the
|
||||
* request-context half (`resolveTabId`, which reads headers and cookies) lives
|
||||
* in tabScopeRequest.ts, and everything here is a pure string function.
|
||||
*/
|
||||
|
||||
/** Names the tab; carries no authority of its own. Not httpOnly — the tab's
|
||||
* own script writes it, and it is not a credential. */
|
||||
export const TAB_POINTER_COOKIE = 'loyaly_tab';
|
||||
|
||||
export const TAB_ID_HEADER = 'x-tab-id';
|
||||
|
||||
/** Where each tab keeps its id. `sessionStorage`, so it is empty in a new tab,
|
||||
* survives that tab's reloads, and dies with it. */
|
||||
export const TAB_ID_STORAGE_KEY = 'loyaly.tab-id';
|
||||
|
||||
const SESSION_PREFIX = 'loyaly_session_';
|
||||
const TOKEN_PREFIX = 'loyaly_tokens_';
|
||||
|
||||
/**
|
||||
* Strict, and this is the load-bearing line in the file.
|
||||
*
|
||||
* The id becomes part of a COOKIE NAME and it arrives from the client. Anything
|
||||
* looser than a fixed alphabet lets a crafted value inject cookie syntax — a
|
||||
* `;`, a space, an `=` — and name a cookie it was never meant to reach.
|
||||
* Lowercase alphanumerics only, bounded length, no exceptions.
|
||||
*/
|
||||
const TAB_ID = /^[a-z0-9]{8,32}$/;
|
||||
|
||||
export function isValidTabId(value: string | undefined | null): value is string {
|
||||
return typeof value === 'string' && TAB_ID.test(value);
|
||||
}
|
||||
|
||||
export function sessionCookieFor(tabId: string): string {
|
||||
return `${SESSION_PREFIX}${tabId}`;
|
||||
}
|
||||
|
||||
export function tokenCookieFor(tabId: string): string {
|
||||
return `${TOKEN_PREFIX}${tabId}`;
|
||||
}
|
||||
|
||||
/** Every session cookie in the jar — one per signed-in tab. */
|
||||
export function isSessionCookieName(name: string): boolean {
|
||||
return (
|
||||
name.startsWith(SESSION_PREFIX) &&
|
||||
isValidTabId(name.slice(SESSION_PREFIX.length))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The pointer is readable by script on purpose — the tab writes it on load and
|
||||
* on focus so the next DOCUMENT navigation, which cannot carry a header, is
|
||||
* server-rendered as the right user. `lax` keeps it off cross-site requests,
|
||||
* and it holds no authority regardless.
|
||||
*/
|
||||
export function tabPointerOptions() {
|
||||
return {
|
||||
httpOnly: false,
|
||||
sameSite: 'lax' as const,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
path: '/',
|
||||
};
|
||||
}
|
||||
53
src/features/auth/services/tabScopeRequest.ts
Normal file
53
src/features/auth/services/tabScopeRequest.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import 'server-only';
|
||||
import {cookies, headers} from 'next/headers';
|
||||
import {randomBytes} from 'node:crypto';
|
||||
import {TAB_ID_HEADER, TAB_POINTER_COOKIE, isValidTabId} from './tabScope';
|
||||
|
||||
/**
|
||||
* Resolving which tab the CURRENT request came from.
|
||||
*
|
||||
* Split from tabScope.ts because this half touches `next/headers` and is
|
||||
* `server-only`; the proxy imports the pure half and could not load this one.
|
||||
*
|
||||
* ── Two channels, in priority order ──────────────────────────────────────
|
||||
* Neither works everywhere, which is why there are two:
|
||||
*
|
||||
* 1. `X-Tab-Id` on fetches from the app. AUTHORITATIVE — it comes from the
|
||||
* tab making the request, so a background tab polling in the same browser
|
||||
* reads its own session rather than the focused tab's.
|
||||
*
|
||||
* 2. The `loyaly_tab` POINTER cookie, for DOCUMENT navigations, which cannot
|
||||
* carry a custom header. The tab writes it on load and on focus, so it
|
||||
* tracks the tab actually in use — only one tab is focused at a time,
|
||||
* which is what makes a single pointer sufficient.
|
||||
*
|
||||
* The pointer is a hint for server rendering and for the proxy's role routing,
|
||||
* never the authority. The client always re-resolves with the header, so a
|
||||
* stale pointer costs one corrected fetch and can never render one tab's data
|
||||
* inside another.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Null when neither channel yields a valid id — a request from outside the app,
|
||||
* or a tab that has not claimed one yet.
|
||||
*
|
||||
* Callers must treat null as "no session", never as "any session". Picking an
|
||||
* arbitrary cookie out of the jar would be precisely the cross-tab leak this
|
||||
* module exists to close.
|
||||
*/
|
||||
export async function resolveTabId(): Promise<string | null> {
|
||||
const fromHeader = (await headers()).get(TAB_ID_HEADER);
|
||||
if (isValidTabId(fromHeader)) return fromHeader;
|
||||
|
||||
const pointer = (await cookies()).get(TAB_POINTER_COOKIE)?.value;
|
||||
return isValidTabId(pointer) ? pointer : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 16 hex characters from a CSPRNG. Not a secret — just unlikely to collide with
|
||||
* another tab in the same browser. Used only by the login route, for the
|
||||
* no-JavaScript path that cannot send a header.
|
||||
*/
|
||||
export function newTabId(): string {
|
||||
return randomBytes(8).toString('hex');
|
||||
}
|
||||
86
src/features/auth/services/tabSession.ts
Normal file
86
src/features/auth/services/tabSession.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
TAB_ID_STORAGE_KEY,
|
||||
TAB_POINTER_COOKIE,
|
||||
} from '@/features/auth/services/tabScope';
|
||||
|
||||
/**
|
||||
* Set on <html> by the script below when the server rendered this document as
|
||||
* ANOTHER tab's user. CSS (globals.css) hides the body while it is present;
|
||||
* SessionProvider removes it once it has rendered this tab's own state.
|
||||
*/
|
||||
export const FOREIGN_SEED_ATTR = 'data-tab-seed-foreign';
|
||||
|
||||
/**
|
||||
* The inline script that gives each tab its own identity.
|
||||
*
|
||||
* ── What this used to do, and why it had to go ───────────────────────────
|
||||
* This file used to enforce a ONE-TAB-PER-SESSION policy. A document served
|
||||
* with a session to a tab that had no `sessionStorage` marker — which is every
|
||||
* newly opened tab — hid the page, called `POST /api/auth/logout`, and bounced
|
||||
* to /login. That made opening a second tab sign the FIRST one out and revoke
|
||||
* its platform session, because the cookies were shared by the whole browser
|
||||
* and there was no way to tell "a tab that inherited someone's cookies" from
|
||||
* "a second tab of the same person".
|
||||
*
|
||||
* There is a way now: each tab carries an id and opens its OWN cookies (see
|
||||
* tabScope.ts). A second tab simply has no session of its own and lands on
|
||||
* /login, which is the correct answer and costs the first tab nothing. So the
|
||||
* destructive guard is gone — nothing in here signs anybody out any more.
|
||||
*
|
||||
* ── What it does instead ─────────────────────────────────────────────────
|
||||
* 1. Mints this tab's id into `sessionStorage` if it has none. Empty in a new
|
||||
* tab, survives that tab's reloads, discarded when it closes — exactly the
|
||||
* lifetime a per-tab session needs.
|
||||
* 2. Writes the id into the `loyaly_tab` pointer cookie, so the NEXT document
|
||||
* navigation (which cannot carry a header) is server-rendered as this tab's
|
||||
* user rather than as whoever signed in last.
|
||||
* 3. Hides the page (FOREIGN_SEED_ATTR) when THIS document was rendered for a
|
||||
* different tab — `seedTabId` is the tab the server resolved the session
|
||||
* from. Otherwise that tab's user would paint until React took over.
|
||||
*
|
||||
* ── Why an inline script and not an effect ───────────────────────────────
|
||||
* A React effect runs after the first paint, so the first document of a tab
|
||||
* would be rendered against the wrong pointer and visibly re-render. This runs
|
||||
* as the first child of <body>, before the app markup below it is parsed.
|
||||
*
|
||||
* ── Why it fails open ────────────────────────────────────────────────────
|
||||
* `sessionStorage` throws in private mode, with site data blocked, and in some
|
||||
* embedded webviews. A storage API that throws must never be the thing that
|
||||
* decides somebody cannot use the console. With no id the server falls back to
|
||||
* the pointer cookie, and the browser behaves as it did before this existed:
|
||||
* one shared session. Degraded, never locked out.
|
||||
*
|
||||
* The id is NOT a credential. Tokens stay sealed in httpOnly cookies that
|
||||
* JavaScript cannot read, and a forged id names a cookie the browser either
|
||||
* already had or does not have at all.
|
||||
*/
|
||||
export function tabSessionScript(seedTabId: string | null): string {
|
||||
// seedTabId has passed isValidTabId ([a-z0-9]), so it cannot break out of
|
||||
// the string literal or the <script> element.
|
||||
return `(function(){try{
|
||||
var K=${JSON.stringify(TAB_ID_STORAGE_KEY)},P=${JSON.stringify(TAB_POINTER_COOKIE)},SEED=${JSON.stringify(seedTabId)};
|
||||
var s=window.sessionStorage,id=s.getItem(K);
|
||||
if(!id||!/^[a-z0-9]{8,32}$/.test(id)){
|
||||
id=(Math.random().toString(36).slice(2)+Math.random().toString(36).slice(2)).slice(0,16);
|
||||
s.setItem(K,id);
|
||||
}
|
||||
if(id!==SEED)document.documentElement.setAttribute(${JSON.stringify(FOREIGN_SEED_ATTR)},'');
|
||||
// Only a VISIBLE tab claims the pointer, and that condition is load-bearing.
|
||||
// A tab opened in the background (a middle-click, "open link in new tab")
|
||||
// starts hidden, and pointing from there would aim the pointer at a tab with
|
||||
// no session while the person is still working in the one that has it — so the
|
||||
// focused tab's very next navigation would be gated against the wrong cookie
|
||||
// and bounced to /login. A hidden tab simply waits: the visibilitychange
|
||||
// listener below points the moment it is actually looked at.
|
||||
var point=function(){
|
||||
if(document.visibilityState!=='visible')return;
|
||||
document.cookie=P+'='+id+';path=/;samesite=lax'+(location.protocol==='https:'?';secure':'');
|
||||
};
|
||||
point();
|
||||
// Re-point on focus: only one tab is focused at a time, so this keeps the
|
||||
// pointer aimed at the tab the person is actually using, which is the tab whose
|
||||
// next navigation the server has to render.
|
||||
window.addEventListener('visibilitychange',point);
|
||||
window.addEventListener('pageshow',point);
|
||||
}catch(e){}})();`;
|
||||
}
|
||||
109
src/features/auth/services/tokenStore.ts
Normal file
109
src/features/auth/services/tokenStore.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import 'server-only';
|
||||
import {createCipheriv, createDecipheriv, createHash, randomBytes} from 'node:crypto';
|
||||
import {authSecret} from '@/shared/config/authSecret';
|
||||
|
||||
/**
|
||||
* Where the platform's access and refresh tokens live.
|
||||
*
|
||||
* A SECOND cookie, separate from `loyaly_session`, and the split is deliberate:
|
||||
*
|
||||
* loyaly_session HMAC-signed identity. `proxy.ts` reads it on every request
|
||||
* to gate routing, so it must stay cheap to verify.
|
||||
* loyaly_tokens AES-256-GCM ENCRYPTED token bundle. Only the code that
|
||||
* actually calls the platform ever opens it.
|
||||
*
|
||||
* Signing is not enough here. A signed cookie is tamper-evident but plainly
|
||||
* readable, and a refresh token is a credential — anyone who obtains the cookie
|
||||
* value obtains the ability to mint sessions until it rotates. Encrypting means
|
||||
* the bundle is worthless without AUTH_SECRET, which never leaves the server.
|
||||
*
|
||||
* Both cookies are httpOnly, so neither is reachable from JavaScript at all.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Derived from the SAME resolved secret the identity cookie is signed with —
|
||||
* see shared/config/authSecret. Both used to read process.env separately, which
|
||||
* meant a value this module accepted could be one sessionToken rejected.
|
||||
*
|
||||
* scrypt would be better against an offline attack on the secret itself, but
|
||||
* this key is derived per process from a value that is already high-entropy and
|
||||
* never transmitted; sha256 keeps cookie reads off the event loop.
|
||||
*/
|
||||
function key(): Buffer {
|
||||
return createHash('sha256').update(authSecret()).digest();
|
||||
}
|
||||
|
||||
export const TOKEN_COOKIE = 'loyaly_tokens';
|
||||
|
||||
export interface TokenBundle {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
/** The ACCESS token's expiry, as reported by the platform. */
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export function sealTokens(bundle: TokenBundle): string {
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv('aes-256-gcm', key(), iv);
|
||||
const body = Buffer.concat([
|
||||
cipher.update(JSON.stringify(bundle), 'utf8'),
|
||||
cipher.final(),
|
||||
]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return `${iv.toString('base64url')}.${Buffer.concat([body, tag]).toString('base64url')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every failure mode — missing, malformed, tampered, wrong key — returns null,
|
||||
* because callers must treat all of them identically: no usable tokens, start
|
||||
* again at the login form.
|
||||
*/
|
||||
export function openTokens(sealed: string | undefined): TokenBundle | null {
|
||||
if (!sealed) return null;
|
||||
|
||||
const dot = sealed.indexOf('.');
|
||||
if (dot <= 0) return null;
|
||||
|
||||
try {
|
||||
const iv = Buffer.from(sealed.slice(0, dot), 'base64url');
|
||||
const payload = Buffer.from(sealed.slice(dot + 1), 'base64url');
|
||||
if (payload.length <= 16) return null;
|
||||
|
||||
const tag = payload.subarray(payload.length - 16);
|
||||
const body = payload.subarray(0, payload.length - 16);
|
||||
|
||||
const decipher = createDecipheriv('aes-256-gcm', key(), iv);
|
||||
decipher.setAuthTag(tag);
|
||||
const json = Buffer.concat([
|
||||
decipher.update(body),
|
||||
decipher.final(),
|
||||
]).toString('utf8');
|
||||
|
||||
const parsed = JSON.parse(json) as TokenBundle;
|
||||
if (!parsed.accessToken || !parsed.refreshToken) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
// GCM authentication failure lands here — that is the tamper case, and it
|
||||
// is indistinguishable from a key rotation, which is the correct outcome.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cookie attributes, in one place so login and logout cannot disagree about
|
||||
* scope — a delete that misses on `path` leaves a live credential behind.
|
||||
*
|
||||
* The token cookie deliberately carries no Max-Age: it is a session cookie
|
||||
* tied to the browser process, while `loyaly_session` carries the real
|
||||
* lifetime. If the two ever disagree, the missing tokens simply force a
|
||||
* re-login rather than leaving a half-authenticated state.
|
||||
*/
|
||||
export function tokenCookieOptions(maxAgeSeconds?: number) {
|
||||
return {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax' as const,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
path: '/',
|
||||
...(maxAgeSeconds ? {maxAge: maxAgeSeconds} : {}),
|
||||
};
|
||||
}
|
||||
248
src/features/auth/services/upstreamSession.ts
Normal file
248
src/features/auth/services/upstreamSession.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import 'server-only';
|
||||
import {cookies} from 'next/headers';
|
||||
import {createHash} from 'node:crypto';
|
||||
import {UpstreamError, upstreamRequest} from '@/services/api/apiClient';
|
||||
import type {ApiTokenBundle} from '@/services/api/types';
|
||||
import {
|
||||
openTokens,
|
||||
sealTokens,
|
||||
tokenCookieOptions,
|
||||
type TokenBundle,
|
||||
} from './tokenStore';
|
||||
import {sessionCookieFor, tokenCookieFor} from './tabScope';
|
||||
import {resolveTabId} from './tabScopeRequest';
|
||||
import {
|
||||
REMEMBERED_MAX_AGE_SECONDS,
|
||||
verifySessionToken,
|
||||
} from './sessionToken';
|
||||
|
||||
/**
|
||||
* Authenticated access to the platform, with the three refresh rules the
|
||||
* platform documents — each of which has already caused a bug somewhere.
|
||||
*
|
||||
* 1. A 401 carrying `token_expired` is NOT a sign-out. Refresh once and retry
|
||||
* silently, or staff are thrown back to a login form twice a day.
|
||||
* 2. Serialise refresh behind ONE lock. Refresh tokens are single-use, so
|
||||
* concurrent callers would each spend it and all but one would lose. This
|
||||
* dashboard fires nine parallel requests on a single page load, so this is
|
||||
* not a rare race — it is the first thing that happens after an expiry.
|
||||
* 3. Persist the rotated pair BEFORE using it. A process killed between
|
||||
* refresh and persist comes back holding a token the platform has already
|
||||
* invalidated, which is indistinguishable from a normal expiry at the
|
||||
* worst possible moment.
|
||||
*/
|
||||
|
||||
/**
|
||||
* In-flight refreshes, keyed by a digest of the refresh token being spent.
|
||||
*
|
||||
* Keyed rather than global so two different users refreshing at the same
|
||||
* instant do not wait on each other. Module scope means one lock per server
|
||||
* process — correct for a single instance, and best-effort across a horizontal
|
||||
* fleet, where two instances can still collide. That residual race is why rule
|
||||
* 3 exists: the loser gets a 401 and re-authenticates rather than corrupting
|
||||
* anything.
|
||||
*/
|
||||
const inFlight = new Map<string, Promise<TokenBundle>>();
|
||||
|
||||
function lockKey(refreshToken: string): string {
|
||||
return createHash('sha256').update(refreshToken).digest('base64url');
|
||||
}
|
||||
|
||||
/**
|
||||
* This TAB's tokens, never another tab's.
|
||||
*
|
||||
* A null tab id returns null rather than falling back to some other cookie in
|
||||
* the jar. "I could not tell which tab this is" must read as "no session" — the
|
||||
* alternative is handing one tab the credentials of whichever tab happened to
|
||||
* sign in last, which is the bug this scoping exists to fix.
|
||||
*/
|
||||
async function readTokens(): Promise<TokenBundle | null> {
|
||||
const tabId = await resolveTabId();
|
||||
if (!tabId) return null;
|
||||
|
||||
const store = await cookies();
|
||||
return openTokens(store.get(tokenCookieFor(tabId))?.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the bundle to the cookie. Only possible inside a route handler —
|
||||
* a Server Component's cookie store is read-only, which is exactly why every
|
||||
* platform call goes through a route rather than being made during render.
|
||||
*/
|
||||
async function persistTokens(
|
||||
bundle: TokenBundle,
|
||||
maxAgeSeconds?: number,
|
||||
forTabId?: string,
|
||||
): Promise<void> {
|
||||
// `forTabId` is passed by the login route, which has just minted the id and
|
||||
// cannot resolve it from a header or a pointer that does not exist yet. Every
|
||||
// other caller — a token refresh mid-session — resolves it normally.
|
||||
const tabId = forTabId ?? (await resolveTabId());
|
||||
if (!tabId) return;
|
||||
|
||||
const store = await cookies();
|
||||
store.set(
|
||||
tokenCookieFor(tabId),
|
||||
sealTokens(bundle),
|
||||
tokenCookieOptions(maxAgeSeconds),
|
||||
);
|
||||
}
|
||||
|
||||
export function toBundle(res: ApiTokenBundle): TokenBundle {
|
||||
return {
|
||||
accessToken: res.access_token,
|
||||
refreshToken: res.refresh_token,
|
||||
expiresAt: res.expires_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Spend the refresh token for a new pair, at most once per token.
|
||||
*
|
||||
* The persist happens INSIDE the locked section, before the promise resolves,
|
||||
* so every waiter observes a bundle that is already durable.
|
||||
*/
|
||||
/**
|
||||
* How long the refreshed token cookie should live — read off the identity
|
||||
* cookie, which is the only thing that still knows.
|
||||
*
|
||||
* ── The bug this closes ──────────────────────────────────────────────────
|
||||
* `persistTokens(next)` was called with no lifetime, and `tokenCookieOptions`
|
||||
* omits `maxAge` when it is falsy. So every refresh quietly downgraded
|
||||
* `loyaly_tokens_<tab>` to a browser-session cookie. For a remembered sign-in
|
||||
* that meant `loyaly_session_<tab>` kept its 30 days while the sealed tokens
|
||||
* did not: after a browser restart the identity survived, the tokens were gone,
|
||||
* the proxy admitted the page on the identity alone, and every data call
|
||||
* answered 401 — the exact half-authenticated state the note on `storeTokens`
|
||||
* below describes, arrived at from the other direction.
|
||||
*
|
||||
* ── Why the identity cookie is the source of truth ───────────────────────
|
||||
* A cookie cannot report its own `maxAge`, and by refresh time the `rememberMe`
|
||||
* checkbox is long gone. But the signed payload carries `iat` and `exp`, and
|
||||
* their span IS the lifetime chosen at sign-in — 12h for a normal session, 30d
|
||||
* for a remembered one. So the span identifies which kind this is without
|
||||
* trusting anything the client sends, and the payload is HMAC-signed, so it
|
||||
* cannot be edited into a longer one.
|
||||
*
|
||||
* Returns the REMAINING time rather than a fresh 30 days: the two cookies must
|
||||
* expire together, and re-granting the full window on every refresh would let
|
||||
* the tokens outlive the identity that authorises them.
|
||||
*
|
||||
* `undefined` for a normal session, which keeps it a browser-session cookie —
|
||||
* the unticked box is asking for exactly that, and it must stay that way.
|
||||
*/
|
||||
async function rememberedLifetime(): Promise<number | undefined> {
|
||||
const tabId = await resolveTabId();
|
||||
if (!tabId) return undefined;
|
||||
|
||||
const store = await cookies();
|
||||
const payload = verifySessionToken(store.get(sessionCookieFor(tabId))?.value);
|
||||
if (!payload) return undefined;
|
||||
|
||||
// Only a remembered sign-in was ever given a persistent cookie.
|
||||
if (payload.exp - payload.iat < REMEMBERED_MAX_AGE_SECONDS) return undefined;
|
||||
|
||||
const remaining = payload.exp - Math.floor(Date.now() / 1000);
|
||||
return remaining > 0 ? remaining : undefined;
|
||||
}
|
||||
|
||||
async function refresh(current: TokenBundle): Promise<TokenBundle> {
|
||||
const k = lockKey(current.refreshToken);
|
||||
const existing = inFlight.get(k);
|
||||
if (existing) return existing;
|
||||
|
||||
const run = (async () => {
|
||||
const res = await upstreamRequest<ApiTokenBundle>({
|
||||
path: '/api/auth/refresh',
|
||||
method: 'POST',
|
||||
body: {refresh_token: current.refreshToken, device: 'Loyaly Web Console'},
|
||||
});
|
||||
const next = toBundle(res);
|
||||
await persistTokens(next, await rememberedLifetime());
|
||||
return next;
|
||||
})();
|
||||
|
||||
inFlight.set(k, run);
|
||||
try {
|
||||
return await run;
|
||||
} finally {
|
||||
inFlight.delete(k);
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown when there is no usable session at all — the caller must 401. */
|
||||
export class NoSessionError extends Error {
|
||||
constructor() {
|
||||
super('Sign in to continue.');
|
||||
this.name = 'NoSessionError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an authenticated platform call, refreshing once if the access token has
|
||||
* expired.
|
||||
*
|
||||
* `fn` receives the token rather than the request being described declaratively
|
||||
* so that domain services stay plain functions: `sitesApi.list(token)` is
|
||||
* callable from a test with any token, and this wrapper is the only thing that
|
||||
* knows about cookies.
|
||||
*
|
||||
* `fn` MUST be safe to run twice. Every domain service in src/services/api
|
||||
* marshals its body from a plain object, so a retry re-sends it correctly; a
|
||||
* caller that streams a body would break rule 3's retry and must not use this.
|
||||
*/
|
||||
export async function withUpstream<T>(
|
||||
fn: (accessToken: string) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const tokens = await readTokens();
|
||||
if (!tokens) throw new NoSessionError();
|
||||
|
||||
try {
|
||||
return await fn(tokens.accessToken);
|
||||
} catch (err) {
|
||||
if (!(err instanceof UpstreamError) || !err.isTokenExpired) throw err;
|
||||
|
||||
// One retry, never a loop: if the freshly minted token is also rejected
|
||||
// the problem is not expiry, and retrying would spend refresh tokens in a
|
||||
// circle while the user waits.
|
||||
const next = await refresh(tokens);
|
||||
return fn(next.accessToken);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a bundle at sign-in / registration. Route handlers only.
|
||||
*
|
||||
* `maxAgeSeconds` MUST match whatever the identity cookie is given, and is
|
||||
* omitted for a browser-session cookie. The two used to disagree: this one was
|
||||
* always session-scoped while `loyaly_session` was always persistent, so after
|
||||
* a browser restart the identity cookie survived and the sealed tokens did not.
|
||||
* The proxy then admitted the page on the identity alone, the shell rendered
|
||||
* looking signed in, and every data call answered 401 — a half-authenticated
|
||||
* state that reads as a broken dashboard rather than as a finished session.
|
||||
*/
|
||||
export async function storeTokens(
|
||||
res: ApiTokenBundle,
|
||||
maxAgeSeconds?: number,
|
||||
forTabId?: string,
|
||||
): Promise<TokenBundle> {
|
||||
const bundle = toBundle(res);
|
||||
await persistTokens(bundle, maxAgeSeconds, forTabId);
|
||||
return bundle;
|
||||
}
|
||||
|
||||
/** Only this tab's. Signing out of one tab must leave the others signed in. */
|
||||
export async function clearTokens(): Promise<void> {
|
||||
const tabId = await resolveTabId();
|
||||
if (!tabId) return;
|
||||
|
||||
const store = await cookies();
|
||||
store.delete(tokenCookieFor(tabId));
|
||||
}
|
||||
|
||||
/** The access token without any refresh attempt — for fire-and-forget calls
|
||||
* such as logout, where a refresh would be pointless work. */
|
||||
export async function peekAccessToken(): Promise<string | null> {
|
||||
const tokens = await readTokens();
|
||||
return tokens?.accessToken ?? null;
|
||||
}
|
||||
34
src/features/auth/services/userMapper.ts
Normal file
34
src/features/auth/services/userMapper.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type {ApiUser} from '@/services/api/types';
|
||||
import type {AuthUser, UserRole} from '@/features/auth/types/auth';
|
||||
|
||||
/**
|
||||
* Platform user → the shape the console's components already consume.
|
||||
*
|
||||
* One translation, in one place. Renaming `full_name` at each call site is how
|
||||
* two screens end up disagreeing about what to show when it is empty.
|
||||
*/
|
||||
export function toAuthUser(u: ApiUser): AuthUser {
|
||||
return {
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
// Falls back to the address rather than rendering an empty header: a user
|
||||
// invited but not yet named still has to be identifiable.
|
||||
name: u.full_name || u.email,
|
||||
role: u.role as UserRole,
|
||||
organisation: u.client_name,
|
||||
// From the real `client_id`, here, once. The browser never receives that
|
||||
// field, so this is the only place the question can be answered honestly.
|
||||
isPlatformAdmin: isPlatformAdmin(u),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A platform operator, not a merchant.
|
||||
*
|
||||
* Both conditions, never the role alone — that pairing is what the platform
|
||||
* documents, and checking only the role would let a tenant-scoped account with
|
||||
* an admin-shaped role read as a platform operator.
|
||||
*/
|
||||
export function isPlatformAdmin(u: ApiUser): boolean {
|
||||
return u.role === 'admin' && u.client_id === '';
|
||||
}
|
||||
74
src/features/auth/types/auth.ts
Normal file
74
src/features/auth/types/auth.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* The auth contract.
|
||||
*
|
||||
* Written as the shape a real identity service would return, not as the shape
|
||||
* the current mock happens to have. When /api/auth/* is repointed at Node,
|
||||
* Nest, Spring or anything else, this file is the negotiation artifact and the
|
||||
* UI below it does not move.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The platform's roles, in increasing order of privilege.
|
||||
*
|
||||
* `analyst` used to be here and does not exist upstream — it was invented by
|
||||
* the fixture directory. `admin` is a PLATFORM operator, identified by the
|
||||
* role AND an empty organisation together, never by the role alone.
|
||||
*/
|
||||
export type UserRole = 'staff' | 'manager' | 'owner' | 'admin';
|
||||
|
||||
/** The authenticated principal. Never contains credentials. */
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: UserRole;
|
||||
/** Merchant/tenant this user belongs to. */
|
||||
organisation: string;
|
||||
/**
|
||||
* A platform operator, who has no tenant. Decided by the server from the
|
||||
* role AND an empty `client_id` together — never from the role alone, and
|
||||
* never from `organisation`. Drives which console the browser renders; it
|
||||
* grants nothing, because every admin call is authorised upstream.
|
||||
*/
|
||||
isPlatformAdmin: boolean;
|
||||
}
|
||||
|
||||
/** What the client is allowed to know about its own session. */
|
||||
export interface AuthSession {
|
||||
user: AuthUser;
|
||||
/** ISO-8601. The client uses this only to display, never to authorise. */
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface LoginCredentials {
|
||||
email: string;
|
||||
password: string;
|
||||
/**
|
||||
* Long-lived cookie vs browser-session cookie. Sent to the server because
|
||||
* cookie lifetime is a server decision — the client cannot be trusted to
|
||||
* enforce its own expiry.
|
||||
*/
|
||||
rememberMe: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Field-addressed failures, so the form can put the message on the input that
|
||||
* caused it rather than dumping everything into a banner.
|
||||
*
|
||||
* `form` covers anything not attributable to one field (network, 500s).
|
||||
*/
|
||||
export type LoginErrorField = 'email' | 'password' | 'form';
|
||||
|
||||
export interface LoginError {
|
||||
field: LoginErrorField;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The result of an attempt. A discriminated union rather than
|
||||
* `{user?, error?}`: the caller cannot forget to check, because there is no
|
||||
* shape where both are present.
|
||||
*/
|
||||
export type LoginResult =
|
||||
| {ok: true; session: AuthSession}
|
||||
| {ok: false; error: LoginError};
|
||||
14
src/features/auth/types/join.ts
Normal file
14
src/features/auth/types/join.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type {UserRole} from '@/features/auth/types/auth';
|
||||
|
||||
/**
|
||||
* What an invitation code is for, shown before anybody picks a password.
|
||||
*
|
||||
* `email` and `role` are fixed by the invitation and cannot be changed on the
|
||||
* join screen — the platform refuses a registration that names either.
|
||||
*/
|
||||
export interface InvitationPreview {
|
||||
companyName: string;
|
||||
email: string;
|
||||
fullName: string;
|
||||
role: Exclude<UserRole, 'admin'>;
|
||||
}
|
||||
128
src/features/commerce/components/SaleDetailDialog.tsx
Normal file
128
src/features/commerce/components/SaleDetailDialog.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
'use client';
|
||||
|
||||
import {useEffect, useState} from 'react';
|
||||
import {Dialog, DialogHeader} from '@astryxdesign/core/Dialog';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text, Heading} from '@astryxdesign/core/Text';
|
||||
import {Banner} from '@astryxdesign/core/Banner';
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
import {formatPaise} from '@/features/commerce/services/money';
|
||||
import {saleRepository} from '@/features/commerce/repositories/saleRepository';
|
||||
import type {Sale} from '@/features/commerce/types/sale';
|
||||
|
||||
/**
|
||||
* One sale, with its lines. LOYALY.md §24.
|
||||
*
|
||||
* Renders only fields the API actually returns. Where the platform has nothing
|
||||
* — no invoice number, no named customer — the row says so rather than
|
||||
* inventing a placeholder, because a fabricated invoice number on a screen
|
||||
* somebody reconciles against is worse than a visible gap.
|
||||
*/
|
||||
export function SaleDetailDialog({
|
||||
saleId,
|
||||
onClose,
|
||||
}: {
|
||||
saleId: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [sale, setSale] = useState<Sale | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
// Through httpClient, so the request carries this tab's session header.
|
||||
const res = await saleRepository.byId(saleId);
|
||||
if (cancelled) return;
|
||||
if (!res.ok || !res.data) {
|
||||
setError(
|
||||
res.status === 0
|
||||
? 'Could not reach the platform.'
|
||||
: (res.message ?? 'Could not load this sale.'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setSale(res.data);
|
||||
})();
|
||||
// A dialog closed mid-request must not write into an unmounted component.
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [saleId]);
|
||||
|
||||
const purchased = sale?.lines.filter((l) => l.intent === 'purchased') ?? [];
|
||||
const enquiries = sale?.lines.filter((l) => l.intent === 'enquired') ?? [];
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
purpose="info"
|
||||
width={520}
|
||||
aria-label="Sale detail"
|
||||
>
|
||||
<VStack gap={4} width="100%">
|
||||
<DialogHeader
|
||||
title={sale?.invoiceNo ?? 'Sale'}
|
||||
subtitle={sale ? new Date(sale.at).toLocaleString() : undefined}
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
/>
|
||||
|
||||
{error ? <Banner status="error" title={error} /> : null}
|
||||
{!sale && !error ? <Text size="sm" color="secondary">Loading…</Text> : null}
|
||||
|
||||
{sale ? (
|
||||
<VStack gap={4}>
|
||||
<VStack gap={1}>
|
||||
<Text size="sm" color="secondary">
|
||||
Customer: {sale.customerLabel ?? sale.customerRef ?? 'Not identified'}
|
||||
</Text>
|
||||
<Text size="sm" color="secondary">
|
||||
Served by: {sale.staffName ?? '—'}
|
||||
</Text>
|
||||
<Text size="sm" color="secondary">
|
||||
Store: {sale.siteId}
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
{purchased.length > 0 ? (
|
||||
<VStack gap={2}>
|
||||
<Heading level={4}>Purchased</Heading>
|
||||
{purchased.map((l, i) => (
|
||||
<HStack key={`${l.productName}-${i}`} gap={2} hAlign="between">
|
||||
<Text size="sm">{l.productName}</Text>
|
||||
<Text size="sm">{formatPaise(l.billablePaise)}</Text>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
) : null}
|
||||
|
||||
{enquiries.length > 0 ? (
|
||||
<VStack gap={2}>
|
||||
<HStack gap={2} vAlign="center">
|
||||
<Heading level={4}>Enquiries</Heading>
|
||||
<Badge label="not billed" />
|
||||
</HStack>
|
||||
{enquiries.map((l, i) => (
|
||||
<HStack key={`${l.productName}-${i}`} gap={2} hAlign="between">
|
||||
<Text size="sm" color="secondary">{l.productName}</Text>
|
||||
<Text size="sm" color="disabled">
|
||||
{l.pricePaise > 0
|
||||
? formatPaise(l.pricePaise)
|
||||
: 'no price'}
|
||||
</Text>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
) : null}
|
||||
|
||||
<HStack gap={2} hAlign="between" vAlign="center">
|
||||
<Text size="sm" color="secondary">Total</Text>
|
||||
<Heading level={3}>{formatPaise(sale.totalPaise)}</Heading>
|
||||
</HStack>
|
||||
</VStack>
|
||||
) : null}
|
||||
</VStack>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
330
src/features/commerce/components/SaleEntryDialog.tsx
Normal file
330
src/features/commerce/components/SaleEntryDialog.tsx
Normal file
@@ -0,0 +1,330 @@
|
||||
'use client';
|
||||
|
||||
import {useMemo, useRef, useState} from 'react';
|
||||
import {Dialog, DialogHeader} from '@astryxdesign/core/Dialog';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {TextInput} from '@astryxdesign/core/TextInput';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Text, Heading} from '@astryxdesign/core/Text';
|
||||
import {Banner} from '@astryxdesign/core/Banner';
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
import {Card} from '@astryxdesign/core/Card';
|
||||
import {
|
||||
billablePaise,
|
||||
formatPaise,
|
||||
parseRupeesToPaise,
|
||||
} from '@/features/commerce/services/money';
|
||||
import {saleRepository} from '@/features/commerce/repositories/saleRepository';
|
||||
import type {FloorVisit} from '@/features/floor/types/floor';
|
||||
|
||||
/**
|
||||
* Sale entry for one customer on the floor. LOYALY.md §10–§13, §17.
|
||||
*
|
||||
* ── The distinction this screen exists to make ───────────────────────────
|
||||
* Every line is PURCHASED or ENQUIRED, and an enquiry never reaches the bill.
|
||||
* §11 calls that the important rule, so the two are shown in separate blocks
|
||||
* rather than hidden behind a dropdown: a merchant must see at a glance what
|
||||
* they are charging for.
|
||||
*
|
||||
* ── There is no product catalogue, and that is correct ───────────────────
|
||||
* §11 records `product_name` and `price`; §33-E leaves `product_id` optional
|
||||
* and unresolved. Free text is the specified behaviour, not a placeholder for
|
||||
* a picker — so this form neither invents a catalogue nor claims one is
|
||||
* coming.
|
||||
*
|
||||
* Quantity is deliberately absent. The spec's line shape is name, price,
|
||||
* intent; a quantity field would be a business rule nobody wrote and a value
|
||||
* the backend cannot store.
|
||||
*/
|
||||
|
||||
interface DraftLine {
|
||||
key: string;
|
||||
productName: string;
|
||||
/** Integer paise, parsed once on entry. Never a float. */
|
||||
pricePaise: number;
|
||||
intent: 'purchased' | 'enquired';
|
||||
}
|
||||
|
||||
/**
|
||||
* A per-sale key. `crypto.randomUUID` everywhere modern; the timestamp branch
|
||||
* is only for a non-secure context, where `crypto` may be absent entirely.
|
||||
*/
|
||||
function newIdempotencyKey(): string {
|
||||
return typeof crypto !== 'undefined' && crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: `draft-${Date.now()}`;
|
||||
}
|
||||
|
||||
export function SaleEntryDialog({
|
||||
visit,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
visit: FloorVisit;
|
||||
onClose: () => void;
|
||||
onSaved: (saleId: string) => void;
|
||||
}) {
|
||||
/**
|
||||
* §13: minted when the DRAFT OPENS, not when Confirm is pressed.
|
||||
*
|
||||
* That is the whole mechanism. A double tap, a timeout retry and an app
|
||||
* restart all carry this same key, so the platform collapses them into one
|
||||
* sale. A key generated at submit time would be unique per attempt and every
|
||||
* retry would create another sale — which is the failure the key exists to
|
||||
* prevent.
|
||||
*
|
||||
* A ref rather than state: it must survive every re-render unchanged.
|
||||
*/
|
||||
const idempotencyKey = useRef<string | null>(null);
|
||||
|
||||
const [lines, setLines] = useState<DraftLine[]>([]);
|
||||
const [name, setName] = useState('');
|
||||
const [price, setPrice] = useState('');
|
||||
const [invoiceNo, setInvoiceNo] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [done, setDone] = useState<{saleId: string; total: string} | null>(null);
|
||||
|
||||
const total = useMemo(() => billablePaise(lines), [lines]);
|
||||
const purchased = lines.filter((l) => l.intent === 'purchased');
|
||||
const enquiries = lines.filter((l) => l.intent === 'enquired');
|
||||
|
||||
function addLine(intent: 'purchased' | 'enquired') {
|
||||
setError(null);
|
||||
const productName = name.trim();
|
||||
if (productName === '') {
|
||||
setError('Give the item a name.');
|
||||
return;
|
||||
}
|
||||
const paise = price.trim() === '' ? 0 : parseRupeesToPaise(price);
|
||||
if (paise === null) {
|
||||
setError('That price is not a valid amount.');
|
||||
return;
|
||||
}
|
||||
// §17, mirrored here for UX only. The server enforces it too, and its
|
||||
// answer is the one that decides — this just saves a round trip.
|
||||
if (intent === 'purchased' && paise <= 0) {
|
||||
setError('A purchased item needs a price.');
|
||||
return;
|
||||
}
|
||||
setLines((prev) => [
|
||||
...prev,
|
||||
{
|
||||
// Index-free and content-based, so removing a line cannot make two
|
||||
// remaining rows collide on a key.
|
||||
key: `${Date.now()}-${prev.length}-${productName}`,
|
||||
productName,
|
||||
pricePaise: paise,
|
||||
intent,
|
||||
},
|
||||
]);
|
||||
setName('');
|
||||
setPrice('');
|
||||
}
|
||||
|
||||
function removeLine(key: string) {
|
||||
setLines((prev) => prev.filter((l) => l.key !== key));
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
/**
|
||||
* Minted here, on the first attempt, and kept in the ref for every one
|
||||
* after it.
|
||||
*
|
||||
* It used to be generated in `useRef(...)`, whose argument React
|
||||
* evaluates on EVERY render — so `crypto.randomUUID()` and `Date.now()`
|
||||
* ran on each keystroke in this dialog, and the lint rule that caught it
|
||||
* is right: `Date.now()` during render is impure and its result is
|
||||
* discarded anyway. An event handler is the correct place for both.
|
||||
*
|
||||
* The retry guarantee is unchanged, which is the part that matters: the
|
||||
* ref is only filled once, so a double tap, a timeout retry and a
|
||||
* resubmit all send the SAME key and the platform collapses them into
|
||||
* one sale. A fresh dialog is a fresh component, so the next sale gets a
|
||||
* fresh key.
|
||||
*/
|
||||
idempotencyKey.current ??= newIdempotencyKey();
|
||||
|
||||
// Through httpClient, so the request carries this tab's session header.
|
||||
const res = await saleRepository.create({
|
||||
idempotencyKey: idempotencyKey.current,
|
||||
// Taken from the floor context, never typed. §4: do not ask for a
|
||||
// visit id the flow already knows. Staff identity is not sent at
|
||||
// all — the platform derives it from the session.
|
||||
visitId: visit.visitId,
|
||||
visitorId: visit.visitorId ?? undefined,
|
||||
invoiceNo: invoiceNo.trim(),
|
||||
site: visit.siteId,
|
||||
lines: lines.map((l) => ({
|
||||
productName: l.productName,
|
||||
pricePaise: l.pricePaise,
|
||||
intent: l.intent,
|
||||
})),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setError(
|
||||
res.status === 0
|
||||
? 'Could not reach the platform. The sale was not recorded.'
|
||||
: (res.message ?? 'Could not record this sale.'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// The figure shown now is the SERVER's, computed by the database from
|
||||
// the purchased lines. The running total above is only what the merchant
|
||||
// watched while typing.
|
||||
const sale = res.data?.sale;
|
||||
setDone({
|
||||
saleId: res.data?.saleId ?? '',
|
||||
total:
|
||||
typeof sale?.totalPaise === 'number'
|
||||
? formatPaise(sale.totalPaise)
|
||||
: formatPaise(total),
|
||||
});
|
||||
} catch {
|
||||
setError('Could not reach the platform. The sale was not recorded.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const customerName = visit.label ?? 'Unrecognised customer';
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
purpose="info"
|
||||
width={560}
|
||||
aria-label="Record a sale"
|
||||
>
|
||||
<VStack gap={4} width="100%">
|
||||
<DialogHeader
|
||||
title="Record a sale"
|
||||
subtitle={`${customerName}${visit.customerRef ? ` · ${visit.customerRef}` : ''}`}
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
/>
|
||||
|
||||
{done ? (
|
||||
<VStack gap={4}>
|
||||
<Banner status="info" title={`Sale recorded — ${done.total}`} />
|
||||
<Text size="sm" color="secondary">
|
||||
The customer is still on the floor. Complete their visit when you
|
||||
have finished with them.
|
||||
</Text>
|
||||
<HStack gap={2} hAlign="end">
|
||||
<Button onClick={() => onSaved(done.saleId)} label="Done" />
|
||||
</HStack>
|
||||
</VStack>
|
||||
) : (
|
||||
<>
|
||||
<HStack gap={2} vAlign="end">
|
||||
<TextInput
|
||||
label="Item"
|
||||
value={name}
|
||||
onChange={setName}
|
||||
placeholder="What did they look at?"
|
||||
/>
|
||||
<TextInput
|
||||
label="Price"
|
||||
value={price}
|
||||
onChange={setPrice}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</HStack>
|
||||
<HStack gap={2}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => addLine('enquired')}
|
||||
label="Add enquiry"
|
||||
/>
|
||||
<Button onClick={() => addLine('purchased')} label="Add purchase" />
|
||||
</HStack>
|
||||
|
||||
{error ? <Banner status="error" title={error} /> : null}
|
||||
|
||||
{purchased.length > 0 ? (
|
||||
<Card>
|
||||
<VStack gap={2}>
|
||||
<Heading level={4}>Purchased</Heading>
|
||||
{purchased.map((l) => (
|
||||
<HStack key={l.key} gap={2} hAlign="between" vAlign="center">
|
||||
<Text size="sm">{l.productName}</Text>
|
||||
<HStack gap={2} vAlign="center">
|
||||
<Text size="sm">{formatPaise(l.pricePaise)}</Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => removeLine(l.key)}
|
||||
label="Remove"
|
||||
/>
|
||||
</HStack>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{enquiries.length > 0 ? (
|
||||
<Card>
|
||||
<VStack gap={2}>
|
||||
<HStack gap={2} vAlign="center">
|
||||
<Heading level={4}>Enquiries</Heading>
|
||||
<Badge label="not billed" />
|
||||
</HStack>
|
||||
{enquiries.map((l) => (
|
||||
<HStack key={l.key} gap={2} hAlign="between" vAlign="center">
|
||||
<Text size="sm" color="secondary">
|
||||
{l.productName}
|
||||
</Text>
|
||||
<HStack gap={2} vAlign="center">
|
||||
{/* A quoted price is kept for the record and shown in
|
||||
a muted tone: §11 allows an enquiry to carry one and
|
||||
requires that it never increase the bill. */}
|
||||
<Text size="sm" color="disabled">
|
||||
{l.pricePaise > 0 ? formatPaise(l.pricePaise) : 'no price'}
|
||||
</Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => removeLine(l.key)}
|
||||
label="Remove"
|
||||
/>
|
||||
</HStack>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<TextInput
|
||||
label="Invoice number (optional)"
|
||||
value={invoiceNo}
|
||||
onChange={setInvoiceNo}
|
||||
placeholder="INV-…"
|
||||
/>
|
||||
|
||||
<HStack gap={2} hAlign="between" vAlign="center">
|
||||
<Text size="sm" color="secondary">
|
||||
{purchased.length} purchased · {enquiries.length} enquiries
|
||||
</Text>
|
||||
<Heading level={3}>{formatPaise(total)}</Heading>
|
||||
</HStack>
|
||||
|
||||
<HStack gap={2} hAlign="end">
|
||||
<Button variant="secondary" onClick={onClose} label="Cancel" />
|
||||
<Button
|
||||
// Disabled in flight so a double tap cannot fire twice. The
|
||||
// idempotency key is the real defence; this is the part the
|
||||
// user can see.
|
||||
isDisabled={saving || lines.length === 0}
|
||||
onClick={() => void confirm()}
|
||||
label={saving ? 'Recording…' : 'Confirm sale'}
|
||||
/>
|
||||
</HStack>
|
||||
</>
|
||||
)}
|
||||
</VStack>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
11
src/features/commerce/hooks/useSales.ts
Normal file
11
src/features/commerce/hooks/useSales.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import {saleRepository} from '@/features/commerce/repositories/saleRepository';
|
||||
import {useResource} from '@/shared/hooks/useResource';
|
||||
import {useScope} from '@/shared/hooks/useScope';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
import type {Sale} from '@/features/commerce/types/sale';
|
||||
|
||||
export function useSales(): Resource<Sale[]> {
|
||||
return useResource(saleRepository.list(useScope()));
|
||||
}
|
||||
77
src/features/commerce/mocks/commerceMock.ts
Normal file
77
src/features/commerce/mocks/commerceMock.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* TEMPORARY sample data for the Sales page.
|
||||
*
|
||||
* - `MOCK_SALES` stands in for the sales list only while the platform returns
|
||||
* none (see `shared/mocks/withSample.ts`). Sample rows never open the detail
|
||||
* dialog — there is no sale behind them to fetch.
|
||||
* - `MOCK_TOP_PRODUCTS` and `MOCK_PAYMENT_METHODS` have NO platform resource
|
||||
* yet. They are always sample and always tagged. When the catalogue and
|
||||
* payment endpoints ship, replace them with hooks and delete this file.
|
||||
*
|
||||
* Money is integer paise, like the real contract.
|
||||
*/
|
||||
|
||||
import type {Sale} from '@/features/commerce/types/sale';
|
||||
|
||||
function sale(
|
||||
n: number,
|
||||
customer: string | null,
|
||||
staff: string,
|
||||
totalPaise: number,
|
||||
purchased: number,
|
||||
enquired: number,
|
||||
at: string,
|
||||
): Sale {
|
||||
return {
|
||||
id: `sample-sale-${n}`,
|
||||
invoiceNo: `INV-${String(2400 + n).padStart(5, '0')}`,
|
||||
siteId: 'sample',
|
||||
customerRef: null,
|
||||
customerLabel: customer,
|
||||
staffName: staff,
|
||||
totalPaise,
|
||||
currency: 'INR',
|
||||
status: 'completed',
|
||||
at,
|
||||
purchasedLines: purchased,
|
||||
enquiryLines: enquired,
|
||||
lines: [],
|
||||
};
|
||||
}
|
||||
|
||||
export const MOCK_SALES: Sale[] = [
|
||||
sale(18, 'Priya S.', 'Arjun', 248_900, 3, 1, '2026-09-25T11:42:00Z'),
|
||||
sale(17, 'Rahul M.', 'Divya', 89_900, 1, 0, '2026-09-25T10:15:00Z'),
|
||||
sale(16, null, 'Arjun', 159_800, 2, 2, '2026-09-24T18:03:00Z'),
|
||||
sale(15, 'Kavya R.', 'Meena', 412_500, 4, 0, '2026-09-24T16:27:00Z'),
|
||||
sale(14, 'Sanjay K.', 'Divya', 64_900, 1, 1, '2026-09-24T13:50:00Z'),
|
||||
sale(13, 'Anitha P.', 'Meena', 198_000, 2, 0, '2026-09-23T19:11:00Z'),
|
||||
];
|
||||
|
||||
export interface SampleProduct {
|
||||
id: string;
|
||||
name: string;
|
||||
sold: number;
|
||||
revenuePaise: number;
|
||||
}
|
||||
|
||||
export const MOCK_TOP_PRODUCTS: SampleProduct[] = [
|
||||
{id: 'p1', name: 'Cotton kurta — indigo', sold: 42, revenuePaise: 5_451_600},
|
||||
{id: 'p2', name: 'Silk dupatta', sold: 31, revenuePaise: 3_689_000},
|
||||
{id: 'p3', name: 'Linen shirt — white', sold: 27, revenuePaise: 2_427_300},
|
||||
{id: 'p4', name: 'Handloom saree', sold: 12, revenuePaise: 5_388_000},
|
||||
{id: 'p5', name: 'Leather sandals', sold: 19, revenuePaise: 1_519_000},
|
||||
];
|
||||
|
||||
export interface SamplePaymentMethod {
|
||||
method: string;
|
||||
/** Share of sales by count, as a percentage. */
|
||||
share: number;
|
||||
}
|
||||
|
||||
export const MOCK_PAYMENT_METHODS: SamplePaymentMethod[] = [
|
||||
{method: 'UPI', share: 58},
|
||||
{method: 'Card', share: 24},
|
||||
{method: 'Cash', share: 14},
|
||||
{method: 'Wallet', share: 4},
|
||||
];
|
||||
24
src/features/commerce/repositories/saleRepository.ts
Normal file
24
src/features/commerce/repositories/saleRepository.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import {getJson, postJson, scopedEndpoint} from '@/shared/services/httpClient';
|
||||
import type {Endpoint, Scope} from '@/shared/services/httpClient';
|
||||
import type {Sale} from '@/features/commerce/types/sale';
|
||||
|
||||
/**
|
||||
* Sales, addressed by the platform's own resource name.
|
||||
*
|
||||
* Scoped like every other read, so the store switcher and the range picker
|
||||
* change what this returns without the Sales screen knowing how.
|
||||
*/
|
||||
/** What recording a sale answers with. A replay is a success carrying the original sale. */
|
||||
export interface SaleCreated {
|
||||
status: string;
|
||||
saleId: string;
|
||||
sale: Sale | null;
|
||||
}
|
||||
|
||||
export const saleRepository = {
|
||||
list: (scope: Scope): Endpoint<Sale[]> => scopedEndpoint('/api/sales', scope, {}),
|
||||
|
||||
create: (body: Record<string, unknown>) => postJson<SaleCreated>('/api/sales', body),
|
||||
|
||||
byId: (id: string) => getJson<Sale>(`/api/sales/${encodeURIComponent(id)}`),
|
||||
};
|
||||
57
src/features/commerce/services/money.ts
Normal file
57
src/features/commerce/services/money.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Rupees ↔ paise, in one place.
|
||||
*
|
||||
* ── Why the form holds PAISE, not rupees ─────────────────────────────────
|
||||
* LOYALY.md §10: money is integer minor units and never a float. If the sale
|
||||
* form kept rupees it would add 18.1 + 240.05 in binary floating point and the
|
||||
* running total a merchant reads would drift from the one the server computes.
|
||||
* So a price is parsed to an integer ONCE, on entry, and every sum after that
|
||||
* is integer arithmetic.
|
||||
*
|
||||
* The backend stays authoritative regardless — `sales.total_paise` is written
|
||||
* by a database trigger from the purchased lines, and nothing the client sends
|
||||
* can set it. What this file protects is the number shown to the person typing.
|
||||
*/
|
||||
|
||||
/**
|
||||
* "18", "18.5", "₹1,250.00" → paise. Returns null for anything that is not a
|
||||
* non-negative amount, so the caller can refuse rather than submit a NaN.
|
||||
*
|
||||
* Parsed by SPLITTING ON THE DECIMAL POINT rather than `Math.round(x * 100)`:
|
||||
* 19.99 * 100 is 1998.9999999999998 in IEEE 754, and rounding hides that only
|
||||
* until it does not.
|
||||
*/
|
||||
export function parseRupeesToPaise(input: string): number | null {
|
||||
const clean = input.replace(/[₹,\s]/g, '').trim();
|
||||
if (clean === '') return null;
|
||||
if (!/^\d+(\.\d{0,2})?$/.test(clean)) return null;
|
||||
|
||||
const [whole, frac = ''] = clean.split('.');
|
||||
const paise = Number(whole) * 100 + Number((frac + '00').slice(0, 2));
|
||||
return Number.isSafeInteger(paise) ? paise : null;
|
||||
}
|
||||
|
||||
/** Paise → "₹1,250.00" for display. Integer division, never a float sum. */
|
||||
export function formatPaise(paise: number): string {
|
||||
const sign = paise < 0 ? '-' : '';
|
||||
const abs = Math.abs(Math.trunc(paise));
|
||||
const rupees = Math.trunc(abs / 100);
|
||||
const rest = abs % 100;
|
||||
return `${sign}₹${rupees.toLocaleString('en-IN')}.${String(rest).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The bill: purchased lines only.
|
||||
*
|
||||
* This mirrors the database trigger deliberately and is NOT the source of
|
||||
* truth — the value shown after submission comes back from the server. It
|
||||
* exists so the running total a merchant watches while typing matches the one
|
||||
* they will be charged, and the rule is stated once here rather than in each
|
||||
* component that renders a subtotal.
|
||||
*/
|
||||
export function billablePaise(lines: {pricePaise: number; intent: string}[]): number {
|
||||
return lines.reduce(
|
||||
(sum, l) => (l.intent === 'purchased' ? sum + l.pricePaise : sum),
|
||||
0,
|
||||
);
|
||||
}
|
||||
72
src/features/commerce/types/commerce.ts
Normal file
72
src/features/commerce/types/commerce.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
export interface CommerceFilterState {
|
||||
dateRange: 'today' | '7d' | '30d' | '90d' | 'custom';
|
||||
storeId: string;
|
||||
category: string;
|
||||
paymentMethod: string;
|
||||
salesChannel: string;
|
||||
}
|
||||
|
||||
export interface MetricCardData {
|
||||
title: string;
|
||||
value: string;
|
||||
change: string;
|
||||
trendDirection: 'up' | 'down' | 'neutral';
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
export interface FunnelStage {
|
||||
stage: string;
|
||||
count: number;
|
||||
label: string;
|
||||
conversionRate: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface PaymentBreakdownItem {
|
||||
method: string;
|
||||
amount: string;
|
||||
percentage: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface TopProductRow {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
unitsSold: number;
|
||||
revenue: string;
|
||||
growth: string;
|
||||
growthDirection: 'up' | 'down';
|
||||
stockCount: number;
|
||||
stockStatus: 'in_stock' | 'low_stock' | 'out_of_stock';
|
||||
}
|
||||
|
||||
export interface HeatmapCell {
|
||||
day: string;
|
||||
hour: string;
|
||||
value: number; // intensity 0 - 100
|
||||
}
|
||||
|
||||
export interface ForecastDataPoint {
|
||||
date: string;
|
||||
actual?: number;
|
||||
forecast: number;
|
||||
lowerBound?: number;
|
||||
upperBound?: number;
|
||||
}
|
||||
|
||||
export interface BusinessAlert {
|
||||
id: string;
|
||||
type: 'low_stock' | 'revenue_drop' | 'refund_spike' | 'best_seller' | 'promotion_opportunity';
|
||||
title: string;
|
||||
description: string;
|
||||
actionText: string;
|
||||
severity: 'critical' | 'warning' | 'info' | 'success';
|
||||
}
|
||||
|
||||
export interface OperationalFlowStep {
|
||||
step: string;
|
||||
label: string;
|
||||
detail: string;
|
||||
iconName: string;
|
||||
}
|
||||
35
src/features/commerce/types/sale.ts
Normal file
35
src/features/commerce/types/sale.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* A sale, as the console consumes it.
|
||||
*
|
||||
* Money stays INTEGER PAISE all the way to the pixel. The first version
|
||||
* converted to rupees in the BFF and every component converted back with
|
||||
* `Math.round(x * 100)` to format it — a float round-trip on every render, in
|
||||
* several places, which is precisely what LOYALY.md §10 forbids.
|
||||
*
|
||||
* Now nothing converts. `formatPaise` turns an integer into "₹1,899.50" for
|
||||
* display and that is the only place money changes shape.
|
||||
*/
|
||||
export interface SaleLine {
|
||||
productName: string;
|
||||
pricePaise: number;
|
||||
intent: 'purchased' | 'enquired';
|
||||
/** What this line put on the bill. Zero for every enquiry, always. */
|
||||
billablePaise: number;
|
||||
}
|
||||
|
||||
export interface Sale {
|
||||
id: string;
|
||||
invoiceNo: string | null;
|
||||
siteId: string;
|
||||
customerRef: string | null;
|
||||
customerLabel: string | null;
|
||||
staffName: string | null;
|
||||
totalPaise: number;
|
||||
currency: string;
|
||||
status: string;
|
||||
at: string;
|
||||
/** Counts for the list. `lines` is populated only by the detail view. */
|
||||
purchasedLines: number;
|
||||
enquiryLines: number;
|
||||
lines: SaleLine[];
|
||||
}
|
||||
189
src/features/customers/components/CustomerDialog.tsx
Normal file
189
src/features/customers/components/CustomerDialog.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import Image from 'next/image';
|
||||
import {Dialog, DialogHeader} from '@astryxdesign/core/Dialog';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text, Heading} from '@astryxdesign/core/Text';
|
||||
import {TextInput} from '@astryxdesign/core/TextInput';
|
||||
import {CheckboxInput} from '@astryxdesign/core/CheckboxInput';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Banner} from '@astryxdesign/core/Banner';
|
||||
import {Divider} from '@astryxdesign/core/Divider';
|
||||
import {Timestamp} from '@astryxdesign/core/Timestamp';
|
||||
import {useSession} from '@/features/auth/providers/SessionProvider';
|
||||
import {hasRole} from '@/features/auth/services/roles';
|
||||
import {customerRepository} from '@/features/customers/repositories/customerRepository';
|
||||
import {useCustomerPhoto} from '@/features/customers/hooks/useCustomers';
|
||||
import {CustomerHistory} from '@/features/customers/components/CustomerHistory';
|
||||
import {RecordPurchaseDialog} from '@/features/customers/components/RecordPurchaseDialog';
|
||||
import {EraseCustomerDialog} from '@/features/customers/components/EraseCustomerDialog';
|
||||
import type {Customer} from '@/features/customers/types/customer';
|
||||
|
||||
/**
|
||||
* One customer: who they are, when they came, and what staff can do about it.
|
||||
*
|
||||
* The photo is read ONCE here, not per component, because every read is a row
|
||||
* in the platform's "who looked at my customers" audit log. A missing photo is
|
||||
* the platform's own reason in plain text — most deployments store none.
|
||||
*
|
||||
* The profile save is a whole-object replace upstream, so the form holds only
|
||||
* what can be read back (name, phone, email, consent) and says what saving
|
||||
* clears. Unticking consent is a withdrawal the platform timestamps.
|
||||
*/
|
||||
export function CustomerDialog({
|
||||
customer,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
customer: Customer;
|
||||
onClose: () => void;
|
||||
/** Something about this customer changed; the list should re-read. */
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const {user} = useSession();
|
||||
const canErase = hasRole(user?.role, 'manager');
|
||||
const photo = useCustomerPhoto(customer.id);
|
||||
|
||||
const [fullName, setFullName] = useState(customer.fullName);
|
||||
const [phone, setPhone] = useState(customer.phone);
|
||||
const [email, setEmail] = useState(customer.email);
|
||||
const [consent, setConsent] = useState(customer.hasConsent);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<{ok: boolean; text: string} | null>(null);
|
||||
const [buying, setBuying] = useState(false);
|
||||
const [erasing, setErasing] = useState(false);
|
||||
|
||||
const hasContact = fullName.trim() !== '' || phone.trim() !== '' || email.trim() !== '';
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
const res = await customerRepository.saveProfile(customer.id, {
|
||||
fullName,
|
||||
phone,
|
||||
email,
|
||||
consent,
|
||||
});
|
||||
setSaving(false);
|
||||
if (!res.ok) {
|
||||
setMessage({ok: false, text: res.message ?? 'Could not save these details.'});
|
||||
return;
|
||||
}
|
||||
setMessage({ok: true, text: 'Saved.'});
|
||||
onChanged();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
purpose="info"
|
||||
width={560}
|
||||
aria-label={`Customer ${customer.label}`}
|
||||
>
|
||||
<VStack gap={4} width="100%">
|
||||
<DialogHeader
|
||||
title={customer.label}
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
/>
|
||||
|
||||
<HStack gap={4} vAlign="start">
|
||||
{photo.status === 'success' && photo.data.available && photo.data.url ? (
|
||||
<Image
|
||||
src={photo.data.url}
|
||||
alt={`Latest photo of ${customer.label}`}
|
||||
width={96}
|
||||
height={96}
|
||||
unoptimized
|
||||
style={{borderRadius: 8, objectFit: 'cover'}}
|
||||
/>
|
||||
) : null}
|
||||
<VStack gap={1}>
|
||||
{customer.ref ? (
|
||||
<Text size="sm" color="secondary" className="font-mono">
|
||||
{customer.ref}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text size="sm">
|
||||
{customer.visitCount} {customer.visitCount === 1 ? 'visit' : 'visits'} · last seen{' '}
|
||||
<Timestamp value={customer.lastSeenAt} format="relative" />
|
||||
</Text>
|
||||
<Text size="xsm" color="secondary">
|
||||
First seen <Timestamp value={customer.firstSeenAt} format="date" />
|
||||
</Text>
|
||||
{photo.status === 'success' && !photo.data.available && photo.data.reason ? (
|
||||
<Text size="xsm" color="secondary">
|
||||
{photo.data.reason}
|
||||
</Text>
|
||||
) : null}
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Heading level={4}>Details</Heading>
|
||||
<TextInput label="Name" value={fullName} onChange={setFullName} />
|
||||
<HStack gap={3} wrap="wrap">
|
||||
<TextInput label="Phone" value={phone} onChange={setPhone} isOptional />
|
||||
<TextInput label="Email" value={email} onChange={setEmail} isOptional />
|
||||
</HStack>
|
||||
<CheckboxInput
|
||||
label="They agreed to be recognised"
|
||||
value={consent}
|
||||
onChange={(v) => setConsent(v === true)}
|
||||
/>
|
||||
<Text size="xsm" color="secondary">
|
||||
Saving replaces their whole profile: any gender, date of birth or notes recorded
|
||||
elsewhere are cleared.
|
||||
</Text>
|
||||
{message ? (
|
||||
<Banner status={message.ok ? 'success' : 'error'} title={message.text} />
|
||||
) : null}
|
||||
|
||||
<Divider />
|
||||
|
||||
<Heading level={4}>Visits</Heading>
|
||||
<CustomerHistory customerId={customer.id} />
|
||||
|
||||
<HStack gap={2} hAlign="between" wrap="wrap">
|
||||
{canErase ? (
|
||||
<Button variant="destructive" onClick={() => setErasing(true)} label="Erase…" />
|
||||
) : null}
|
||||
<HStack gap={2} wrap="wrap">
|
||||
<Button variant="secondary" onClick={() => setBuying(true)} label="Record purchase" />
|
||||
<Button
|
||||
onClick={() => void save()}
|
||||
isDisabled={!hasContact || saving}
|
||||
isLoading={saving}
|
||||
label="Save details"
|
||||
/>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
{buying ? (
|
||||
<RecordPurchaseDialog
|
||||
customer={customer}
|
||||
onClose={() => setBuying(false)}
|
||||
onRecorded={() => {
|
||||
setBuying(false);
|
||||
setMessage({ok: true, text: 'Purchase recorded.'});
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{erasing ? (
|
||||
<EraseCustomerDialog
|
||||
customer={customer}
|
||||
onClose={() => setErasing(false)}
|
||||
onErased={() => {
|
||||
setErasing(false);
|
||||
onChanged();
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
137
src/features/customers/components/CustomerDirectory.tsx
Normal file
137
src/features/customers/components/CustomerDirectory.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
'use client';
|
||||
|
||||
import {useEffect, useState} from 'react';
|
||||
import {proportional, pixel} from '@astryxdesign/core/Table';
|
||||
import type {TableColumn} from '@astryxdesign/core/Table';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {TextInput} from '@astryxdesign/core/TextInput';
|
||||
import {Timestamp} from '@astryxdesign/core/Timestamp';
|
||||
import {PanelCard} from '@/shared/components/patterns/PanelCard';
|
||||
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
|
||||
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {useCustomerSearch} from '@/features/customers/hooks/useCustomers';
|
||||
import {CustomerDialog} from '@/features/customers/components/CustomerDialog';
|
||||
import type {Customer} from '@/features/customers/types/customer';
|
||||
|
||||
/** Long enough that a word typed at speed is one request, short enough to feel live. */
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
|
||||
/**
|
||||
* Find a customer by name, phone, email or number (`42` or `V-42`).
|
||||
*
|
||||
* The term is debounced before it becomes the request: useResource keys on the
|
||||
* URL, so without it every keystroke would be a round trip to the platform.
|
||||
* With no term the list is the most recently seen — the useful default for a
|
||||
* person who has just walked in.
|
||||
*/
|
||||
export function CustomerDirectory() {
|
||||
const [input, setInput] = useState('');
|
||||
const [query, setQuery] = useState('');
|
||||
const [open, setOpen] = useState<Customer | null>(null);
|
||||
const customers = useCustomerSearch(query);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setQuery(input), SEARCH_DEBOUNCE_MS);
|
||||
return () => clearTimeout(t);
|
||||
}, [input]);
|
||||
|
||||
const columns: TableColumn<Customer>[] = [
|
||||
{
|
||||
key: 'label',
|
||||
header: 'Customer',
|
||||
width: proportional(2),
|
||||
renderCell: (row) => (
|
||||
<VStack gap={0}>
|
||||
<Text size="sm" weight="medium">
|
||||
{row.label}
|
||||
</Text>
|
||||
<Text size="sm" color="secondary">
|
||||
{/* An unnamed customer's label already IS their number ("Visitor 42"). */}
|
||||
{[row.fullName ? row.ref : null, row.phone || null].filter(Boolean).join(' · ') || '—'}
|
||||
</Text>
|
||||
</VStack>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'visitCount',
|
||||
header: 'Visits',
|
||||
width: pixel(90),
|
||||
renderCell: (row) => (
|
||||
<Text size="sm" weight="medium">
|
||||
{row.visitCount}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'lastSeenAt',
|
||||
header: 'Last seen',
|
||||
width: proportional(1),
|
||||
renderCell: (row) => <Timestamp value={row.lastSeenAt} format="relative" />,
|
||||
},
|
||||
{
|
||||
key: 'hasConsent',
|
||||
header: 'Consent',
|
||||
width: pixel(110),
|
||||
renderCell: (row) =>
|
||||
row.hasConsent ? <Badge variant="success" label="Given" /> : <Text size="sm">—</Text>,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
align: 'center',
|
||||
width: pixel(100),
|
||||
renderCell: (row) => (
|
||||
<HStack hAlign="center">
|
||||
<Button size="sm" variant="secondary" onClick={() => setOpen(row)} label="Open" />
|
||||
</HStack>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<VStack gap={4}>
|
||||
<TextInput
|
||||
label="Search customers"
|
||||
isLabelHidden
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
placeholder="Name, phone, email or number (V-42)"
|
||||
hasClear
|
||||
/>
|
||||
|
||||
<PanelCard
|
||||
title={query ? `Results for “${query}”` : 'Recently seen'}
|
||||
resource={customers}
|
||||
loading={<SkeletonRows count={6} height={52} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="visitors"
|
||||
title={query ? 'No customer matches that' : 'No customers yet'}
|
||||
description={
|
||||
query
|
||||
? 'Try part of a name, a phone number, or their customer number.'
|
||||
: 'Customers appear here once a camera has recognised them.'
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(rows) => (
|
||||
<ResponsiveTable columns={columns} data={rows} idKey="id" primaryKey="label" />
|
||||
)}
|
||||
</PanelCard>
|
||||
|
||||
{open ? (
|
||||
<CustomerDialog
|
||||
key={open.id}
|
||||
customer={open}
|
||||
onClose={() => setOpen(null)}
|
||||
onChanged={customers.refetch}
|
||||
/>
|
||||
) : null}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
49
src/features/customers/components/CustomerHistory.tsx
Normal file
49
src/features/customers/components/CustomerHistory.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
import {Timestamp} from '@astryxdesign/core/Timestamp';
|
||||
import {Divider} from '@astryxdesign/core/Divider';
|
||||
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
|
||||
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {useCustomerHistory} from '@/features/customers/hooks/useCustomers';
|
||||
|
||||
/** Their visits, newest first — the one that enrolled them is marked. */
|
||||
export function CustomerHistory({customerId}: {customerId: string}) {
|
||||
const history = useCustomerHistory(customerId);
|
||||
|
||||
return (
|
||||
<AsyncBoundary
|
||||
resource={history}
|
||||
loading={<SkeletonRows count={4} height={28} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="visitors"
|
||||
title="No visits recorded"
|
||||
description="Visits appear here once a camera recognises them."
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(visits) => (
|
||||
<VStack gap={2}>
|
||||
{visits.map((v, i) => (
|
||||
<VStack key={v.id} gap={2}>
|
||||
{i > 0 ? <Divider /> : null}
|
||||
<HStack gap={2} vAlign="center" hAlign="between">
|
||||
<VStack gap={0}>
|
||||
<Timestamp value={v.occurredAt} format="date_time" />
|
||||
<Text size="xsm" color="secondary">
|
||||
{v.siteName}
|
||||
</Text>
|
||||
</VStack>
|
||||
{v.isNewVisitor ? <Badge variant="neutral" label="First visit" /> : null}
|
||||
</HStack>
|
||||
</VStack>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</AsyncBoundary>
|
||||
);
|
||||
}
|
||||
88
src/features/customers/components/EraseCustomerDialog.tsx
Normal file
88
src/features/customers/components/EraseCustomerDialog.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {Dialog, DialogHeader} from '@astryxdesign/core/Dialog';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {TextInput} from '@astryxdesign/core/TextInput';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Banner} from '@astryxdesign/core/Banner';
|
||||
import {customerRepository} from '@/features/customers/repositories/customerRepository';
|
||||
import type {Customer} from '@/features/customers/types/customer';
|
||||
|
||||
/**
|
||||
* Erase a customer — the right-to-be-forgotten path. Manager and above.
|
||||
*
|
||||
* Irreversible, so it asks for the customer number to be typed. What it does
|
||||
* is said plainly: the face and photo are destroyed; the visits stay as
|
||||
* anonymous footfall; the consent record stays, revoked.
|
||||
*
|
||||
* A failure is shown and never softened. The platform's 502 means the photo
|
||||
* could not be deleted and NOTHING was erased — telling the merchant otherwise
|
||||
* would leave them believing data is gone that is still there.
|
||||
*/
|
||||
export function EraseCustomerDialog({
|
||||
customer,
|
||||
onClose,
|
||||
onErased,
|
||||
}: {
|
||||
customer: Customer;
|
||||
onClose: () => void;
|
||||
onErased: () => void;
|
||||
}) {
|
||||
const expected = customer.ref ?? customer.id;
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function submit() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const res = await customerRepository.erase(customer.id);
|
||||
setBusy(false);
|
||||
if (!res.ok) {
|
||||
setError(res.message ?? 'Nothing was erased. Please try again.');
|
||||
return;
|
||||
}
|
||||
onErased();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
purpose="required"
|
||||
width={460}
|
||||
aria-label="Erase customer"
|
||||
>
|
||||
<VStack gap={4} width="100%">
|
||||
<DialogHeader
|
||||
title={`Erase ${customer.label}?`}
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
/>
|
||||
<Banner
|
||||
status="error"
|
||||
title="This cannot be undone"
|
||||
description="Their face template and photo are deleted permanently, and their name and contact details are removed. Their visits stay as anonymous footfall, and the record of their consent stays, marked withdrawn. The cameras will not re-enrol them as a new customer."
|
||||
/>
|
||||
{error ? <Banner status="error" title={error} /> : null}
|
||||
<TextInput
|
||||
label="Type the customer number to confirm"
|
||||
value={confirm}
|
||||
onChange={setConfirm}
|
||||
placeholder={expected}
|
||||
description={`Exactly: ${expected}`}
|
||||
/>
|
||||
<HStack gap={2} hAlign="end">
|
||||
<Button variant="secondary" onClick={onClose} label="Cancel" />
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => void submit()}
|
||||
isDisabled={confirm.trim() !== expected || busy}
|
||||
isLoading={busy}
|
||||
label="Erase customer"
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
126
src/features/customers/components/RecordPurchaseDialog.tsx
Normal file
126
src/features/customers/components/RecordPurchaseDialog.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {Dialog, DialogHeader} from '@astryxdesign/core/Dialog';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {TextInput} from '@astryxdesign/core/TextInput';
|
||||
import {NumberInput} from '@astryxdesign/core/NumberInput';
|
||||
import {Selector} from '@astryxdesign/core/Selector';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Banner} from '@astryxdesign/core/Banner';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {useWorkspace} from '@/shared/providers/WorkspaceProvider';
|
||||
import {customerRepository} from '@/features/customers/repositories/customerRepository';
|
||||
import type {Customer} from '@/features/customers/types/customer';
|
||||
|
||||
const LAST_SEEN = '__last_seen__';
|
||||
|
||||
/**
|
||||
* Link a sale to this customer, so the conversion report can say who bought.
|
||||
*
|
||||
* Lighter than the till's itemised sale on the Commerce page: an amount, and
|
||||
* optionally what they bought. Left on "where they were last seen", the
|
||||
* platform books it at that shop — what "somebody on the floor just sold them
|
||||
* something" means. Items are a comma-separated list of names, because that is
|
||||
* what the platform stores.
|
||||
*/
|
||||
export function RecordPurchaseDialog({
|
||||
customer,
|
||||
onClose,
|
||||
onRecorded,
|
||||
}: {
|
||||
customer: Customer;
|
||||
onClose: () => void;
|
||||
onRecorded: () => void;
|
||||
}) {
|
||||
const {stores} = useWorkspace();
|
||||
const [amount, setAmount] = useState<number | null>(null);
|
||||
const [site, setSite] = useState(LAST_SEEN);
|
||||
const [items, setItems] = useState('');
|
||||
const [notes, setNotes] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const shopOptions = [
|
||||
{value: LAST_SEEN, label: 'Where they were last seen'},
|
||||
...stores.filter((s) => s.id !== 'all').map((s) => ({value: s.id, label: s.name})),
|
||||
];
|
||||
|
||||
async function submit() {
|
||||
if (amount === null || amount <= 0) {
|
||||
setError('Enter the amount they paid.');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const res = await customerRepository.recordPurchase({
|
||||
visitorId: customer.id,
|
||||
site: site === LAST_SEEN ? undefined : site,
|
||||
amount,
|
||||
currency: 'INR',
|
||||
items: items
|
||||
.split(',')
|
||||
.map((i) => i.trim())
|
||||
.filter(Boolean),
|
||||
notes: notes.trim(),
|
||||
});
|
||||
setBusy(false);
|
||||
if (!res.ok) {
|
||||
setError(res.message ?? 'Could not record that purchase.');
|
||||
return;
|
||||
}
|
||||
onRecorded();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
purpose="required"
|
||||
width={440}
|
||||
aria-label="Record a purchase"
|
||||
>
|
||||
<VStack gap={4} width="100%">
|
||||
<DialogHeader
|
||||
title={`Record a purchase — ${customer.label}`}
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Amount (₹)"
|
||||
value={amount}
|
||||
onChange={setAmount}
|
||||
hasClear
|
||||
min={0}
|
||||
/>
|
||||
<Selector
|
||||
label="Shop"
|
||||
value={site}
|
||||
onChange={(v) => setSite(v)}
|
||||
options={shopOptions}
|
||||
/>
|
||||
<TextInput
|
||||
label="Items"
|
||||
value={items}
|
||||
onChange={setItems}
|
||||
isOptional
|
||||
placeholder="Silk saree, Blouse"
|
||||
description="Separate items with commas."
|
||||
/>
|
||||
<TextInput label="Notes" value={notes} onChange={setNotes} isOptional />
|
||||
<Text size="xsm" color="secondary">
|
||||
For an itemised bill with enquiries, use New sale on the Commerce page.
|
||||
</Text>
|
||||
{error ? <Banner status="error" title={error} /> : null}
|
||||
<HStack gap={2} hAlign="end">
|
||||
<Button variant="secondary" onClick={onClose} label="Cancel" />
|
||||
<Button
|
||||
onClick={() => void submit()}
|
||||
isDisabled={busy}
|
||||
isLoading={busy}
|
||||
label="Record purchase"
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
32
src/features/customers/hooks/useCustomers.ts
Normal file
32
src/features/customers/hooks/useCustomers.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import {useResource} from '@/shared/hooks/useResource';
|
||||
import {useSession} from '@/features/auth/providers/SessionProvider';
|
||||
import {customerRepository} from '@/features/customers/repositories/customerRepository';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
import type {
|
||||
Customer,
|
||||
CustomerPhoto,
|
||||
CustomerVisit,
|
||||
} from '@/features/customers/types/customer';
|
||||
|
||||
/**
|
||||
* Customer search. An empty term lists the most recently seen — the useful
|
||||
* default for somebody who has just walked in.
|
||||
*
|
||||
* Session-gated like every other hook here, so nothing is requested from a
|
||||
* tab that has signed out.
|
||||
*/
|
||||
export function useCustomerSearch(q: string): Resource<Customer[]> {
|
||||
const {isAuthenticated} = useSession();
|
||||
return useResource(isAuthenticated ? customerRepository.search(q) : null);
|
||||
}
|
||||
|
||||
export function useCustomerHistory(id: string): Resource<CustomerVisit[]> {
|
||||
return useResource(customerRepository.history(id));
|
||||
}
|
||||
|
||||
/** A missing photo is `available: false`, never 'empty' or an error. */
|
||||
export function useCustomerPhoto(id: string): Resource<CustomerPhoto> {
|
||||
return useResource(customerRepository.photo(id), {isEmpty: () => false});
|
||||
}
|
||||
46
src/features/customers/repositories/customerRepository.ts
Normal file
46
src/features/customers/repositories/customerRepository.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
deleteJson,
|
||||
postJson,
|
||||
putJson,
|
||||
type Endpoint,
|
||||
} from '@/shared/services/httpClient';
|
||||
import type {
|
||||
Customer,
|
||||
CustomerPhoto,
|
||||
CustomerVisit,
|
||||
ProfileDraft,
|
||||
PurchaseDraft,
|
||||
} from '@/features/customers/types/customer';
|
||||
|
||||
/**
|
||||
* TRANSPORT ONLY — customers, their history and photo, and the writes.
|
||||
*
|
||||
* The reads are Endpoints so useResource keys on the URL: a new search term or
|
||||
* a different customer refetches on its own. `id` may be the uuid or `V-42`.
|
||||
*/
|
||||
export const customerRepository = {
|
||||
search: (q: string): Endpoint<Customer[]> => ({
|
||||
path: '/api/visitors',
|
||||
params: q.trim() ? {q: q.trim()} : {},
|
||||
}),
|
||||
|
||||
history: (id: string): Endpoint<CustomerVisit[]> => ({
|
||||
path: `/api/visitors/${encodeURIComponent(id)}/history`,
|
||||
params: {},
|
||||
}),
|
||||
|
||||
/** Audited upstream on every read — fetch it once per screen. */
|
||||
photo: (id: string): Endpoint<CustomerPhoto> => ({
|
||||
path: `/api/visitors/${encodeURIComponent(id)}/image`,
|
||||
params: {},
|
||||
}),
|
||||
|
||||
/** Whole-object replace; see ProfileDraft for why it is these four fields. */
|
||||
saveProfile: (id: string, draft: ProfileDraft) =>
|
||||
putJson<null>(`/api/visitors/${encodeURIComponent(id)}/profile`, draft),
|
||||
|
||||
recordPurchase: (draft: PurchaseDraft) => postJson<null>('/api/purchases', draft),
|
||||
|
||||
/** Irreversible erasure. Manager and above. */
|
||||
erase: (id: string) => deleteJson<null>(`/api/visitors/${encodeURIComponent(id)}`),
|
||||
};
|
||||
110
src/features/customers/services/mapCustomer.ts
Normal file
110
src/features/customers/services/mapCustomer.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type {
|
||||
ApiImageRef,
|
||||
ApiProfileInput,
|
||||
ApiPurchaseInput,
|
||||
ApiVisitor,
|
||||
ApiVisitorHistoryEntry,
|
||||
} from '@/services/api/types';
|
||||
import type {
|
||||
Customer,
|
||||
CustomerPhoto,
|
||||
CustomerVisit,
|
||||
} from '@/features/customers/types/customer';
|
||||
|
||||
/**
|
||||
* Platform shapes → what the customer screens consume, and request bodies →
|
||||
* what the platform accepts.
|
||||
*
|
||||
* Kept out of the route files so the list, the history, the save and the
|
||||
* purchase routes agree on one mapping, the way `mapTeam` does for the team.
|
||||
*/
|
||||
export function toCustomer(v: ApiVisitor): Customer {
|
||||
return {
|
||||
id: v.id,
|
||||
ref: v.ref || null,
|
||||
// Upstream `label` is the name given at ENROLMENT ("Visitor 42") and is
|
||||
// never changed by naming them — the chosen name is `full_name`. So the
|
||||
// name wins when there is one, as it does in the platform's own console.
|
||||
label: v.full_name || v.label || v.ref || 'Customer',
|
||||
fullName: v.full_name ?? '',
|
||||
phone: v.phone ?? '',
|
||||
email: v.email ?? '',
|
||||
visitCount: v.visit_count ?? 0,
|
||||
firstSeenAt: v.first_seen_at,
|
||||
lastSeenAt: v.last_seen_at,
|
||||
hasProfile: v.has_profile === true,
|
||||
hasConsent: v.has_consent === true,
|
||||
};
|
||||
}
|
||||
|
||||
export function toCustomerVisit(h: ApiVisitorHistoryEntry): CustomerVisit {
|
||||
return {
|
||||
id: h.id,
|
||||
occurredAt: h.occurred_at,
|
||||
siteName: h.site,
|
||||
cameraId: h.camera_id,
|
||||
isNewVisitor: h.is_new_visitor === true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A relative URL is served by the platform and needs the session's token,
|
||||
* which an <img> cannot send — so it goes through /api/faces. A presigned
|
||||
* object-storage link carries its own signature and is used as it is.
|
||||
*/
|
||||
export function toCustomerPhoto(img: ApiImageRef): CustomerPhoto {
|
||||
if (!img.available || !img.url) {
|
||||
return {available: false, url: null, reason: img.reason ?? null};
|
||||
}
|
||||
return {
|
||||
available: true,
|
||||
url: img.url.startsWith('http')
|
||||
? img.url
|
||||
: `/api/faces?src=${encodeURIComponent(img.url)}`,
|
||||
reason: null,
|
||||
};
|
||||
}
|
||||
|
||||
function text(v: unknown): string {
|
||||
return typeof v === 'string' ? v.trim() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* The save is a WHOLE-OBJECT replace upstream. Gender, date of birth and notes
|
||||
* are sent empty because this console cannot read them back to preserve them —
|
||||
* see ProfileDraft — and the form says so before anybody saves.
|
||||
*/
|
||||
export function toProfileInput(body: Record<string, unknown>): ApiProfileInput {
|
||||
return {
|
||||
full_name: text(body.fullName),
|
||||
phone: text(body.phone),
|
||||
email: text(body.email),
|
||||
gender: '',
|
||||
date_of_birth: '',
|
||||
notes: '',
|
||||
consent: body.consent === true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A request body → the platform's `PurchaseInput`.
|
||||
*
|
||||
* `items` upstream is a list of item NAMES; anything that is not a non-empty
|
||||
* string is dropped rather than sent, because a number there fails the
|
||||
* platform's JSON decode for the whole request.
|
||||
*/
|
||||
export function toPurchaseInput(body: Record<string, unknown>): ApiPurchaseInput {
|
||||
const items = Array.isArray(body.items)
|
||||
? body.items.map(text).filter((i) => i !== '')
|
||||
: [];
|
||||
const site = text(body.site) || text(body.siteId);
|
||||
return {
|
||||
visitor_id: text(body.visitorId),
|
||||
site_id: site || undefined,
|
||||
amount: Number(body.amount),
|
||||
currency: text(body.currency).toUpperCase() || 'INR',
|
||||
items: items.length > 0 ? items : undefined,
|
||||
source: 'console',
|
||||
notes: text(body.notes) || undefined,
|
||||
};
|
||||
}
|
||||
74
src/features/customers/types/customer.ts
Normal file
74
src/features/customers/types/customer.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* A customer, as the console consumes it — `GET /api/visitors`.
|
||||
*
|
||||
* `label` is what to show: it reads "Visitor 42" until somebody names them,
|
||||
* then the name. `fullName` stays empty until then, which is how the screen
|
||||
* tells an unnamed regular from a named one without guessing from the label.
|
||||
*
|
||||
* `ref` ("V-42") is per tenant and immutable — safe to show, to search by and
|
||||
* to type into a till. It is null only on a deployment that predates numbering.
|
||||
*/
|
||||
export interface Customer extends Record<string, unknown> {
|
||||
id: string;
|
||||
ref: string | null;
|
||||
label: string;
|
||||
fullName: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
visitCount: number;
|
||||
firstSeenAt: string;
|
||||
lastSeenAt: string;
|
||||
hasProfile: boolean;
|
||||
/** Recorded agreement to be recognised. Withdrawn, never deleted, upstream. */
|
||||
hasConsent: boolean;
|
||||
}
|
||||
|
||||
/** One visit in a customer's history. Newest first. */
|
||||
export interface CustomerVisit {
|
||||
id: string;
|
||||
occurredAt: string;
|
||||
/** The shop's display name at the time of reading, not its slug. */
|
||||
siteName: string;
|
||||
cameraId: string;
|
||||
/** True on exactly one row — the visit that enrolled them. */
|
||||
isNewVisitor: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The customer's latest photo.
|
||||
*
|
||||
* `available: false` is DATA, not an error: images are off by default across
|
||||
* the product, so most deployments legitimately have none, and `reason` says
|
||||
* why. `url` is already proxied — a component never builds a platform URL.
|
||||
*/
|
||||
export interface CustomerPhoto {
|
||||
available: boolean;
|
||||
url: string | null;
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the profile form edits.
|
||||
*
|
||||
* Deliberately only what the platform lets this console READ back. The save is
|
||||
* a whole-object replace, and gender, date of birth and notes are not on any
|
||||
* read endpoint — a form offering them would show them blank and could only
|
||||
* ever overwrite what somebody else had recorded.
|
||||
*/
|
||||
export interface ProfileDraft {
|
||||
fullName: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
consent: boolean;
|
||||
}
|
||||
|
||||
/** A sale linked to a customer, so the conversion report can say who bought. */
|
||||
export interface PurchaseDraft {
|
||||
visitorId: string;
|
||||
/** Slug or uuid. Omitted = where this customer was last seen. */
|
||||
site?: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
items: string[];
|
||||
notes: string;
|
||||
}
|
||||
122
src/features/dashboard/components/ArrivalsFeed.tsx
Normal file
122
src/features/dashboard/components/ArrivalsFeed.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
'use client';
|
||||
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Avatar} from '@astryxdesign/core/Avatar';
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
import {Timestamp} from '@astryxdesign/core/Timestamp';
|
||||
import {PanelCard} from '@/shared/components/patterns/PanelCard';
|
||||
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import type {Arrival, VisitsPage} from '@/features/dashboard/types/visits';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
|
||||
/**
|
||||
* Who just walked in.
|
||||
*
|
||||
* This replaces the generated event timeline. The platform reports ARRIVALS —
|
||||
* a camera recognising someone at a door — and nothing else, so the five event
|
||||
* kinds the old feed showed (redemptions, staff check-ins, store openings,
|
||||
* reward expiries) are gone rather than simulated.
|
||||
*
|
||||
* ── Photos ───────────────────────────────────────────────────────────────
|
||||
* A missing photo is DATA, not an error: images are off by default across the
|
||||
* product, so on most deployments every row legitimately has none. Initials
|
||||
* are the normal case, and a screen of broken-image icons for a system working
|
||||
* as configured is a screen whose real errors stop being read.
|
||||
*
|
||||
* When a photo IS available it is served through this origin's /api/faces
|
||||
* proxy, because an <img> cannot send the Authorization header the platform
|
||||
* requires — and because every hand-out is written to the platform's audit
|
||||
* log, so it must be fetched once per row rather than once per component.
|
||||
*/
|
||||
function ArrivalRow({arrival}: {arrival: Arrival}) {
|
||||
return (
|
||||
<HStack
|
||||
gap={3}
|
||||
vAlign="center"
|
||||
paddingBlock={1.5}
|
||||
paddingInline={2}
|
||||
className="rounded-md transition-colors hover:bg-overlay-hover"
|
||||
>
|
||||
<Avatar
|
||||
name={arrival.label}
|
||||
src={arrival.image.url ?? undefined}
|
||||
size="sm"
|
||||
tooltip={false}
|
||||
/>
|
||||
|
||||
<VStack gap={0.5} width="100%">
|
||||
<HStack gap={2} vAlign="center" hAlign="between" wrap="wrap">
|
||||
<HStack gap={2} vAlign="center">
|
||||
<Text size="sm" weight="medium">
|
||||
{arrival.label}
|
||||
</Text>
|
||||
{/* The one enumerated state worth a badge: a face the platform has
|
||||
not seen before is the thing a merchant reacts to. */}
|
||||
{arrival.isNewVisitor ? (
|
||||
<Badge variant="success" label="New" />
|
||||
) : null}
|
||||
</HStack>
|
||||
<Timestamp value={arrival.occurredAt} format="relative" isLive />
|
||||
</HStack>
|
||||
|
||||
<Text size="sm" color="secondary">
|
||||
{arrival.visitorRef} · {arrival.siteName}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArrivalsFeed({
|
||||
resource,
|
||||
title = 'Recent arrivals',
|
||||
subtitle = 'Customers recognised at the door, newest first',
|
||||
viewAllHref,
|
||||
}: {
|
||||
resource: Resource<VisitsPage>;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
viewAllHref?: string;
|
||||
}) {
|
||||
return (
|
||||
<PanelCard
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
resource={resource}
|
||||
loading={<SkeletonRows count={6} />}
|
||||
actions={
|
||||
viewAllHref ? (
|
||||
<Button variant="ghost" size="sm" label="View all" href={viewAllHref} />
|
||||
) : undefined
|
||||
}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="activityVisit"
|
||||
title="No arrivals yet"
|
||||
description="Customers appear here as the shop's cameras recognise them."
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(page) =>
|
||||
page.arrivals.length === 0 ? (
|
||||
<EmptyPanel
|
||||
icon="activityVisit"
|
||||
title="No arrivals in this period"
|
||||
description="Try a wider date range, or another store."
|
||||
/>
|
||||
) : (
|
||||
<VStack gap={0} height={285} isScrollable>
|
||||
{/* Keyed on visit_id: delivery is at-least-once, so the same visit
|
||||
can legitimately arrive twice and must not render twice. */}
|
||||
{page.arrivals.map((a) => (
|
||||
<ArrivalRow key={a.visitId} arrival={a} />
|
||||
))}
|
||||
</VStack>
|
||||
)
|
||||
}
|
||||
</PanelCard>
|
||||
);
|
||||
}
|
||||
113
src/features/dashboard/components/CustomerFlowChart.tsx
Normal file
113
src/features/dashboard/components/CustomerFlowChart.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
Cell,
|
||||
LabelList,
|
||||
ResponsiveContainer,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import {CHART} from '@/shared/components/charts/palette';
|
||||
import {AXIS_TICK} from '@/shared/components/charts/ChartFrame';
|
||||
import {formatCompact} from '@/shared/utils/format';
|
||||
import type {
|
||||
ConversionReport,
|
||||
FootfallReport,
|
||||
} from '@/features/dashboard/types/reports';
|
||||
|
||||
interface FlowStep {
|
||||
name: string;
|
||||
value: number;
|
||||
caption: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Visitors → Returning → Purchased, as step bars against one shared scale.
|
||||
*
|
||||
* Each bar is a share of everyone who came in, so the drop between steps reads
|
||||
* at a glance. Every figure comes straight off the reports. "Purchased" is
|
||||
* purchases clamped to visitors: purchases count transactions, and a regular
|
||||
* who bought twice would otherwise out-run the visitor bar.
|
||||
*/
|
||||
export function buildFlow(
|
||||
footfall: FootfallReport,
|
||||
conversion: ConversionReport,
|
||||
): FlowStep[] {
|
||||
const total = footfall.total;
|
||||
const fresh =
|
||||
footfall.newVisitors ?? footfall.buckets.reduce((a, b) => a + b.newVisitors, 0);
|
||||
const returning = footfall.returningVisitors ?? Math.max(0, total - fresh);
|
||||
const purchased = Math.min(conversion.purchases, total);
|
||||
const share = (v: number) =>
|
||||
total > 0 ? ` · ${Math.round((v / total) * 100)}%` : '';
|
||||
|
||||
return [
|
||||
{name: 'Visitors', value: total, caption: formatCompact(total), color: CHART.brand.cool},
|
||||
{
|
||||
name: 'Returning',
|
||||
value: returning,
|
||||
caption: formatCompact(returning) + share(returning),
|
||||
color: CHART.brand.cool,
|
||||
},
|
||||
{
|
||||
name: 'Purchased',
|
||||
value: purchased,
|
||||
caption: formatCompact(purchased) + share(purchased),
|
||||
color: CHART.brand.warm,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function CustomerFlowChart({
|
||||
footfall,
|
||||
conversion,
|
||||
height = 220,
|
||||
}: {
|
||||
footfall: FootfallReport;
|
||||
conversion: ConversionReport;
|
||||
height?: number;
|
||||
}) {
|
||||
const steps = buildFlow(footfall, conversion);
|
||||
const max = Math.max(1, footfall.total);
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<BarChart
|
||||
data={steps}
|
||||
layout="vertical"
|
||||
margin={{top: 8, right: 72, bottom: 8, left: 0}}
|
||||
barCategoryGap="28%"
|
||||
>
|
||||
<XAxis type="number" hide domain={[0, max]} />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
width={84}
|
||||
tick={{...AXIS_TICK, fontSize: 12, fill: 'var(--color-text-secondary)'}}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="value"
|
||||
radius={6}
|
||||
background={{fill: 'var(--color-overlay-hover)', radius: 6}}
|
||||
isAnimationActive={false}
|
||||
>
|
||||
{steps.map((s) => (
|
||||
<Cell key={s.name} fill={s.color} />
|
||||
))}
|
||||
<LabelList
|
||||
dataKey="caption"
|
||||
position="right"
|
||||
offset={10}
|
||||
fontSize={12}
|
||||
fill="var(--color-text-primary)"
|
||||
/>
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
74
src/features/dashboard/components/KpiRow.tsx
Normal file
74
src/features/dashboard/components/KpiRow.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
'use client';
|
||||
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
|
||||
import {MetricCard} from '@/shared/components/patterns/MetricCard';
|
||||
import {SkeletonMetricGrid} from '@/shared/components/patterns/LoadingState';
|
||||
import {ICONS} from '@/shared/utils/icons';
|
||||
import {useBreakpoint, metricColumns} from '@/shared/hooks/useBreakpoint';
|
||||
import {useLoyalyAi} from '@/features/loyaly-ai/providers/LoyalyAiProvider';
|
||||
import {formatCompact, formatInrCompact} from '@/shared/utils/format';
|
||||
import type {Kpi, KpiId} from '@/features/dashboard/types/dashboard';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
import type {IconType} from '@astryxdesign/core/Icon';
|
||||
|
||||
const KPI_ICON: Record<KpiId, IconType> = {
|
||||
visitors: ICONS.visitors,
|
||||
purchases: ICONS.purchases,
|
||||
revenue: ICONS.revenue,
|
||||
conversion: ICONS.conversion,
|
||||
};
|
||||
|
||||
const KPI_ACCENT: Record<KpiId, 'cool' | 'warm'> = {
|
||||
visitors: 'cool',
|
||||
purchases: 'warm',
|
||||
revenue: 'warm',
|
||||
conversion: 'cool',
|
||||
};
|
||||
|
||||
/**
|
||||
* Four KPIs must lay out 4-up, 2×2, or stacked — never 3+1, which reads as a
|
||||
* broken grid. `repeat: 'fit'` cannot express that: at a ~800px workspace it
|
||||
* yields exactly three columns whatever minWidth you choose, and the fourth
|
||||
* card drops to an orphan row.
|
||||
*
|
||||
* So the count is computed rather than inferred. The two things that decide
|
||||
* the available width are the breakpoint and whether the Loyaly AI panel is
|
||||
* taking 320–380px of it, and both are already known here.
|
||||
*/
|
||||
export function useMetricColumns(): number {
|
||||
const bp = useBreakpoint();
|
||||
const {isOpen} = useLoyalyAi();
|
||||
// The rule itself lives in lib/breakpoints alongside every other layout
|
||||
// threshold, so a breakpoint change is one edit rather than a hunt.
|
||||
return metricColumns(bp, isOpen);
|
||||
}
|
||||
|
||||
export function KpiRow({resource}: {resource: Resource<Kpi[]>}) {
|
||||
const columns = useMetricColumns();
|
||||
|
||||
return (
|
||||
<AsyncBoundary
|
||||
resource={resource}
|
||||
loading={<SkeletonMetricGrid columns={columns} />}
|
||||
>
|
||||
{(kpis) => (
|
||||
<Grid columns={columns} gap={4}>
|
||||
{kpis.map((k) => (
|
||||
<MetricCard
|
||||
key={k.id}
|
||||
label={k.label}
|
||||
value={k.value}
|
||||
format={k.unit === 'inr' ? formatInrCompact : formatCompact}
|
||||
icon={KPI_ICON[k.id]}
|
||||
deltaPct={k.deltaPct}
|
||||
isRiseGood={k.isRiseGood}
|
||||
trend={k.trend}
|
||||
accent={KPI_ACCENT[k.id]}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</AsyncBoundary>
|
||||
);
|
||||
}
|
||||
81
src/features/dashboard/hooks/useArrivalStream.ts
Normal file
81
src/features/dashboard/hooks/useArrivalStream.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
|
||||
import {useEffect, useEffectEvent, useState} from 'react';
|
||||
import {useSession} from '@/features/auth/providers/SessionProvider';
|
||||
import {useScope} from '@/shared/hooks/useScope';
|
||||
import {reportRepository} from '@/features/dashboard/repositories/reportRepository';
|
||||
|
||||
/** First retry after 5 s, doubling to a minute — a restarting server is not hammered. */
|
||||
const RETRY_MIN_MS = 5_000;
|
||||
const RETRY_MAX_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Listen for arrivals as they happen, and call `onArrivals` for each batch.
|
||||
*
|
||||
* The event is used as a SIGNAL — the caller re-reads the feed it already
|
||||
* renders — rather than as data to splice in. That keeps one mapping of an
|
||||
* arrival in the app and means a missed event costs nothing: the next re-read
|
||||
* returns everything, because the feed is cursor-based and lossless.
|
||||
*
|
||||
* ── Opened FROM the page's cursor, and only once there is one ────────────
|
||||
* Without a cursor the platform's first event is its backlog: the same rows
|
||||
* the page is already fetching. Treating that as news aborted the page's
|
||||
* in-flight read and sent it again — a "(canceled)" request and a duplicate on
|
||||
* every load. So the stream waits for the first page (`cursor` defined) and
|
||||
* resumes after it: every event is then genuinely new. The cursor is read
|
||||
* again at each reconnect, so rows that arrived while the stream was down
|
||||
* come through as one event rather than being lost.
|
||||
*
|
||||
* The connection follows the store switcher, closes on unmount, and reconnects
|
||||
* with backoff when the server ends it. `isLive` is true only while events can
|
||||
* actually arrive, so a screen never claims "Live" over a dead connection.
|
||||
*/
|
||||
export function useArrivalStream(
|
||||
onArrivals: () => void,
|
||||
/** The cursor of the page on screen; undefined until it has loaded. */
|
||||
cursor: string | undefined,
|
||||
): {isLive: boolean} {
|
||||
const {isAuthenticated} = useSession();
|
||||
const {storeId} = useScope();
|
||||
const [isLive, setIsLive] = useState(false);
|
||||
const notify = useEffectEvent(onArrivals);
|
||||
const currentCursor = useEffectEvent(() => cursor);
|
||||
const hasCursor = cursor !== undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !hasCursor) return;
|
||||
const controller = new AbortController();
|
||||
let delay = RETRY_MIN_MS;
|
||||
|
||||
async function run() {
|
||||
while (!controller.signal.aborted) {
|
||||
const res = await reportRepository.arrivalStream(
|
||||
storeId,
|
||||
currentCursor(),
|
||||
(e) => {
|
||||
if (e.event === 'arrivals') notify();
|
||||
},
|
||||
controller.signal,
|
||||
() => {
|
||||
setIsLive(true);
|
||||
delay = RETRY_MIN_MS;
|
||||
},
|
||||
);
|
||||
if (controller.signal.aborted) return;
|
||||
setIsLive(false);
|
||||
// A refusal (signed out, forbidden) will not fix itself by retrying.
|
||||
if (!res.ok && (res.status === 401 || res.status === 403)) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
delay = Math.min(delay * 2, RETRY_MAX_MS);
|
||||
}
|
||||
}
|
||||
|
||||
void run();
|
||||
return () => {
|
||||
controller.abort();
|
||||
setIsLive(false);
|
||||
};
|
||||
}, [isAuthenticated, storeId, hasCursor]);
|
||||
|
||||
return {isLive};
|
||||
}
|
||||
88
src/features/dashboard/hooks/useDashboard.ts
Normal file
88
src/features/dashboard/hooks/useDashboard.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
'use client';
|
||||
|
||||
import {useMemo} from 'react';
|
||||
import {buildKpis} from '@/features/dashboard/services/kpiBuilder';
|
||||
import {
|
||||
useConversionReport,
|
||||
useFootfallReport,
|
||||
useRecentVisits,
|
||||
} from './useReports';
|
||||
import type {Kpi} from '@/features/dashboard/types/dashboard';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
import type {
|
||||
ConversionReport,
|
||||
FootfallReport,
|
||||
} from '@/features/dashboard/types/reports';
|
||||
|
||||
export {useConversionReport, useFootfallReport, useRecentVisits};
|
||||
|
||||
/**
|
||||
* The KPI row, composed from the two reports that back it.
|
||||
*
|
||||
* Two resources, one card row. The composition happens here rather than in the
|
||||
* component so that KpiRow keeps taking a plain `Resource<Kpi[]>` and does not
|
||||
* learn which platform endpoints exist.
|
||||
*
|
||||
* The combined status is deliberately pessimistic: loading while EITHER is in
|
||||
* flight, failed if EITHER failed. A row that renders three real cards and one
|
||||
* stale one is worse than a row that waits — the merchant cannot tell which is
|
||||
* which.
|
||||
*/
|
||||
/**
|
||||
* The KPI row, built from the page's OWN footfall and conversion reports.
|
||||
*
|
||||
* It used to fetch its own copies (with `compare`) while the charts fetched
|
||||
* the same reports again without it: four requests for two answers, since
|
||||
* useResource does not share results between hooks. The comparison response
|
||||
* carries the current window's buckets unchanged, so the page now fetches
|
||||
* each report once — `{bucket: 'day', compare: true}` — and hands it to both
|
||||
* the charts and this.
|
||||
*/
|
||||
export function useDashboardKpis(
|
||||
footfall: Resource<FootfallReport>,
|
||||
conversion: Resource<ConversionReport>,
|
||||
): Resource<Kpi[]> {
|
||||
|
||||
return useMemo<Resource<Kpi[]>>(() => {
|
||||
const refetch = () => {
|
||||
footfall.refetch();
|
||||
conversion.refetch();
|
||||
};
|
||||
const isRefreshing = footfall.isRefreshing || conversion.isRefreshing;
|
||||
// The server's clock, from whichever response landed — every relative time
|
||||
// on this page measures against it rather than the device.
|
||||
const meta = footfall.meta ?? conversion.meta;
|
||||
|
||||
if (footfall.status === 'error' || conversion.status === 'error') {
|
||||
return {
|
||||
status: 'error',
|
||||
data: undefined,
|
||||
error: footfall.error ?? conversion.error!,
|
||||
refetch,
|
||||
isRefreshing,
|
||||
meta,
|
||||
};
|
||||
}
|
||||
|
||||
if (footfall.status === 'loading' || conversion.status === 'loading') {
|
||||
return {
|
||||
status: 'loading',
|
||||
data: undefined,
|
||||
error: undefined,
|
||||
refetch,
|
||||
isRefreshing,
|
||||
meta,
|
||||
};
|
||||
}
|
||||
|
||||
const kpis = buildKpis(footfall.data, conversion.data);
|
||||
return {
|
||||
status: kpis.length === 0 ? 'empty' : 'success',
|
||||
data: kpis,
|
||||
error: undefined,
|
||||
refetch,
|
||||
isRefreshing,
|
||||
meta,
|
||||
} as Resource<Kpi[]>;
|
||||
}, [footfall, conversion]);
|
||||
}
|
||||
34
src/features/dashboard/hooks/useReports.ts
Normal file
34
src/features/dashboard/hooks/useReports.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import {reportRepository} from '@/features/dashboard/repositories/reportRepository';
|
||||
import {useResource} from '@/shared/hooks/useResource';
|
||||
import {useScope} from '@/shared/hooks/useScope';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
import type {
|
||||
ConversionReport,
|
||||
FootfallReport,
|
||||
} from '@/features/dashboard/types/reports';
|
||||
import type {VisitsPage} from '@/features/dashboard/types/visits';
|
||||
|
||||
/**
|
||||
* One hook per resource, scoped by the workspace's {site, range}.
|
||||
*
|
||||
* `compare` asks the BFF for the preceding window in the same round trip, so a
|
||||
* KPI card gets its delta without a second request and without doing its own
|
||||
* date arithmetic.
|
||||
*/
|
||||
export function useFootfallReport(
|
||||
opts: {bucket?: string; compare?: boolean} = {},
|
||||
): Resource<FootfallReport> {
|
||||
return useResource(reportRepository.footfall(useScope(), opts));
|
||||
}
|
||||
|
||||
export function useConversionReport(
|
||||
opts: {bucket?: string; compare?: boolean} = {},
|
||||
): Resource<ConversionReport> {
|
||||
return useResource(reportRepository.conversion(useScope(), opts));
|
||||
}
|
||||
|
||||
export function useRecentVisits(limit = 6): Resource<VisitsPage> {
|
||||
return useResource(reportRepository.visits(useScope(), {limit}));
|
||||
}
|
||||
124
src/features/dashboard/mocks/dashboardMock.ts
Normal file
124
src/features/dashboard/mocks/dashboardMock.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* TEMPORARY sample data for the dashboard charts.
|
||||
*
|
||||
* ── Why this exists ──────────────────────────────────────────────────────
|
||||
* A new merchant's reports come back empty or near-empty, and an empty axis
|
||||
* teaches nothing about what the panel is for. Until the platform has history
|
||||
* for a shop, each chart falls back to this sample and wears a "Sample data"
|
||||
* token so it can never be mistaken for the merchant's own numbers.
|
||||
*
|
||||
* ── Rules ────────────────────────────────────────────────────────────────
|
||||
* - Real data always wins — see `shared/mocks/withSample.ts`.
|
||||
* - The KPI row never uses this — a headline number must be real or absent.
|
||||
* - Values are deterministic (no Math.random) so SSR and hydration agree.
|
||||
*
|
||||
* ── Removing it ──────────────────────────────────────────────────────────
|
||||
* See `shared/mocks/withSample.ts` — one procedure for every mock folder.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ConversionReport,
|
||||
FootfallReport,
|
||||
ReportBucket,
|
||||
} from '@/features/dashboard/types/reports';
|
||||
|
||||
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
|
||||
/** A smooth, repeatable wave: weekly rhythm plus a gentle upward drift. */
|
||||
function wave(i: number, base: number, amp: number): number {
|
||||
const weekly = Math.sin((i / 7) * Math.PI * 2 - 1.2);
|
||||
const drift = i * 0.012;
|
||||
const ripple = Math.sin(i * 1.7) * 0.18;
|
||||
return Math.max(0, Math.round(base * (1 + drift) + amp * (weekly + ripple)));
|
||||
}
|
||||
|
||||
function dayBuckets(days: number): ReportBucket[] {
|
||||
// Fixed anchor so the labels are stable across renders and runtimes.
|
||||
const start = Date.UTC(2026, 7, 27);
|
||||
return Array.from({length: days}, (_, i) => {
|
||||
const d = new Date(start + i * 86_400_000);
|
||||
const visitors = wave(i, 140, 42);
|
||||
const newVisitors = Math.round(visitors * (0.38 + 0.06 * Math.sin(i / 3)));
|
||||
const purchases = Math.round(visitors * (0.22 + 0.04 * Math.sin(i / 4 + 1)));
|
||||
const revenue = purchases * (620 + Math.round(90 * Math.sin(i / 5)));
|
||||
return {
|
||||
label: `${MONTHS[d.getUTCMonth()]} ${String(d.getUTCDate()).padStart(2, '0')}`,
|
||||
visitors,
|
||||
newVisitors,
|
||||
returningVisitors: visitors - newVisitors,
|
||||
purchases,
|
||||
revenue,
|
||||
conversion: Number(((purchases / visitors) * 100).toFixed(1)),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function weekBuckets(days: ReportBucket[]): ReportBucket[] {
|
||||
const weeks: ReportBucket[] = [];
|
||||
for (let i = 0; i < days.length; i += 7) {
|
||||
const slice = days.slice(i, i + 7);
|
||||
const sum = (k: keyof ReportBucket) =>
|
||||
slice.reduce((acc, b) => acc + (b[k] as number), 0);
|
||||
const visitors = sum('visitors');
|
||||
const purchases = sum('purchases');
|
||||
weeks.push({
|
||||
label: `W${Math.floor(i / 7) + 1}`,
|
||||
visitors,
|
||||
newVisitors: sum('newVisitors'),
|
||||
returningVisitors: sum('returningVisitors'),
|
||||
purchases,
|
||||
revenue: sum('revenue'),
|
||||
conversion: Number(((purchases / visitors) * 100).toFixed(1)),
|
||||
});
|
||||
}
|
||||
return weeks;
|
||||
}
|
||||
|
||||
const DAYS = dayBuckets(30);
|
||||
const WEEKS = weekBuckets(DAYS);
|
||||
|
||||
function footfallFrom(buckets: ReportBucket[]): FootfallReport {
|
||||
const visits = buckets.reduce((a, b) => a + b.visitors, 0);
|
||||
const newVisitors = buckets.reduce((a, b) => a + b.newVisitors, 0);
|
||||
// Unique people < visit events: regulars come more than once.
|
||||
const total = Math.round(visits * 0.46);
|
||||
return {
|
||||
total,
|
||||
newVisitors,
|
||||
returningVisitors: Math.max(0, Math.round(total * 0.9) - newVisitors),
|
||||
visits,
|
||||
buckets,
|
||||
timezone: null,
|
||||
previousTotal: Math.round(total * 0.88),
|
||||
};
|
||||
}
|
||||
|
||||
function conversionFrom(buckets: ReportBucket[], total: number): ConversionReport {
|
||||
const purchases = buckets.reduce((a, b) => a + b.purchases, 0);
|
||||
const revenue = buckets.reduce((a, b) => a + b.revenue, 0);
|
||||
return {
|
||||
purchases,
|
||||
revenue,
|
||||
conversionPct: Number(((purchases / total) * 100).toFixed(1)),
|
||||
basketSize: Math.round(revenue / purchases),
|
||||
buckets,
|
||||
timezone: null,
|
||||
previousPurchases: Math.round(purchases * 0.91),
|
||||
previousRevenue: Math.round(revenue * 0.86),
|
||||
};
|
||||
}
|
||||
|
||||
export const MOCK_FOOTFALL_DAILY = footfallFrom(DAYS);
|
||||
export const MOCK_FOOTFALL_WEEKLY = footfallFrom(WEEKS);
|
||||
export const MOCK_CONVERSION_DAILY = conversionFrom(DAYS, MOCK_FOOTFALL_DAILY.total);
|
||||
export const MOCK_CONVERSION_WEEKLY = conversionFrom(WEEKS, MOCK_FOOTFALL_WEEKLY.total);
|
||||
|
||||
/** True when the report carries at least one non-zero value for `key`. */
|
||||
export function hasSignal(
|
||||
report: {buckets: ReportBucket[]} | undefined,
|
||||
key: keyof Omit<ReportBucket, 'label'>,
|
||||
): boolean {
|
||||
return !!report && report.buckets.some((b) => b[key] > 0);
|
||||
}
|
||||
|
||||
export {withSample} from '@/shared/mocks/withSample';
|
||||
63
src/features/dashboard/repositories/reportRepository.ts
Normal file
63
src/features/dashboard/repositories/reportRepository.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import {readEventStream, scopedEndpoint} from '@/shared/services/httpClient';
|
||||
import type {Endpoint, Scope, StreamEvent} from '@/shared/services/httpClient';
|
||||
import type {
|
||||
ConversionReport,
|
||||
FootfallReport,
|
||||
} from '@/features/dashboard/types/reports';
|
||||
import type {VisitsPage} from '@/features/dashboard/types/visits';
|
||||
|
||||
/**
|
||||
* The platform's own resources, addressed by their own names.
|
||||
*
|
||||
* There is no `/api/dashboard/*` here and there must not be: the dashboard is
|
||||
* a READ MODEL over footfall, conversion and visits, not a data domain of its
|
||||
* own. Mobile reads the same three resources from the same platform, so
|
||||
* neither client can drift into having its own version of a number.
|
||||
*
|
||||
* (`/api/dashboard/summary` does exist, but it is the PLATFORM's endpoint —
|
||||
* the merchant app's "today" figures — and is read by the Floor page through
|
||||
* floorRepository, not by this dashboard.)
|
||||
*/
|
||||
export const reportRepository = {
|
||||
footfall: (scope: Scope, opts: {bucket?: string; compare?: boolean} = {}):
|
||||
Endpoint<FootfallReport> =>
|
||||
scopedEndpoint('/api/reports/footfall', scope, {
|
||||
...(opts.bucket ? {bucket: opts.bucket} : {}),
|
||||
...(opts.compare ? {compare: 'previous'} : {}),
|
||||
}),
|
||||
|
||||
conversion: (scope: Scope, opts: {bucket?: string; compare?: boolean} = {}):
|
||||
Endpoint<ConversionReport> =>
|
||||
scopedEndpoint('/api/reports/conversion', scope, {
|
||||
...(opts.bucket ? {bucket: opts.bucket} : {}),
|
||||
...(opts.compare ? {compare: 'previous'} : {}),
|
||||
}),
|
||||
|
||||
visits: (scope: Scope, opts: {limit?: number; cursor?: string} = {}):
|
||||
Endpoint<VisitsPage> =>
|
||||
scopedEndpoint('/api/visits', scope, {
|
||||
...(opts.limit ? {limit: String(opts.limit)} : {}),
|
||||
...(opts.cursor ? {cursor: opts.cursor} : {}),
|
||||
}),
|
||||
|
||||
/**
|
||||
* The same arrivals, pushed. Scoped by shop only — a stream has no range.
|
||||
* Resolves when the stream ends; the caller decides whether to reconnect.
|
||||
*/
|
||||
arrivalStream: (
|
||||
storeId: string,
|
||||
/** Resume after this position; without it the first event is the backlog. */
|
||||
cursor: string | undefined,
|
||||
onEvent: (e: StreamEvent) => void,
|
||||
signal: AbortSignal,
|
||||
onOpen?: () => void,
|
||||
) =>
|
||||
readEventStream(
|
||||
`/api/visits/stream?${new URLSearchParams(
|
||||
cursor ? {storeId, cursor} : {storeId},
|
||||
).toString()}`,
|
||||
onEvent,
|
||||
signal,
|
||||
onOpen,
|
||||
),
|
||||
};
|
||||
28
src/features/dashboard/services/dashboardService.ts
Normal file
28
src/features/dashboard/services/dashboardService.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Domain rules for the dashboard.
|
||||
*
|
||||
* Small, but it is the right home for them: a greeting derived from a clock is
|
||||
* a rule about time of day, not a rendering concern, and having it in the page
|
||||
* meant the one non-obvious thing about it (which clock) lived next to JSX.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The header greeting, derived from the SERVER's clock.
|
||||
*
|
||||
* Never Date.now(): calling it during render is impure, disagrees between the
|
||||
* SSR and hydration passes, and reports the wrong time of day to anyone whose
|
||||
* device clock is off. The instant arrives on every response's `meta`, and
|
||||
* getHours() resolves it in the viewer's own zone — the hour the merchant is
|
||||
* actually living in.
|
||||
*
|
||||
* Before the first response there is no instant to reason about, so the
|
||||
* greeting is the time-independent one. Both strings are a single line, so the
|
||||
* swap costs no layout shift.
|
||||
*/
|
||||
export function greetingFor(generatedAt?: string): string {
|
||||
if (!generatedAt) return 'Welcome back';
|
||||
const hour = new Date(generatedAt).getHours();
|
||||
if (hour < 12) return 'Good morning';
|
||||
if (hour < 17) return 'Good afternoon';
|
||||
return 'Good evening';
|
||||
}
|
||||
83
src/features/dashboard/services/kpiBuilder.ts
Normal file
83
src/features/dashboard/services/kpiBuilder.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import type {Kpi} from '@/features/dashboard/types/dashboard';
|
||||
import type {
|
||||
ConversionReport,
|
||||
FootfallReport,
|
||||
} from '@/features/dashboard/types/reports';
|
||||
|
||||
/**
|
||||
* The KPI row, derived from the two reports that back it.
|
||||
*
|
||||
* ── What this is allowed to compute, and what it is not ──────────────────
|
||||
* It computes PRESENTATION: a percentage change between two totals the
|
||||
* platform reported, and a sparkline from buckets the platform returned. It
|
||||
* does not compute business metrics. In particular it never sums buckets to
|
||||
* produce a total — `total` is unique people over the window, and adding the
|
||||
* buckets double-counts everybody who came twice.
|
||||
*
|
||||
* A metric the platform did not report is OMITTED, not zeroed. A card reading
|
||||
* "0" is a claim; an absent card is the truth.
|
||||
*/
|
||||
|
||||
function delta(current: number, previous: number | null): number | undefined {
|
||||
if (previous === null || previous === 0) return undefined;
|
||||
return Number((((current - previous) / previous) * 100).toFixed(1));
|
||||
}
|
||||
|
||||
export function buildKpis(
|
||||
footfall: FootfallReport | undefined,
|
||||
conversion: ConversionReport | undefined,
|
||||
): Kpi[] {
|
||||
const kpis: Kpi[] = [];
|
||||
|
||||
if (footfall) {
|
||||
kpis.push({
|
||||
id: 'visitors',
|
||||
label: 'Visitors',
|
||||
// The server's total — unique people — not the sum of the buckets.
|
||||
value: footfall.total,
|
||||
unit: 'count',
|
||||
deltaPct: delta(footfall.total, footfall.previousTotal) ?? 0,
|
||||
isRiseGood: true,
|
||||
trend: footfall.buckets.map((b) => ({t: b.label, v: b.visitors})),
|
||||
});
|
||||
}
|
||||
|
||||
if (conversion) {
|
||||
kpis.push({
|
||||
id: 'purchases',
|
||||
label: 'Purchases',
|
||||
value: conversion.purchases,
|
||||
unit: 'count',
|
||||
deltaPct: delta(conversion.purchases, conversion.previousPurchases) ?? 0,
|
||||
isRiseGood: true,
|
||||
trend: conversion.buckets.map((b) => ({t: b.label, v: b.purchases})),
|
||||
});
|
||||
|
||||
kpis.push({
|
||||
id: 'revenue',
|
||||
label: 'Revenue',
|
||||
value: conversion.revenue,
|
||||
unit: 'inr',
|
||||
deltaPct: delta(conversion.revenue, conversion.previousRevenue) ?? 0,
|
||||
isRiseGood: true,
|
||||
trend: conversion.buckets.map((b) => ({t: b.label, v: b.revenue})),
|
||||
});
|
||||
|
||||
// Only when the platform reports it. Deriving purchases ÷ visitors here
|
||||
// would be this app inventing a definition of conversion that the reports
|
||||
// may not share — exactly the drift the single-API rule exists to stop.
|
||||
if (conversion.conversionPct !== null) {
|
||||
kpis.push({
|
||||
id: 'conversion',
|
||||
label: 'Conversion',
|
||||
value: conversion.conversionPct,
|
||||
unit: 'pct',
|
||||
deltaPct: 0,
|
||||
isRiseGood: true,
|
||||
trend: conversion.buckets.map((b) => ({t: b.label, v: b.conversion})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return kpis;
|
||||
}
|
||||
120
src/features/dashboard/types/dashboard.ts
Normal file
120
src/features/dashboard/types/dashboard.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Dashboard and shared analytics contracts.
|
||||
*
|
||||
* The wire shape for this feature. Imported by BOTH its route handlers and its
|
||||
* components, so a server/client drift is a type error rather than a runtime
|
||||
* surprise. When a real backend arrives, this file is the negotiation artifact.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The four headline metrics, each backed by a platform report.
|
||||
*
|
||||
* `activeRewards` used to be the fourth and has been replaced by `conversion`.
|
||||
* There is no rewards resource in the platform contract, so that card could
|
||||
* only ever have been invented — and a KPI nobody can trace to a report is
|
||||
* worse than one fewer KPI. Conversion is reported, so it is real.
|
||||
*/
|
||||
export type KpiId = 'visitors' | 'purchases' | 'revenue' | 'conversion';
|
||||
|
||||
export type KpiUnit = 'count' | 'inr' | 'lyt' | 'pct';
|
||||
|
||||
export interface Kpi {
|
||||
id: KpiId;
|
||||
label: string;
|
||||
value: number;
|
||||
unit: KpiUnit;
|
||||
/** Change against the previous comparable period. */
|
||||
deltaPct: number;
|
||||
/** Whether a rise is good. Churn rises are not. */
|
||||
isRiseGood: boolean;
|
||||
trend: {t: string; v: number}[];
|
||||
}
|
||||
|
||||
export interface TimePoint {
|
||||
t: string;
|
||||
visitors: number;
|
||||
purchases: number;
|
||||
revenue: number;
|
||||
/** Purchases ÷ visitors, as a percentage. */
|
||||
conversion: number;
|
||||
}
|
||||
|
||||
export interface HourCell {
|
||||
day: number;
|
||||
hour: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export type ActivityKind =
|
||||
| 'reward_redeemed'
|
||||
| 'staff_checked_in'
|
||||
| 'purchase'
|
||||
| 'reward_expired'
|
||||
| 'store_opened';
|
||||
|
||||
export interface ActivityEvent {
|
||||
id: string;
|
||||
at: string;
|
||||
kind: ActivityKind;
|
||||
title: string;
|
||||
detail?: string;
|
||||
storeId: string;
|
||||
}
|
||||
|
||||
export interface StoreComparison {
|
||||
storeId: string;
|
||||
name: string;
|
||||
visitors: number;
|
||||
purchases: number;
|
||||
revenueInr: number;
|
||||
conversionPct: number;
|
||||
/** 14-point trend for the store's sparkline. */
|
||||
trend: {t: string; v: number}[];
|
||||
}
|
||||
|
||||
export interface RewardUsagePoint {
|
||||
rewardId: string;
|
||||
name: string;
|
||||
claimed: number;
|
||||
used: number;
|
||||
/** used ÷ claimed, as a percentage. */
|
||||
usageRatePct: number;
|
||||
}
|
||||
|
||||
export type Granularity = 'weekly' | 'monthly';
|
||||
|
||||
export interface PeriodPoint {
|
||||
/** Bucket label — "W32" or "Aug". */
|
||||
label: string;
|
||||
visitors: number;
|
||||
purchases: number;
|
||||
revenue: number;
|
||||
}
|
||||
|
||||
export type InsightSeverity = 'info' | 'success' | 'warning' | 'error';
|
||||
|
||||
export interface Insight {
|
||||
id: string;
|
||||
severity: InsightSeverity;
|
||||
title: string;
|
||||
body: string;
|
||||
action?: {label: string; href: string};
|
||||
}
|
||||
|
||||
export interface DashboardTask {
|
||||
id: string;
|
||||
label: string;
|
||||
detail?: string;
|
||||
/** Plain-language deadline — "before 18:00", "today". Never a raw date. */
|
||||
due: string;
|
||||
isDone: boolean;
|
||||
action?: {label: string; href: string};
|
||||
}
|
||||
|
||||
export interface DashboardBriefing {
|
||||
/** One-paragraph narrative of the selected scope and period. */
|
||||
summary: string;
|
||||
/** Most severe first. */
|
||||
alerts: Insight[];
|
||||
tasks: DashboardTask[];
|
||||
}
|
||||
69
src/features/dashboard/types/reports.ts
Normal file
69
src/features/dashboard/types/reports.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Reports, as the console consumes them.
|
||||
*
|
||||
* Deliberately close to the platform's own shape. The one thing added is
|
||||
* `previous*`, because a KPI card shows a change against the comparable
|
||||
* previous window and the platform reports one window at a time — the BFF
|
||||
* fetches both and returns them together rather than making every screen fire
|
||||
* two requests and do its own date arithmetic.
|
||||
*/
|
||||
|
||||
/**
|
||||
* One bucket. `label` is LOCAL WALL TIME with no offset, already formatted by
|
||||
* the platform for the timezone that was requested.
|
||||
*
|
||||
* It is a string and must stay one. Parsing it into a Date would re-interpret
|
||||
* it in the viewer's own zone and shift every label on the axis.
|
||||
*/
|
||||
export interface ReportBucket {
|
||||
label: string;
|
||||
visitors: number;
|
||||
/**
|
||||
* Distinct people whose FIRST-EVER visit falls in this bucket, and those
|
||||
* seen before. Both come straight off the footfall report and were being
|
||||
* discarded by the mapper — the only real split the platform reports today.
|
||||
*
|
||||
* Per-bucket only. `new` is safe to sum across buckets (a person is new
|
||||
* once ever); `returning` is not, because somebody who came back in three
|
||||
* buckets appears in all three.
|
||||
*/
|
||||
newVisitors: number;
|
||||
returningVisitors: number;
|
||||
purchases: number;
|
||||
revenue: number;
|
||||
conversion: number;
|
||||
}
|
||||
|
||||
export interface FootfallReport {
|
||||
/**
|
||||
* UNIQUE PEOPLE over the window — not the sum of the buckets. Somebody who
|
||||
* came Monday and Thursday is one person here and two bucket-visitors, so
|
||||
* anything that adds up `buckets` and calls it a total is wrong.
|
||||
*/
|
||||
total: number;
|
||||
/**
|
||||
* `new + returning` can be LESS than `total`: a site sending counts without
|
||||
* face templates records real footfall by an unidentified person, which
|
||||
* belongs to neither. Null when the platform does not report the split.
|
||||
*/
|
||||
newVisitors: number | null;
|
||||
returningVisitors: number | null;
|
||||
/** Total visit events, which DOES sum the buckets. */
|
||||
visits: number | null;
|
||||
buckets: ReportBucket[];
|
||||
timezone: string | null;
|
||||
/** Same metric over the immediately preceding window of equal length. */
|
||||
previousTotal: number | null;
|
||||
}
|
||||
|
||||
export interface ConversionReport {
|
||||
purchases: number;
|
||||
revenue: number;
|
||||
/** Purchases ÷ unique visitors, as reported. Null when not supplied. */
|
||||
conversionPct: number | null;
|
||||
basketSize: number | null;
|
||||
buckets: ReportBucket[];
|
||||
timezone: string | null;
|
||||
previousPurchases: number | null;
|
||||
previousRevenue: number | null;
|
||||
}
|
||||
40
src/features/dashboard/types/visits.ts
Normal file
40
src/features/dashboard/types/visits.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* The arrivals feed, as the console consumes it.
|
||||
*/
|
||||
|
||||
export interface ArrivalImage {
|
||||
/** False is the NORMAL case — images are off by default across the product.
|
||||
* Render initials, never an error. */
|
||||
available: boolean;
|
||||
/** Already rewritten to a URL this origin can serve, so an <img> works
|
||||
* without the Authorization header a browser cannot attach. */
|
||||
url: string | null;
|
||||
/** The platform's own explanation when there is no photo. Show it. */
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
export interface Arrival {
|
||||
/** De-duplication key. Delivery is at-least-once, so the same visit can
|
||||
* arrive twice; it addresses no route. */
|
||||
visitId: string;
|
||||
occurredAt: string;
|
||||
/** The site's immutable slug. */
|
||||
siteId: string;
|
||||
siteName: string;
|
||||
cameraId: string;
|
||||
visitorId: string;
|
||||
/** "V-42" — the reference a person can read and search by. */
|
||||
visitorRef: string;
|
||||
/** Reads "Visitor 42" until somebody names them. */
|
||||
label: string;
|
||||
isNewVisitor: boolean;
|
||||
similarity: number;
|
||||
image: ArrivalImage;
|
||||
}
|
||||
|
||||
export interface VisitsPage {
|
||||
arrivals: Arrival[];
|
||||
/** Opaque. Echo it back verbatim on the next poll. */
|
||||
cursor: string;
|
||||
polledAt: string;
|
||||
}
|
||||
144
src/features/engagement/components/ActivitiesPanel.tsx
Normal file
144
src/features/engagement/components/ActivitiesPanel.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {proportional, pixel} from '@astryxdesign/core/Table';
|
||||
import type {TableColumn} from '@astryxdesign/core/Table';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {useToast} from '@astryxdesign/core/Toast';
|
||||
import {PanelCard} from '@/shared/components/patterns/PanelCard';
|
||||
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
|
||||
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {formatCount, formatInr} from '@/shared/utils/format';
|
||||
import {useActivities} from '@/features/engagement/hooks/useEngagement';
|
||||
import {AttributionNote} from '@/features/engagement/components/AttributionNote';
|
||||
import {RecordActivityDialog} from '@/features/engagement/components/RecordActivityDialog';
|
||||
import {withSample} from '@/shared/mocks/withSample';
|
||||
import {SampleTag} from '@/shared/mocks/SampleTag';
|
||||
import {MOCK_ACTIVITIES} from '@/features/engagement/mocks/engagementMock';
|
||||
import type {ActivityRow} from '@/features/engagement/types/engagement';
|
||||
|
||||
/** Null is "not reported", which is not the same fact as zero. */
|
||||
function maybe(v: number | null, format: (n: number) => string = formatCount): string {
|
||||
return v === null ? '—' : format(v);
|
||||
}
|
||||
|
||||
/**
|
||||
* Each activity: how many took part, and — ATTRIBUTED — how many of them came
|
||||
* back and bought afterwards. The disclosure sits in the panel whenever any
|
||||
* row's basis is estimated.
|
||||
*/
|
||||
export function ActivitiesPanel() {
|
||||
const toast = useToast();
|
||||
const activities = useActivities();
|
||||
// Recording always acts on the REAL catalogue, never the sample.
|
||||
const shown = withSample(activities, MOCK_ACTIVITIES, (d) => !!d?.length);
|
||||
const [recording, setRecording] = useState(false);
|
||||
|
||||
const canRecord =
|
||||
activities.status === 'success' && activities.data.some((a) => a.isActive);
|
||||
|
||||
const columns: TableColumn<ActivityRow>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Activity',
|
||||
width: proportional(2),
|
||||
renderCell: (row) => (
|
||||
<HStack gap={2} vAlign="center">
|
||||
<Text size="sm" weight="medium">
|
||||
{row.name}
|
||||
</Text>
|
||||
{row.isActive ? null : <Badge variant="neutral" label="Paused" />}
|
||||
</HStack>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'participants',
|
||||
header: 'Took part',
|
||||
width: pixel(120),
|
||||
renderCell: (row) => (
|
||||
<VStack gap={0}>
|
||||
<Text size="sm" weight="medium">
|
||||
{formatCount(row.participants)}
|
||||
</Text>
|
||||
{row.anonymous > 0 ? (
|
||||
<Text size="xsm" color="secondary">
|
||||
+{formatCount(row.anonymous)} unrecognised
|
||||
</Text>
|
||||
) : null}
|
||||
</VStack>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'repeatVisitors',
|
||||
header: 'Came back',
|
||||
width: pixel(110),
|
||||
renderCell: (row) => <Text size="sm">{maybe(row.repeatVisitors)}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'purchases',
|
||||
header: 'Bought after',
|
||||
width: pixel(120),
|
||||
renderCell: (row) => <Text size="sm">{maybe(row.purchases)}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'attributedRevenue',
|
||||
header: 'Attributed revenue',
|
||||
width: proportional(1),
|
||||
renderCell: (row) => (
|
||||
<Text size="sm" weight="medium">
|
||||
{maybe(row.attributedRevenue, formatInr)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PanelCard
|
||||
title="Customer activities"
|
||||
subtitle="Who took part, and what they did afterwards"
|
||||
actions={
|
||||
canRecord ? (
|
||||
<Button size="sm" variant="secondary" onClick={() => setRecording(true)} label="Record activity" />
|
||||
) : (
|
||||
<SampleTag show={shown.isSample} />
|
||||
)
|
||||
}
|
||||
resource={shown}
|
||||
loading={<SkeletonRows count={4} height={44} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="activeRewards"
|
||||
title="No activities set up"
|
||||
description="Selfies, spins, challenges and referrals appear here once they are set up for your company and somebody takes part."
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(rows) => (
|
||||
<VStack gap={3}>
|
||||
<AttributionNote
|
||||
basis={rows.some((r) => r.attribution === 'estimated') ? 'estimated' : 'observed'}
|
||||
/>
|
||||
<ResponsiveTable columns={columns} data={rows} idKey="id" primaryKey="name" />
|
||||
</VStack>
|
||||
)}
|
||||
</PanelCard>
|
||||
|
||||
{recording && activities.data ? (
|
||||
<RecordActivityDialog
|
||||
activities={activities.data}
|
||||
onClose={() => setRecording(false)}
|
||||
onRecorded={() => {
|
||||
setRecording(false);
|
||||
toast({body: 'Activity recorded'});
|
||||
activities.refetch();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
18
src/features/engagement/components/AttributionNote.tsx
Normal file
18
src/features/engagement/components/AttributionNote.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {ATTRIBUTION_NOTE, type Attribution} from '@/features/engagement/types/engagement';
|
||||
|
||||
/**
|
||||
* The disclosure beside every attributed figure. Renders NOTHING when the
|
||||
* basis is `observed`, so a real attribution backend retires it with no copy
|
||||
* change. The basis is read off the payload, never hardcoded.
|
||||
*/
|
||||
export function AttributionNote({basis}: {basis: Attribution}) {
|
||||
if (basis === 'observed') return null;
|
||||
return (
|
||||
<Text size="xsm" color="secondary">
|
||||
{ATTRIBUTION_NOTE}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
106
src/features/engagement/components/CampaignsPanel.tsx
Normal file
106
src/features/engagement/components/CampaignsPanel.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
'use client';
|
||||
|
||||
import {proportional, pixel} from '@astryxdesign/core/Table';
|
||||
import type {TableColumn} from '@astryxdesign/core/Table';
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
import {Timestamp} from '@astryxdesign/core/Timestamp';
|
||||
import {PanelCard} from '@/shared/components/patterns/PanelCard';
|
||||
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
|
||||
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {formatCount, formatInr} from '@/shared/utils/format';
|
||||
import {useCampaigns} from '@/features/engagement/hooks/useEngagement';
|
||||
import {AttributionNote} from '@/features/engagement/components/AttributionNote';
|
||||
import {withSample} from '@/shared/mocks/withSample';
|
||||
import {SampleTag} from '@/shared/mocks/SampleTag';
|
||||
import {MOCK_CAMPAIGNS} from '@/features/engagement/mocks/engagementMock';
|
||||
import type {Campaign} from '@/features/engagement/types/engagement';
|
||||
|
||||
/** Each campaign's funnel over the dashboard's window. Revenue is attributed. */
|
||||
export function CampaignsPanel() {
|
||||
const campaigns = withSample(useCampaigns(), MOCK_CAMPAIGNS, (d) => !!d?.length);
|
||||
|
||||
const columns: TableColumn<Campaign>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Campaign',
|
||||
width: proportional(2),
|
||||
renderCell: (row) => (
|
||||
<VStack gap={0}>
|
||||
<Text size="sm" weight="medium">
|
||||
{row.name}
|
||||
</Text>
|
||||
<Text size="xsm" color="secondary">
|
||||
<Timestamp value={row.startsAt} format="date" />
|
||||
{row.endsAt ? (
|
||||
<>
|
||||
{' – '}
|
||||
<Timestamp value={row.endsAt} format="date" />
|
||||
</>
|
||||
) : (
|
||||
' onwards'
|
||||
)}
|
||||
</Text>
|
||||
</VStack>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'isActive',
|
||||
header: 'Status',
|
||||
width: pixel(100),
|
||||
renderCell: (row) => (
|
||||
<Badge variant={row.isActive ? 'success' : 'neutral'} label={row.isActive ? 'Live' : 'Ended'} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'participants',
|
||||
header: 'Took part',
|
||||
width: pixel(110),
|
||||
renderCell: (row) => <Text size="sm">{formatCount(row.participants)}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'purchasers',
|
||||
header: 'Bought',
|
||||
width: pixel(100),
|
||||
renderCell: (row) => <Text size="sm">{formatCount(row.purchasers)}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'attributedRevenue',
|
||||
header: 'Attributed revenue',
|
||||
width: proportional(1),
|
||||
renderCell: (row) => (
|
||||
<Text size="sm" weight="medium">
|
||||
{formatInr(row.attributedRevenue)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PanelCard
|
||||
title="Campaigns"
|
||||
subtitle="Each campaign's funnel in this period"
|
||||
actions={<SampleTag show={campaigns.isSample} />}
|
||||
resource={campaigns}
|
||||
loading={<SkeletonRows count={3} height={44} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="campaign"
|
||||
title="No campaigns in this period"
|
||||
description="Campaigns that ran during the selected dates appear here with who took part and who bought."
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(rows) => (
|
||||
<VStack gap={3}>
|
||||
<AttributionNote
|
||||
basis={rows.some((r) => r.attribution === 'estimated') ? 'estimated' : 'observed'}
|
||||
/>
|
||||
<ResponsiveTable columns={columns} data={rows} idKey="id" primaryKey="name" />
|
||||
</VStack>
|
||||
)}
|
||||
</PanelCard>
|
||||
);
|
||||
}
|
||||
25
src/features/engagement/components/EngagementSection.tsx
Normal file
25
src/features/engagement/components/EngagementSection.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {ActivitiesPanel} from '@/features/engagement/components/ActivitiesPanel';
|
||||
import {CampaignsPanel} from '@/features/engagement/components/CampaignsPanel';
|
||||
import {JourneyPanel} from '@/features/engagement/components/JourneyPanel';
|
||||
|
||||
/**
|
||||
* Customer activity and engagement, from the platform: activities and their
|
||||
* attributed impact, campaigns, and the customer journey. Every panel follows
|
||||
* the dashboard's shop and range, and says plainly when it has nothing yet —
|
||||
* nothing here is estimated to fill a gap.
|
||||
*/
|
||||
export function EngagementSection() {
|
||||
return (
|
||||
<VStack gap={4}>
|
||||
<ActivitiesPanel />
|
||||
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
|
||||
<CampaignsPanel />
|
||||
<JourneyPanel />
|
||||
</Grid>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
59
src/features/engagement/components/JourneyPanel.tsx
Normal file
59
src/features/engagement/components/JourneyPanel.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
'use client';
|
||||
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {ChartCard} from '@/shared/components/charts/ChartCard';
|
||||
import {BarChartView} from '@/shared/components/charts/BarChartView';
|
||||
import {CHART} from '@/shared/components/charts/palette';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {formatCompact} from '@/shared/utils/format';
|
||||
import {useJourney} from '@/features/engagement/hooks/useEngagement';
|
||||
import {withSample} from '@/shared/mocks/withSample';
|
||||
import {SampleTag} from '@/shared/mocks/SampleTag';
|
||||
import {MOCK_JOURNEY} from '@/features/engagement/mocks/engagementMock';
|
||||
import {AttributionNote} from '@/features/engagement/components/AttributionNote';
|
||||
|
||||
/**
|
||||
* Visit → take part → buy → come back → refer: distinct people per stage.
|
||||
*
|
||||
* Drawn as bars, NOT a funnel, and the caption says why: somebody can buy
|
||||
* without ever taking part, so a later stage is not a subset of the one before
|
||||
* and a funnel shape would imply a drop-off that did not happen.
|
||||
*/
|
||||
export function JourneyPanel() {
|
||||
const journey = withSample(useJourney(), MOCK_JOURNEY, (d) =>
|
||||
!!d?.stages.some((s) => s.customers > 0),
|
||||
);
|
||||
|
||||
return (
|
||||
<ChartCard
|
||||
title="Customer journey"
|
||||
subtitle="Distinct people who reached each stage in this period"
|
||||
actions={<SampleTag show={journey.isSample} />}
|
||||
resource={journey}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="journey"
|
||||
title="No journey yet"
|
||||
description="Stages fill in as customers visit, take part and buy."
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(j) => (
|
||||
<VStack gap={2}>
|
||||
<BarChartView
|
||||
data={j.stages}
|
||||
xKey="label"
|
||||
yFormat={formatCompact}
|
||||
isInteger
|
||||
series={[{key: 'customers', label: 'Customers', color: CHART.brand.cool}]}
|
||||
/>
|
||||
<Text size="xsm" color="secondary">
|
||||
Not a funnel: somebody can buy without taking part, so each stage is counted on its own.
|
||||
</Text>
|
||||
<AttributionNote basis={j.attribution} />
|
||||
</VStack>
|
||||
)}
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
111
src/features/engagement/components/RecordActivityDialog.tsx
Normal file
111
src/features/engagement/components/RecordActivityDialog.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {Dialog, DialogHeader} from '@astryxdesign/core/Dialog';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {TextInput} from '@astryxdesign/core/TextInput';
|
||||
import {Selector} from '@astryxdesign/core/Selector';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Banner} from '@astryxdesign/core/Banner';
|
||||
import {useWorkspace} from '@/shared/providers/WorkspaceProvider';
|
||||
import {engagementRepository} from '@/features/engagement/repositories/engagementRepository';
|
||||
import type {ActivityRow} from '@/features/engagement/types/engagement';
|
||||
|
||||
const NO_SHOP = '__none__';
|
||||
|
||||
/**
|
||||
* Record that somebody took part in an activity — a selfie, a spin, a
|
||||
* challenge. The cameras cannot observe these, so without this the activity
|
||||
* panels stay at zero forever.
|
||||
*
|
||||
* ── One id per real-world event ──────────────────────────────────────────
|
||||
* `sourceEventId` is minted ONCE, when the dialog opens, and reused by every
|
||||
* retry of this submission. The platform de-duplicates on it, so tapping
|
||||
* "Record" again after a timeout cannot count the same spin twice. A new
|
||||
* dialog is a new event and gets a new id.
|
||||
*/
|
||||
export function RecordActivityDialog({
|
||||
activities,
|
||||
onClose,
|
||||
onRecorded,
|
||||
}: {
|
||||
activities: ActivityRow[];
|
||||
onClose: () => void;
|
||||
onRecorded: () => void;
|
||||
}) {
|
||||
const {stores, storeId} = useWorkspace();
|
||||
const live = activities.filter((a) => a.isActive);
|
||||
const [sourceEventId] = useState(() => crypto.randomUUID());
|
||||
const [kind, setKind] = useState(live[0]?.kind ?? '');
|
||||
const [site, setSite] = useState(storeId !== 'all' ? storeId : NO_SHOP);
|
||||
const [customer, setCustomer] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function submit() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const res = await engagementRepository.recordEvent({
|
||||
kind,
|
||||
sourceEventId,
|
||||
site: site === NO_SHOP ? undefined : site,
|
||||
visitorId: customer.trim() || undefined,
|
||||
});
|
||||
setBusy(false);
|
||||
if (!res.ok) {
|
||||
setError(res.message ?? 'Could not record that.');
|
||||
return;
|
||||
}
|
||||
onRecorded();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
purpose="required"
|
||||
width={440}
|
||||
aria-label="Record an activity"
|
||||
>
|
||||
<VStack gap={4} width="100%">
|
||||
<DialogHeader
|
||||
title="Record an activity"
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
/>
|
||||
<Selector
|
||||
label="Activity"
|
||||
value={kind}
|
||||
onChange={(v) => setKind(v)}
|
||||
options={live.map((a) => ({value: a.kind, label: a.name}))}
|
||||
/>
|
||||
<Selector
|
||||
label="Shop"
|
||||
value={site}
|
||||
onChange={(v) => setSite(v)}
|
||||
options={[
|
||||
{value: NO_SHOP, label: 'Not at a shop'},
|
||||
...stores.filter((s) => s.id !== 'all').map((s) => ({value: s.id, label: s.name})),
|
||||
]}
|
||||
/>
|
||||
<TextInput
|
||||
label="Customer number"
|
||||
value={customer}
|
||||
onChange={setCustomer}
|
||||
placeholder="V-42"
|
||||
isOptional
|
||||
description="Leave blank for somebody the cameras have not recognised — it still counts, but cannot appear in the impact figures."
|
||||
/>
|
||||
{error ? <Banner status="error" title={error} /> : null}
|
||||
<HStack gap={2} hAlign="end">
|
||||
<Button variant="secondary" onClick={onClose} label="Cancel" />
|
||||
<Button
|
||||
onClick={() => void submit()}
|
||||
isDisabled={!kind || busy}
|
||||
isLoading={busy}
|
||||
label="Record"
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
26
src/features/engagement/hooks/useEngagement.ts
Normal file
26
src/features/engagement/hooks/useEngagement.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import {useResource} from '@/shared/hooks/useResource';
|
||||
import {useScope} from '@/shared/hooks/useScope';
|
||||
import {engagementRepository} from '@/features/engagement/repositories/engagementRepository';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
import type {
|
||||
ActivityRow,
|
||||
Campaign,
|
||||
Journey,
|
||||
} from '@/features/engagement/types/engagement';
|
||||
|
||||
export function useActivities(): Resource<ActivityRow[]> {
|
||||
return useResource(engagementRepository.activities(useScope()));
|
||||
}
|
||||
|
||||
export function useCampaigns(): Resource<Campaign[]> {
|
||||
return useResource(engagementRepository.campaigns(useScope()));
|
||||
}
|
||||
|
||||
/** Empty only when there are no stages at all; a stage of zero people is data. */
|
||||
export function useJourney(): Resource<Journey> {
|
||||
return useResource(engagementRepository.journey(useScope()), {
|
||||
isEmpty: (j) => j.stages.length === 0,
|
||||
});
|
||||
}
|
||||
114
src/features/engagement/mocks/engagementMock.ts
Normal file
114
src/features/engagement/mocks/engagementMock.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* TEMPORARY sample data for the engagement panels (activities, campaigns,
|
||||
* journey), shown only while the platform returns none for this shop.
|
||||
*
|
||||
* Attribution stays `'estimated'` on every row, so the AttributionNote still
|
||||
* renders — sample data obeys the same disclosure rule as real data.
|
||||
*
|
||||
* Removal: see `shared/mocks/withSample.ts`.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ActivityRow,
|
||||
Campaign,
|
||||
Journey,
|
||||
} from '@/features/engagement/types/engagement';
|
||||
|
||||
export const MOCK_ACTIVITIES: ActivityRow[] = [
|
||||
{
|
||||
id: 'sample-selfie',
|
||||
kind: 'selfie',
|
||||
name: 'Selfie wall',
|
||||
isActive: true,
|
||||
events: 412,
|
||||
participants: 318,
|
||||
anonymous: 24,
|
||||
repeatVisitors: 142,
|
||||
purchases: 96,
|
||||
attributedRevenue: 61_400,
|
||||
currency: 'INR',
|
||||
attribution: 'estimated',
|
||||
},
|
||||
{
|
||||
id: 'sample-spin',
|
||||
kind: 'spin',
|
||||
name: 'Spin the wheel',
|
||||
isActive: true,
|
||||
events: 286,
|
||||
participants: 231,
|
||||
anonymous: 12,
|
||||
repeatVisitors: 118,
|
||||
purchases: 74,
|
||||
attributedRevenue: 44_900,
|
||||
currency: 'INR',
|
||||
attribution: 'estimated',
|
||||
},
|
||||
{
|
||||
id: 'sample-challenge',
|
||||
kind: 'challenge',
|
||||
name: 'Weekend challenge',
|
||||
isActive: true,
|
||||
events: 138,
|
||||
participants: 102,
|
||||
anonymous: 0,
|
||||
repeatVisitors: 61,
|
||||
purchases: 38,
|
||||
attributedRevenue: 27_300,
|
||||
currency: 'INR',
|
||||
attribution: 'estimated',
|
||||
},
|
||||
{
|
||||
id: 'sample-referral',
|
||||
kind: 'referral',
|
||||
name: 'Refer a friend',
|
||||
isActive: false,
|
||||
events: 54,
|
||||
participants: 47,
|
||||
anonymous: 0,
|
||||
repeatVisitors: 29,
|
||||
purchases: 18,
|
||||
attributedRevenue: 12_800,
|
||||
currency: 'INR',
|
||||
attribution: 'estimated',
|
||||
},
|
||||
];
|
||||
|
||||
export const MOCK_CAMPAIGNS: Campaign[] = [
|
||||
{
|
||||
id: 'sample-festive',
|
||||
name: 'Festive week',
|
||||
startsAt: '2026-09-15T00:00:00Z',
|
||||
endsAt: null,
|
||||
isActive: true,
|
||||
activities: ['selfie', 'spin'],
|
||||
events: 520,
|
||||
participants: 388,
|
||||
purchasers: 131,
|
||||
attributedRevenue: 82_600,
|
||||
attribution: 'estimated',
|
||||
},
|
||||
{
|
||||
id: 'sample-monsoon',
|
||||
name: 'Monsoon rewards',
|
||||
startsAt: '2026-08-28T00:00:00Z',
|
||||
endsAt: '2026-09-10T00:00:00Z',
|
||||
isActive: false,
|
||||
activities: ['challenge'],
|
||||
events: 214,
|
||||
participants: 163,
|
||||
purchasers: 57,
|
||||
attributedRevenue: 34_100,
|
||||
attribution: 'estimated',
|
||||
},
|
||||
];
|
||||
|
||||
export const MOCK_JOURNEY: Journey = {
|
||||
stages: [
|
||||
{id: 'visit', label: 'Visited', customers: 1_240},
|
||||
{id: 'engage', label: 'Took part', customers: 562},
|
||||
{id: 'buy', label: 'Bought', customers: 298},
|
||||
{id: 'return', label: 'Came back', customers: 211},
|
||||
{id: 'refer', label: 'Referred', customers: 47},
|
||||
],
|
||||
attribution: 'estimated',
|
||||
};
|
||||
27
src/features/engagement/repositories/engagementRepository.ts
Normal file
27
src/features/engagement/repositories/engagementRepository.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import {postJson, scopedEndpoint} from '@/shared/services/httpClient';
|
||||
import type {Endpoint, Scope} from '@/shared/services/httpClient';
|
||||
import type {
|
||||
ActivityEventDraft,
|
||||
ActivityRow,
|
||||
Campaign,
|
||||
Journey,
|
||||
} from '@/features/engagement/types/engagement';
|
||||
|
||||
/**
|
||||
* TRANSPORT ONLY — engagement, scoped by the workspace's shop and range like
|
||||
* every other report, so the panels move with the dashboard's controls.
|
||||
*/
|
||||
export const engagementRepository = {
|
||||
/** The catalogue, already joined to its impact chain by the BFF. */
|
||||
activities: (scope: Scope): Endpoint<ActivityRow[]> =>
|
||||
scopedEndpoint('/api/activities', scope),
|
||||
|
||||
campaigns: (scope: Scope): Endpoint<Campaign[]> =>
|
||||
scopedEndpoint('/api/campaigns', scope),
|
||||
|
||||
journey: (scope: Scope): Endpoint<Journey> =>
|
||||
scopedEndpoint('/api/reports/journey', scope),
|
||||
|
||||
recordEvent: (draft: ActivityEventDraft) =>
|
||||
postJson<{id?: string; duplicate?: boolean}>('/api/activities/events', draft),
|
||||
};
|
||||
88
src/features/engagement/services/mapEngagement.ts
Normal file
88
src/features/engagement/services/mapEngagement.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type {
|
||||
ApiActivity,
|
||||
ApiActivityImpact,
|
||||
ApiAttribution,
|
||||
ApiCampaign,
|
||||
ApiJourneyReport,
|
||||
} from '@/services/api/types';
|
||||
import type {
|
||||
ActivityRow,
|
||||
Attribution,
|
||||
Campaign,
|
||||
Journey,
|
||||
} from '@/features/engagement/types/engagement';
|
||||
|
||||
/**
|
||||
* An unrecognised basis is treated as `estimated`: the cost of a needless
|
||||
* disclosure is a sentence, the cost of a missing one is a merchant spending
|
||||
* against a correlation.
|
||||
*/
|
||||
function basis(a: ApiAttribution | string | undefined): Attribution {
|
||||
return a === 'observed' ? 'observed' : 'estimated';
|
||||
}
|
||||
|
||||
/**
|
||||
* The catalogue joined to its impact chain, by activity id.
|
||||
*
|
||||
* Joined here rather than in a component so the table reads one row type. An
|
||||
* activity with no impact row keeps null impact figures — "not reported" —
|
||||
* rather than zeroes, which would read as "nobody came back".
|
||||
*/
|
||||
export function toActivityRows(
|
||||
activities: ApiActivity[] | null,
|
||||
impact: ApiActivityImpact[] | null,
|
||||
): ActivityRow[] {
|
||||
const byId = new Map((impact ?? []).map((i) => [i.id, i]));
|
||||
return (activities ?? []).map((a) => {
|
||||
const i = byId.get(a.id);
|
||||
return {
|
||||
id: a.id,
|
||||
kind: a.kind,
|
||||
name: a.name,
|
||||
isActive: a.active,
|
||||
events: a.events,
|
||||
participants: a.participants,
|
||||
anonymous: a.anonymous,
|
||||
repeatVisitors: i ? i.repeat_visitors : null,
|
||||
purchases: i ? i.purchases : null,
|
||||
attributedRevenue: i ? i.attributed_revenue : null,
|
||||
currency: i?.currency || 'INR',
|
||||
attribution: basis(i?.attribution),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function toCampaign(c: ApiCampaign): Campaign {
|
||||
return {
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
startsAt: c.starts_at,
|
||||
endsAt: c.ends_at || null,
|
||||
isActive: c.active,
|
||||
activities: c.activities ?? [],
|
||||
events: c.events,
|
||||
participants: c.participants,
|
||||
purchasers: c.purchasers,
|
||||
attributedRevenue: c.revenue,
|
||||
attribution: basis(c.attribution),
|
||||
};
|
||||
}
|
||||
|
||||
const STAGE_LABELS: Record<string, string> = {
|
||||
visit: 'Visited',
|
||||
engage: 'Took part',
|
||||
purchase: 'Bought',
|
||||
return: 'Came back',
|
||||
refer: 'Referred someone',
|
||||
};
|
||||
|
||||
export function toJourney(r: ApiJourneyReport): Journey {
|
||||
return {
|
||||
stages: (r.stages ?? []).map((s) => ({
|
||||
id: s.stage,
|
||||
label: STAGE_LABELS[s.stage] ?? s.stage,
|
||||
customers: s.customers,
|
||||
})),
|
||||
attribution: basis(r.attribution),
|
||||
};
|
||||
}
|
||||
72
src/features/engagement/types/engagement.ts
Normal file
72
src/features/engagement/types/engagement.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Engagement — what customers did, and what it was worth.
|
||||
*
|
||||
* ── Attribution is estimated — say so ────────────────────────────────────
|
||||
* Everything downstream of `participants` (repeat visits, purchases, revenue)
|
||||
* is correlation over a time window: no purchase is joined to a specific spin
|
||||
* or selfie. Every payload that carries such a figure carries `attribution`,
|
||||
* and the panel discloses it while it reads `estimated`. The field is
|
||||
* `attributedRevenue`, never `revenue`, for the same reason.
|
||||
*/
|
||||
export type Attribution = 'estimated' | 'observed';
|
||||
|
||||
/** The one sentence every attributed panel shows, so panels cannot drift. */
|
||||
export const ATTRIBUTION_NOTE =
|
||||
'Attributed figures are estimated: they count what participants did afterwards, not purchases linked to the activity itself.';
|
||||
|
||||
/** One activity in the catalogue, with participation and its impact chain. */
|
||||
export interface ActivityRow extends Record<string, unknown> {
|
||||
id: string;
|
||||
kind: string;
|
||||
name: string;
|
||||
isActive: boolean;
|
||||
events: number;
|
||||
participants: number;
|
||||
/** Participation by somebody the cameras never enrolled. */
|
||||
anonymous: number;
|
||||
/** Null when the impact report has no row for this activity. */
|
||||
repeatVisitors: number | null;
|
||||
purchases: number | null;
|
||||
attributedRevenue: number | null;
|
||||
currency: string;
|
||||
attribution: Attribution;
|
||||
}
|
||||
|
||||
export interface Campaign extends Record<string, unknown> {
|
||||
id: string;
|
||||
name: string;
|
||||
startsAt: string;
|
||||
endsAt: string | null;
|
||||
isActive: boolean;
|
||||
activities: string[];
|
||||
events: number;
|
||||
participants: number;
|
||||
purchasers: number;
|
||||
attributedRevenue: number;
|
||||
attribution: Attribution;
|
||||
}
|
||||
|
||||
export interface JourneyStage {
|
||||
id: string;
|
||||
label: string;
|
||||
/** DISTINCT people who reached this stage in the window. */
|
||||
customers: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Not a strict funnel: somebody can buy without ever engaging, so a later
|
||||
* stage is not always a subset of the one before, and the chart must not
|
||||
* imply it is.
|
||||
*/
|
||||
export interface Journey {
|
||||
stages: JourneyStage[];
|
||||
attribution: Attribution;
|
||||
}
|
||||
|
||||
export interface ActivityEventDraft {
|
||||
kind: string;
|
||||
/** Minted once per real-world event and reused on every retry. */
|
||||
sourceEventId: string;
|
||||
site?: string;
|
||||
visitorId?: string;
|
||||
}
|
||||
110
src/features/floor/components/NameCustomerDialog.tsx
Normal file
110
src/features/floor/components/NameCustomerDialog.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {Dialog, DialogHeader} from '@astryxdesign/core/Dialog';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {TextInput} from '@astryxdesign/core/TextInput';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Banner} from '@astryxdesign/core/Banner';
|
||||
import {floorRepository} from '@/features/floor/repositories/floorRepository';
|
||||
import type {FloorVisit} from '@/features/floor/types/floor';
|
||||
|
||||
/**
|
||||
* Naming somebody the cameras could not identify. LOYALY.md §18.
|
||||
*
|
||||
* The visit id travels with the request: that is what makes this the
|
||||
* first-visit flow rather than a directory entry, and it is what stops the
|
||||
* face on the floor staying anonymous.
|
||||
*
|
||||
* The platform requires a name OR a phone — a record with neither is not a
|
||||
* customer, and without that rule Save mints a blank "Visitor N" every time
|
||||
* somebody taps it.
|
||||
*/
|
||||
export function NameCustomerDialog({
|
||||
visit,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
visit: FloorVisit;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [name, setName] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const canSave = name.trim() !== '' || phone.trim() !== '';
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
// Through httpClient, so the request carries this tab's session header.
|
||||
const res = await floorRepository.createCustomer({
|
||||
name,
|
||||
phone,
|
||||
visitId: visit.visitId,
|
||||
});
|
||||
if (!res.ok) {
|
||||
// The platform's own wording — including the 409 that says this
|
||||
// arrival was identified while the form was open.
|
||||
setError(
|
||||
res.status === 0
|
||||
? 'Could not reach the platform. Try again.'
|
||||
: (res.message ?? 'Could not save this customer.'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
onSaved();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
purpose="info"
|
||||
width={420}
|
||||
aria-label="Add customer"
|
||||
>
|
||||
<VStack gap={4} width="100%">
|
||||
<DialogHeader
|
||||
title="Add customer"
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
/>
|
||||
<Text size="sm" color="secondary">
|
||||
This person was seen just now and does not match anyone on record.
|
||||
Their details will be linked to this arrival.
|
||||
</Text>
|
||||
|
||||
<TextInput
|
||||
label="Name"
|
||||
value={name}
|
||||
onChange={setName}
|
||||
placeholder="Full name"
|
||||
/>
|
||||
<TextInput
|
||||
label="Phone"
|
||||
value={phone}
|
||||
onChange={setPhone}
|
||||
placeholder="+91…"
|
||||
/>
|
||||
|
||||
{error ? <Banner status="error" title={error} /> : null}
|
||||
|
||||
<HStack gap={2} hAlign="end">
|
||||
<Button variant="secondary" onClick={onClose} label="Cancel" />
|
||||
<Button
|
||||
isDisabled={!canSave || saving}
|
||||
onClick={() => void save()}
|
||||
label={saving ? 'Saving…' : 'Save customer'}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
39
src/features/floor/components/TodaySummaryCard.tsx
Normal file
39
src/features/floor/components/TodaySummaryCard.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
'use client';
|
||||
|
||||
import {Card} from '@astryxdesign/core/Card';
|
||||
import {Skeleton} from '@astryxdesign/core/Skeleton';
|
||||
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
|
||||
import {StatPair, StatRow} from '@/shared/components/patterns/StatPair';
|
||||
import {formatCount, formatInr} from '@/shared/utils/format';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
import type {TodaySummary} from '@/features/floor/types/summary';
|
||||
|
||||
/**
|
||||
* Today at a glance, above the floor: what the day has taken, how many sales
|
||||
* and enquiries it took, who is in the shop now, and how many came in.
|
||||
*
|
||||
* From GET /api/dashboard/summary. "Today" is the shop's business day, and it
|
||||
* does not follow the range picker — this is the counter's question, not the
|
||||
* report's. Takings are shown only in rupees: the platform reports one
|
||||
* currency, and a figure labelled ₹ that summed another would not be money.
|
||||
*/
|
||||
export function TodaySummaryCard({resource}: {resource: Resource<TodaySummary>}) {
|
||||
return (
|
||||
<Card>
|
||||
<AsyncBoundary resource={resource} loading={<Skeleton height={48} width="100%" />}>
|
||||
{(today) => (
|
||||
<StatRow>
|
||||
<StatPair
|
||||
label="Takings today"
|
||||
value={today.currency === 'INR' ? formatInr(today.revenue) : `${today.revenue} ${today.currency}`}
|
||||
/>
|
||||
<StatPair label="Sales" value={formatCount(today.salesCount)} />
|
||||
<StatPair label="Enquiries" value={formatCount(today.enquiriesCount)} />
|
||||
<StatPair label="On the floor now" value={formatCount(today.onFloorNow)} />
|
||||
<StatPair label="Visitors today" value={formatCount(today.visitors)} align="end" />
|
||||
</StatRow>
|
||||
)}
|
||||
</AsyncBoundary>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
74
src/features/floor/hooks/useFloor.ts
Normal file
74
src/features/floor/hooks/useFloor.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
'use client';
|
||||
|
||||
import {useCallback, useState} from 'react';
|
||||
import {
|
||||
floorRepository,
|
||||
type FloorAction,
|
||||
} from '@/features/floor/repositories/floorRepository';
|
||||
import {useResource} from '@/shared/hooks/useResource';
|
||||
import {useScope} from '@/shared/hooks/useScope';
|
||||
|
||||
export type {FloorAction};
|
||||
|
||||
/**
|
||||
* The floor, plus the three lifecycle calls.
|
||||
*
|
||||
* ── Why a conflict re-reads instead of patching local state ──────────────
|
||||
* Only the platform knows who actually won a race for a customer. When a Take
|
||||
* comes back 409 the screen must show the CURRENT truth — which staff member
|
||||
* holds them — and that is a fact this browser does not have. So every action,
|
||||
* success or conflict, is followed by a refetch. Nothing about ownership is
|
||||
* simulated here.
|
||||
*
|
||||
* Any OTHER failure — a 403, a 404, the platform unreachable — is reported in
|
||||
* `failure`. It used to be dropped silently, so a tap that did nothing looked
|
||||
* exactly like a tap that worked.
|
||||
*/
|
||||
export function useFloor() {
|
||||
const resource = useResource(floorRepository.list(useScope()));
|
||||
const [pending, setPending] = useState<string | null>(null);
|
||||
const [conflict, setConflict] = useState<{visitId: string; message: string} | null>(null);
|
||||
const [failure, setFailure] = useState<{visitId: string; message: string} | null>(null);
|
||||
|
||||
const act = useCallback(
|
||||
async (visitId: string, action: FloorAction) => {
|
||||
setPending(visitId);
|
||||
setConflict(null);
|
||||
setFailure(null);
|
||||
try {
|
||||
// Through httpClient, so the request carries this tab's session header.
|
||||
const res = await floorRepository.act(visitId, action);
|
||||
if (res.status === 409) {
|
||||
setConflict({
|
||||
visitId,
|
||||
// The platform's own wording names who holds the customer.
|
||||
message: res.message ?? 'Somebody else is already serving this customer.',
|
||||
});
|
||||
} else if (!res.ok) {
|
||||
setFailure({
|
||||
visitId,
|
||||
message:
|
||||
res.status === 0
|
||||
? 'Could not reach the platform. Try again.'
|
||||
: (res.message ?? 'Could not update this customer. Try again.'),
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setPending(null);
|
||||
// Refetch on every path, including the conflict: the row the browser
|
||||
// is holding is now known to be stale.
|
||||
resource.refetch();
|
||||
}
|
||||
},
|
||||
[resource],
|
||||
);
|
||||
|
||||
return {
|
||||
resource,
|
||||
act,
|
||||
pending,
|
||||
conflict,
|
||||
failure,
|
||||
dismissConflict: () => setConflict(null),
|
||||
};
|
||||
}
|
||||
12
src/features/floor/hooks/useTodaySummary.ts
Normal file
12
src/features/floor/hooks/useTodaySummary.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import {floorRepository} from '@/features/floor/repositories/floorRepository';
|
||||
import {useResource} from '@/shared/hooks/useResource';
|
||||
import {useScope} from '@/shared/hooks/useScope';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
import type {TodaySummary} from '@/features/floor/types/summary';
|
||||
|
||||
/** A day with no sales is still a day — zeroes are an answer, never 'empty'. */
|
||||
export function useTodaySummary(): Resource<TodaySummary> {
|
||||
return useResource(floorRepository.today(useScope()), {isEmpty: () => false});
|
||||
}
|
||||
24
src/features/floor/repositories/floorRepository.ts
Normal file
24
src/features/floor/repositories/floorRepository.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import {postJson, scopedEndpoint} from '@/shared/services/httpClient';
|
||||
import type {Endpoint, Scope} from '@/shared/services/httpClient';
|
||||
import type {FloorVisit} from '@/features/floor/types/floor';
|
||||
import type {TodaySummary} from '@/features/floor/types/summary';
|
||||
|
||||
export type FloorAction = 'attend' | 'release' | 'complete';
|
||||
|
||||
/** The floor, scoped by the workspace's selected store. */
|
||||
export const floorRepository = {
|
||||
list: (scope: Scope): Endpoint<FloorVisit[]> =>
|
||||
scopedEndpoint('/api/floor/visits', scope, {}),
|
||||
|
||||
/** Today, for the selected shop. The range in `scope` is ignored upstream of here. */
|
||||
today: (scope: Scope): Endpoint<TodaySummary> =>
|
||||
scopedEndpoint('/api/dashboard/summary', scope, {}),
|
||||
|
||||
/** Take, release or complete a customer. A 409 means somebody else holds them. */
|
||||
act: (visitId: string, action: FloorAction) =>
|
||||
postJson<FloorVisit>(`/api/visits/${encodeURIComponent(visitId)}/${action}`, {}),
|
||||
|
||||
/** Name somebody the cameras could not identify, linked to this arrival. */
|
||||
createCustomer: (body: {name: string; phone: string; visitId: string}) =>
|
||||
postJson<unknown>('/api/customers', body),
|
||||
};
|
||||
15
src/features/floor/services/mapSummary.ts
Normal file
15
src/features/floor/services/mapSummary.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type {ApiDashboardSummary} from '@/services/api/types';
|
||||
import type {TodaySummary} from '@/features/floor/types/summary';
|
||||
|
||||
/** Paise to rupees here, once, so no component ever divides by 100 itself. */
|
||||
export function toTodaySummary(s: ApiDashboardSummary): TodaySummary {
|
||||
return {
|
||||
date: s.date,
|
||||
revenue: (s.total_revenue_paise ?? 0) / 100,
|
||||
currency: s.currency || 'INR',
|
||||
salesCount: s.sales_count,
|
||||
enquiriesCount: s.enquiries_count,
|
||||
onFloorNow: s.active_customer_count,
|
||||
visitors: s.visitors_count,
|
||||
};
|
||||
}
|
||||
24
src/features/floor/types/floor.ts
Normal file
24
src/features/floor/types/floor.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* The shop floor, as the console consumes it.
|
||||
*
|
||||
* `visitorId === null` means the cameras saw somebody they could not identify.
|
||||
* That is not missing data — it is the state the add-customer flow exists for,
|
||||
* and it must stay distinguishable from a known customer with no name yet.
|
||||
*/
|
||||
export interface FloorVisit {
|
||||
visitId: string;
|
||||
siteId: string;
|
||||
detectedAt: string;
|
||||
status: 'waiting' | 'attending' | 'completed' | 'cancelled';
|
||||
visitorId: string | null;
|
||||
customerRef: string | null;
|
||||
label: string | null;
|
||||
phone: string | null;
|
||||
/** Times this customer was seen BEFORE this visit. 0 for a first arrival. */
|
||||
previousVisits: number;
|
||||
attendedBy: string | null;
|
||||
attendedByName: string | null;
|
||||
/** Decides whether the button says Take or Continue. */
|
||||
attendedByMe: boolean;
|
||||
imageUrl: string | null;
|
||||
}
|
||||
18
src/features/floor/types/summary.ts
Normal file
18
src/features/floor/types/summary.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Today at a glance, from `GET /api/dashboard/summary`.
|
||||
*
|
||||
* "Today" is the business day in the shop's zone, not the UTC one, and it is
|
||||
* not moved by the range picker — this is the shop screen's question, not the
|
||||
* report's.
|
||||
*/
|
||||
export interface TodaySummary {
|
||||
date: string;
|
||||
/** Rupees, converted from the platform's paise. */
|
||||
revenue: number;
|
||||
currency: string;
|
||||
salesCount: number;
|
||||
enquiriesCount: number;
|
||||
/** Waiting or being served right now. */
|
||||
onFloorNow: number;
|
||||
visitors: number;
|
||||
}
|
||||
108
src/features/loyaly-ai/components/ChatHeader.tsx
Normal file
108
src/features/loyaly-ai/components/ChatHeader.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
'use client';
|
||||
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {IconButton} from '@astryxdesign/core/IconButton';
|
||||
import {HStack} from '@astryxdesign/core/Layout';
|
||||
import {useLoyalyAi} from '@/features/loyaly-ai/providers/LoyalyAiProvider';
|
||||
import {ICONS} from '@/shared/utils/icons';
|
||||
|
||||
/**
|
||||
* Minimal by design: a wordmark and three controls.
|
||||
*
|
||||
* What used to be here — a three-tab strip (AI / Analytics / Chat) — made the
|
||||
* assistant read as a dashboard widget with a chat feature. A conversational
|
||||
* product does not ask you to choose a mode before it will talk to you, so the
|
||||
* tabs are gone and the conversation is the surface.
|
||||
*
|
||||
* `onClose` is optional because the two hosts differ: the slide-over closes
|
||||
* itself, the inline panel is collapsed by the top bar's toggle, and rendering
|
||||
* a close button with nothing to close would be a lie.
|
||||
*/
|
||||
import {BrandLogo} from '@/shared/components/brand/BrandLogo';
|
||||
|
||||
export function ChatHeader({onClose}: {onClose?: () => void}) {
|
||||
const {
|
||||
newChat,
|
||||
reload,
|
||||
isHistoryOpen,
|
||||
setHistoryOpen,
|
||||
panelMode,
|
||||
toggleExpand,
|
||||
toggleFullscreen,
|
||||
} = useLoyalyAi();
|
||||
|
||||
const isExpanded = panelMode === 'expanded';
|
||||
const isFullscreen = panelMode === 'fullscreen';
|
||||
|
||||
return (
|
||||
<HStack
|
||||
vAlign="center"
|
||||
hAlign="between"
|
||||
gap={2}
|
||||
paddingInline={4}
|
||||
paddingBlock={3}
|
||||
width="100%"
|
||||
// The header is the only thing pinned above the scroll area, so it owns
|
||||
// the hairline that separates the conversation from the chrome.
|
||||
className="border-b border-border shrink-0"
|
||||
>
|
||||
<HStack gap={2} vAlign="center">
|
||||
<BrandLogo height={20} />
|
||||
</HStack>
|
||||
|
||||
<HStack gap={0.5} vAlign="center">
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
label={isExpanded ? 'Restore width' : 'Expand panel'}
|
||||
tooltip={isExpanded ? 'Restore' : 'Expand'}
|
||||
icon={<Icon icon={isExpanded ? ICONS.restore : ICONS.expand} size="sm" />}
|
||||
onClick={toggleExpand}
|
||||
/>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
label={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
||||
tooltip={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
||||
icon={<Icon icon={isFullscreen ? ICONS.minimize : ICONS.fullscreen} size="sm" />}
|
||||
onClick={toggleFullscreen}
|
||||
/>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
label="Reload"
|
||||
tooltip="Reload"
|
||||
icon={<Icon icon={ICONS.reload} size="sm" />}
|
||||
onClick={reload}
|
||||
/>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
label="New chat"
|
||||
tooltip="New chat"
|
||||
icon={<Icon icon={ICONS.newChat} size="sm" />}
|
||||
onClick={newChat}
|
||||
/>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
label="Chat history"
|
||||
tooltip="History"
|
||||
icon={<Icon icon={ICONS.history} size="sm" />}
|
||||
onClick={() => setHistoryOpen(!isHistoryOpen)}
|
||||
aria-expanded={isHistoryOpen}
|
||||
/>
|
||||
{onClose ? (
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
label="Close Loyaly AI"
|
||||
tooltip="Close"
|
||||
icon={<Icon icon="close" size="sm" />}
|
||||
onClick={onClose}
|
||||
/>
|
||||
) : null}
|
||||
</HStack>
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
139
src/features/loyaly-ai/components/Composer.tsx
Normal file
139
src/features/loyaly-ai/components/Composer.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
'use client';
|
||||
|
||||
import {useEffect, useRef} from 'react';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {IconButton} from '@astryxdesign/core/IconButton';
|
||||
import {HStack} from '@astryxdesign/core/Layout';
|
||||
import {ICONS} from '@/shared/utils/icons';
|
||||
|
||||
/**
|
||||
* The composer: one pill, three zones.
|
||||
*
|
||||
* [ + ] Ask Loyaly AI about your business... [ mic ] [ ↑ ]
|
||||
*
|
||||
* ── Why this is hand-built and not Astryx's ChatComposer ─────────────────
|
||||
* ChatComposer is a multi-row surface: `headerActions` render in a row ABOVE
|
||||
* the input, `footerActions` in a row below. That is the right shape for a
|
||||
* desktop IDE assistant with model pickers and context chips, and the wrong
|
||||
* shape for the single 56–64px pill the brief specifies, where the attachment
|
||||
* button sits inline to the LEFT of the text. Bending it into one row would
|
||||
* have meant fighting its layout with utilities on every slot.
|
||||
*
|
||||
* What is NOT hand-built is anything that carries behaviour: the docking and
|
||||
* auto-scroll come from ChatLayout, which this is passed to as `composer`.
|
||||
*
|
||||
* ── Auto-grow ─────────────────────────────────────────────────────────────
|
||||
* The textarea grows with its content up to MAX_ROWS then scrolls, which is
|
||||
* why the height is a range rather than a number. Measured by resetting height
|
||||
* to `auto` before reading scrollHeight — without the reset, scrollHeight can
|
||||
* only ever grow, and the box never shrinks back after a deletion.
|
||||
*/
|
||||
|
||||
/** ~5 lines before the field starts scrolling instead of growing. */
|
||||
const MAX_HEIGHT_PX = 132;
|
||||
|
||||
const PILL = [
|
||||
'rounded-[28px] bg-card border border-border-strong shadow-md',
|
||||
'transition-colors duration-150',
|
||||
'focus-within:border-border-strong focus-within:ring-1 focus-within:ring-border-strong',
|
||||
'pl-4 pr-2 py-1.5',
|
||||
].join(' ');
|
||||
|
||||
const FIELD = [
|
||||
'flex-1 min-w-0 resize-none bg-transparent border-0 outline-none',
|
||||
'text-sm font-medium text-primary placeholder:text-secondary placeholder:font-normal',
|
||||
'py-2 leading-6 overflow-y-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden',
|
||||
].join(' ');
|
||||
|
||||
export function Composer({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
onStop,
|
||||
isStreaming,
|
||||
onFocus,
|
||||
onBlur,
|
||||
placeholder = 'Ask Loyaly AI about your business...',
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
onSubmit: (v: string) => void;
|
||||
onStop: () => void;
|
||||
isStreaming: boolean;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const fieldRef = useRef<HTMLTextAreaElement>(null);
|
||||
const canSend = value.trim().length > 0 && !isStreaming;
|
||||
|
||||
// Auto-grow. Runs on every value change, including the reset to '' after a
|
||||
// send — which is what shrinks the pill back to one line.
|
||||
useEffect(() => {
|
||||
const field = fieldRef.current;
|
||||
if (!field) return;
|
||||
field.style.height = 'auto';
|
||||
field.style.height = `${Math.min(field.scrollHeight, MAX_HEIGHT_PX)}px`;
|
||||
}, [value]);
|
||||
|
||||
const submit = () => {
|
||||
if (!canSend) return;
|
||||
onSubmit(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<HStack gap={2} vAlign="center" width="100%" className={PILL}>
|
||||
<textarea
|
||||
ref={fieldRef}
|
||||
className={FIELD}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
placeholder={placeholder}
|
||||
rows={1}
|
||||
aria-label="Message Loyaly AI"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<HStack gap={1} vAlign="center" className="shrink-0 pb-0.5">
|
||||
{/* <IconButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
label="Dictate a message"
|
||||
tooltip="Dictate"
|
||||
icon={<Icon icon={ICONS.mic} size="sm" />}
|
||||
isDisabled
|
||||
className="size-9 rounded-full flex items-center justify-center"
|
||||
/> */}
|
||||
|
||||
{isStreaming ? (
|
||||
<IconButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
label="Stop generating"
|
||||
tooltip="Stop"
|
||||
icon={<Icon icon={ICONS.stop} size="sm" />}
|
||||
onClick={onStop}
|
||||
className="size-9 rounded-full flex items-center justify-center"
|
||||
/>
|
||||
) : (
|
||||
<IconButton
|
||||
variant="primary"
|
||||
size="sm"
|
||||
label="Send message"
|
||||
icon={<Icon icon={ICONS.send} size="sm" />}
|
||||
isDisabled={!canSend}
|
||||
onClick={submit}
|
||||
className="size-9 rounded-full flex items-center justify-center"
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
126
src/features/loyaly-ai/components/Conversation.tsx
Normal file
126
src/features/loyaly-ai/components/Conversation.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {motion, AnimatePresence} from 'framer-motion';
|
||||
import {useLoyalyAi} from '@/features/loyaly-ai/providers/LoyalyAiProvider';
|
||||
import {Composer} from './Composer';
|
||||
import {EmptyState} from './EmptyState';
|
||||
import {SuggestionChips} from './SuggestionChips';
|
||||
import {MessageList} from './MessageList';
|
||||
|
||||
/**
|
||||
* ChatGPT & Gemini style two-stage chat experience:
|
||||
*
|
||||
* 1. STAGE 1 — Initial Minimal Landing:
|
||||
* - Vertically centered Loyaly logo mark, heading, subtitle, and composer.
|
||||
* - No suggestion chips shown initially to keep interface calm and minimal.
|
||||
*
|
||||
* 2. STAGE 2 — Input Focused / Typing:
|
||||
* - Focusing or typing in composer smoothly animates suggestion chips into view directly BELOW the composer.
|
||||
*
|
||||
* 3. Active Conversation Mode:
|
||||
* - First message sent removes landing elements and morphs composer to sticky bottom.
|
||||
*
|
||||
* 4. New Chat:
|
||||
* - Returns to STAGE 1 (minimal landing, chips hidden until focused again).
|
||||
*/
|
||||
export function Conversation() {
|
||||
const {conversation, draft, setDraft, send, stop, isStreaming} =
|
||||
useLoyalyAi();
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
const hasMessages = conversation.messages.length > 0;
|
||||
const showChips = isFocused || draft.trim().length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full w-full relative overflow-hidden bg-background">
|
||||
<AnimatePresence>
|
||||
{!hasMessages ? (
|
||||
<motion.div
|
||||
key="empty-landing-view"
|
||||
initial={{opacity: 0, scale: 0.98}}
|
||||
animate={{opacity: 1, scale: 1}}
|
||||
exit={{opacity: 0, scale: 0.96, transition: {duration: 0.2}}}
|
||||
transition={{duration: 0.3, ease: 'easeOut'}}
|
||||
className="flex-1 flex flex-col items-center px-4 py-6 w-full max-w-2xl mx-auto overflow-y-auto"
|
||||
>
|
||||
<div className="w-full flex flex-col items-center py-1">
|
||||
<EmptyState onPick={send} />
|
||||
|
||||
{/* Chat Composer (36px gap from subtitle) */}
|
||||
<motion.div
|
||||
transition={{duration: 0.3, ease: [0.16, 1, 0.3, 1]}}
|
||||
className="w-full max-w-xl mt-9"
|
||||
>
|
||||
<Composer
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
onSubmit={send}
|
||||
onStop={stop}
|
||||
isStreaming={isStreaming}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* STAGE 2 — Suggestion Chips animate in BELOW the composer when focused or typing */}
|
||||
<AnimatePresence>
|
||||
{showChips && (
|
||||
<motion.div
|
||||
key="stage-2-chips"
|
||||
initial={{opacity: 0, y: -10}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
exit={{opacity: 0, y: -8}}
|
||||
transition={{duration: 0.22, ease: 'easeOut'}}
|
||||
className="w-full max-w-xl mt-[28px]"
|
||||
>
|
||||
<SuggestionChips onPick={send} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="active-chat-view"
|
||||
/*
|
||||
* No initial/animate opacity here.
|
||||
*
|
||||
* The composer below shares a `layoutId` with the one in the empty
|
||||
* view, and framer defers an entering element's `animate` while it
|
||||
* projects a shared layout across the swap — which left this view
|
||||
* mounted at `opacity: 0` with the conversation invisible behind
|
||||
* it (measured: two messages in the DOM, blank panel). The morph
|
||||
* itself already carries the continuity this fade was for.
|
||||
*/
|
||||
className="flex-1 flex flex-col h-full w-full relative overflow-hidden"
|
||||
>
|
||||
<div className="flex-1 overflow-y-auto px-4 py-6 w-full">
|
||||
<div className="max-w-4xl mx-auto w-full space-y-4">
|
||||
<MessageList
|
||||
messages={conversation.messages}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
transition={{duration: 0.3, ease: [0.16, 1, 0.3, 1]}}
|
||||
className="p-3 border-t border-border bg-popover/90 backdrop-blur-md sticky bottom-0 z-10 w-full shrink-0"
|
||||
>
|
||||
<div className="max-w-3xl mx-auto w-full">
|
||||
<Composer
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
onSubmit={send}
|
||||
onStop={stop}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
173
src/features/loyaly-ai/components/EmptyState.tsx
Normal file
173
src/features/loyaly-ai/components/EmptyState.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {motion} from 'framer-motion';
|
||||
import {Heading, Text} from '@astryxdesign/core/Text';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {BrandMark} from '@/shared/components/brand/BrandLogo';
|
||||
import {ICONS} from '@/shared/utils/icons';
|
||||
import {SUGGESTIONS} from '@/features/loyaly-ai/utils/suggestions';
|
||||
|
||||
const GREETINGS = [
|
||||
'Ready to optimize your stores?',
|
||||
'What would you like to analyze today?',
|
||||
'Need proactive retail insights?',
|
||||
'Explore your store intelligence',
|
||||
"Let's boost today's footfall & sales",
|
||||
];
|
||||
|
||||
interface InsightCardItem {
|
||||
id: string;
|
||||
tag: string;
|
||||
tagColor: 'cool' | 'warm';
|
||||
title: string;
|
||||
description: string;
|
||||
actionText: string;
|
||||
prompt: string;
|
||||
icon: keyof typeof ICONS;
|
||||
}
|
||||
|
||||
const ACTIVE_INSIGHTS: InsightCardItem[] = [
|
||||
{
|
||||
id: 'peak-hours',
|
||||
tag: 'Traffic Optimization',
|
||||
tagColor: 'cool',
|
||||
title: 'Peak Footfall Window Detected',
|
||||
description: 'Traffic consistently surges +28% on weekends from 2 PM – 5 PM. Adjusting shop-floor staffing can increase conversion by ~14%.',
|
||||
actionText: 'Analyze Store Hours',
|
||||
prompt: 'How can I optimize store hours and staffing shifts during peak weekend footfall?',
|
||||
icon: 'activityEvent',
|
||||
},
|
||||
{
|
||||
id: 'campaign-roi',
|
||||
tag: 'Reward Performance',
|
||||
tagColor: 'warm',
|
||||
title: 'Spin & Win Loyalty Surge',
|
||||
description: '34% repeat visits recorded from the recent loyalty campaign. Attributed revenue up +₹42,000.',
|
||||
actionText: 'View Campaign ROI',
|
||||
prompt: 'Show me the performance of the Spin & Win campaign and recommend settings for this weekend.',
|
||||
icon: 'lyts',
|
||||
},
|
||||
];
|
||||
|
||||
export function EmptyState({onPick}: {onPick?: (prompt: string) => void}) {
|
||||
const [greeting] = useState(GREETINGS[0]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center w-full max-w-xl mx-auto space-y-5 pt-2">
|
||||
{/* 1. Official Loyaly Heart Logo with Glow */}
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="relative mb-3 flex justify-center group cursor-pointer pt-1">
|
||||
<div className="absolute -inset-2 bg-gradient-to-r from-amber-500/20 to-purple-600/20 rounded-full blur-md opacity-75 group-hover:opacity-100 transition-opacity pointer-events-none" />
|
||||
<BrandMark size={52} priority />
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
key={greeting}
|
||||
initial={{opacity: 0, y: 6}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.25, ease: 'easeOut'}}
|
||||
className="flex flex-col items-center"
|
||||
>
|
||||
<Heading level={2} justify="center" className="text-xl sm:text-2xl font-bold tracking-tight">
|
||||
{greeting}
|
||||
</Heading>
|
||||
<Text type="supporting" justify="center" className="text-xs sm:text-sm mt-1 max-w-sm">
|
||||
Your proactive AI copilot for smarter retail decisions and instant analytics.
|
||||
</Text>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* 2. Live Telemetry Strip */}
|
||||
<div className="w-full flex items-center justify-between px-3.5 py-1.5 rounded-full bg-zinc-500/5 dark:bg-zinc-400/5 border border-border text-[11px] text-secondary">
|
||||
<div className="flex items-center gap-1.5 font-medium">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
|
||||
<span>Live Store Stream</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span>Footfall: <strong className="text-primary font-semibold">+18.4%</strong></span>
|
||||
<span>Sync: <strong className="text-emerald-500 font-semibold">99.8%</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. Proactive Active Insights */}
|
||||
<div className="w-full space-y-2.5 text-left">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-secondary flex items-center gap-1.5">
|
||||
<Icon icon={ICONS.insight} size="xsm" />
|
||||
Active Insights
|
||||
</span>
|
||||
<span className="text-[11px] text-zinc-400">Real-time</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-2.5">
|
||||
{ACTIVE_INSIGHTS.map((item) => {
|
||||
const isCool = item.tagColor === 'cool';
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="group relative p-3.5 rounded-xl border border-border bg-card hover:border-border-emphasized transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer"
|
||||
onClick={() => onPick?.(item.prompt)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={`w-7 h-7 rounded-lg flex items-center justify-center shrink-0 mt-0.5 ${
|
||||
isCool
|
||||
? 'bg-violet-500/10 text-violet-600 dark:text-violet-400 border border-violet-500/20'
|
||||
: 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20'
|
||||
}`}
|
||||
>
|
||||
<Icon icon={ICONS[item.icon] ?? ICONS.insight} size="xsm" color="inherit" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<span className="text-xs font-semibold text-primary truncate">
|
||||
{item.title}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[10px] px-1.5 py-0.2 rounded-full font-medium ${
|
||||
isCool
|
||||
? 'bg-violet-500/10 text-violet-600 dark:text-violet-300'
|
||||
: 'bg-amber-500/10 text-amber-600 dark:text-amber-300'
|
||||
}`}
|
||||
>
|
||||
{item.tag}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-secondary leading-relaxed line-clamp-2">
|
||||
{item.description}
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-1 text-[11px] font-medium text-primary group-hover:text-amber-500 dark:group-hover:text-amber-400 transition-colors">
|
||||
<span>{item.actionText}</span>
|
||||
<Icon icon={ICONS.arrowRight} size="xsm" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 4. Quick Suggestion Action Chips */}
|
||||
<div className="w-full text-left space-y-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-secondary px-1 flex items-center gap-1.5">
|
||||
<Icon icon={ICONS.chat} size="xsm" />
|
||||
Quick Queries
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{SUGGESTIONS.slice(0, 5).map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
className="px-3 py-1.5 text-xs rounded-full border border-border bg-card text-secondary hover:text-primary hover:bg-muted hover:border-border-emphasized transition-all duration-150 cursor-pointer shadow-xs active:scale-95"
|
||||
onClick={() => onPick?.(s.prompt)}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
80
src/features/loyaly-ai/components/HistoryDrawer.tsx
Normal file
80
src/features/loyaly-ai/components/HistoryDrawer.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {Item} from '@astryxdesign/core/Item';
|
||||
import {HStack, VStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Timestamp} from '@astryxdesign/core/Timestamp';
|
||||
import {useLoyalyAi} from '@/features/loyaly-ai/providers/LoyalyAiProvider';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {ICONS} from '@/shared/utils/icons';
|
||||
|
||||
/**
|
||||
* Past conversations, as an overlay INSIDE the panel.
|
||||
*
|
||||
* Not a second drawer off the side of the screen: the assistant already is a
|
||||
* drawer at most widths, and a drawer opening out of a drawer is how you end
|
||||
* up with two dismiss layers and a stack of Escape handlers that disagree.
|
||||
* Covering the conversation area instead keeps the header — with the same
|
||||
* History button, now toggled on — in place as the way back out.
|
||||
*
|
||||
* Absolutely positioned over the conversation rather than replacing it, so
|
||||
* the transcript is not unmounted: opening history and closing it again
|
||||
* returns to the same scroll position mid-conversation.
|
||||
*/
|
||||
export function HistoryDrawer() {
|
||||
const {history, openConversation, isHistoryOpen, setHistoryOpen} =
|
||||
useLoyalyAi();
|
||||
|
||||
if (!isHistoryOpen) return null;
|
||||
|
||||
return (
|
||||
<VStack
|
||||
gap={2}
|
||||
padding={3}
|
||||
width="100%"
|
||||
className="absolute inset-0 z-10 overflow-y-auto bg-surface"
|
||||
role="region"
|
||||
aria-label="Chat history"
|
||||
>
|
||||
<HStack hAlign="between" vAlign="center" paddingInline={1}>
|
||||
<Text size="sm" type="supporting">
|
||||
Recent chats
|
||||
</Text>
|
||||
{/* A text button rather than an IconButton: this closes a region, not
|
||||
the assistant, and an X here would be mistaken for the panel's own
|
||||
close in the header directly above it. */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
label="Done"
|
||||
onClick={() => setHistoryOpen(false)}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
{history.length === 0 ? (
|
||||
<EmptyPanel
|
||||
icon="chat"
|
||||
title="No conversations yet"
|
||||
description="Chats you start appear here for the rest of the session."
|
||||
/>
|
||||
) : (
|
||||
<VStack gap={0.5}>
|
||||
{history.map((c) => (
|
||||
<Item
|
||||
key={c.id}
|
||||
label={c.title}
|
||||
labelLines={1}
|
||||
description={<Timestamp value={c.updatedAt} />}
|
||||
density="balanced"
|
||||
className="rounded-lg cursor-pointer"
|
||||
startContent={<Icon icon={ICONS.chat} size="sm" />}
|
||||
onClick={() => openConversation(c.id)}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
30
src/features/loyaly-ai/components/LoyalyAiPanel.tsx
Normal file
30
src/features/loyaly-ai/components/LoyalyAiPanel.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {ChatHeader} from './ChatHeader';
|
||||
import {Conversation} from './Conversation';
|
||||
import {HistoryDrawer} from './HistoryDrawer';
|
||||
import {ResizeHandle} from './ResizeHandle';
|
||||
|
||||
/**
|
||||
* The assistant surface, identical inline and in the slide-over.
|
||||
*
|
||||
* Three children and no branching: a header, the conversation, and the history
|
||||
* overlay that covers the conversation when it is open. Everything it renders
|
||||
* reads from LoyalyAiProvider, so it is safe to unmount and remount when the
|
||||
* breakpoint changes presentation.
|
||||
*
|
||||
* `relative` on the frame is load-bearing — it is the positioning context
|
||||
* HistoryDrawer's `absolute inset-0` resolves against, which is what keeps the
|
||||
* overlay inside the panel instead of over the whole workspace.
|
||||
*/
|
||||
export function LoyalyAiPanel({onClose}: {onClose?: () => void}) {
|
||||
return (
|
||||
<VStack gap={0} height="100%" width="100%" className="relative">
|
||||
<ResizeHandle />
|
||||
<ChatHeader onClose={onClose} />
|
||||
<Conversation />
|
||||
<HistoryDrawer />
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
71
src/features/loyaly-ai/components/LoyalyAiSlideOver.tsx
Normal file
71
src/features/loyaly-ai/components/LoyalyAiSlideOver.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
import {MobileNav} from '@astryxdesign/core/MobileNav';
|
||||
import {useLoyalyAi} from '@/features/loyaly-ai/providers/LoyalyAiProvider';
|
||||
import {useBreakpoint} from '@/shared/hooks/useBreakpoint';
|
||||
import {LoyalyAiPanel} from './LoyalyAiPanel';
|
||||
|
||||
/**
|
||||
* Tablet and mobile presentation.
|
||||
*
|
||||
* MobileNav rather than Dialog: a Dialog is content-sized (height:
|
||||
* fit-content, maxHeight 480px) and ignores top/bottom insets, so it renders
|
||||
* as a floating card rather than a full-height surface. MobileNav is the
|
||||
* edge-anchored primitive this actually needs, and its native <dialog>
|
||||
* brings the focus trap, Escape and backdrop with it.
|
||||
*
|
||||
* It renders the SAME <LoyalyAiPanel /> as the inline path — crossing the
|
||||
* breakpoint changes where the assistant lives, never what it contains or
|
||||
* remembers, because all state sits in LoyalyAiProvider above the route tree.
|
||||
*/
|
||||
const SHEET = [
|
||||
// The composer must clear the home indicator on a notched phone. env() is
|
||||
// not expressible as a token and pb-safe is not in core Tailwind, so the
|
||||
// inset is an arbitrary property on the one surface that docks to the
|
||||
// bottom edge.
|
||||
'[&>div]:pb-[env(safe-area-inset-bottom)]',
|
||||
/*
|
||||
* Hide MobileNav's own header row.
|
||||
*
|
||||
* MobileNav always renders a close button after its `header` slot. With
|
||||
* ChatHeader inside, the sheet showed TWO X buttons stacked — one in
|
||||
* MobileNav's empty header row, one in ours — and two rows of chrome above
|
||||
* the conversation. ChatHeader is the assistant's header at every width, so
|
||||
* the drawer's own row is removed rather than duplicated.
|
||||
*/
|
||||
'[&>div>*:first-child]:hidden',
|
||||
].join(' ');
|
||||
|
||||
/**
|
||||
* Full-bleed on a phone.
|
||||
*
|
||||
* MobileNav's `width` is a MAX-width and it defaults to 320px, so passing
|
||||
* `undefined` on mobile — as this did before — left a 320px sheet with 70px of
|
||||
* dashboard showing beside it. A conversation is the whole task on a phone;
|
||||
* `max-w-none` lets the drawer's own `width: 100vw` fill the screen.
|
||||
*/
|
||||
const FULL_BLEED = '[&>div]:max-w-none';
|
||||
|
||||
export function LoyalyAiSlideOver() {
|
||||
const bp = useBreakpoint();
|
||||
const {isSlideOverOpen, setSlideOverOpen} = useLoyalyAi();
|
||||
|
||||
return (
|
||||
<MobileNav
|
||||
// Explicit, because MobileNav otherwise takes its id from AppShell's
|
||||
// mobile context — which every MobileNav in the tree shares, so this and
|
||||
// the navigation drawer would both render the same id, and the menu
|
||||
// button's aria-controls would resolve to whichever the DOM reached
|
||||
// first.
|
||||
id="loyaly-ai-slide-over"
|
||||
isOpen={isSlideOverOpen}
|
||||
onOpenChange={setSlideOverOpen}
|
||||
side="end"
|
||||
width={420}
|
||||
label="Loyaly AI"
|
||||
className={bp === 'mobile' ? `${SHEET} ${FULL_BLEED}` : SHEET}
|
||||
>
|
||||
<LoyalyAiPanel onClose={() => setSlideOverOpen(false)} />
|
||||
</MobileNav>
|
||||
);
|
||||
}
|
||||
31
src/features/loyaly-ai/components/LoyalyAiToggle.tsx
Normal file
31
src/features/loyaly-ai/components/LoyalyAiToggle.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {IconButton} from '@astryxdesign/core/IconButton';
|
||||
import {useLoyalyAi} from '@/features/loyaly-ai/providers/LoyalyAiProvider';
|
||||
import {isPanelInline, useBreakpoint} from '@/shared/hooks/useBreakpoint';
|
||||
import {ICONS} from '@/shared/utils/icons';
|
||||
|
||||
/**
|
||||
* One control, two behaviours: above the laptop breakpoint it collapses the
|
||||
* inline panel to give the workspace full width; below it, it opens the
|
||||
* slide-over. Both read and write the same provider, so the assistant's
|
||||
* contents never notice which one is in play.
|
||||
*/
|
||||
export function LoyalyAiToggle() {
|
||||
const bp = useBreakpoint();
|
||||
const {isOpen, toggle, isSlideOverOpen, setSlideOverOpen} = useLoyalyAi();
|
||||
const inline = isPanelInline(bp);
|
||||
|
||||
const isShown = inline ? isOpen : isSlideOverOpen;
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
icon={<Icon icon={isShown ? ICONS.panelClose : ICONS.panelOpen} />}
|
||||
label={isShown ? 'Hide Loyaly AI' : 'Show Loyaly AI'}
|
||||
tooltip={isShown ? 'Hide Loyaly AI' : 'Show Loyaly AI'}
|
||||
variant="ghost"
|
||||
onClick={() => (inline ? toggle() : setSlideOverOpen(!isSlideOverOpen))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
103
src/features/loyaly-ai/components/MessageBubble.tsx
Normal file
103
src/features/loyaly-ai/components/MessageBubble.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
ChatMessage as ChatMessageRow,
|
||||
ChatMessageBubble,
|
||||
} from '@astryxdesign/core/Chat';
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {MessageContent} from './MessageContent';
|
||||
import {TypingIndicator} from './TypingIndicator';
|
||||
import {BrandMark} from '@/shared/components/brand/BrandLogo';
|
||||
import type {ChatMessage} from '@/features/loyaly-ai/types/chat';
|
||||
|
||||
/**
|
||||
* One turn.
|
||||
*
|
||||
* User turns are a filled bubble on the right; assistant turns are `ghost` —
|
||||
* transparent, full-width, no container. That asymmetry is the single biggest
|
||||
* reason ChatGPT and Claude read as conversation rather than as messaging: a
|
||||
* long analytical answer inside a chat bubble reads as a quote, while the same
|
||||
* text set flush against the panel reads as prose written for you.
|
||||
*
|
||||
* It also solves a practical problem. Assistant answers contain tables and
|
||||
* code blocks, and a bubble with a max-width would force both to scroll
|
||||
* horizontally inside a 380px panel.
|
||||
*
|
||||
* Timestamps are deliberately absent. They are metadata about a chat log, and
|
||||
* this is a working session — ChatGPT, Claude and Gemini all omit them for the
|
||||
* same reason.
|
||||
*/
|
||||
export function MessageBubble({message}: {message: ChatMessage}) {
|
||||
const isAssistant = message.role === 'assistant';
|
||||
// Nothing has arrived yet: the dots stand in for the answer, and are
|
||||
// replaced by it rather than joined to it.
|
||||
const isThinking = isAssistant && message.isStreaming && message.parts.length === 0;
|
||||
|
||||
return (
|
||||
<ChatMessageRow
|
||||
sender={message.role}
|
||||
density="compact"
|
||||
avatar={
|
||||
isAssistant ? (
|
||||
<div className="w-6 h-6 rounded-full flex items-center justify-center bg-amber-500/10 border border-amber-500/25 shrink-0 mt-0.5 shadow-xs overflow-hidden">
|
||||
<BrandMark size={16} />
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<ChatMessageBubble
|
||||
variant={isAssistant ? 'ghost' : 'filled'}
|
||||
/**
|
||||
* The assistant bubble must have a DEFINITE width. This is the fix for
|
||||
* report titles rendering one character per line.
|
||||
*
|
||||
* ChatMessage lays its row out with `display:flex; align-items:
|
||||
* flex-start`, so the bubble is a flex item on the main axis and sizes
|
||||
* shrink-to-fit — its width is asked of its contents. ResponseRenderer
|
||||
* then sets `container-type: inline-size` to drive the report's
|
||||
* container queries, and inline-axis size containment means the report
|
||||
* contributes ZERO to that intrinsic measurement. The bubble asked how
|
||||
* wide its content wanted to be, containment answered "nothing", and
|
||||
* the bubble collapsed to its min-content width.
|
||||
*
|
||||
* Astryx's own `word-break: break-word` on the bubble is what made the
|
||||
* collapse land on a single GLYPH rather than a single word: that
|
||||
* value is defined as `word-break: normal` plus `overflow-wrap:
|
||||
* anywhere`, and `anywhere` is the one wrapping mode that lets a break
|
||||
* opportunity count against min-content size. Min-content therefore
|
||||
* became the widest character — "S / t / o / r / e".
|
||||
*
|
||||
* Stating the width breaks the cycle: the bubble no longer measures
|
||||
* its contents, so containment has nothing to poison.
|
||||
*
|
||||
* `max-w-full` is deliberate too. `styles.content` applies
|
||||
* `max-width: max(80%, 280px)` to EVERY bubble including `ghost`,
|
||||
* so an assistant report in the 440px rail was being capped around
|
||||
* 302px — despite this component's contract that ghost turns are
|
||||
* full-width. Tailwind's utilities layer is declared after
|
||||
* `astryx-base` in globals.css, so these win over StyleX's
|
||||
* `:not(#\#)` specificity hack by cascade layer rather than by
|
||||
* out-specifying it.
|
||||
*/
|
||||
className={isAssistant ? 'w-full max-w-full min-w-0' : undefined}
|
||||
>
|
||||
{isThinking ? (
|
||||
<TypingIndicator />
|
||||
) : (
|
||||
<VStack gap={3}>
|
||||
{message.parts.map((part, index) => (
|
||||
<MessageContent
|
||||
// Index is a stable key here: parts only ever grow at the end,
|
||||
// and the streaming text part is replaced in place rather than
|
||||
// reordered — see applyStreamedPart.
|
||||
key={`${part.type}-${index}`}
|
||||
part={part}
|
||||
isStreaming={message.isStreaming}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</ChatMessageBubble>
|
||||
</ChatMessageRow>
|
||||
);
|
||||
}
|
||||
73
src/features/loyaly-ai/components/MessageContent.tsx
Normal file
73
src/features/loyaly-ai/components/MessageContent.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
'use client';
|
||||
|
||||
import {Markdown} from '@astryxdesign/core/Markdown';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {ResponseRenderer} from './response/ResponseRenderer';
|
||||
import {BriefFlow} from './response/BriefFlow';
|
||||
import {parseBrief} from '@/features/loyaly-ai/utils/parseBrief';
|
||||
import type {MessagePart} from '@/features/loyaly-ai/types/chat';
|
||||
|
||||
/**
|
||||
* The renderer registry: one message part in, one block out.
|
||||
*
|
||||
* THIS is the extension point the brief asks for. Charts, interactive tables,
|
||||
* business dashboards, file previews and image responses each become a case
|
||||
* below plus a variant in types/chat — and nothing else in the module moves,
|
||||
* because the transport already yields parts and the message list already maps
|
||||
* over them.
|
||||
*
|
||||
* The unimplemented cases are not silently dropped. A part the UI cannot draw
|
||||
* yet says so, which is the difference between "this version cannot render a
|
||||
* chart" and "the answer arrived empty".
|
||||
*/
|
||||
export function MessageContent({
|
||||
part,
|
||||
isStreaming,
|
||||
}: {
|
||||
part: MessagePart;
|
||||
isStreaming?: boolean;
|
||||
}) {
|
||||
switch (part.type) {
|
||||
case 'report':
|
||||
return <ResponseRenderer report={part} isStreaming={isStreaming} />;
|
||||
|
||||
case 'text': {
|
||||
// A labelled answer ("Footfall: … / Sales: … / The one thing worth
|
||||
// doing today: …") is drawn as a flow of cards. The part itself stays
|
||||
// prose, so history, copy and the platform's context are unchanged;
|
||||
// anything that does not parse cleanly falls through to markdown.
|
||||
const brief = isStreaming ? null : parseBrief(part.text);
|
||||
if (brief) return <BriefFlow brief={brief} />;
|
||||
return (
|
||||
// `isStreaming` lets Markdown tolerate half-finished syntax — a table
|
||||
// that is three rows in, a code fence with no closing backticks — and
|
||||
// draws the caret. Without it, every chunk boundary would flash raw
|
||||
// markup on screen.
|
||||
<Markdown
|
||||
density="compact"
|
||||
isStreaming={isStreaming}
|
||||
// The panel is 380px wide and lives inside a page that already has
|
||||
// an h1. Starting at h3 keeps an assistant heading from
|
||||
// out-ranking the page it is advising on.
|
||||
headingLevelStart={3}
|
||||
>
|
||||
{part.text}
|
||||
</Markdown>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Not yet rendered. Each is a component away, not a refactor away. ──
|
||||
case 'chart':
|
||||
case 'table':
|
||||
case 'file':
|
||||
case 'image':
|
||||
return (
|
||||
<VStack gap={1}>
|
||||
<Text size="sm" type="supporting">
|
||||
{`This answer includes a ${part.type} that this version cannot display yet.`}
|
||||
</Text>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
}
|
||||
44
src/features/loyaly-ai/components/MessageList.tsx
Normal file
44
src/features/loyaly-ai/components/MessageList.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import {ChatMessageList} from '@astryxdesign/core/Chat';
|
||||
import {MessageBubble} from './MessageBubble';
|
||||
import type {ChatMessage} from '@/features/loyaly-ai/types/chat';
|
||||
|
||||
/**
|
||||
* The transcript.
|
||||
*
|
||||
* ── On vertical anchoring ────────────────────────────────────────────────
|
||||
* ChatMessageList renders a `flex: 1 1 0` spacer before its first message, so
|
||||
* a short conversation sits at the BOTTOM of the scroll area and grows upward
|
||||
* — Slack's behaviour rather than ChatGPT's. Measured: one short exchange sat
|
||||
* 561px down the panel with the whole upper half blank.
|
||||
*
|
||||
* TOP_ANCHORED collapses that spacer. It is safe here because Conversation
|
||||
* owns the scroll container: an earlier attempt with Astryx's ChatLayout
|
||||
* scrolled a short conversation clean out of view, because that layout's
|
||||
* auto-scroll assumed the spacer was filling the box.
|
||||
*
|
||||
* ChatMessageList is Astryx's own list primitive and it carries the
|
||||
* behaviour worth not reimplementing: it is an aria-live region, so a screen
|
||||
* reader announces an answer as it arrives, and `isStreaming` coordinates with
|
||||
* ChatLayout's auto-scroll so the view follows the text without fighting a
|
||||
* user who has scrolled up to re-read something.
|
||||
*/
|
||||
/** See the anchoring note above. */
|
||||
const TOP_ANCHORED = '[&>div>div:first-child]:hidden';
|
||||
|
||||
export function MessageList({
|
||||
messages,
|
||||
isStreaming,
|
||||
}: {
|
||||
messages: ChatMessage[];
|
||||
isStreaming: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ChatMessageList isStreaming={isStreaming} className={TOP_ANCHORED}>
|
||||
{messages.map((message) => (
|
||||
<MessageBubble key={message.id} message={message} />
|
||||
))}
|
||||
</ChatMessageList>
|
||||
);
|
||||
}
|
||||
60
src/features/loyaly-ai/components/ResizeHandle.tsx
Normal file
60
src/features/loyaly-ai/components/ResizeHandle.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
'use client';
|
||||
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
import {useLoyalyAi} from '@/features/loyaly-ai/providers/LoyalyAiProvider';
|
||||
|
||||
export function ResizeHandle() {
|
||||
const {panelWidth, setPanelWidth, panelMode} = useLoyalyAi();
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const newWidth = window.innerWidth - e.clientX;
|
||||
setPanelWidth(newWidth);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [isDragging, setPanelWidth]);
|
||||
|
||||
// Hidden when in fullscreen mode
|
||||
if (panelMode === 'fullscreen') return null;
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
setPanelWidth(440);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onMouseDown={handleMouseDown}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
title="Drag to resize panel (Double click to reset)"
|
||||
className={`absolute left-0 top-0 bottom-0 w-2 -ml-1 cursor-col-resize z-20 group flex justify-center ${
|
||||
isDragging ? 'select-none' : ''
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-0.5 h-full transition-colors duration-150 ${
|
||||
isDragging
|
||||
? 'bg-primary shadow-sm'
|
||||
: 'bg-transparent group-hover:bg-border-strong'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
src/features/loyaly-ai/components/SuggestionChips.tsx
Normal file
42
src/features/loyaly-ai/components/SuggestionChips.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import {HStack} from '@astryxdesign/core/Layout';
|
||||
import {SUGGESTIONS} from '@/features/loyaly-ai/utils/suggestions';
|
||||
|
||||
/**
|
||||
* The opening moves, as a wrapped row of chips.
|
||||
*
|
||||
* Chips rather than the stacked button list this replaces: six full-width
|
||||
* secondary buttons read as a menu of commands, which is what made the old
|
||||
* panel feel like a dashboard. A wrapped row of quiet pills reads as
|
||||
* suggestions — the same distinction ChatGPT, Claude and Gemini all make.
|
||||
*
|
||||
* Hand-built rather than Astryx's Token: Token is a data chip with a
|
||||
* dismiss/selected vocabulary that does not apply here, and Button carries a
|
||||
* control's weight. What is wanted is a quiet, fully-rounded, tappable label —
|
||||
* three token-backed utilities, and no component contract to fight.
|
||||
*/
|
||||
const CHIP = [
|
||||
'rounded-full border border-border bg-card',
|
||||
'px-3.5 py-2 text-sm text-secondary text-left',
|
||||
'transition-colors duration-150 cursor-pointer',
|
||||
'hover:bg-muted hover:text-primary hover:border-border-strong',
|
||||
'focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2',
|
||||
].join(' ');
|
||||
|
||||
export function SuggestionChips({onPick}: {onPick: (prompt: string) => void}) {
|
||||
return (
|
||||
<HStack gap={2} wrap="wrap" hAlign="center">
|
||||
{SUGGESTIONS.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
className={CHIP}
|
||||
onClick={() => onPick(s.prompt)}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
33
src/features/loyaly-ai/components/TypingIndicator.tsx
Normal file
33
src/features/loyaly-ai/components/TypingIndicator.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import {HStack} from '@astryxdesign/core/Layout';
|
||||
import {VisuallyHidden} from '@astryxdesign/core/VisuallyHidden';
|
||||
|
||||
/**
|
||||
* Three dots, while the first token is still on its way.
|
||||
*
|
||||
* Shown only BEFORE any text arrives. Once the answer starts streaming, the
|
||||
* text itself is the progress indicator, and keeping the dots underneath it
|
||||
* would say "still thinking" about something already being read.
|
||||
*
|
||||
* The stagger is an arbitrary-property utility rather than
|
||||
* `style={{animationDelay}}` — the project bans inline style objects, and an
|
||||
* animation offset is not a design token, so there is no token-backed utility
|
||||
* to reach for. One shared keyframe with three offsets keeps the dots in phase
|
||||
* with each other regardless of when the component mounts.
|
||||
*
|
||||
* Reduced motion is handled globally in globals.css; the dots then rest as
|
||||
* three static marks, which is a fine still frame.
|
||||
*/
|
||||
const DOT = 'size-1.5 rounded-full bg-secondary animate-bounce';
|
||||
|
||||
export function TypingIndicator() {
|
||||
return (
|
||||
<HStack gap={1} vAlign="center">
|
||||
<span aria-hidden="true" className={`${DOT} [animation-delay:-300ms]`} />
|
||||
<span aria-hidden="true" className={`${DOT} [animation-delay:-150ms]`} />
|
||||
<span aria-hidden="true" className={DOT} />
|
||||
<VisuallyHidden>Loyaly AI is thinking</VisuallyHidden>
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
'use client';
|
||||
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {useToast} from '@astryxdesign/core/Toast';
|
||||
import {useRouter} from 'next/navigation';
|
||||
import {ICONS} from '@/shared/utils/icons';
|
||||
import type {ReportAction} from '@/features/loyaly-ai/types/chat';
|
||||
|
||||
import {exportData} from '@/shared/utils/export/exportManager';
|
||||
|
||||
export function ActionToolbarBlock({actions}: {actions: ReportAction[]}) {
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
|
||||
if (!actions || actions.length === 0) return null;
|
||||
|
||||
const handleActionClick = async (act: ReportAction) => {
|
||||
switch (act.actionType) {
|
||||
case 'open_dashboard':
|
||||
router.push('/dashboard');
|
||||
break;
|
||||
case 'compare_stores':
|
||||
router.push('/stores');
|
||||
break;
|
||||
case 'view_rewards':
|
||||
router.push('/lyts');
|
||||
break;
|
||||
case 'export_csv':
|
||||
try {
|
||||
await exportData({
|
||||
filename: 'Loyaly_AI_Report',
|
||||
title: 'Loyaly AI Business Report',
|
||||
subtitle: 'Exported from Loyaly AI Assistant',
|
||||
columns: [
|
||||
{key: 'metric', header: 'Metric Name'},
|
||||
{key: 'val', header: 'Value'},
|
||||
],
|
||||
data: [
|
||||
{metric: 'Daily Revenue', val: '₹3,40,000'},
|
||||
{metric: 'Visitors', val: '1,284'},
|
||||
{metric: 'Purchases', val: '276'},
|
||||
],
|
||||
format: 'csv',
|
||||
});
|
||||
toast({body: '✓ CSV downloaded successfully'});
|
||||
} catch {
|
||||
toast({body: '❌ Download failed'});
|
||||
}
|
||||
break;
|
||||
case 'download_report':
|
||||
try {
|
||||
await exportData({
|
||||
filename: 'Loyaly_AI_Report',
|
||||
title: 'Loyaly AI Business Report',
|
||||
subtitle: 'Exported from Loyaly AI Assistant',
|
||||
columns: [
|
||||
{key: 'metric', header: 'Metric Name'},
|
||||
{key: 'val', header: 'Value'},
|
||||
],
|
||||
data: [
|
||||
{metric: 'Daily Revenue', val: '₹3,40,000'},
|
||||
{metric: 'Visitors', val: '1,284'},
|
||||
{metric: 'Purchases', val: '276'},
|
||||
],
|
||||
format: 'pdf',
|
||||
});
|
||||
toast({body: '✓ PDF downloaded successfully'});
|
||||
} catch {
|
||||
toast({body: '❌ Download failed'});
|
||||
}
|
||||
break;
|
||||
default:
|
||||
toast({body: `Executed: ${act.label}`});
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pt-2 flex flex-wrap items-center gap-2 w-full">
|
||||
{actions.map((act) => (
|
||||
<Button
|
||||
key={act.id || act.label}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
label={act.label}
|
||||
icon={
|
||||
act.iconName ? (
|
||||
<Icon icon={(ICONS as any)[act.iconName] || 'externalLink'} size="sm" />
|
||||
) : undefined
|
||||
}
|
||||
onClick={() => handleActionClick(act)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
207
src/features/loyaly-ai/components/response/BriefFlow.tsx
Normal file
207
src/features/loyaly-ai/components/response/BriefFlow.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
'use client';
|
||||
|
||||
import {Fragment} from 'react';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Card} from '@astryxdesign/core/Card';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {HStack, VStack} from '@astryxdesign/core/Layout';
|
||||
import {StatusDot} from '@astryxdesign/core/StatusDot';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Token} from '@astryxdesign/core/Token';
|
||||
import {ICONS} from '@/shared/utils/icons';
|
||||
import {useLoyalyAi} from '@/features/loyaly-ai/providers/LoyalyAiProvider';
|
||||
import type {
|
||||
Brief,
|
||||
BriefActionSection,
|
||||
BriefMetricSection,
|
||||
BriefStat,
|
||||
} from '@/features/loyaly-ai/utils/parseBrief';
|
||||
|
||||
/**
|
||||
* A brief drawn as a top-to-bottom flow: one card per section, joined by
|
||||
* arrows, ending on the single action. The reading order is the answer's own
|
||||
* — what happened, what it led to, what to do — so the arrows say "therefore",
|
||||
* not "and also".
|
||||
*
|
||||
* Inside a section of three or more measures (visitors → purchases → revenue)
|
||||
* the measures are themselves a flow, and the step where it stalls — a zero
|
||||
* after a non-zero — is marked, because that is usually the finding.
|
||||
*/
|
||||
export function BriefFlow({brief}: {brief: Brief}) {
|
||||
const {send} = useLoyalyAi();
|
||||
|
||||
return (
|
||||
<VStack gap={2} width="100%">
|
||||
{brief.intro ? (
|
||||
<Text size="sm" color="secondary">
|
||||
{brief.intro}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{brief.sections.map((section, i) => (
|
||||
<Fragment key={`${section.title}-${i}`}>
|
||||
{i > 0 ? <FlowConnector /> : null}
|
||||
{section.kind === 'metric' ? (
|
||||
<MetricCard section={section} />
|
||||
) : (
|
||||
<ActionCard
|
||||
section={section}
|
||||
followUp={
|
||||
i === brief.sections.length - 1 ? brief.followUp : undefined
|
||||
}
|
||||
onAccept={() => send('Yes, go ahead.')}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
{brief.checked.length > 0 ? (
|
||||
<HStack gap={1} vAlign="center" className="flex-wrap pt-1">
|
||||
<Text size="xsm" color="secondary">
|
||||
Checked
|
||||
</Text>
|
||||
{brief.checked.map((tool) => (
|
||||
<Token key={tool} label={tool} />
|
||||
))}
|
||||
</HStack>
|
||||
) : null}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function FlowConnector() {
|
||||
return (
|
||||
<HStack hAlign="center" aria-hidden>
|
||||
<Icon icon="arrowDown" size="sm" color="disabled" />
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
function iconFor(title: string) {
|
||||
const t = title.toLowerCase();
|
||||
if (/foot|visit|people|traffic/.test(t)) return ICONS.visitors;
|
||||
if (/sale|revenue|purchase|order/.test(t)) return ICONS.purchases;
|
||||
if (/reward|lyt/.test(t)) return ICONS.lyts;
|
||||
if (/staff|team/.test(t)) return ICONS.staff;
|
||||
if (/store|shop/.test(t)) return ICONS.stores;
|
||||
return ICONS.analytics;
|
||||
}
|
||||
|
||||
function SectionHeader({title, icon}: {title: string; icon: typeof ICONS.ai}) {
|
||||
return (
|
||||
<HStack gap={2} vAlign="center">
|
||||
<Icon icon={icon} size="sm" color="secondary" />
|
||||
<Text size="xsm" weight="semibold" color="secondary" className="uppercase tracking-wide">
|
||||
{title}
|
||||
</Text>
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({section}: {section: BriefMetricSection}) {
|
||||
const isFlow = section.stats.length >= 3;
|
||||
|
||||
return (
|
||||
<Card padding={4} elevation="low">
|
||||
<VStack gap={3}>
|
||||
<SectionHeader title={section.title} icon={iconFor(section.title)} />
|
||||
|
||||
{section.stats.length > 0 ? (
|
||||
<HStack gap={2} vAlign="center" width="100%">
|
||||
{section.stats.map((stat, i) => (
|
||||
<Fragment key={`${stat.label}-${i}`}>
|
||||
{i > 0 && isFlow ? (
|
||||
<Icon icon={ICONS.arrowRight} size="sm" color="disabled" aria-hidden />
|
||||
) : null}
|
||||
<StatStep stat={stat} />
|
||||
</Fragment>
|
||||
))}
|
||||
</HStack>
|
||||
) : null}
|
||||
|
||||
{section.detail ? (
|
||||
<Text size="sm" color="primary">
|
||||
{section.detail}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{section.caveats.map((caveat) => (
|
||||
<HStack key={caveat} gap={2} vAlign="start">
|
||||
<Icon icon="warning" size="sm" color="warning" className="shrink-0 mt-0.5" />
|
||||
<Text size="sm" color="secondary">
|
||||
{caveat}
|
||||
</Text>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function StatStep({stat}: {stat: BriefStat}) {
|
||||
return (
|
||||
<Card variant="muted" padding={3} className="flex-1 min-w-0">
|
||||
<VStack gap={0.5}>
|
||||
<HStack gap={1.5} vAlign="center">
|
||||
<Text size="xl" weight="bold" color="primary" className="tabular-nums">
|
||||
{stat.value}
|
||||
</Text>
|
||||
{stat.isStall ? (
|
||||
<StatusDot
|
||||
variant="error"
|
||||
label="The flow stops here"
|
||||
tooltip="The flow stops here"
|
||||
/>
|
||||
) : null}
|
||||
</HStack>
|
||||
<Text size="xsm" color="secondary" className="truncate">
|
||||
{stat.label}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionCard({
|
||||
section,
|
||||
followUp,
|
||||
onAccept,
|
||||
}: {
|
||||
section: BriefActionSection;
|
||||
followUp?: string;
|
||||
onAccept: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Card padding={4} elevation="med">
|
||||
<VStack gap={3}>
|
||||
<SectionHeader title={section.title} icon={ICONS.insight} />
|
||||
|
||||
<Text size="base" weight="semibold" color="primary">
|
||||
{section.action}
|
||||
</Text>
|
||||
|
||||
{section.reason ? (
|
||||
<Text size="sm" color="secondary">
|
||||
{section.reason}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{followUp ? (
|
||||
<VStack gap={2}>
|
||||
<Text size="sm" color="primary">
|
||||
{followUp}
|
||||
</Text>
|
||||
<HStack>
|
||||
<Button
|
||||
label="Yes, go ahead"
|
||||
size="sm"
|
||||
icon={<Icon icon="check" size="sm" />}
|
||||
onClick={onAccept}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
) : null}
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
56
src/features/loyaly-ai/components/response/ChartBlock.tsx
Normal file
56
src/features/loyaly-ai/components/response/ChartBlock.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import {Heading} from '@astryxdesign/core/Text';
|
||||
import {AreaChartView} from '@/shared/components/charts/AreaChartView';
|
||||
import {BarChartView} from '@/shared/components/charts/BarChartView';
|
||||
import {LineChartView} from '@/shared/components/charts/LineChartView';
|
||||
import type {ChartPartSpec} from '@/features/loyaly-ai/types/chat';
|
||||
|
||||
export function ChartBlock({charts}: {charts: ChartPartSpec[]}) {
|
||||
if (!charts || charts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 w-full">
|
||||
{charts.map((c, idx) => (
|
||||
<div
|
||||
key={c.title || idx}
|
||||
className="rounded-xl border border-border bg-card p-4 shadow-sm space-y-3 overflow-hidden"
|
||||
>
|
||||
<div>
|
||||
<Heading level={4} className="text-base font-bold text-primary">
|
||||
{c.title}
|
||||
</Heading>
|
||||
{c.subtitle ? (
|
||||
<p className="text-sm font-medium text-secondary mt-0.5">{c.subtitle}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="h-56 @sm/report:h-64 w-full max-w-full pt-2">
|
||||
{c.chartType === 'area' ? (
|
||||
<AreaChartView
|
||||
data={c.data}
|
||||
xKey={c.xKey}
|
||||
series={c.series}
|
||||
height="100%"
|
||||
/>
|
||||
) : c.chartType === 'bar' ? (
|
||||
<BarChartView
|
||||
data={c.data}
|
||||
xKey={c.xKey}
|
||||
series={c.series}
|
||||
height="100%"
|
||||
/>
|
||||
) : (
|
||||
<LineChartView
|
||||
data={c.data}
|
||||
xKey={c.xKey}
|
||||
series={c.series}
|
||||
height="100%"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
'use client';
|
||||
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
import type {ReportInsight} from '@/features/loyaly-ai/types/chat';
|
||||
|
||||
export function InsightCardsBlock({insights}: {insights: ReportInsight[]}) {
|
||||
if (!insights || insights.length === 0) return null;
|
||||
|
||||
const getInsightMeta = (type: ReportInsight['type']) => {
|
||||
switch (type) {
|
||||
case 'opportunity':
|
||||
return {
|
||||
icon: '💡',
|
||||
badgeVariant: 'info' as const,
|
||||
label: 'Opportunity',
|
||||
borderClass: 'border-blue-500/30 bg-blue-500/[0.03]',
|
||||
};
|
||||
case 'risk':
|
||||
return {
|
||||
icon: '⚠️',
|
||||
badgeVariant: 'warning' as const,
|
||||
label: 'Risk Alert',
|
||||
borderClass: 'border-amber-500/30 bg-amber-500/[0.03]',
|
||||
};
|
||||
case 'growth':
|
||||
return {
|
||||
icon: '📈',
|
||||
badgeVariant: 'success' as const,
|
||||
label: 'Growth Factor',
|
||||
borderClass: 'border-emerald-500/30 bg-emerald-500/[0.03]',
|
||||
};
|
||||
case 'best_performer':
|
||||
return {
|
||||
icon: '🏆',
|
||||
badgeVariant: 'success' as const,
|
||||
label: 'Top Performer',
|
||||
borderClass: 'border-purple-500/30 bg-purple-500/[0.03]',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3 w-full">
|
||||
{insights.map((insight) => {
|
||||
const meta = getInsightMeta(insight.type);
|
||||
return (
|
||||
<div
|
||||
key={insight.id || insight.title}
|
||||
className={`rounded-xl border p-4 shadow-sm space-y-2 transition-colors ${meta.borderClass}`}
|
||||
>
|
||||
{/*
|
||||
`flex-wrap` so the label and the badges drop to their own lines
|
||||
rather than compressing each other. Without it, a narrow panel
|
||||
set the title and "87% confidence" against a Badge that will not
|
||||
shrink, and the title absorbed the entire deficit.
|
||||
*/}
|
||||
<div className="flex flex-wrap items-start justify-between gap-x-2 gap-y-1.5">
|
||||
<div className="flex items-start gap-2 min-w-0">
|
||||
<span className="text-lg shrink-0">{meta.icon}</span>
|
||||
<span className="text-base font-bold text-primary whitespace-normal break-words min-w-0">
|
||||
{insight.title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{insight.confidence ? (
|
||||
<span className="text-sm font-semibold text-secondary font-mono tabular-nums whitespace-nowrap">
|
||||
{insight.confidence}% confidence
|
||||
</span>
|
||||
) : null}
|
||||
<Badge variant={meta.badgeVariant} label={meta.label} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm font-medium text-primary leading-relaxed whitespace-normal break-words">
|
||||
{insight.explanation}
|
||||
</p>
|
||||
|
||||
{insight.recommendedAction ? (
|
||||
<div className="pt-2 flex flex-wrap items-baseline gap-x-2 gap-y-1 text-sm font-semibold">
|
||||
<span className="text-amber-300 font-bold shrink-0">Action:</span>
|
||||
<span className="text-primary whitespace-normal break-words min-w-0">
|
||||
{insight.recommendedAction}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
126
src/features/loyaly-ai/components/response/KpiGridBlock.tsx
Normal file
126
src/features/loyaly-ai/components/response/KpiGridBlock.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
'use client';
|
||||
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import type {ReportKpi} from '@/features/loyaly-ai/types/chat';
|
||||
|
||||
function MiniSparkline({data}: {data: number[]}) {
|
||||
if (!data || data.length < 2) return null;
|
||||
const min = Math.min(...data);
|
||||
const max = Math.max(...data);
|
||||
const range = max - min || 1;
|
||||
const width = 80;
|
||||
const height = 24;
|
||||
|
||||
const points = data
|
||||
.map((val, idx) => {
|
||||
const x = (idx / (data.length - 1)) * width;
|
||||
const y = height - ((val - min) / range) * (height - 4) - 2;
|
||||
return `${x},${y}`;
|
||||
})
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<svg width={width} height={height} className="overflow-visible">
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
points={points}
|
||||
className="text-primary/70"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function KpiGridBlock({kpis}: {kpis: ReportKpi[]}) {
|
||||
if (!kpis || kpis.length === 0) return null;
|
||||
|
||||
return (
|
||||
/**
|
||||
* Intrinsic sizing, not a column count.
|
||||
*
|
||||
* This was `grid-cols-2 sm:grid-cols-3`, and `sm:` is a VIEWPORT query —
|
||||
* always true on desktop — so three columns were forced into a 440px
|
||||
* panel and each card landed at ~117px. The grid now states the only
|
||||
* thing that is actually true of a KPI card (it needs ~180px to be
|
||||
* legible) and lets the browser fit as many as the panel allows: two in
|
||||
* the default rail, five or six when it is dragged wide. Cards reflow to
|
||||
* the next row instead of shrinking past readability.
|
||||
*
|
||||
* `min(180px, 100%)` rather than a bare `180px` is what keeps this from
|
||||
* trading one overflow for another: at a container narrower than the
|
||||
* track minimum — the mobile slide-over, or a panel dragged to its 420px
|
||||
* floor with deep padding — a rigid 180px track would push the grid wider
|
||||
* than its parent and reintroduce horizontal scrolling. The `min()` lets
|
||||
* the track collapse to the container in exactly that case and behave as
|
||||
* a fixed minimum everywhere else.
|
||||
*/
|
||||
<div className="grid w-full max-w-full gap-3 grid-cols-[repeat(auto-fit,minmax(min(180px,100%),1fr))]">
|
||||
{kpis.map((kpi) => {
|
||||
const isUp = kpi.trendDirection === 'up' || (kpi.trend && kpi.trend.startsWith('+'));
|
||||
const isDown = kpi.trendDirection === 'down' || (kpi.trend && (kpi.trend.startsWith('-') || kpi.trend.startsWith('−')));
|
||||
|
||||
return (
|
||||
<div
|
||||
key={kpi.id || kpi.title}
|
||||
// `min-w-0` so the card may size to its grid track. A grid item
|
||||
// defaults to `min-width: auto` — its min-CONTENT width — which
|
||||
// means one long unbroken value silently widens the whole track
|
||||
// and pushes the row past the panel.
|
||||
className="rounded-xl border border-border bg-card p-3.5 flex flex-col justify-between gap-1 min-w-0 shadow-sm hover:border-border-strong transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
{/*
|
||||
`truncate` used to hide the consequence of the squeeze rather
|
||||
than fix it — a card too narrow for "Indiranagar footfall"
|
||||
simply clipped it to "Indiran…". With a real minimum width the
|
||||
label has room, and on the rare long one it wraps by WORD:
|
||||
`break-words` sets overflow-wrap without touching word-break,
|
||||
so a long token can still break as a last resort but ordinary
|
||||
prose never breaks mid-word.
|
||||
*/}
|
||||
<span className="text-xs font-semibold text-secondary whitespace-normal break-words min-w-0 flex-1">
|
||||
{kpi.title}
|
||||
</span>
|
||||
{kpi.iconName ? (
|
||||
<span className="shrink-0">
|
||||
<Icon icon={kpi.iconName as any} size="xsm" color="secondary" />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-baseline justify-between gap-x-2 gap-y-1 min-w-0 w-full">
|
||||
{/*
|
||||
`tabular-nums` so a streaming figure does not jitter as digits
|
||||
land, and the size step is a CONTAINER query — the panel's
|
||||
width decides whether there is room for the larger setting,
|
||||
which is the question `sm:` was answering with the window's.
|
||||
*/}
|
||||
<span className="text-xl @xs/report:text-2xl font-bold tracking-tight text-primary tabular-nums whitespace-nowrap shrink-0">
|
||||
{kpi.value}
|
||||
</span>
|
||||
{kpi.trend ? (
|
||||
<span
|
||||
title={kpi.trend}
|
||||
className={`text-xs font-bold truncate max-w-full min-w-0 ${
|
||||
isUp ? 'text-emerald-400' : isDown ? 'text-rose-400' : 'text-secondary'
|
||||
}`}
|
||||
>
|
||||
{kpi.trend}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{kpi.sparkline ? (
|
||||
<div className="mt-2 pt-1 flex justify-end">
|
||||
<MiniSparkline data={kpi.sparkline} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {IconButton} from '@astryxdesign/core/IconButton';
|
||||
import {useToast} from '@astryxdesign/core/Toast';
|
||||
import {ICONS} from '@/shared/utils/icons';
|
||||
import {exportData} from '@/shared/utils/export/exportManager';
|
||||
|
||||
export function MessageToolbarBlock({
|
||||
textToCopy,
|
||||
onRegenerate,
|
||||
}: {
|
||||
textToCopy?: string;
|
||||
onRegenerate?: () => void;
|
||||
}) {
|
||||
const toast = useToast();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [liked, setLiked] = useState<boolean | null>(null);
|
||||
|
||||
const handleCopy = () => {
|
||||
if (textToCopy) {
|
||||
navigator.clipboard.writeText(textToCopy);
|
||||
setCopied(true);
|
||||
toast({body: 'Response copied to clipboard'});
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportPdf = async () => {
|
||||
try {
|
||||
await exportData({
|
||||
filename: 'Loyaly_AI_Response',
|
||||
title: 'Loyaly AI Assistant Response Report',
|
||||
subtitle: 'Exported from Loyaly AI Assistant',
|
||||
columns: [
|
||||
{key: 'content', header: 'AI Response Summary'},
|
||||
],
|
||||
data: [
|
||||
{content: textToCopy || 'Loyaly AI Business Assistant Response'},
|
||||
],
|
||||
format: 'pdf',
|
||||
});
|
||||
toast({body: '✓ PDF downloaded successfully'});
|
||||
} catch {
|
||||
toast({body: '❌ Download failed'});
|
||||
}
|
||||
};
|
||||
|
||||
const handleShare = () => {
|
||||
toast({body: 'Share link generated and copied'});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pt-3 border-t border-border/40 flex items-center justify-between gap-2 text-secondary w-full">
|
||||
<div className="flex items-center gap-1">
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
label={copied ? 'Copied' : 'Copy'}
|
||||
tooltip={copied ? 'Copied' : 'Copy response'}
|
||||
icon={<Icon icon={copied ? 'check' : 'copy'} size="xsm" />}
|
||||
onClick={handleCopy}
|
||||
/>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
label="Helpful"
|
||||
tooltip="Helpful"
|
||||
icon={
|
||||
<Icon
|
||||
icon="arrowUp"
|
||||
size="xsm"
|
||||
color={liked === true ? 'primary' : 'secondary'}
|
||||
/>
|
||||
}
|
||||
onClick={() => {
|
||||
setLiked(true);
|
||||
toast({body: 'Thank you for your feedback!'});
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
label="Unhelpful"
|
||||
tooltip="Unhelpful"
|
||||
icon={
|
||||
<Icon
|
||||
icon="arrowDown"
|
||||
size="xsm"
|
||||
color={liked === false ? 'primary' : 'secondary'}
|
||||
/>
|
||||
}
|
||||
onClick={() => {
|
||||
setLiked(false);
|
||||
toast({body: 'Feedback submitted. We will improve.'});
|
||||
}}
|
||||
/>
|
||||
{onRegenerate ? (
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
label="Regenerate"
|
||||
tooltip="Regenerate"
|
||||
icon={<Icon icon={ICONS.compare} size="xsm" />}
|
||||
onClick={onRegenerate}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
label="Export PDF"
|
||||
tooltip="Export PDF"
|
||||
icon={<Icon icon={ICONS.download} size="xsm" />}
|
||||
onClick={handleExportPdf}
|
||||
/>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
label="Share"
|
||||
tooltip="Share response"
|
||||
icon={<Icon icon="externalLink" size="xsm" />}
|
||||
onClick={handleShare}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
102
src/features/loyaly-ai/components/response/ResponseRenderer.tsx
Normal file
102
src/features/loyaly-ai/components/response/ResponseRenderer.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
'use client';
|
||||
|
||||
import {SummaryCard} from './SummaryCard';
|
||||
import {KpiGridBlock} from './KpiGridBlock';
|
||||
import {ChartBlock} from './ChartBlock';
|
||||
import {TableBlock} from './TableBlock';
|
||||
import {InsightCardsBlock} from './InsightCardsBlock';
|
||||
import {ActionToolbarBlock} from './ActionToolbarBlock';
|
||||
import {MessageToolbarBlock} from './MessageToolbarBlock';
|
||||
import type {ReportPart} from '@/features/loyaly-ai/types/chat';
|
||||
|
||||
export function ResponseRenderer({
|
||||
report,
|
||||
isStreaming,
|
||||
}: {
|
||||
report: ReportPart;
|
||||
isStreaming?: boolean;
|
||||
}) {
|
||||
const textContent = report.summary || report.title || '';
|
||||
|
||||
return (
|
||||
/**
|
||||
* `@container/report` is the fix for the squeezed-report bug, and it is
|
||||
* the whole reason the blocks below can be written once.
|
||||
*
|
||||
* The report renders inside a resizable panel (440px by default, 420px
|
||||
* minimum, draggable to 85vw) that sits inside a desktop viewport. Every
|
||||
* block underneath used to size itself with VIEWPORT breakpoints — `sm:`
|
||||
* and friends — which on any desktop are unconditionally true. So a report
|
||||
* in a 440px rail was laid out as though it had 640px+ to work with:
|
||||
* KpiGridBlock took `sm:grid-cols-3` and split ~376px of usable width into
|
||||
* three ~117px cards, of which padding ate 28px. A figure like
|
||||
* "₹12,45,600" cannot set in 89px, so it wrapped down the card.
|
||||
*
|
||||
* A container query asks the right question. `width: 100%` was never the
|
||||
* problem — the blocks always filled the panel; they were choosing their
|
||||
* COLUMN COUNT from the window instead of from the space they were given.
|
||||
* Named (`/report`) rather than anonymous so a future nested container —
|
||||
* a two-up comparison, say — cannot silently retarget these variants.
|
||||
*/
|
||||
/*
|
||||
* The wrapping trio overrides Astryx's `word-break: break-word` on the
|
||||
* chat bubble (that value means `word-break: normal` + `overflow-wrap:
|
||||
* anywhere`, and `anywhere` lets break opportunities shrink an element's
|
||||
* min-content width down to a single glyph). Restating it as `normal` +
|
||||
* `break-word` keeps the useful half — one unbreakable token, a long ID
|
||||
* or URL, still breaks rather than overflowing — while making min-content
|
||||
* the longest WORD. Both properties inherit, so this covers every title,
|
||||
* cell and label below.
|
||||
*
|
||||
* What it does NOT do is protect against a collapsed ancestor, and that
|
||||
* is worth stating because it is tempting to assume otherwise. Measured
|
||||
* in a headless replica of this exact chain: with the bubble left
|
||||
* shrink-to-fit, adding `word-break: normal` here changed nothing — the
|
||||
* title still stacked one character per line, because inline-axis size
|
||||
* containment makes the report contribute ZERO to the intrinsic width no
|
||||
* matter how its text wraps. Only the definite width on the bubble (see
|
||||
* MessageBubble) prevents that. This declaration guards the different
|
||||
* case of a merely NARROW container, not a collapsed one.
|
||||
*/
|
||||
<div className="@container/report space-y-4 w-full max-w-full min-w-0 my-1 whitespace-normal [word-break:normal] [overflow-wrap:break-word]">
|
||||
{/* 1. Summary / Title */}
|
||||
{report.summary ? (
|
||||
<SummaryCard
|
||||
title={report.title}
|
||||
summary={report.summary}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* 2. KPI Cards Grid */}
|
||||
{report.kpis && report.kpis.length > 0 ? (
|
||||
<KpiGridBlock kpis={report.kpis} />
|
||||
) : null}
|
||||
|
||||
{/* 3. Dynamic Charts */}
|
||||
{report.charts && report.charts.length > 0 ? (
|
||||
<ChartBlock charts={report.charts} />
|
||||
) : null}
|
||||
|
||||
{/* 4. Interactive Data Tables */}
|
||||
{report.tables && report.tables.length > 0 ? (
|
||||
<TableBlock tables={report.tables} />
|
||||
) : null}
|
||||
|
||||
{/* 5. AI Insight Cards */}
|
||||
{report.insights && report.insights.length > 0 ? (
|
||||
<InsightCardsBlock insights={report.insights} />
|
||||
) : null}
|
||||
|
||||
{/* 6. Contextual Action Buttons */}
|
||||
{report.actions && report.actions.length > 0 ? (
|
||||
<ActionToolbarBlock actions={report.actions} />
|
||||
) : null}
|
||||
|
||||
{/* 7. Bottom Message Toolbar (Copy, Like/Dislike, Export, Share) */}
|
||||
{!isStreaming ? (
|
||||
<MessageToolbarBlock textToCopy={textContent} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
src/features/loyaly-ai/components/response/SummaryCard.tsx
Normal file
29
src/features/loyaly-ai/components/response/SummaryCard.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import {Markdown} from '@astryxdesign/core/Markdown';
|
||||
import {Heading} from '@astryxdesign/core/Text';
|
||||
|
||||
export function SummaryCard({
|
||||
title,
|
||||
summary,
|
||||
isStreaming,
|
||||
}: {
|
||||
title?: string;
|
||||
summary: string;
|
||||
isStreaming?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card p-4 @sm/report:p-5 shadow-sm space-y-3 w-full max-w-full">
|
||||
{title ? (
|
||||
<Heading level={3} className="text-base font-semibold text-primary">
|
||||
{title}
|
||||
</Heading>
|
||||
) : null}
|
||||
<div className="text-sm text-primary leading-relaxed">
|
||||
<Markdown density="compact" isStreaming={isStreaming} headingLevelStart={4}>
|
||||
{summary}
|
||||
</Markdown>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
103
src/features/loyaly-ai/components/response/TableBlock.tsx
Normal file
103
src/features/loyaly-ai/components/response/TableBlock.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
'use client';
|
||||
|
||||
import {Heading} from '@astryxdesign/core/Text';
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
import type {TablePartSpec} from '@/features/loyaly-ai/types/chat';
|
||||
|
||||
export function TableBlock({tables}: {tables: TablePartSpec[]}) {
|
||||
if (!tables || tables.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 w-full">
|
||||
{tables.map((t, idx) => (
|
||||
<div
|
||||
key={t.title || idx}
|
||||
className="rounded-xl border border-border bg-card overflow-hidden shadow-sm"
|
||||
>
|
||||
{t.title ? (
|
||||
<div className="p-4 border-b border-border bg-card">
|
||||
<Heading level={4} className="text-base font-bold text-primary whitespace-normal break-words">
|
||||
{t.title}
|
||||
</Heading>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/*
|
||||
The scroll container is what lets the table below refuse to be
|
||||
crushed. `w-full` alone made the table a prisoner of the panel: at
|
||||
five columns in ~376px the browser had to honour width:100%, so it
|
||||
drove every column to its min-content width and long labels came
|
||||
apart. Pairing a readable floor (`min-w-[34rem]`) with horizontal
|
||||
scroll means a narrow panel scrolls a legible table instead of
|
||||
displaying an illegible one — and a wide panel never scrolls,
|
||||
because `w-full` still stretches it past the floor.
|
||||
*/}
|
||||
<div className="overflow-x-auto w-full max-w-full">
|
||||
<table
|
||||
data-ai-report
|
||||
className="w-full min-w-full table-auto text-left text-sm border-collapse"
|
||||
>
|
||||
<thead>
|
||||
<tr className="border-b border-border-strong bg-muted text-primary font-bold">
|
||||
{t.columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className={`px-3.5 py-3.5 whitespace-nowrap text-sm font-bold text-primary ${
|
||||
col.align === 'end'
|
||||
? 'text-right'
|
||||
: col.align === 'center'
|
||||
? 'text-center'
|
||||
: 'text-left'
|
||||
}`}
|
||||
>
|
||||
{col.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{t.rows.map((row, rIdx) => (
|
||||
<tr key={rIdx} className="hover:bg-overlay-hover transition-colors">
|
||||
{t.columns.map((col) => {
|
||||
const val = row[col.key];
|
||||
const valStr = String(val ?? '');
|
||||
const isStatus = col.key === 'status' || col.key === 'state';
|
||||
|
||||
return (
|
||||
<td
|
||||
key={col.key}
|
||||
className={`px-3.5 py-3.5 text-primary font-semibold align-middle text-sm ${
|
||||
col.align === 'end'
|
||||
? 'text-right tabular-nums whitespace-nowrap'
|
||||
: col.align === 'center'
|
||||
? 'text-center'
|
||||
: 'text-left whitespace-normal break-words'
|
||||
}`}
|
||||
>
|
||||
{isStatus ? (
|
||||
<Badge
|
||||
variant={
|
||||
valStr.toLowerCase() === 'active' || valStr.toLowerCase() === 'paid'
|
||||
? 'success'
|
||||
: valStr.toLowerCase() === 'warning' || valStr.toLowerCase() === 'at risk'
|
||||
? 'warning'
|
||||
: 'neutral'
|
||||
}
|
||||
label={valStr}
|
||||
/>
|
||||
) : (
|
||||
valStr
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
367
src/features/loyaly-ai/providers/LoyalyAiProvider.tsx
Normal file
367
src/features/loyaly-ai/providers/LoyalyAiProvider.tsx
Normal file
@@ -0,0 +1,367 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {loyalyAiRepository} from '@/features/loyaly-ai/repositories/loyalyAiRepository';
|
||||
import {
|
||||
applyStreamedPart,
|
||||
createConversation,
|
||||
createMessage,
|
||||
titleFromPrompt,
|
||||
toHistory,
|
||||
} from '@/features/loyaly-ai/services/loyalyAiService';
|
||||
import type {
|
||||
ChatMessage,
|
||||
Conversation,
|
||||
ConversationSummary,
|
||||
} from '@/features/loyaly-ai/types/chat';
|
||||
|
||||
/**
|
||||
* All Loyaly AI state, mounted in app/providers.tsx — ABOVE the route tree.
|
||||
*
|
||||
* Two things force that placement:
|
||||
* 1. Route changes. The panel lives in the workspace shell, which the App
|
||||
* Router preserves across sibling navigations, so it would survive
|
||||
* /dashboard → /staff on its own.
|
||||
* 2. The tablet breakpoint. Crossing it swaps the inline panel for a
|
||||
* slide-over, which genuinely unmounts the surface. Holding state up here
|
||||
* means the presentation can change without losing a conversation, a
|
||||
* half-typed message, or a stream in flight.
|
||||
*
|
||||
* Every component below is a pure view over this context.
|
||||
*/
|
||||
|
||||
export type PanelMode = 'normal' | 'expanded' | 'fullscreen';
|
||||
|
||||
interface LoyalyAiValue {
|
||||
/** Inline panel visibility, above the laptop breakpoint. */
|
||||
isOpen: boolean;
|
||||
setIsOpen: (v: boolean) => void;
|
||||
toggle: () => void;
|
||||
/** Slide-over visibility, below it. */
|
||||
isSlideOverOpen: boolean;
|
||||
setSlideOverOpen: (v: boolean) => void;
|
||||
|
||||
panelMode: PanelMode;
|
||||
setPanelMode: (mode: PanelMode) => void;
|
||||
toggleExpand: () => void;
|
||||
toggleFullscreen: () => void;
|
||||
panelWidth: number;
|
||||
setPanelWidth: (w: number) => void;
|
||||
|
||||
conversation: Conversation;
|
||||
history: ConversationSummary[];
|
||||
isHistoryOpen: boolean;
|
||||
setHistoryOpen: (v: boolean) => void;
|
||||
|
||||
/** The composer value. Kept here so navigation never drops it. */
|
||||
draft: string;
|
||||
setDraft: (v: string) => void;
|
||||
|
||||
send: (text: string) => void;
|
||||
stop: () => void;
|
||||
isStreaming: boolean;
|
||||
|
||||
newChat: () => void;
|
||||
reload: () => void;
|
||||
openConversation: (id: string) => void;
|
||||
}
|
||||
|
||||
const LoyalyAiContext = createContext<LoyalyAiValue | null>(null);
|
||||
|
||||
const STORAGE_MODE_KEY = 'loyaly_ai_panel_mode';
|
||||
const STORAGE_WIDTH_KEY = 'loyaly_ai_panel_width';
|
||||
|
||||
export function LoyalyAiProvider({children}: {children: React.ReactNode}) {
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const [isSlideOverOpen, setSlideOverOpen] = useState(false);
|
||||
const [isHistoryOpen, setHistoryOpen] = useState(false);
|
||||
const [draft, setDraft] = useState('');
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
|
||||
const [panelMode, setPanelModeState] = useState<PanelMode>('normal');
|
||||
const [panelWidth, setPanelWidthState] = useState<number>(440);
|
||||
|
||||
// Sync saved localStorage settings post-hydration to eliminate SSR mismatch
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
const savedMode = localStorage.getItem(STORAGE_MODE_KEY);
|
||||
if (savedMode === 'expanded' || savedMode === 'fullscreen' || savedMode === 'normal') {
|
||||
setPanelModeState(savedMode as PanelMode);
|
||||
}
|
||||
const savedWidth = localStorage.getItem(STORAGE_WIDTH_KEY);
|
||||
if (savedWidth) {
|
||||
const parsed = parseInt(savedWidth, 10);
|
||||
if (!isNaN(parsed) && parsed >= 420 && parsed <= 1800) {
|
||||
setPanelWidthState(parsed);
|
||||
}
|
||||
}
|
||||
}, 0);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
const prevModeRef = useRef<PanelMode>('normal');
|
||||
|
||||
const setPanelMode = useCallback((mode: PanelMode) => {
|
||||
setPanelModeState((prev) => {
|
||||
prevModeRef.current = prev;
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem(STORAGE_MODE_KEY, mode);
|
||||
}
|
||||
return mode;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setPanelWidth = useCallback((w: number) => {
|
||||
const clamped = Math.max(420, Math.min(w, typeof window !== 'undefined' ? window.innerWidth * 0.85 : 1400));
|
||||
setPanelWidthState(clamped);
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem(STORAGE_WIDTH_KEY, clamped.toString());
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleExpand = useCallback(() => {
|
||||
setPanelModeState((prev) => {
|
||||
const next = prev === 'expanded' ? 'normal' : 'expanded';
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem(STORAGE_MODE_KEY, next);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
setPanelModeState((prev) => {
|
||||
const next = prev === 'fullscreen' ? (prevModeRef.current === 'fullscreen' ? 'normal' : prevModeRef.current) : 'fullscreen';
|
||||
prevModeRef.current = prev;
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem(STORAGE_MODE_KEY, next);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Every conversation this session, newest state included. The active one is
|
||||
// referenced by id rather than held separately, so there is no way for the
|
||||
// list and the open chat to disagree about the same conversation.
|
||||
const [conversations, setConversations] = useState<Conversation[]>(() => [
|
||||
createConversation(),
|
||||
]);
|
||||
const [activeId, setActiveId] = useState<string>(
|
||||
() => conversations[0]?.id ?? '',
|
||||
);
|
||||
|
||||
// Aborts the stream in flight. A ref because it is machinery, not state:
|
||||
// nothing renders differently because a controller exists.
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const conversation = useMemo(
|
||||
() => conversations.find((c) => c.id === activeId) ?? conversations[0],
|
||||
[conversations, activeId],
|
||||
);
|
||||
|
||||
const history = useMemo(() => toHistory(conversations), [conversations]);
|
||||
|
||||
/** Update one conversation in place, stamping updatedAt. */
|
||||
const patchConversation = useCallback(
|
||||
(id: string, update: (c: Conversation) => Conversation) => {
|
||||
setConversations((prev) =>
|
||||
prev.map((c) =>
|
||||
c.id === id ? {...update(c), updatedAt: new Date().toISOString()} : c,
|
||||
),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = null;
|
||||
setIsStreaming(false);
|
||||
// Clear the streaming flag on whatever was being written, or the cursor
|
||||
// blinks forever on an answer that stopped arriving.
|
||||
setConversations((prev) =>
|
||||
prev.map((c) => ({
|
||||
...c,
|
||||
messages: c.messages.map((m) =>
|
||||
m.isStreaming ? {...m, isStreaming: false} : m,
|
||||
),
|
||||
})),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const send = useCallback(
|
||||
(text: string) => {
|
||||
const prompt = text.trim();
|
||||
if (!prompt || isStreaming) return;
|
||||
|
||||
const targetId = conversation.id;
|
||||
const userMessage = createMessage('user', [{type: 'text', text: prompt}]);
|
||||
const assistantMessage = createMessage('assistant', [], {
|
||||
isStreaming: true,
|
||||
});
|
||||
|
||||
patchConversation(targetId, (c) => ({
|
||||
...c,
|
||||
// Named from the first prompt, so history is readable without opening
|
||||
// anything. Later prompts do not rename it — a conversation that
|
||||
// renamed itself as it went would be impossible to find again.
|
||||
title: c.messages.length === 0 ? titleFromPrompt(prompt) : c.title,
|
||||
messages: [...c.messages, userMessage, assistantMessage],
|
||||
}));
|
||||
setDraft('');
|
||||
setIsStreaming(true);
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const stream = loyalyAiRepository.streamReply({
|
||||
prompt,
|
||||
history: conversation.messages,
|
||||
signal: controller.signal,
|
||||
});
|
||||
for await (const part of stream) {
|
||||
patchConversation(targetId, (c) => ({
|
||||
...c,
|
||||
messages: c.messages.map((m) =>
|
||||
m.id === assistantMessage.id ? applyStreamedPart(m, part) : m,
|
||||
),
|
||||
}));
|
||||
}
|
||||
} finally {
|
||||
// Runs on success, on abort and on failure. Whatever happened, the
|
||||
// message must stop claiming to be mid-stream.
|
||||
if (abortRef.current === controller) abortRef.current = null;
|
||||
patchConversation(targetId, (c) => ({
|
||||
...c,
|
||||
messages: c.messages.map((m) =>
|
||||
m.id === assistantMessage.id ? {...m, isStreaming: false} : m,
|
||||
),
|
||||
}));
|
||||
setIsStreaming(false);
|
||||
}
|
||||
})();
|
||||
},
|
||||
[conversation, isStreaming, patchConversation],
|
||||
);
|
||||
|
||||
const newChat = useCallback(() => {
|
||||
stop();
|
||||
setConversations((prev) => {
|
||||
// Reuse the current one when it is already empty. Otherwise every click
|
||||
// of New chat strands another blank conversation in memory.
|
||||
const current = prev.find((c) => c.id === activeId);
|
||||
if (current && current.messages.length === 0) return prev;
|
||||
const fresh = createConversation();
|
||||
setActiveId(fresh.id);
|
||||
return [fresh, ...prev];
|
||||
});
|
||||
setDraft('');
|
||||
setHistoryOpen(false);
|
||||
}, [activeId, stop]);
|
||||
|
||||
const openConversation = useCallback(
|
||||
(id: string) => {
|
||||
stop();
|
||||
setActiveId(id);
|
||||
setHistoryOpen(false);
|
||||
},
|
||||
[stop],
|
||||
);
|
||||
|
||||
const reload = useCallback(() => {
|
||||
stop();
|
||||
const lastUserMsg = [...(conversation?.messages || [])].reverse().find((m) => m.role === 'user');
|
||||
if (lastUserMsg) {
|
||||
const textPart = lastUserMsg.parts.find((p) => p.type === 'text');
|
||||
const text = textPart && 'text' in textPart ? textPart.text : '';
|
||||
if (text) {
|
||||
patchConversation(conversation.id, (c) => {
|
||||
const msgs = [...c.messages];
|
||||
if (msgs[msgs.length - 1]?.role === 'assistant') {
|
||||
msgs.pop();
|
||||
}
|
||||
return {...c, messages: msgs};
|
||||
});
|
||||
send(text);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setDraft('');
|
||||
}, [conversation, patchConversation, send, stop]);
|
||||
|
||||
const toggle = useCallback(() => setIsOpen((v) => !v), []);
|
||||
|
||||
const value = useMemo<LoyalyAiValue>(
|
||||
() => ({
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
toggle,
|
||||
isSlideOverOpen,
|
||||
setSlideOverOpen,
|
||||
panelMode,
|
||||
setPanelMode,
|
||||
toggleExpand,
|
||||
toggleFullscreen,
|
||||
panelWidth,
|
||||
setPanelWidth,
|
||||
conversation,
|
||||
history,
|
||||
isHistoryOpen,
|
||||
setHistoryOpen,
|
||||
draft,
|
||||
setDraft,
|
||||
send,
|
||||
stop,
|
||||
isStreaming,
|
||||
newChat,
|
||||
reload,
|
||||
openConversation,
|
||||
}),
|
||||
[
|
||||
isOpen,
|
||||
toggle,
|
||||
isSlideOverOpen,
|
||||
panelMode,
|
||||
setPanelMode,
|
||||
toggleExpand,
|
||||
toggleFullscreen,
|
||||
panelWidth,
|
||||
setPanelWidth,
|
||||
conversation,
|
||||
history,
|
||||
isHistoryOpen,
|
||||
draft,
|
||||
send,
|
||||
stop,
|
||||
isStreaming,
|
||||
newChat,
|
||||
reload,
|
||||
openConversation,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<LoyalyAiContext value={value}>{children}</LoyalyAiContext>
|
||||
);
|
||||
}
|
||||
|
||||
export function useLoyalyAi(): LoyalyAiValue {
|
||||
const ctx = useContext(LoyalyAiContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useLoyalyAi must be used inside <LoyalyAiProvider>');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/** Re-exported so components do not reach past the provider for a type. */
|
||||
export type {ChatMessage};
|
||||
142
src/features/loyaly-ai/repositories/loyalyAiRepository.ts
Normal file
142
src/features/loyaly-ai/repositories/loyalyAiRepository.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import type {ChatMessage, MessagePart, TextPart} from '@/features/loyaly-ai/types/chat';
|
||||
|
||||
/**
|
||||
* Loyaly AI's transport. One POST to this app's BFF, which calls the
|
||||
* platform's assistant as the signed-in user.
|
||||
*
|
||||
* ── What this used to be ─────────────────────────────────────────────────
|
||||
* `streamDynamicAiResponse` from `services/ai/mockAi.ts`: a keyword router
|
||||
* over seven hardcoded templates. Asking for a store report returned four
|
||||
* invented shops, revenue nobody earned, a fabricated "98% confidence" and a
|
||||
* recommendation to "conduct staff training on checkout upsell workflows" —
|
||||
* none of which touched the network. It has been deleted rather than disabled,
|
||||
* because a mock behind a flag is a mock somebody re-enables.
|
||||
*
|
||||
* ── Why this is not a token stream ───────────────────────────────────────
|
||||
* The platform answers once, when its whole tool loop has finished — it may
|
||||
* call several tools before it can say anything true. This generator therefore
|
||||
* yields once. It stays an AsyncGenerator so the provider's `for await` loop
|
||||
* is unchanged, and so a genuinely streamed endpoint can be swapped in here
|
||||
* with no change above this line.
|
||||
*/
|
||||
|
||||
const NETWORK_MESSAGE =
|
||||
'Could not reach the Loyaly platform, so there is nothing to report. ' +
|
||||
'Check the connection and ask again.';
|
||||
|
||||
/**
|
||||
* What to say when there is no answer.
|
||||
*
|
||||
* Every one of these is an honest statement of the state. None of them
|
||||
* substitutes a plausible reply, because a merchant cannot tell an invented
|
||||
* answer from a real one — and acting on one is the failure the whole
|
||||
* mock removal exists to prevent.
|
||||
*/
|
||||
function unavailableText(status: number, reason: string, message: string): string {
|
||||
if (status === 501 || reason === 'assistant_off') {
|
||||
return (
|
||||
'Loyaly AI is not switched on for this deployment, so there is no ' +
|
||||
'answer to give. Every figure it reports has to come from the platform, ' +
|
||||
'and nothing here will be filled in with an estimate.'
|
||||
);
|
||||
}
|
||||
if (reason === 'assistant_misconfigured') {
|
||||
return (
|
||||
'Loyaly AI is switched on but not configured correctly on the server, ' +
|
||||
'so it cannot answer. Your administrator will see the exact cause in ' +
|
||||
'the server log.'
|
||||
);
|
||||
}
|
||||
if (status === 401) {
|
||||
return 'Your session has expired. Sign in again to ask Loyaly AI.';
|
||||
}
|
||||
// The platform's own wording, which is written to be shown to a person.
|
||||
return message || 'Loyaly AI could not answer that. Please try again.';
|
||||
}
|
||||
|
||||
function text(value: string): TextPart {
|
||||
return {type: 'text', text: value};
|
||||
}
|
||||
|
||||
export interface StreamRequest {
|
||||
prompt: string;
|
||||
history: ChatMessage[];
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/** A chat message flattened to the prose the platform's history expects. */
|
||||
function toTurn(m: ChatMessage): {role: 'user' | 'assistant'; text: string} {
|
||||
const body = m.parts
|
||||
.map((p) => (p.type === 'text' ? p.text : ''))
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
return {role: m.role === 'user' ? 'user' : 'assistant', text: body};
|
||||
}
|
||||
|
||||
export const loyalyAiRepository = {
|
||||
async *streamReply({
|
||||
prompt,
|
||||
history,
|
||||
signal,
|
||||
}: StreamRequest): AsyncGenerator<MessagePart, void, undefined> {
|
||||
// The turn being asked is not in `history` yet — the provider appends the
|
||||
// assistant's placeholder before calling this, and the user's message is
|
||||
// passed separately as `prompt`.
|
||||
const turns = [
|
||||
...history.map(toTurn).filter((t) => t.text !== ''),
|
||||
{role: 'user' as const, text: prompt},
|
||||
];
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch('/api/assistant', {
|
||||
method: 'POST',
|
||||
headers: {'content-type': 'application/json'},
|
||||
body: JSON.stringify({history: turns}),
|
||||
signal,
|
||||
});
|
||||
} catch (err) {
|
||||
// An abort is the user pressing Stop, not a failure to report.
|
||||
if ((err as Error)?.name === 'AbortError') return;
|
||||
yield text(NETWORK_MESSAGE);
|
||||
return;
|
||||
}
|
||||
|
||||
if (signal?.aborted) return;
|
||||
|
||||
let body: {
|
||||
text?: string;
|
||||
used?: string[];
|
||||
error?: {code?: string; message?: string};
|
||||
reason?: string;
|
||||
};
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch {
|
||||
yield text(NETWORK_MESSAGE);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
yield text(
|
||||
unavailableText(res.status, body.reason ?? '', body.error?.message ?? ''),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const answer = (body.text ?? '').trim();
|
||||
if (!answer) {
|
||||
yield text('Loyaly AI returned no answer to that question.');
|
||||
return;
|
||||
}
|
||||
|
||||
// The tools it used, named. An assistant that silently ran a camera check
|
||||
// would be alarming, and naming what it looked at makes a wrong answer
|
||||
// traceable rather than mysterious.
|
||||
const used = body.used?.length
|
||||
? `\n\n*Checked: ${body.used.join(', ')}*`
|
||||
: '';
|
||||
|
||||
yield text(answer + used);
|
||||
},
|
||||
};
|
||||
115
src/features/loyaly-ai/services/loyalyAiService.ts
Normal file
115
src/features/loyaly-ai/services/loyalyAiService.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import type {
|
||||
ChatMessage,
|
||||
Conversation,
|
||||
ConversationSummary,
|
||||
MessagePart,
|
||||
MessageRole,
|
||||
} from '@/features/loyaly-ai/types/chat';
|
||||
|
||||
/**
|
||||
* Domain rules for Loyaly AI.
|
||||
*
|
||||
* Framework-free: no React, no Astryx. Everything here is about what a
|
||||
* conversation IS — how a message is built, how a chat gets its title, what
|
||||
* order history appears in — and none of it should have to change when the
|
||||
* surface is redesigned or the transport is swapped.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Ids are generated, and deliberately not with Math.random() or Date.now()
|
||||
* during render. A monotonic counter seeded per module gives stable,
|
||||
* collision-free ids without touching either.
|
||||
*/
|
||||
let sequence = 0;
|
||||
function nextId(prefix: string): string {
|
||||
sequence += 1;
|
||||
return `${prefix}_${sequence}`;
|
||||
}
|
||||
|
||||
export function createMessage(
|
||||
role: MessageRole,
|
||||
parts: MessagePart[],
|
||||
options?: {isStreaming?: boolean},
|
||||
): ChatMessage {
|
||||
return {
|
||||
id: nextId(role === 'user' ? 'usr' : 'ast'),
|
||||
role,
|
||||
parts,
|
||||
// Stamped on a user event, never during render — Date.now() in a render
|
||||
// path is a hydration mismatch waiting to happen.
|
||||
at: new Date().toISOString(),
|
||||
isStreaming: options?.isStreaming,
|
||||
};
|
||||
}
|
||||
|
||||
export function createConversation(): Conversation {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: nextId('conv'),
|
||||
title: 'New chat',
|
||||
messages: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
/** The longest prefix of a title that reads as a phrase rather than a truncation. */
|
||||
const TITLE_MAX = 48;
|
||||
|
||||
/**
|
||||
* Name a conversation from its first user message.
|
||||
*
|
||||
* Cut on a word boundary rather than mid-word: "Compare stores by conve…" is
|
||||
* a title, "Compare stores by conv" is a bug. Falls back to the raw slice only
|
||||
* when the first word is itself longer than the budget.
|
||||
*/
|
||||
export function titleFromPrompt(prompt: string): string {
|
||||
const clean = prompt.trim().replace(/\s+/g, ' ');
|
||||
if (!clean) return 'New chat';
|
||||
if (clean.length <= TITLE_MAX) return clean;
|
||||
|
||||
const cut = clean.slice(0, TITLE_MAX);
|
||||
const lastSpace = cut.lastIndexOf(' ');
|
||||
return `${lastSpace > 16 ? cut.slice(0, lastSpace) : cut}…`;
|
||||
}
|
||||
|
||||
/**
|
||||
* History, most recently touched first, with empty conversations omitted.
|
||||
*
|
||||
* A chat you opened and never used is not history — listing it means "New
|
||||
* chat" accumulates in the drawer every time someone clicks the button.
|
||||
*/
|
||||
export function toHistory(conversations: Conversation[]): ConversationSummary[] {
|
||||
return conversations
|
||||
.filter((c) => c.messages.length > 0)
|
||||
.slice()
|
||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
||||
.map((c) => ({
|
||||
id: c.id,
|
||||
title: c.title,
|
||||
updatedAt: c.updatedAt,
|
||||
messageCount: c.messages.length,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a streamed part to the assistant message being written.
|
||||
*
|
||||
* Text REPLACES the last text part (the repository yields cumulative text, so
|
||||
* a dropped chunk cannot corrupt the message); anything else appends, because
|
||||
* a chart and a table are separate blocks rather than revisions of one.
|
||||
*/
|
||||
export function applyStreamedPart(
|
||||
message: ChatMessage,
|
||||
part: MessagePart,
|
||||
): ChatMessage {
|
||||
const parts = [...message.parts];
|
||||
const lastIndex = parts.length - 1;
|
||||
|
||||
if (lastIndex >= 0 && parts[lastIndex].type === part.type) {
|
||||
parts[lastIndex] = part;
|
||||
} else {
|
||||
parts.push(part);
|
||||
}
|
||||
return {...message, parts};
|
||||
}
|
||||
157
src/features/loyaly-ai/types/chat.ts
Normal file
157
src/features/loyaly-ai/types/chat.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* The Loyaly AI conversation model.
|
||||
*
|
||||
* ── Why a message is PARTS, not a string ──────────────────────────────────
|
||||
* An assistant that answers business questions will not answer only in prose.
|
||||
* "Compare stores" wants a table, "analyze sales" wants a chart, an uploaded
|
||||
* invoice wants a file preview. Modelling a message as `text: string` forces
|
||||
* every one of those to be smuggled through markdown or bolted on later as a
|
||||
* second field, and both roads end in a renderer that switches on the shape of
|
||||
* a string.
|
||||
*
|
||||
* So a message carries an ordered list of PARTS, each independently typed and
|
||||
* independently rendered (see components/MessageContent). Today only `text`
|
||||
* has a renderer. Adding charts is a new variant here plus a case there —
|
||||
* nothing about the transport, the provider or the message list changes.
|
||||
*/
|
||||
|
||||
export type MessageRole = 'user' | 'assistant';
|
||||
|
||||
/** Prose. Rendered as markdown: lists, tables, code blocks all come free. */
|
||||
export interface TextPart {
|
||||
type: 'text';
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A chart the assistant chose to draw. Deliberately references a dataset by id
|
||||
* rather than embedding points: an answer that ships 90 days of raw series
|
||||
* inside a chat message cannot be cached, diffed or re-rendered at a new size.
|
||||
*/
|
||||
export interface ChartPart {
|
||||
type: 'chart';
|
||||
chart: 'line' | 'bar' | 'area';
|
||||
title: string;
|
||||
datasetId: string;
|
||||
}
|
||||
|
||||
/** A result set the merchant can sort and page through. */
|
||||
export interface TablePart {
|
||||
type: 'table';
|
||||
title: string;
|
||||
columns: {key: string; header: string}[];
|
||||
rows: Record<string, string | number>[];
|
||||
}
|
||||
|
||||
/** An uploaded or generated artefact. */
|
||||
export interface FilePart {
|
||||
type: 'file';
|
||||
name: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface ImagePart {
|
||||
type: 'image';
|
||||
url: string;
|
||||
alt: string;
|
||||
}
|
||||
|
||||
export interface ReportKpi {
|
||||
id: string;
|
||||
title: string;
|
||||
value: string;
|
||||
trend?: string;
|
||||
trendDirection?: 'up' | 'down' | 'neutral';
|
||||
iconName?: string;
|
||||
sparkline?: number[];
|
||||
}
|
||||
|
||||
export interface ReportInsight {
|
||||
id: string;
|
||||
type: 'opportunity' | 'risk' | 'growth' | 'best_performer';
|
||||
title: string;
|
||||
explanation: string;
|
||||
confidence?: number;
|
||||
recommendedAction?: string;
|
||||
}
|
||||
|
||||
export interface ReportAction {
|
||||
id: string;
|
||||
label: string;
|
||||
iconName?: string;
|
||||
actionType: string;
|
||||
}
|
||||
|
||||
export interface ChartPartSpec {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
chartType: 'line' | 'bar' | 'area';
|
||||
data: Record<string, any>[];
|
||||
xKey: string;
|
||||
series: {key: string; label: string}[];
|
||||
}
|
||||
|
||||
export interface TablePartSpec {
|
||||
title: string;
|
||||
columns: {key: string; header: string; align?: 'start' | 'center' | 'end'}[];
|
||||
rows: Record<string, any>[];
|
||||
}
|
||||
|
||||
export interface ReportPart {
|
||||
type: 'report';
|
||||
title?: string;
|
||||
summary?: string;
|
||||
kpis?: ReportKpi[];
|
||||
charts?: ChartPartSpec[];
|
||||
tables?: TablePartSpec[];
|
||||
insights?: ReportInsight[];
|
||||
actions?: ReportAction[];
|
||||
}
|
||||
|
||||
export type MessagePart =
|
||||
| TextPart
|
||||
| ChartPart
|
||||
| TablePart
|
||||
| FilePart
|
||||
| ImagePart
|
||||
| ReportPart;
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: MessageRole;
|
||||
parts: MessagePart[];
|
||||
/** ISO-8601, stamped on send — never during render. */
|
||||
at: string;
|
||||
/**
|
||||
* True while tokens are still arriving. Drives the streaming cursor and
|
||||
* tells Markdown to tolerate half-finished syntax.
|
||||
*/
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
export interface Conversation {
|
||||
id: string;
|
||||
/** Derived from the first user message; "New chat" until there is one. */
|
||||
title: string;
|
||||
messages: ChatMessage[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** A conversation as the history drawer needs it — no message bodies. */
|
||||
export interface ConversationSummary {
|
||||
id: string;
|
||||
title: string;
|
||||
updatedAt: string;
|
||||
messageCount: number;
|
||||
}
|
||||
|
||||
/** One suggestion chip on the empty state. */
|
||||
export interface Suggestion {
|
||||
id: string;
|
||||
label: string;
|
||||
/** What is actually sent — chips are short, prompts should not be. */
|
||||
prompt: string;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user