update ui update and fix layout issue

This commit is contained in:
2026-08-06 13:21:10 +05:30
parent 92322bff16
commit e5f8144fb3
251 changed files with 9041 additions and 2119 deletions

View File

@@ -96,7 +96,7 @@ page padding in an individual settings page — change the container instead.
| | value | why |
|---|---|---|
| Horizontal | 32px (`paddingInline={8}`) | clears the sub-nav and the Copilot rail |
| Horizontal | 32px (`paddingInline={8}`) | clears the sub-nav and the Loyaly AI rail |
| Top | 32px (`paddingBlock={8}`) | title never touches the header |
| Bottom | 48px (`className="pb-12"`) | last card is never flush to the fold |
| Header → body | 32px (`gap={8}`) | title block reads as its own layer |

120
ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,120 @@
# Architecture
How this codebase is organised, and the rules that keep it that way. Read this
before adding a feature; it should take about five minutes.
## The layers
Data flows in one direction, and each layer is allowed to know only the one
below it:
```
component / page renders state, owns no rules
features/<f>/hooks binds a service or repository to React
features/<f>/services domain rules: validation, interpretation, defaults
features/<f>/repositories TRANSPORT ONLY — the single place that knows a URL
app/api/<f>/route.ts HTTP boundary: authorises, parses, responds
features/<f>/mock/*.mock fixtures, server-side only
```
**Connecting a real backend touches the repository layer and nothing else.**
Point `NEXT_PUBLIC_API_BASE` at a host, or rewrite the four lines in a
repository, and every component above it is untouched. That is the property the
whole structure exists to protect, so:
- **Never** `import` from a `mock/` module in a component, hook or page. Server
Components read through a `*ServerRepository`; clients go over HTTP.
- **Never** call `fetch` in a component. `shared/services/httpClient` is the
only module that does.
- A repository never interprets a response, and a service never builds a URL.
## Where things live
```
src/
app/ routing only — thin files, no logic
(public)/login unauthenticated routes → PublicLayout → GuestGuard
(workspace)/… authenticated routes → ProtectedLayout → AuthGuard
api/ route handlers
proxy.ts the server-side auth gate (Next 16 renamed middleware → proxy)
features/<feature>/
components/ UI for this feature only
hooks/ data access + view state
services/ domain rules, framework-free, unit-testable
repositories/ URLs and verbs
types/ the wire contract for this feature
mock/ fixtures (server-side)
utils/ · config/ · guards/ · providers/ as needed
shared/
components/ brand · charts · patterns · primitives · scope · data · motion
hooks/ useResource · useScope · useBreakpoint · usePersistentFlag
layouts/ ProtectedLayout · PublicLayout · workspace shell
providers/ app-wide state (workspace scope)
services/ httpClient (transport) · apiRoute (handler helpers)
types/ the response envelope
mock/ seeded RNG shared by every fixture
theme/ design tokens; edit loyalyTheme.ts, run `npm run theme:build`
```
A feature owns everything about itself. If two features need the same thing, it
moves to `shared/` — it does not get imported across features, with one
deliberate exception: `features/dashboard/types` holds the analytics primitives
(`TimePoint`, `ActivityEvent`) that LYTs and Stores genuinely share, because
duplicating them would let two modules disagree about the same wire shape.
## Authentication
Three independent gates, in order of authority:
1. **`src/proxy.ts`** — runs before any protected route renders. No valid
session cookie, no page. API paths get a 401 JSON; pages get a redirect to
`/login?next=…`. This is the one that matters.
2. **`requireApiSession()`** — every data route handler calls it. Next's own
docs warn that proxy coverage can be silently lost by a matcher change or a
route move, so the check is repeated where the data is.
3. **`AuthGuard` / `GuestGuard`** — client-side. Covers what the server never
sees: a session expiring in an open tab, a logout in another tab, a
client-side navigation. **Not a security boundary.**
The session is a signed httpOnly cookie (`features/auth/services/sessionToken`),
so the browser cannot read or forge it. `SessionProvider` holds only what to
draw, never what to permit — it asks `GET /api/auth/session` and believes the
answer.
Swapping the mock for a real identity provider means changing
`verifyCredentials` in `features/auth/mock/users.mock.ts` and the three URLs in
`authRepository`. Nothing else, including the login form, is aware.
## Conventions
- **No `any`.** The codebase has zero, and `tsc --noEmit` is expected to pass
before every commit.
- **Components stay under ~300 lines.** When one grows past that, the split is
usually behaviour-into-a-hook, not markup-into-more-markup.
- **Names say what the thing is**: `DashboardKpiCard`, `StaffAttendanceTable`.
Never `Card.tsx`, `utils.ts`, `NewFile.tsx`.
- **Styling comes from the design system**, in this order: component props →
token-backed Tailwind utilities → a theme override in `loyalyTheme.ts`. There
are no per-feature CSS files; see the note below.
### On CSS
The brief that produced this refactor asked for per-feature stylesheets
(`dashboard/styles/kpi.css`, and so on) so that changing one module cannot
affect another. That isolation is already total here, by construction rather
than by convention: styling lives in component props and utility classes scoped
to the element they are written on, so there is no selector in the codebase
that *can* reach another feature. Adding stylesheets would introduce global
selectors — the one mechanism that can leak — and they would have to fight
StyleX's `@layer astryx-base` for the cascade. Shared visual decisions belong in
`theme/loyalyTheme.ts`, which is the single place a change is meant to be
system-wide.
If a future component genuinely needs CSS that props and utilities cannot
express, `astryx swizzle <Component>` ejects the source for that one component
rather than opening a stylesheet the whole app can be edited through.

42
Dockerfile Normal file
View File

@@ -0,0 +1,42 @@
# Stage 1: Install dependencies
FROM node:22-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# Stage 2: Build the Next.js application
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
RUN npm run build
# Stage 3: Production runner with Nginx reverse proxy
FROM node:22-alpine AS runner
WORKDIR /app
RUN apk add --no-cache nginx
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
# Copy public static assets and standalone build output
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
# Copy custom Nginx configuration
COPY nginx.conf /etc/nginx/nginx.conf
EXPOSE 80
# Start Next.js standalone server in background and Nginx in foreground
CMD ["sh", "-c", "node server.js & nginx -g 'daemon off;'"]

56
nginx.conf Normal file
View File

@@ -0,0 +1,56 @@
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
gzip on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml;
upstream nextjs_upstream {
server 127.0.0.1:3000;
}
server {
listen 80;
server_name localhost;
# Serve Next.js compiled static assets directly via Nginx
location /_next/static/ {
alias /app/.next/static/;
expires 365d;
access_log off;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# Serve public directory assets
location /public/ {
alias /app/public/;
expires 30d;
access_log off;
}
# Reverse proxy dynamic routes, SSR, and API routes to Next.js standalone server
location / {
proxy_pass http://nextjs_upstream;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}

9
package-lock.json generated
View File

@@ -16,7 +16,8 @@
"next": "^16.3.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"recharts": "^3.10.1"
"recharts": "^3.10.1",
"server-only": "^0.0.1"
},
"devDependencies": {
"@astryxdesign/cli": "^0.2.0",
@@ -7050,6 +7051,12 @@
"semver": "bin/semver.js"
}
},
"node_modules/server-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz",
"integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==",
"license": "MIT"
},
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",

View File

@@ -20,7 +20,8 @@
"next": "^16.3.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"recharts": "^3.10.1"
"recharts": "^3.10.1",
"server-only": "^0.0.1"
},
"devDependencies": {
"@astryxdesign/cli": "^0.2.0",

View File

@@ -16,22 +16,22 @@ import {Card} from '@astryxdesign/core/Card';
import {Heading, Text} from '@astryxdesign/core/Text';
import {Button} from '@astryxdesign/core/Button';
import {Badge} from '@astryxdesign/core/Badge';
import {useResource} from '@/lib/api/useResource';
import {endpoints} from '@/lib/api/client';
import {ChartCard} from '@/components/charts/ChartCard';
import {LineChartView} from '@/components/charts/LineChartView';
import {AreaChartView} from '@/components/charts/AreaChartView';
import {BarChartView} from '@/components/charts/BarChartView';
import {Sparkline} from '@/components/charts/Sparkline';
import {HeatmapGrid} from '@/components/charts/HeatmapGrid';
import type {HourCell, Kpi, TimePoint} from '@/lib/api/contracts';
import {useResource} from '@/shared/hooks/useResource';
import {dashboardRepository} from '@/features/dashboard/repositories/dashboardRepository';
import {ChartCard} from '@/shared/components/charts/ChartCard';
import {LineChartView} from '@/shared/components/charts/LineChartView';
import {AreaChartView} from '@/shared/components/charts/AreaChartView';
import {BarChartView} from '@/shared/components/charts/BarChartView';
import {Sparkline} from '@/shared/components/charts/Sparkline';
import {HeatmapGrid} from '@/shared/components/charts/HeatmapGrid';
import type {HourCell, Kpi, TimePoint} from '@/features/dashboard/types/dashboard';
import {
formatCompact,
formatDayLabel,
formatDelta,
formatInrCompact,
formatPct,
} from '@/lib/format';
} from '@/shared/utils/format';
type Forced = '' | 'empty' | 'error' | 'delay';
@@ -52,11 +52,11 @@ export default function ChartsGallery() {
({...e, params: {...e.params, ...extra}}) as never as T;
const series = useResource<TimePoint[]>(
withExtra(endpoints.dashboardTimeseries(scope)),
withExtra(dashboardRepository.timeseries(scope)),
);
const kpis = useResource<Kpi[]>(withExtra(endpoints.dashboardKpis(scope)));
const kpis = useResource<Kpi[]>(withExtra(dashboardRepository.kpis(scope)));
const peak = useResource<HourCell[]>(
withExtra(endpoints.dashboardPeakHours(scope)),
withExtra(dashboardRepository.peakHours(scope)),
);
return (

View File

@@ -0,0 +1,10 @@
import {PublicLayout} from '@/shared/layouts/PublicLayout';
/** The route-group boundary for screens reachable without a session. */
export default function PublicRouteLayout({
children,
}: {
children: React.ReactNode;
}) {
return <PublicLayout>{children}</PublicLayout>;
}

View File

@@ -0,0 +1,19 @@
import type {Metadata} from 'next';
import {LoginSplit} from '@/features/auth/components/LoginSplit';
export const metadata: Metadata = {
title: 'Sign in · Loyaly.ai',
};
/**
* Sits in the (public) group on purpose: no shell, no nav, no store scope, and
* a GuestGuard instead of an AuthGuard — see PublicLayout.
*
* Reaching this page with a live session is already impossible via a fresh
* request (src/proxy.ts redirects it to /dashboard); the guard covers the
* client-side navigation the proxy never sees.
*/
export default function LoginPage() {
return <LoginSplit />;
}

View File

@@ -1,13 +1,11 @@
'use client';
import {VStack} from '@astryxdesign/core/Layout';
import {PageHeader} from '@/components/primitives/PageHeader';
import {ScopeControls} from '@/components/scope/ScopeControls';
import {ActivityTimeline} from '@/features/dashboard/ActivityTimeline';
import {useResource} from '@/lib/api/useResource';
import {endpoints} from '@/lib/api/client';
import {useWorkspace} from '@/components/shell/WorkspaceProvider';
import {storeName} from '@/lib/mock/stores';
import {PageHeader} from '@/shared/components/primitives/PageHeader';
import {ScopeControls} from '@/shared/components/scope/ScopeControls';
import {ActivityTimeline} from '@/features/dashboard/components/ActivityTimeline';
import {useDashboardActivity} from '@/features/dashboard/hooks/useDashboard';
import {useScopeLabel} from '@/features/stores/hooks/useStoreDirectory';
/**
* The complete event history — where the dashboard's "View all" leads.
@@ -22,10 +20,8 @@ import {storeName} from '@/lib/mock/stores';
* uncapped: one feed implementation, two budgets.
*/
export default function ActivityPage() {
const {storeId, range} = useWorkspace();
const activity = useResource(endpoints.dashboardActivity({range, storeId}));
const scopeLabel = storeId === 'all' ? 'all stores' : storeName(storeId);
const activity = useDashboardActivity();
const scopeLabel = useScopeLabel();
return (
<VStack gap={5}>

View File

@@ -5,30 +5,37 @@ import {VStack} from '@astryxdesign/core/Layout';
import {Grid} from '@astryxdesign/core/Grid';
import {Button} from '@astryxdesign/core/Button';
import {Icon} from '@astryxdesign/core/Icon';
import {PageHeader} from '@/components/primitives/PageHeader';
import {ScopeControls} from '@/components/scope/ScopeControls';
import {ICONS} from '@/lib/icons';
import {ChartCard} from '@/components/charts/ChartCard';
import {AreaChartView} from '@/components/charts/AreaChartView';
import {LineChartView} from '@/components/charts/LineChartView';
import {BarChartView} from '@/components/charts/BarChartView';
import {HeatmapGrid} from '@/components/charts/HeatmapGrid';
import {KpiRow} from '@/features/dashboard/KpiRow';
import {ActivityTimeline} from '@/features/dashboard/ActivityTimeline';
import {RewardUsageChart} from '@/features/dashboard/RewardUsageChart';
import {StoreComparisonPanel} from '@/features/dashboard/StoreComparison';
import {PerformancePanel} from '@/features/dashboard/PerformancePanel';
import {useResource} from '@/lib/api/useResource';
import {endpoints} from '@/lib/api/client';
import {useWorkspace} from '@/components/shell/WorkspaceProvider';
import {storeName} from '@/lib/mock/stores';
import type {Granularity} from '@/lib/api/contracts';
import {PageHeader} from '@/shared/components/primitives/PageHeader';
import {ScopeControls} from '@/shared/components/scope/ScopeControls';
import {ICONS} from '@/shared/utils/icons';
import {ChartCard} from '@/shared/components/charts/ChartCard';
import {AreaChartView} from '@/shared/components/charts/AreaChartView';
import {LineChartView} from '@/shared/components/charts/LineChartView';
import {BarChartView} from '@/shared/components/charts/BarChartView';
import {HeatmapGrid} from '@/shared/components/charts/HeatmapGrid';
import {KpiRow} from '@/features/dashboard/components/KpiRow';
import {ActivityTimeline} from '@/features/dashboard/components/ActivityTimeline';
import {RewardUsageChart} from '@/features/dashboard/components/RewardUsageChart';
import {StoreComparisonPanel} from '@/features/dashboard/components/StoreComparison';
import {PerformancePanel} from '@/features/dashboard/components/PerformancePanel';
import {
useDashboardActivity,
useDashboardKpis,
useDashboardPeakHours,
useDashboardRewardUsage,
useDashboardStoreComparison,
useDashboardTimeseries,
} from '@/features/dashboard/hooks/useDashboard';
import {useWorkspace} from '@/shared/providers/WorkspaceProvider';
import {useScopeLabel} from '@/features/stores/hooks/useStoreDirectory';
import {greetingFor} from '@/features/dashboard/services/dashboardService';
import type {Granularity} from '@/features/dashboard/types/dashboard';
import {
formatCompact,
formatDayLabel,
formatInrCompact,
formatPct,
} from '@/lib/format';
} from '@/shared/utils/format';
/**
* An analytics workspace. Charts are the subject, not evidence for a to-do list.
@@ -39,7 +46,7 @@ import {
*
* Operational widgets — quick actions, tasks, the AI briefing, top store/reward,
* staff status — deliberately do NOT live here. The briefing endpoint they were
* built on is still live and feeds the Copilot's AI tab, which is where that
* built on is still live and feeds Loyaly AI's AI tab, which is where that
* class of content belongs: a panel you open to be told what to do, beside a
* dashboard you read to work it out yourself.
*
@@ -52,75 +59,35 @@ import {
* charts drawn from it, so they cannot disagree.
*/
/**
* Derived from the SERVER's clock, carried on the response meta.
*
* Calling Date.now() during render is impure and disagrees between the SSR and
* hydration passes. Before meta lands 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. getHours() resolves the server's instant in
* the viewer's zone, which is the hour the merchant is actually living in.
*/
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';
}
export default function DashboardPage() {
const {storeId, range} = useWorkspace();
const {storeId} = useWorkspace();
const [granularity, setGranularity] = useState<Granularity>('weekly');
// On by default: store comparison has always been part of the all-stores
// dashboard, so Compare starts pressed and switches the panel off, rather
// than hiding a panel that used to be there until someone finds the button.
const [isComparing, setIsComparing] = useState(true);
const scope = {range, storeId};
const kpis = useResource(endpoints.dashboardKpis(scope));
const series = useResource(endpoints.dashboardTimeseries(scope));
const peak = useResource(endpoints.dashboardPeakHours(scope));
const activity = useResource(endpoints.dashboardActivity(scope));
const rewards = useResource(endpoints.dashboardRewardUsage(scope));
const comparison = useResource(endpoints.dashboardStoreComparison(scope));
const kpis = useDashboardKpis();
const series = useDashboardTimeseries();
const peak = useDashboardPeakHours();
const activity = useDashboardActivity();
const rewards = useDashboardRewardUsage();
const comparison = useDashboardStoreComparison();
const isAllStores = storeId === 'all';
const scopeLabel = isAllStores ? 'all stores' : storeName(storeId);
const scopeLabel = useScopeLabel();
return (
<VStack gap={5}>
{/* 1 + 2 — greeting, title, and the store / period / compare filters. */}
{/* 1 + 2 — Sticky header: greeting, title, description, and filter controls. */}
<div className="sticky -top-5 z-40 -mx-5 px-5 pt-5 pb-4 bg-[#0F0F10] border-b border-[#2F2F2F] shadow-sm">
<PageHeader
eyebrow={greetingFor(kpis.meta?.generatedAt)}
title="Dashboard"
description={`Business performance across ${scopeLabel}.`}
controls={
<>
<ScopeControls />
<Button
size="sm"
// Pressed state is the raised gray of `primary` in this theme —
// a toggle needs a visible on-state, and it must not be colour.
variant={isComparing ? 'primary' : 'secondary'}
label="Compare"
icon={<Icon icon={ICONS.compare} size="sm" />}
aria-pressed={isComparing}
isDisabled={!isAllStores}
// With a tooltip present, Button uses aria-disabled rather than
// the native attribute, so the reason stays reachable by keyboard
// instead of the control just going dead.
tooltip={
isAllStores
? undefined
: 'Comparing stores needs the All stores scope'
}
onClick={() => setIsComparing((v) => !v)}
/>
</>
}
controls={<ScopeControls />}
/>
</div>
{/* 3 — headline numbers. */}
<KpiRow resource={kpis} />
@@ -209,7 +176,6 @@ export default function DashboardPage() {
{/* 7 — period rollup. */}
<PerformancePanel
scope={scope}
granularity={granularity}
onGranularityChange={setGranularity}
/>

View File

@@ -1,160 +1,16 @@
'use client';
import {AppShell} from '@astryxdesign/core/AppShell';
import {
Layout,
LayoutContent,
LayoutHeader,
LayoutPanel,
VStack,
} from '@astryxdesign/core/Layout';
import {AppSideNav} from '@/components/shell/AppSideNav';
import {AppTopNav} from '@/components/shell/AppTopNav';
import {MobileMenu} from '@/components/shell/MobileMenu';
import {SidebarProvider, useSidebar} from '@/components/shell/SidebarProvider';
import {CopilotPanel} from '@/features/copilot/CopilotPanel';
import {CopilotSlideOver} from '@/features/copilot/CopilotSlideOver';
import {useCopilot} from '@/features/copilot/CopilotProvider';
import {
useBreakpoint,
isPanelInline,
isSideNavInline,
copilotWidth,
contentMaxWidth,
} from '@/lib/breakpoints';
import {ProtectedLayout} from '@/shared/layouts/ProtectedLayout';
/**
* The three-column shell, and the reason the Copilot survives navigation.
* The route-group boundary for every authenticated screen.
*
* Next's App Router preserves a layout's element identity across sibling route
* changes, so /dashboard → /staff swaps only {children}. Everything else here —
* nav, scroll position, the Copilot — is untouched.
*
* Structure, and why AppShell alone is not enough: AppShell renders
* Layout{header, start, content} internally and exposes no end/panel slot. The
* Copilot column therefore comes from a NESTED Layout inside its children.
*
* AppShell(sideNav)
* └─ Layout(header = TopNav, content = page, end = LayoutPanel > Copilot)
*
* The top nav is deliberately NOT passed to AppShell's `topNav` slot. That slot
* renders Layout{header} at the shell root, which spans the full viewport width
* and pushes the sidebar — and therefore the branding — 48px down the screen.
* Measured in the browser, the logo sat at y=64 with the top-left 260x48 region
* empty. Moving the bar into the content column's own header starts the sidebar
* at y=0, so the brand anchors in the window's top-left corner and the bar
* begins where the content does. This is the arrangement Linear, Cursor and
* Stripe use, and no amount of padding inside the sidebar could produce it.
*
* The one thing given up: with no `topNav`, AppShell drops the --radius-page
* corner it draws where the top bar meets the sidebar. The content area still
* paints elevated --color-background-surface (#0F0F10) against the #000 rails,
* and with a full-height sidebar that corner had nothing left to round.
* Deliberately thin: the composition lives in ProtectedLayout, so this file
* never needs editing again and the shell can be reused (tests, a future
* embedded view) without a route existing for it.
*/
export default function WorkspaceLayout({
export default function WorkspaceRouteLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<SidebarProvider>
<WorkspaceShell>{children}</WorkspaceShell>
</SidebarProvider>
);
}
/**
* Split out from the layout only so it can READ the sidebar context that the
* layout mounts — a provider's own component sits above its value.
*/
function WorkspaceShell({children}: {children: React.ReactNode}) {
const bp = useBreakpoint();
const {isOpen} = useCopilot();
const inline = isPanelInline(bp);
const showInlinePanel = inline && isOpen;
// Both flags come from SidebarProvider now. AppShell keeps a copy of the
// open state for its own context (MobileNav reads it for aria wiring), but
// ours is the one that decides — uncontrolled, there would be no way to
// dismiss the drawer when a nav item is selected.
const {isDrawerOpen, setDrawerOpen} = useSidebar();
// Below the breakpoint the rail is gone — it lives in the drawer — so there
// is no top-left corner to anchor the brand to and nothing for the bar to
// start after. Hand the top nav back to AppShell there. Keeping it in the
// content column instead pushed the SideNav into AppShell's
// `autoMobileTopBar` fallback, which crops the lockup into a 48px bar.
const sideNavInline = isSideNavInline(bp);
return (
<AppShell
variant="elevated"
height="fill"
// Layout/LayoutContent own the padding so the Copilot panel can sit
// flush against the workspace edge.
contentPadding={0}
topNav={sideNavInline ? undefined : <AppTopNav />}
sideNav={<AppSideNav />}
// 'sm' (640px) matches the mobile/tablet line in lib/breakpoints —
// AppShell's own threshold and ours have to agree or the hamburger and
// the rail are both visible, or neither is.
mobileNav={{
breakpoint: 'sm',
isOpen: isDrawerOpen,
onOpenChange: setDrawerOpen,
// hasToggle: false — AppShell would otherwise inject a SECOND
// hamburger into TopNav's mobile bar, next to the one in `heading`
// that works at every width. See AppTopNav.
hasToggle: false,
content: <MobileMenu />,
}}
>
<Layout
height="fill"
header={
sideNavInline ? (
<LayoutHeader padding={0}>
<AppTopNav />
</LayoutHeader>
) : undefined
}
content={
<LayoutContent padding={5}>
{/*
Above 1920 the workspace is capped and centred. Uncapped, a
30-day line chart stretches across ~1900px, which flattens every
trend it exists to show, and body copy runs well past the ~90ch
where reading breaks down. Below the cap this is a no-op —
maxWidth only binds once there is more width than content wants.
*/}
<VStack
width="100%"
maxWidth={contentMaxWidth(bp)}
className={bp === 'ultrawide' ? 'mx-auto' : undefined}
>
{children}
</VStack>
</LayoutContent>
}
end={
showInlinePanel ? (
<LayoutPanel
hasDivider
width={copilotWidth(bp)}
// CopilotPanel supplies its own header and tab chrome.
padding={0}
role="complementary"
label="Loyaly AI"
>
<CopilotPanel />
</LayoutPanel>
) : undefined
}
/>
{/* Mounted only below the inline breakpoint. Because every piece of
Copilot state lives in CopilotProvider (above the route tree), moving
between these two presentations is lossless. */}
{!inline ? <CopilotSlideOver /> : null}
</AppShell>
);
return <ProtectedLayout>{children}</ProtectedLayout>;
}

View File

@@ -3,31 +3,33 @@
import {VStack} from '@astryxdesign/core/Layout';
import {Grid} from '@astryxdesign/core/Grid';
import {Text} from '@astryxdesign/core/Text';
import {PageHeader} from '@/components/primitives/PageHeader';
import {MetricCard} from '@/components/patterns/MetricCard';
import {SkeletonMetricGrid} from '@/components/patterns/LoadingState';
import {AsyncBoundary} from '@/components/data/AsyncBoundary';
import {ChartCard} from '@/components/charts/ChartCard';
import {AreaChartView} from '@/components/charts/AreaChartView';
import {LineChartView} from '@/components/charts/LineChartView';
import {BarChartView} from '@/components/charts/BarChartView';
import {RewardGrid} from '@/features/lyts/RewardGrid';
import {ExpiryAlerts} from '@/features/lyts/ExpiryAlerts';
import {RewardPerformanceTable} from '@/features/lyts/RewardPerformanceTable';
import {ActivityTimeline} from '@/features/dashboard/ActivityTimeline';
import {useMetricColumns} from '@/features/dashboard/KpiRow';
import {useResource} from '@/lib/api/useResource';
import {endpoints} from '@/lib/api/client';
import {useWorkspace} from '@/components/shell/WorkspaceProvider';
import {ScopeControls} from '@/components/scope/ScopeControls';
import {ICONS} from '@/lib/icons';
import {usageRate} from '@/features/lyts/reward-status';
import {PageHeader} from '@/shared/components/primitives/PageHeader';
import {MetricCard} from '@/shared/components/patterns/MetricCard';
import {SkeletonMetricGrid} from '@/shared/components/patterns/LoadingState';
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
import {ChartCard} from '@/shared/components/charts/ChartCard';
import {AreaChartView} from '@/shared/components/charts/AreaChartView';
import {LineChartView} from '@/shared/components/charts/LineChartView';
import {BarChartView} from '@/shared/components/charts/BarChartView';
import {RewardGrid} from '@/features/lyts/components/RewardGrid';
import {ExpiryAlerts} from '@/features/lyts/components/ExpiryAlerts';
import {RewardPerformanceTable} from '@/features/lyts/components/RewardPerformanceTable';
import {ActivityTimeline} from '@/features/dashboard/components/ActivityTimeline';
import {useMetricColumns} from '@/features/dashboard/components/KpiRow';
import {
useLytsActivity,
useRedemptions,
useRewards,
} from '@/features/lyts/hooks/useLyts';
import {ScopeControls} from '@/shared/components/scope/ScopeControls';
import {ICONS} from '@/shared/utils/icons';
import {usageRate} from '@/features/lyts/services/lytsService';
import {
formatCompact,
formatDayLabel,
formatLyt,
formatPct,
} from '@/lib/format';
} from '@/shared/utils/format';
/**
* The LYT programme.
@@ -40,12 +42,9 @@ import {
* rupee liability — which is why they lead.
*/
export default function LytsPage() {
const {storeId, range} = useWorkspace();
const scope = {range, storeId};
const rewards = useResource(endpoints.lytsRewards(scope));
const redemptions = useResource(endpoints.lytsRedemptions(scope));
const activity = useResource(endpoints.lytsActivity(scope));
const rewards = useRewards();
const redemptions = useRedemptions();
const activity = useLytsActivity();
const columns = useMetricColumns();
@@ -63,7 +62,7 @@ export default function LytsPage() {
return (
<VStack gap={5}>
<PageHeader
title="Lyts"
title="Lytsup"
description="Reward performance, redemption and outstanding LYT liability. 1 LYT = ₹1."
controls={<ScopeControls />}
/>

View File

@@ -1,5 +1,5 @@
import {SettingsPage} from '@/features/settings/SettingsPage';
import {ApiWebhooksManager} from '@/features/settings/ApiWebhooksManager';
import {SettingsPage} from '@/features/settings/components/SettingsPage';
import {ApiWebhooksManager} from '@/features/settings/components/ApiWebhooksManager';
export default function ApiSettingsPage() {
return (

View File

@@ -1,5 +1,5 @@
import {SettingsPage} from '@/features/settings/SettingsPage';
import {BillingOverview} from '@/features/settings/BillingOverview';
import {SettingsPage} from '@/features/settings/components/SettingsPage';
import {BillingOverview} from '@/features/settings/components/BillingOverview';
export default function SettingsBillingPage() {
return (

View File

@@ -1,5 +1,5 @@
import {SettingsPage} from '@/features/settings/SettingsPage';
import {IntegrationsGrid} from '@/features/settings/IntegrationsGrid';
import {SettingsPage} from '@/features/settings/components/SettingsPage';
import {IntegrationsGrid} from '@/features/settings/components/IntegrationsGrid';
export default function IntegrationsSettingsPage() {
return (

View File

@@ -11,8 +11,8 @@ import {SideNav, SideNavItem, SideNavSection} from '@astryxdesign/core/SideNav';
import {DropdownMenu} from '@astryxdesign/core/DropdownMenu';
import {Icon} from '@astryxdesign/core/Icon';
import {useRouter} from 'next/navigation';
import {useBreakpoint} from '@/lib/breakpoints';
import {SETTINGS_NAV, isSettingsActive} from '@/features/settings/settings-nav';
import {useBreakpoint} from '@/shared/hooks/useBreakpoint';
import {SETTINGS_NAV, isSettingsActive} from '@/features/settings/config/settingsNav';
/**
* Settings gets its own sub-navigation.
@@ -97,7 +97,7 @@ export default function SettingsLayout({
bottom edge (measured: scrollWidth 272 in a 240px panel).
*/}
<SideNav className="w-full">
<SideNavSection title="Settings" isHeaderHidden>
<SideNavSection title="Settings">
{SETTINGS_NAV.map((s) => (
<SideNavItem
key={s.href}

View File

@@ -1,5 +1,5 @@
import {SettingsPage} from '@/features/settings/SettingsPage';
import {NotificationsForm} from '@/features/settings/NotificationsForm';
import {SettingsPage} from '@/features/settings/components/SettingsPage';
import {NotificationsForm} from '@/features/settings/components/NotificationsForm';
export default function NotificationsSettingsPage() {
return (

View File

@@ -1,5 +1,5 @@
import {SettingsPage} from '@/features/settings/SettingsPage';
import {BusinessForm} from '@/features/settings/BusinessForm';
import {SettingsPage} from '@/features/settings/components/SettingsPage';
import {BusinessForm} from '@/features/settings/components/BusinessForm';
export default function BusinessSettingsPage() {
return (

View File

@@ -1,5 +1,5 @@
import {SettingsPage} from '@/features/settings/SettingsPage';
import {PreferencesForm} from '@/features/settings/PreferencesForm';
import {SettingsPage} from '@/features/settings/components/SettingsPage';
import {PreferencesForm} from '@/features/settings/components/PreferencesForm';
export default function PreferencesSettingsPage() {
return (

View File

@@ -1,9 +1,18 @@
import {SettingsPage} from '@/features/settings/SettingsPage';
import {ProfileForm} from '@/features/settings/ProfileForm';
import {readProfile} from '@/lib/mock/settings';
import {SettingsPage} from '@/features/settings/components/SettingsPage';
import {ProfileForm} from '@/features/settings/components/ProfileForm';
import {settingsServerRepository} from '@/features/settings/repositories/settingsServerRepository';
export default function MerchantProfilePage() {
const profile = readProfile();
/**
* Server Component: the record is read on the server and handed to the form as
* its initial state, so the inputs paint filled rather than flashing empty.
* The form then saves through the client repository over HTTP.
*
* The read goes through a repository rather than the fixture module the page
* used to import — a page that knows the shape of a mock is a page that breaks
* the day the mock is deleted.
*/
export default async function MerchantProfilePage() {
const profile = await settingsServerRepository.getProfile();
return (
<SettingsPage

View File

@@ -1,5 +1,5 @@
import {SettingsPage} from '@/features/settings/SettingsPage';
import {RoleMatrix} from '@/features/settings/RoleMatrix';
import {SettingsPage} from '@/features/settings/components/SettingsPage';
import {RoleMatrix} from '@/features/settings/components/RoleMatrix';
export default function RolesSettingsPage() {
return (

View File

@@ -1,5 +1,5 @@
import {SettingsPage} from '@/features/settings/SettingsPage';
import {SecurityManager} from '@/features/settings/SecurityManager';
import {SettingsPage} from '@/features/settings/components/SettingsPage';
import {SecurityManager} from '@/features/settings/components/SecurityManager';
export default function SecuritySettingsPage() {
return (

View File

@@ -1,5 +1,5 @@
import {SettingsPage} from '@/features/settings/SettingsPage';
import {StoreManagement} from '@/features/settings/StoreManagement';
import {SettingsPage} from '@/features/settings/components/SettingsPage';
import {StoreManagement} from '@/features/settings/components/StoreManagement';
export default function StoreSettingsPage() {
return (

View File

@@ -1,5 +1,5 @@
import {SettingsPage} from '@/features/settings/SettingsPage';
import {TeamManagement} from '@/features/settings/TeamManagement';
import {SettingsPage} from '@/features/settings/components/SettingsPage';
import {TeamManagement} from '@/features/settings/components/TeamManagement';
export default function TeamSettingsPage() {
return (

View File

@@ -2,19 +2,21 @@
import {VStack} from '@astryxdesign/core/Layout';
import {Grid} from '@astryxdesign/core/Grid';
import {PageHeader} from '@/components/primitives/PageHeader';
import {ChartCard} from '@/components/charts/ChartCard';
import {BarChartView} from '@/components/charts/BarChartView';
import {StaffKpis} from '@/features/staff/StaffKpis';
import {StaffGrid} from '@/features/staff/StaffGrid';
import {AttendanceChart} from '@/features/staff/AttendanceChart';
import {Leaderboard} from '@/features/staff/Leaderboard';
import {useResource} from '@/lib/api/useResource';
import {endpoints} from '@/lib/api/client';
import {useWorkspace} from '@/components/shell/WorkspaceProvider';
import {ScopeControls} from '@/components/scope/ScopeControls';
import {storeName} from '@/lib/mock/stores';
import {formatCompact} from '@/lib/format';
import {PageHeader} from '@/shared/components/primitives/PageHeader';
import {ChartCard} from '@/shared/components/charts/ChartCard';
import {BarChartView} from '@/shared/components/charts/BarChartView';
import {StaffKpis} from '@/features/staff/components/StaffKpis';
import {StaffGrid} from '@/features/staff/components/StaffGrid';
import {AttendanceChart} from '@/features/staff/components/AttendanceChart';
import {Leaderboard} from '@/features/staff/components/Leaderboard';
import {
useStaffAttendance,
useStaffList,
useStaffSummary,
} from '@/features/staff/hooks/useStaff';
import {useScopeLabel} from '@/features/stores/hooks/useStoreDirectory';
import {ScopeControls} from '@/shared/components/scope/ScopeControls';
import {formatCompact} from '@/shared/utils/format';
/**
* The team.
@@ -23,18 +25,10 @@ import {formatCompact} from '@/lib/format';
* morning is "who is in today", and only then "who is performing".
*/
export default function StaffPage() {
const {storeId, range} = useWorkspace();
const scope = {range, storeId};
const summary = useResource(endpoints.staffSummary(scope), {
// A summary object is never "empty" the way a list is — zero present is
// a real answer, not an absence of data.
isEmpty: () => false,
});
const staff = useResource(endpoints.staff(scope));
const attendance = useResource(endpoints.staffAttendance(scope));
const scopeLabel = storeId === 'all' ? 'all stores' : storeName(storeId);
const summary = useStaffSummary();
const staff = useStaffList();
const attendance = useStaffAttendance();
const scopeLabel = useScopeLabel();
return (
<VStack gap={5}>

View File

@@ -9,29 +9,30 @@ import {
BreadcrumbItem,
Breadcrumbs,
} from '@astryxdesign/core/Breadcrumbs';
import {PageHeader} from '@/components/primitives/PageHeader';
import {MetricCard} from '@/components/patterns/MetricCard';
import {ChartCard} from '@/components/charts/ChartCard';
import {AreaChartView} from '@/components/charts/AreaChartView';
import {BarChartView} from '@/components/charts/BarChartView';
import {LineChartView} from '@/components/charts/LineChartView';
import {HeatmapGrid} from '@/components/charts/HeatmapGrid';
import {AsyncBoundary} from '@/components/data/AsyncBoundary';
import {SkeletonMetricGrid} from '@/components/patterns/LoadingState';
import {ActivityTimeline} from '@/features/dashboard/ActivityTimeline';
import {useMetricColumns} from '@/features/dashboard/KpiRow';
import {useResource} from '@/lib/api/useResource';
import {endpoints} from '@/lib/api/client';
import {useWorkspace} from '@/components/shell/WorkspaceProvider';
import {ScopeControls} from '@/components/scope/ScopeControls';
import {ICONS} from '@/lib/icons';
import {PageHeader} from '@/shared/components/primitives/PageHeader';
import {MetricCard} from '@/shared/components/patterns/MetricCard';
import {ChartCard} from '@/shared/components/charts/ChartCard';
import {AreaChartView} from '@/shared/components/charts/AreaChartView';
import {BarChartView} from '@/shared/components/charts/BarChartView';
import {LineChartView} from '@/shared/components/charts/LineChartView';
import {HeatmapGrid} from '@/shared/components/charts/HeatmapGrid';
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
import {SkeletonMetricGrid} from '@/shared/components/patterns/LoadingState';
import {ActivityTimeline} from '@/features/dashboard/components/ActivityTimeline';
import {useMetricColumns} from '@/features/dashboard/components/KpiRow';
import {useResource} from '@/shared/hooks/useResource';
import {storeRepository} from '@/features/stores/repositories/storeRepository';
import {dashboardRepository} from '@/features/dashboard/repositories/dashboardRepository';
import {useWorkspace} from '@/shared/providers/WorkspaceProvider';
import {ScopeControls} from '@/shared/components/scope/ScopeControls';
import {ICONS} from '@/shared/utils/icons';
import {
formatCompact,
formatDayLabel,
formatInrCompact,
formatPct,
} from '@/lib/format';
import type {StoreStatus} from '@/lib/api/contracts';
} from '@/shared/utils/format';
import type {StoreStatus} from '@/features/stores/types/store';
const STATUS: Record<
StoreStatus,
@@ -59,13 +60,13 @@ export default function StoreDetailPage({
const {range} = useWorkspace();
const scope = {range, storeId};
const store = useResource(endpoints.store(scope, storeId), {
const store = useResource(storeRepository.byId(scope, storeId), {
isEmpty: () => false,
});
const kpis = useResource(endpoints.dashboardKpis(scope));
const series = useResource(endpoints.dashboardTimeseries(scope));
const peak = useResource(endpoints.dashboardPeakHours(scope));
const activity = useResource(endpoints.dashboardActivity(scope));
const kpis = useResource(dashboardRepository.kpis(scope));
const series = useResource(dashboardRepository.timeseries(scope));
const peak = useResource(dashboardRepository.peakHours(scope));
const activity = useResource(dashboardRepository.activity(scope));
const columns = useMetricColumns();
const name = store.data?.name ?? 'Store';

View File

@@ -1,15 +1,14 @@
'use client';
import {VStack} from '@astryxdesign/core/Layout';
import {PageHeader} from '@/components/primitives/PageHeader';
import {StoreGrid} from '@/features/stores/StoreGrid';
import {useResource} from '@/lib/api/useResource';
import {endpoints} from '@/lib/api/client';
import {PageHeader} from '@/shared/components/primitives/PageHeader';
import {StoreGrid} from '@/features/stores/components/StoreGrid';
import {useStoreList} from '@/features/stores/hooks/useStores';
import {
RANGE_LABELS,
useWorkspace,
} from '@/components/shell/WorkspaceProvider';
import {ScopeControls} from '@/components/scope/ScopeControls';
} from '@/shared/providers/WorkspaceProvider';
import {ScopeControls} from '@/shared/components/scope/ScopeControls';
/**
* The store roster.
@@ -21,7 +20,7 @@ import {ScopeControls} from '@/components/scope/ScopeControls';
*/
export default function StoresPage() {
const {range} = useWorkspace();
const stores = useResource(endpoints.stores({range, storeId: 'all'}));
const stores = useStoreList();
return (
<VStack gap={5}>

View File

@@ -0,0 +1,117 @@
import {cookies} from 'next/headers';
import type {NextRequest} from 'next/server';
import {verifyCredentials} from '@/features/auth/mock/users.mock';
import {
REMEMBERED_MAX_AGE_SECONDS,
SESSION_COOKIE,
SESSION_MAX_AGE_SECONDS,
createSessionToken,
sessionCookieOptions,
} from '@/features/auth/services/sessionToken';
import type {AuthSession, LoginError} from '@/features/auth/types/auth';
import type {ApiFailure, ApiSuccess} from '@/shared/types/api';
export const dynamic = 'force-dynamic';
/**
* POST /api/auth/login
*
* The seam a real backend replaces. Everything above it — the repository, the
* service, the form — speaks in {credentials} → {session | error} and does not
* care whether the check happened against a fixture or an identity provider.
*
* Responsibilities that deliberately live HERE and not in the client:
* • deciding whether the credentials are valid
* • deciding how long the session lasts (rememberMe is a request, not an
* instruction — the server sets the cookie lifetime)
* • issuing the httpOnly cookie the client can never read or forge
*/
interface LoginRequestBody {
email?: unknown;
password?: unknown;
rememberMe?: unknown;
}
function failure(error: LoginError, status: number): Response {
// Shaped as the app's standard envelope so the client's error path is the
// same one every other endpoint uses; `field` rides alongside for the form.
const body: ApiFailure & {field: LoginError['field']} = {
error: {code: status === 401 ? 'unauthorized' : 'bad_request', message: error.message},
field: error.field,
};
return Response.json(body, {status, headers: {'cache-control': 'no-store'}});
}
export async function POST(req: NextRequest): Promise<Response> {
let body: LoginRequestBody;
try {
body = (await req.json()) as LoginRequestBody;
} catch {
return failure({field: 'form', message: 'Malformed request body.'}, 400);
}
const email = typeof body.email === 'string' ? body.email.trim() : '';
const password = typeof body.password === 'string' ? body.password : '';
const rememberMe = body.rememberMe === true;
// Server-side validation, repeated rather than trusted from the client. The
// form validates too, for latency; this validates because the form is not a
// security boundary and a POST can arrive without it.
if (!email) {
return failure({field: 'email', message: 'Enter your email address.'}, 400);
}
if (!/^\S+@\S+\.\S+$/.test(email)) {
return failure(
{field: 'email', message: 'Enter a valid email address.'},
400,
);
}
if (!password) {
return failure({field: 'password', message: 'Enter your password.'}, 400);
}
const check = verifyCredentials(email, password);
if (check.outcome === 'unknown_email' || check.outcome === 'wrong_password') {
return failure(
{field: 'form', message: 'Invalid email or password.'},
401,
);
}
const maxAge = rememberMe
? REMEMBERED_MAX_AGE_SECONDS
: SESSION_MAX_AGE_SECONDS;
const token = createSessionToken(
{
sub: check.user.id,
email: check.user.email,
name: check.user.name,
role: check.user.role,
organisation: check.user.organisation,
},
maxAge,
);
// A browser-session cookie still needs a server-side expiry, or a tab left
// open for a week would hold a valid token indefinitely.
const store = await cookies();
store.set(
SESSION_COOKIE,
token,
sessionCookieOptions(rememberMe ? maxAge : undefined),
);
const session: AuthSession = {
user: check.user,
expiresAt: new Date(Date.now() + maxAge * 1000).toISOString(),
};
const payload: ApiSuccess<AuthSession> = {
data: session,
meta: {generatedAt: new Date().toISOString()},
};
return Response.json(payload, {headers: {'cache-control': 'no-store'}});
}

View File

@@ -0,0 +1,29 @@
import {cookies} from 'next/headers';
import {
SESSION_COOKIE,
sessionCookieOptions,
} from '@/features/auth/services/sessionToken';
export const dynamic = 'force-dynamic';
/**
* POST /api/auth/logout
*
* Ending a session is a server action, not a client one: only the server can
* invalidate an httpOnly cookie. The client clearing its own state would leave
* the credential intact and the next request still authenticated.
*
* Overwritten with an expired value rather than only `.delete()`-ed — some
* proxies drop a bare deletion, and an empty value fails signature
* verification anyway, so the session is dead by two independent routes.
*/
export async function POST(): Promise<Response> {
const store = await cookies();
store.set(SESSION_COOKIE, '', sessionCookieOptions(0));
store.delete(SESSION_COOKIE);
return Response.json(
{data: {ok: true}, meta: {generatedAt: new Date().toISOString()}},
{headers: {'cache-control': 'no-store'}},
);
}

View File

@@ -0,0 +1,42 @@
import {cookies} from 'next/headers';
import {findUserById} from '@/features/auth/mock/users.mock';
import {
SESSION_COOKIE,
verifySessionToken,
} from '@/features/auth/services/sessionToken';
import type {AuthSession} from '@/features/auth/types/auth';
import type {ApiSuccess} from '@/shared/types/api';
export const dynamic = 'force-dynamic';
/**
* GET /api/auth/session
*
* The client's only source of truth about who it is. It cannot read the
* httpOnly cookie, so it asks — and the answer comes from verifying a
* signature, not from believing something the browser stored.
*
* Returns 200 with `data: null` for "no session" rather than 401. A signed-out
* visitor is a normal state for this endpoint, not an error, and modelling it
* as one means every caller has to special-case a failure that is not one.
*
* The user is re-resolved from the directory rather than read straight off the
* token: a role change or a deactivation must take effect on the next request,
* not whenever the cookie happens to expire.
*/
export async function GET(): Promise<Response> {
const store = await cookies();
const payload = verifySessionToken(store.get(SESSION_COOKIE)?.value);
const user = payload ? findUserById(payload.sub) : null;
const body: ApiSuccess<AuthSession | null> = {
data:
payload && user
? {user, expiresAt: new Date(payload.exp * 1000).toISOString()}
: null,
meta: {generatedAt: new Date().toISOString()},
};
return Response.json(body, {headers: {'cache-control': 'no-store'}});
}

View File

@@ -1,10 +1,12 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, simulate, wantsEmpty} from '@/lib/api/server';
import {buildActivity} from '@/lib/mock/dashboard';
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
import {buildActivity} from '@/features/dashboard/mock/dashboard.mock';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;

View File

@@ -1,12 +1,14 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, simulate, wantsEmpty} from '@/lib/api/server';
import {buildBriefing} from '@/lib/mock/briefing';
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
import {buildBriefing} from '@/features/dashboard/mock/briefing.mock';
export const dynamic = 'force-dynamic';
const EMPTY = {summary: '', alerts: [], tasks: []};
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;

View File

@@ -1,10 +1,12 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, simulate, wantsEmpty} from '@/lib/api/server';
import {buildKpis} from '@/lib/mock/dashboard';
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
import {buildKpis} from '@/features/dashboard/mock/dashboard.mock';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;

View File

@@ -1,10 +1,12 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, simulate, wantsEmpty} from '@/lib/api/server';
import {buildPeakHours} from '@/lib/mock/dashboard';
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
import {buildPeakHours} from '@/features/dashboard/mock/dashboard.mock';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;

View File

@@ -1,11 +1,13 @@
import type {NextRequest} from 'next/server';
import type {Granularity} from '@/lib/api/contracts';
import {ok, parseQuery, simulate, wantsEmpty} from '@/lib/api/server';
import {buildPeriodPerformance} from '@/lib/mock/analytics';
import type {Granularity} from '@/features/dashboard/types/dashboard';
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
import {buildPeriodPerformance} from '@/features/dashboard/mock/analytics.mock';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;

View File

@@ -1,10 +1,12 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, simulate, wantsEmpty} from '@/lib/api/server';
import {buildRewardUsage} from '@/lib/mock/analytics';
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
import {buildRewardUsage} from '@/features/dashboard/mock/analytics.mock';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;

View File

@@ -1,10 +1,12 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, simulate, wantsEmpty} from '@/lib/api/server';
import {buildStoreComparison} from '@/lib/mock/analytics';
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
import {buildStoreComparison} from '@/features/dashboard/mock/analytics.mock';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;

View File

@@ -1,10 +1,12 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, simulate, wantsEmpty} from '@/lib/api/server';
import {buildTimeseries} from '@/lib/mock/dashboard';
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
import {buildTimeseries} from '@/features/dashboard/mock/dashboard.mock';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;

View File

@@ -1,10 +1,12 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, simulate, wantsEmpty} from '@/lib/api/server';
import {buildLytActivity} from '@/lib/mock/lyts';
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
import {buildLytActivity} from '@/features/lyts/mock/lyts.mock';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;

View File

@@ -1,10 +1,12 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, simulate, wantsEmpty} from '@/lib/api/server';
import {buildRedemptions} from '@/lib/mock/lyts';
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
import {buildRedemptions} from '@/features/lyts/mock/lyts.mock';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;

View File

@@ -1,10 +1,12 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, simulate, wantsEmpty} from '@/lib/api/server';
import {buildRewards} from '@/lib/mock/lyts';
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
import {buildRewards} from '@/features/lyts/mock/lyts.mock';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;

View File

@@ -1,10 +1,12 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, simulate} from '@/lib/api/server';
import {readProfile} from '@/lib/mock/settings';
import {ok, parseQuery, requireApiSession, simulate} from '@/shared/services/apiRoute';
import {readProfile} from '@/features/settings/mock/settings.mock';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
@@ -19,6 +21,8 @@ export async function GET(req: NextRequest) {
* which is enough for the form to exercise its success path honestly.
*/
export async function PATCH(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;

View File

@@ -1,10 +1,12 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, simulate, wantsEmpty} from '@/lib/api/server';
import {buildAttendance} from '@/lib/mock/staff';
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
import {buildAttendance} from '@/features/staff/mock/staff.mock';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;

View File

@@ -1,10 +1,12 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, simulate, wantsEmpty} from '@/lib/api/server';
import {buildStaff} from '@/lib/mock/staff';
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
import {buildStaff} from '@/features/staff/mock/staff.mock';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;

View File

@@ -1,10 +1,12 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, simulate} from '@/lib/api/server';
import {buildStaffSummary} from '@/lib/mock/staff';
import {ok, parseQuery, requireApiSession, simulate} from '@/shared/services/apiRoute';
import {buildStaffSummary} from '@/features/staff/mock/staff.mock';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;

View File

@@ -1,6 +1,6 @@
import type {NextRequest} from 'next/server';
import {fail, ok, parseQuery, simulate} from '@/lib/api/server';
import {buildStore} from '@/lib/mock/stores';
import {fail, ok, parseQuery, requireApiSession, simulate} from '@/shared/services/apiRoute';
import {buildStore} from '@/features/stores/mock/stores.mock';
export const dynamic = 'force-dynamic';
@@ -8,6 +8,8 @@ export async function GET(
req: NextRequest,
{params}: {params: Promise<{storeId: string}>},
) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;

View File

@@ -1,10 +1,12 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, simulate, wantsEmpty} from '@/lib/api/server';
import {buildStores} from '@/lib/mock/stores';
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
import {buildStores} from '@/features/stores/mock/stores.mock';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;

View File

@@ -30,6 +30,34 @@ html {
}
@layer components {
/* Global Data Table Inset Contract: 24px left lead-in, 32px right inset */
.astryx-table-cell:first-child,
.astryx-table-header-cell:first-child,
table th:first-child,
table td:first-child {
padding-inline-start: var(--spacing-6) !important;
}
.astryx-table-cell:last-child,
.astryx-table-header-cell:last-child,
table th:last-child,
table td:last-child {
padding-inline-end: var(--spacing-8) !important;
}
/* Sidebar Navigation typography & bold icon styling */
.astryx-side-nav-item {
font-size: 15px !important;
font-weight: 600 !important;
letter-spacing: -0.01em !important;
}
.astryx-side-nav-item svg {
width: 20px !important;
height: 20px !important;
stroke-width: 2.2px !important;
}
/* Kill switch for our own CSS transitions. Framer Motion is handled by
<MotionConfig reducedMotion="user">, recharts by useChartMotion(). */
@media (prefers-reduced-motion: reduce) {

View File

@@ -1,6 +1,7 @@
import type {Metadata, Viewport} from 'next';
import {Sora, Inter} from 'next/font/google';
import './globals.css';
import {getServerSession} from '@/features/auth/services/serverSession';
import {Providers} from './providers';
const sora = Sora({
@@ -43,7 +44,19 @@ export const viewport: Viewport = {
themeColor: '#000000',
};
export default function RootLayout({children}: {children: React.ReactNode}) {
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
/**
* Resolved on the server so the client provider starts already knowing who
* it is. Without the seed, every full page load would paint a guard spinner
* while the browser asked `/api/auth/session` a question the server had
* already answered in this very request.
*/
const session = await getServerSession();
return (
// The theme attributes are server-rendered so the @scope rules in
// loyaly.css match on the very first byte — no flash from stock defaults.
@@ -55,7 +68,7 @@ export default function RootLayout({children}: {children: React.ReactNode}) {
suppressHydrationWarning
>
<body>
<Providers>{children}</Providers>
<Providers initialSession={session}>{children}</Providers>
</body>
</html>
);

View File

@@ -1,15 +0,0 @@
import type {Metadata} from 'next';
import {LoginSplit} from '@/features/auth/LoginSplit';
export const metadata: Metadata = {
title: 'Sign in · Loyaly.ai',
};
/**
* Sits outside the (workspace) group on purpose: no shell, no nav, no store
* scope. Uses the official Astryx Login Split template layout tailored for Loyaly.
*/
export default function LoginPage() {
return <LoginSplit />;
}

View File

@@ -1,5 +1,16 @@
import {redirect} from 'next/navigation';
import {getServerSession} from '@/features/auth/services/serverSession';
export default function RootPage() {
redirect('/dashboard');
/**
* `/` normally never renders — src/proxy.ts redirects it first. This is the
* backstop for the case where the proxy's matcher is ever narrowed, and it
* makes the same decision from the same source of truth rather than guessing:
* signed in → the workspace, signed out → the sign-in screen.
*
* Never a static redirect to /dashboard. That was the old behaviour, and it is
* exactly how an unauthenticated visitor used to land inside the app.
*/
export default async function RootPage() {
const session = await getServerSession();
redirect(session ? '/dashboard' : '/login');
}

View File

@@ -6,8 +6,10 @@ import {Theme} from '@astryxdesign/core/theme';
import {LinkProvider} from '@astryxdesign/core/Link';
import {ToastViewport} from '@astryxdesign/core/Toast';
import {loyalyTheme} from '@/theme';
import {WorkspaceProvider} from '@/components/shell/WorkspaceProvider';
import {CopilotProvider} from '@/features/copilot/CopilotProvider';
import {WorkspaceProvider} from '@/shared/providers/WorkspaceProvider';
import {SessionProvider} from '@/features/auth/providers/SessionProvider';
import type {AuthSession} from '@/features/auth/types/auth';
import {LoyalyAiProvider} from '@/features/loyaly-ai/providers/LoyalyAiProvider';
/**
* @/theme wraps the GENERATED module (__built: true), so <Theme> skips runtime
@@ -18,19 +20,30 @@ import {CopilotProvider} from '@/features/copilot/CopilotProvider';
* @/theme also re-attaches the Lucide icon registry that `theme build` drops;
* <Theme> registers it during render, on both the SSR and hydration passes.
*
* WorkspaceProvider and CopilotProvider sit here, ABOVE the route tree, so
* query scope and Copilot state outlive both navigation and the responsive
* WorkspaceProvider and LoyalyAiProvider sit here, ABOVE the route tree, so
* query scope and Loyaly AI state outlive both navigation and the responsive
* swap between the inline panel and the slide-over.
*/
export function Providers({children}: {children: React.ReactNode}) {
export function Providers({
children,
initialSession,
}: {
children: React.ReactNode;
/** Resolved from the signed cookie in the root layout — see SessionProvider. */
initialSession: AuthSession | null;
}) {
return (
<Theme theme={loyalyTheme} mode="dark">
<LinkProvider component={NextLink}>
{/* One line, and every motion.* in the app honours prefers-reduced-motion. */}
<MotionConfig reducedMotion="user">
{/* Above the route tree AND outside (workspace), because /login
establishes the session that the workspace then reads. */}
<SessionProvider initialSession={initialSession}>
<WorkspaceProvider>
<CopilotProvider>{children}</CopilotProvider>
<LoyalyAiProvider>{children}</LoyalyAiProvider>
</WorkspaceProvider>
</SessionProvider>
{/* Mounted once at the root: useToast() positions, stacks and
expires notifications into this viewport from anywhere. */}
<ToastViewport />

View File

@@ -1,89 +0,0 @@
'use client';
import {useEffect, useMemo, useState} from 'react';
import {useRouter} from 'next/navigation';
import {CommandPalette} from '@astryxdesign/core/CommandPalette';
import {createStaticSource} from '@astryxdesign/core/Typeahead';
import {Button} from '@astryxdesign/core/Button';
import {IconButton} from '@astryxdesign/core/IconButton';
import {Icon} from '@astryxdesign/core/Icon';
import {useBreakpoint} from '@/lib/breakpoints';
import {FOOTER_NAV, PRIMARY_NAV} from './nav-config';
/**
* Search is a command palette, not a text field. In a workspace this dense the
* fastest path to anything is ⌘K → type → Enter, so the thing in the nav bar
* is an affordance that opens the palette rather than an input of its own.
*
* Right now it indexes navigation only. Stores, rewards and staff join the
* same source once their modules land — the palette does not change, only the
* items array does.
*/
export function GlobalSearch() {
const [isOpen, setIsOpen] = useState(false);
const router = useRouter();
const isCompact = useBreakpoint() === 'mobile';
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key.toLowerCase() === 'k' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setIsOpen((v) => !v);
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
const source = useMemo(
() =>
createStaticSource(
[...PRIMARY_NAV, ...FOOTER_NAV].map((n) => ({
id: n.href,
label: n.label,
auxiliaryData: {group: 'Navigate', href: n.href},
})),
),
[],
);
return (
<>
{/*
Icon-only on phones. The ⌘K affordance is the point of the wide form,
and a phone has no ⌘ — spending 110px of a 375px bar advertising a
shortcut that cannot be pressed would push the profile menu off it.
*/}
{isCompact ? (
<IconButton
variant="ghost"
size="lg"
label="Search"
tooltip="Search"
icon={<Icon icon="search" />}
onClick={() => setIsOpen(true)}
className="size-11"
/>
) : (
<Button
variant="secondary"
size="sm"
label="Search ⌘K"
icon={<Icon icon="search" size="sm" />}
onClick={() => setIsOpen(true)}
/>
)}
<CommandPalette
isOpen={isOpen}
onOpenChange={setIsOpen}
searchSource={source}
label="Search Loyaly"
emptyBootstrapText="Search stores, rewards and staff…"
onValueChange={(value) => {
router.push(value);
setIsOpen(false);
}}
/>
</>
);
}

View File

@@ -1,3 +0,0 @@
'use client';
export {LoginSplit as LoginForm} from './LoginSplit';

View File

@@ -1,307 +0,0 @@
'use client';
import {useState} from 'react';
import {useRouter} from 'next/navigation';
import Image from 'next/image';
import {BrandLogo, BrandMark} from '@/components/brand/BrandLogo';
const HERO_IMAGE_URL =
'https://images.unsplash.com/photo-1556742049-0a670fc8078a?q=80&w=1400&auto=format&fit=crop';
const HERO_FALLBACK_URL =
'https://images.unsplash.com/photo-1441986300917-64674bd600d8?q=80&w=1400&auto=format&fit=crop';
function GoogleIcon() {
return (
<svg width={18} height={18} viewBox="0 0 24 24">
<path
fill="#4285F4"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="#FBBC05"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.06H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.94l2.85-2.22.81-.63z"
/>
<path
fill="#EA4335"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.06l3.66 2.84c.87-2.6 3.3-4.52 6.16-4.52z"
/>
</svg>
);
}
function MicrosoftIcon() {
return (
<svg width={18} height={18} viewBox="0 0 23 23">
<path fill="#f35325" d="M1 1h10v10H1z" />
<path fill="#81bc06" d="M12 1h10v10H1z" />
<path fill="#05a6f0" d="M1 12h10v10H1z" />
<path fill="#ffba08" d="M12 12h10v10H1z" />
</svg>
);
}
function CheckIcon() {
return (
<svg
width={13}
height={13}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
className="text-white"
>
<polyline points="20 6 9 17 4 12" />
</svg>
);
}
export function LoginSplit() {
const router = useRouter();
const [heroSrc, setHeroSrc] = useState(HERO_IMAGE_URL);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [rememberMe, setRememberMe] = useState(false);
const [errors, setErrors] = useState<{email?: string; password?: string}>({});
const [isLoading, setIsLoading] = useState(false);
const handleLogin = (e: React.FormEvent) => {
e.preventDefault();
const next: typeof errors = {};
if (!/^\S+@\S+\.\S+$/.test(email)) {
next.email = 'Enter a valid email address';
}
if (password.length < 8) {
next.password = 'Password must be at least 8 characters';
}
setErrors(next);
if (Object.keys(next).length > 0) return;
setIsLoading(true);
setTimeout(() => {
setIsLoading(false);
router.push('/dashboard');
}, 700);
};
return (
<div className="min-h-screen w-full relative flex items-center justify-center p-4 sm:p-6 lg:p-10 bg-[#0C0D12] overflow-hidden selection:bg-white selection:text-black">
{/* Rich Ambient Background Layers */}
<div
className="absolute inset-0 pointer-events-none"
style={{
backgroundImage: `
radial-gradient(circle at 50% -10%, rgba(255, 255, 255, 0.09) 0%, transparent 55%),
radial-gradient(circle at 85% 20%, rgba(245, 158, 11, 0.06) 0%, transparent 45%),
radial-gradient(circle at 15% 80%, rgba(99, 102, 241, 0.06) 0%, transparent 50%)
`,
}}
/>
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,transparent_0%,rgba(0,0,0,0.6)_100%)] pointer-events-none" />
{/* Main Split Container */}
<div className="w-full max-w-[1360px] min-h-[760px] lg:h-[82vh] lg:max-h-[840px] rounded-[24px] bg-[#14151B]/95 border border-white/[0.1] shadow-[0_40px_100px_-20px_rgba(0,0,0,0.85)] backdrop-blur-2xl flex flex-col md:flex-row overflow-hidden relative z-10">
{/* LEFT PANEL: Enterprise Visual (55% Desktop / 40% Tablet / Hidden Mobile) */}
<div className="hidden md:flex md:w-[40%] lg:w-[55%] h-full p-3 relative">
<div className="w-full h-full rounded-[20px] overflow-hidden relative border border-white/10 bg-[#090A0E]">
{/* Main Retail Store Hero Image */}
<Image
src={heroSrc}
alt="Loyaly Merchant Operating System"
fill
priority
sizes="(max-width: 768px) 0vw, (max-width: 1024px) 40vw, 55vw"
className="object-cover object-center"
onError={() => {
setHeroSrc(HERO_FALLBACK_URL);
}}
/>
{/* Subtle Gradient Overlays */}
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent pointer-events-none" />
<div className="absolute inset-0 bg-gradient-to-l from-[#0E0E10]/70 via-transparent to-transparent pointer-events-none" />
{/* Clean Enterprise Brand Watermark (Bottom-Left) */}
<div className="absolute bottom-6 left-6 flex items-center gap-2.5 px-3.5 py-2 rounded-xl bg-black/50 backdrop-blur-md border border-white/10 text-white/80">
<BrandMark size={16} priority />
<span className="text-[11px] font-medium tracking-wide text-neutral-300">
Loyaly.ai · Merchant Operating System
</span>
</div>
</div>
</div>
{/* RIGHT PANEL: Authentication (45% Desktop / 60% Tablet / 100% Mobile) */}
<div className="w-full md:w-[60%] lg:w-[45%] h-full p-8 sm:p-10 lg:p-12 flex flex-col justify-between overflow-y-auto">
{/* Top Brand Header */}
<div className="flex items-center justify-between gap-4 mb-8">
<div className="flex items-center gap-3">
<BrandLogo height={42} priority />
<div className="h-4 w-px bg-white/20" />
<span className="text-xs font-semibold tracking-wide text-neutral-400 uppercase">
Merchant OS
</span>
</div>
<span className="px-2.5 py-0.5 text-[10px] font-semibold tracking-wider uppercase rounded-full bg-white/[0.06] border border-white/10 text-neutral-300">
v2.4 Enterprise
</span>
</div>
{/* Form Content */}
<div className="my-auto py-4">
<div className="mb-8">
<h1 className="text-3xl sm:text-4xl font-bold tracking-tight text-white mb-2.5">
Welcome back
</h1>
<p className="text-sm text-neutral-400 leading-relaxed max-w-md">
Access your Merchant Operating System to manage stores, rewards, staff and AI insights.
</p>
</div>
<form onSubmit={handleLogin} noValidate className="space-y-4">
<div>
<label className="block text-xs font-medium text-neutral-300 mb-1.5">
Email address
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="name@company.com"
className={`w-full h-11 px-3.5 rounded-xl bg-white/[0.04] border ${
errors.email ? 'border-red-500/80' : 'border-white/10'
} text-white text-sm placeholder:text-neutral-500 focus:outline-none focus:border-white/30 focus:ring-1 focus:ring-white/20 transition-all`}
/>
{errors.email && (
<p className="text-xs text-red-400 mt-1">{errors.email}</p>
)}
</div>
<div>
<label className="block text-xs font-medium text-neutral-300 mb-1.5">
Password
</label>
<input
type="password"
value={password}
onChange={(e) => {
setPassword(e.target.value);
setErrors({});
}}
placeholder="Enter your password"
className={`w-full h-11 px-3.5 rounded-xl bg-white/[0.04] border ${
errors.password ? 'border-red-500/80' : 'border-white/10'
} text-white text-sm placeholder:text-neutral-500 focus:outline-none focus:border-white/30 focus:ring-1 focus:ring-white/20 transition-all`}
/>
{errors.password && (
<p className="text-xs text-red-400 mt-1">{errors.password}</p>
)}
</div>
{/* Controls Row */}
<div className="flex items-center justify-between pt-1">
<label className="flex items-center gap-2 cursor-pointer group">
<input
type="checkbox"
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
className="w-4 h-4 rounded border-white/20 bg-white/5 text-white focus:ring-0 focus:ring-offset-0 cursor-pointer accent-white"
/>
<span className="text-xs text-neutral-300 group-hover:text-white transition-colors">
Remember me
</span>
</label>
<a
href="#"
className="text-xs text-neutral-400 hover:text-white transition-colors font-medium"
>
Forgot password?
</a>
</div>
{/* Primary CTA */}
<button
type="submit"
disabled={isLoading}
className="w-full h-11 mt-2 rounded-xl bg-white text-black font-semibold text-sm hover:bg-neutral-200 active:scale-[0.99] transition-all shadow-md shadow-white/5 flex items-center justify-center gap-2 disabled:opacity-70"
>
{isLoading ? (
<span className="inline-block w-4 h-4 border-2 border-black/30 border-t-black rounded-full animate-spin" />
) : (
<>
Continue
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
</>
)}
</button>
{/* OAuth Divider */}
<div className="relative my-5 text-center">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-white/[0.08]" />
</div>
<span className="relative px-3 text-xs text-neutral-500 bg-[#14151B]">
or continue with
</span>
</div>
{/* OAuth Buttons */}
<div className="grid grid-cols-2 gap-3">
<button
type="button"
className="h-11 rounded-xl bg-white/[0.04] border border-white/10 hover:bg-white/[0.08] hover:border-white/20 text-white text-xs font-medium transition-all flex items-center justify-center gap-2.5"
>
<GoogleIcon />
Google
</button>
<button
type="button"
className="h-11 rounded-xl bg-white/[0.04] border border-white/10 hover:bg-white/[0.08] hover:border-white/20 text-white text-xs font-medium transition-all flex items-center justify-center gap-2.5"
>
<MicrosoftIcon />
Microsoft
</button>
</div>
</form>
</div>
{/* Bottom Footer Access Note */}
<div className="pt-6 border-t border-white/[0.06] mt-4">
<p className="text-xs text-neutral-400">
Need access?{' '}
<a href="#" className="text-white hover:underline font-medium">
Contact your organization administrator.
</a>
</p>
</div>
</div>
</div>
{/* Page Footer Terms */}
<div className="absolute bottom-3 text-center w-full pointer-events-auto">
<p className="text-[11px] text-neutral-500">
By continuing, you agree to Loyaly&apos;s{' '}
<a href="#" className="text-neutral-400 hover:text-white transition-colors underline">
Terms of Service
</a>{' '}
and{' '}
<a href="#" className="text-neutral-400 hover:text-white transition-colors underline">
Privacy Policy
</a>
.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,236 @@
'use client';
import {useState} from 'react';
import {useLoginForm} from '@/features/auth/hooks/useLoginForm';
import {GoogleIcon, MicrosoftIcon} from './ProviderIcons';
/**
* The sign-in form itself: two fields, the remember-me control, the submit
* button and the federated options.
*
* 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 was signed off looking like this.
* The brief says preserve the existing UI, so the markup is unchanged —
* what changed is that the button beneath it now talks to a real endpoint.
*/
export function LoginCredentialsForm() {
const [showPassword, setShowPassword] = useState(false);
const {
email,
password,
rememberMe,
errors,
isSubmitting,
setEmail,
setPassword,
setRememberMe,
submit,
} = useLoginForm();
return (
<form onSubmit={submit} noValidate className="space-y-4">
<div>
<label
htmlFor="login-email"
className="block text-xs font-medium text-neutral-300 mb-1.5"
>
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={`w-full h-11 px-3.5 rounded-xl bg-white/[0.04] border ${
errors.email || errors.form ? 'border-red-500/80' : 'border-white/10'
} text-white text-sm placeholder:text-neutral-500 focus:outline-none focus:border-white/30 focus:ring-1 focus:ring-white/20 transition-all disabled:opacity-60`}
/>
{errors.email && (
<p id="login-email-error" className="text-xs text-red-400 mt-1">
{errors.email}
</p>
)}
</div>
<div>
<label
htmlFor="login-password"
className="block text-xs font-medium text-neutral-300 mb-1.5"
>
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={`w-full h-11 pl-3.5 pr-10 rounded-xl bg-white/[0.04] border ${
errors.password || errors.form ? 'border-red-500/80' : 'border-white/10'
} text-white text-sm placeholder:text-neutral-500 focus:outline-none focus:border-white/30 focus:ring-1 focus:ring-white/20 transition-all disabled:opacity-60`}
/>
<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 text-neutral-400 hover:text-white transition-colors focus:outline-none focus:text-white disabled:opacity-50"
>
{showPassword ? (
<svg
width="16"
height="16"
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="16"
height="16"
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-red-400 mt-1">
{errors.password}
</p>
)}
</div>
{/* Anything the server could not attribute to a field — network, 5xx. */}
{errors.form && (
<p role="alert" className="text-xs text-red-400">
{errors.form}
</p>
)}
<div className="flex items-center justify-between pt-1">
<label className="flex items-center gap-2 cursor-pointer group">
<input
type="checkbox"
name="rememberMe"
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
className="w-4 h-4 rounded border-white/20 bg-white/5 text-white focus:ring-0 focus:ring-offset-0 cursor-pointer accent-white"
/>
<span className="text-xs text-neutral-300 group-hover:text-white transition-colors">
Remember me
</span>
</label>
<a
href="#"
className="text-xs text-neutral-400 hover:text-white transition-colors font-medium"
>
Forgot password?
</a>
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full h-11 mt-2 rounded-xl bg-white text-black font-semibold text-sm hover:bg-neutral-200 active:scale-[0.99] transition-all shadow-md shadow-white/5 flex items-center justify-center gap-2 disabled:opacity-70 disabled:cursor-not-allowed"
>
{isSubmitting ? (
<>
<span className="inline-block w-4 h-4 border-2 border-black/30 border-t-black rounded-full animate-spin" />
<span className="sr-only">Signing in</span>
</>
) : (
<>
Continue
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
aria-hidden="true"
>
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
</>
)}
</button>
<div className="relative my-5 text-center">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-white/[0.08]" />
</div>
<span className="relative px-3 text-xs text-neutral-500 bg-[#14151B]">
or continue with
</span>
</div>
{/*
Federated sign-in is not wired up — there is no OAuth client yet, and a
button that silently does nothing is worse than one that says so. They
stay disabled and labelled until /api/auth/oauth/:provider exists.
*/}
<div className="grid grid-cols-2 gap-3">
<button
type="button"
disabled
title="Google sign-in is not configured yet"
className="h-11 rounded-xl bg-white/[0.04] border border-white/10 text-white text-xs font-medium transition-all flex items-center justify-center gap-2.5 opacity-50 cursor-not-allowed"
>
<GoogleIcon />
Google
</button>
<button
type="button"
disabled
title="Microsoft sign-in is not configured yet"
className="h-11 rounded-xl bg-white/[0.04] border border-white/10 text-white text-xs font-medium transition-all flex items-center justify-center gap-2.5 opacity-50 cursor-not-allowed"
>
<MicrosoftIcon />
Microsoft
</button>
</div>
</form>
);
}

View File

@@ -0,0 +1,52 @@
'use client';
import {useState} from 'react';
import Image from 'next/image';
import {BrandMark} from '@/shared/components/brand/BrandLogo';
const HERO_IMAGE_URL =
'https://images.unsplash.com/photo-1556742049-0a670fc8078a?q=80&w=1400&auto=format&fit=crop';
const HERO_FALLBACK_URL =
'https://images.unsplash.com/photo-1441986300917-64674bd600d8?q=80&w=1400&auto=format&fit=crop';
/**
* The left half of the sign-in screen: photography, scrim, brand watermark.
*
* 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 piece of
* state on the screen that has nothing to do with signing in (the image
* fallback), while the form half holds all the behaviour.
*
* Hidden below `md`, where the photograph would push the form off the fold.
*/
export function LoginHeroPanel() {
const [heroSrc, setHeroSrc] = useState(HERO_IMAGE_URL);
return (
<div className="hidden md:flex md:w-[40%] lg:w-[55%] h-full p-3 relative">
<div className="w-full h-full rounded-[20px] overflow-hidden relative border border-white/10 bg-[#090A0E]">
<Image
src={heroSrc}
alt=""
fill
priority
sizes="(max-width: 768px) 0vw, (max-width: 1024px) 40vw, 55vw"
className="object-cover object-center"
// Unsplash is a third party; a dead URL must degrade to the other
// photograph rather than to an empty black panel.
onError={() => setHeroSrc(HERO_FALLBACK_URL)}
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent pointer-events-none" />
<div className="absolute inset-0 bg-gradient-to-l from-[#0E0E10]/70 via-transparent to-transparent pointer-events-none" />
<div className="absolute bottom-6 left-6 flex items-center gap-2.5 px-3.5 py-2 rounded-xl bg-black/50 backdrop-blur-md border border-white/10 text-white/80">
<BrandMark size={16} priority />
<span className="text-[11px] font-medium tracking-wide text-neutral-300">
Loyaly.ai · Merchant Operating System
</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,108 @@
'use client';
import {BrandLogo} from '@/shared/components/brand/BrandLogo';
import {LoginCredentialsForm} from './LoginCredentialsForm';
import {LoginHeroPanel} from './LoginHeroPanel';
/**
* The sign-in screen's frame: ambient background, 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 photograph in LoginHeroPanel — which is what makes each of them
* separately readable and separately changeable.
*/
export function LoginSplit() {
return (
<div className="min-h-screen w-full relative flex items-center justify-center p-4 sm:p-6 lg:p-10 bg-[#050506] overflow-hidden selection:bg-white selection:text-black">
{/* Premium Charcoal Radial Gradient (Center #17171C -> Mid #101015 -> Edges #050506) */}
<div
className="absolute inset-0 pointer-events-none"
style={{
background: 'radial-gradient(ellipse 110% 110% at 50% 50%, #17171C 0%, #101015 55%, #050506 100%)',
}}
/>
{/* Soft Vignette Layer focusing vision on the center container */}
<div
className="absolute inset-0 pointer-events-none"
style={{
background: 'radial-gradient(ellipse 80% 80% at 50% 50%, transparent 35%, rgba(5, 5, 6, 0.75) 100%)',
}}
/>
{/* Ultra-subtle High-Frequency Fine Grain Noise Overlay (1.8% Opacity) */}
<div
className="absolute inset-0 pointer-events-none opacity-[0.018] mix-blend-overlay"
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 250 250' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E")`,
backgroundRepeat: 'repeat',
}}
/>
<div className="w-full max-w-[1360px] min-h-[760px] lg:h-[82vh] lg:max-h-[840px] rounded-[24px] bg-[#14151B]/95 border border-white/[0.1] shadow-[0_40px_100px_-20px_rgba(0,0,0,0.85)] backdrop-blur-2xl flex flex-col md:flex-row overflow-hidden relative z-10">
<LoginHeroPanel />
<div className="w-full md:w-[60%] lg:w-[45%] h-full p-8 sm:p-10 lg:p-12 flex flex-col justify-between overflow-y-auto">
<div className="flex items-center justify-between gap-4 mb-8">
<div className="flex items-center gap-3">
<BrandLogo height={42} priority />
<div className="h-4 w-px bg-white/20" />
<span className="text-xs font-semibold tracking-wide text-neutral-400 uppercase">
Merchant OS
</span>
</div>
<span className="px-2.5 py-0.5 text-[10px] font-semibold tracking-wider uppercase rounded-full bg-white/[0.06] border border-white/10 text-neutral-300">
v2.4 Enterprise
</span>
</div>
<div className="my-auto py-4">
<div className="mb-8">
<h1 className="text-3xl sm:text-4xl font-bold tracking-tight text-white mb-2.5">
Welcome back
</h1>
<p className="text-sm text-neutral-400 leading-relaxed max-w-md">
Access your Merchant Operating System to manage stores, rewards,
staff and AI insights.
</p>
</div>
<LoginCredentialsForm />
</div>
<div className="pt-6 border-t border-white/[0.06] mt-4">
<p className="text-xs text-neutral-400">
Need access?{' '}
<a href="#" className="text-white hover:underline font-medium">
Contact your organization administrator.
</a>
</p>
</div>
</div>
</div>
<div className="absolute bottom-3 text-center w-full pointer-events-auto">
<p className="text-[11px] text-neutral-500">
By continuing, you agree to Loyaly&apos;s{' '}
<a
href="#"
className="text-neutral-400 hover:text-white transition-colors underline"
>
Terms of Service
</a>{' '}
and{' '}
<a
href="#"
className="text-neutral-400 hover:text-white transition-colors underline"
>
Privacy Policy
</a>
.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,43 @@
/**
* Brand marks for the federated sign-in buttons.
*
* Inline SVG rather than the icon registry, and the ONLY place in the app
* exempt from the monochrome rule: Google's and Microsoft's marks are
* trademarked artwork whose colours are part of the identity. Recolouring them
* to grey would be both wrong and, in Google's case, a brand-guideline
* violation.
*/
export function GoogleIcon() {
return (
<svg width={18} height={18} viewBox="0 0 24 24" aria-hidden="true">
<path
fill="#4285F4"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="#FBBC05"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.06H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.94l2.85-2.22.81-.63z"
/>
<path
fill="#EA4335"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.06l3.66 2.84c.87-2.6 3.3-4.52 6.16-4.52z"
/>
</svg>
);
}
export function MicrosoftIcon() {
return (
<svg width={18} height={18} viewBox="0 0 23 23" aria-hidden="true">
<path fill="#f35325" d="M1 1h10v10H1z" />
<path fill="#81bc06" d="M12 1h10v10H12z" />
<path fill="#05a6f0" d="M1 12h10v10H1z" />
<path fill="#ffba08" d="M12 12h10v10H12z" />
</svg>
);
}

View 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}</>;
}

View File

@@ -0,0 +1,45 @@
'use client';
import {useEffect} from 'react';
import {useRouter, useSearchParams} from 'next/navigation';
import {Center} from '@astryxdesign/core/Center';
import {Spinner} from '@astryxdesign/core/Spinner';
import {useSession} from '@/features/auth/providers/SessionProvider';
import {resolveRedirectTarget} from '@/features/auth/services/redirectTarget';
/**
* The mirror of AuthGuard: keeps a signed-in user OFF the sign-in screen.
*
* The proxy already redirects /login → /dashboard for a request carrying a
* valid cookie. This covers the case the proxy cannot see: a client-side
* navigation back to /login after logging in within the same page session.
* Offering the form again to someone who is already authenticated reads as the
* login having failed.
*
* While `loading`, the form is NOT rendered. Painting it and then yanking it
* away is worse than a beat of spinner, and it invites someone to start typing
* into a form that is about to disappear.
*/
export function GuestGuard({children}: {children: React.ReactNode}) {
const {status} = useSession();
const router = useRouter();
const searchParams = useSearchParams();
useEffect(() => {
if (status !== 'authenticated') return;
// The SAME destination the login form resolves. These two redirects race
// on a successful sign-in, and a hardcoded /dashboard here silently threw
// away the deep link the proxy had preserved in `?next=`.
router.replace(resolveRedirectTarget(searchParams.get('next')));
}, [status, router, searchParams]);
if (status !== 'unauthenticated') {
return (
<Center height="100vh" role="status" aria-label="Checking your session">
<Spinner size="lg" />
</Center>
);
}
return <>{children}</>;
}

View File

@@ -0,0 +1,114 @@
'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 {resolveRedirectTarget} from '@/features/auth/services/redirectTarget';
import type {LoginError} from '@/features/auth/types/auth';
/**
* 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);
const [errors, setErrors] = useState<LoginFormState['errors']>({});
const [isSubmitting, setIsSubmitting] = useState(false);
// Shared with GuestGuard, which redirects on the same event — see
// resolveRedirectTarget for why that matters and how `next` is validated.
const destination = resolveRedirectTarget(searchParams.get('next'));
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});
// The toast carries what the inline message cannot: it is announced,
// it survives a field the user has scrolled past, and it distinguishes
// "we rejected your credentials" from "we never reached the server".
toast({type: 'error', body: result.error.message});
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.
router.replace(destination);
// 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,
destination,
],
);
/** 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,
setEmail: updateEmail,
setPassword: updatePassword,
setRememberMe,
submit,
};
}

View File

@@ -0,0 +1,105 @@
import type {AuthUser} from '@/features/auth/types/auth';
/**
* The mock user directory. Server-only — nothing here is ever bundled to the
* client, because the only importers are route handlers.
*
* ── On the plaintext passwords ────────────────────────────────────────────
* They are plaintext BECAUSE this is a fixture, and the fixture is the one
* place where that is safe: it never leaves the server, and there is no real
* credential in it. What matters architecturally is that `verifyCredentials`
* below is the ONLY function that ever sees a password. When a real backend
* arrives, that function's body becomes an HTTP call (or an argon2/bcrypt
* comparison against a user table) and every other file in the app is
* untouched — including the login form, which already only knows how to ask.
*
* Do not add a "demo" user that accepts any password. The whole point of this
* layer is that wrong credentials fail.
*/
interface MockUserRecord extends AuthUser {
password: string;
}
const USERS: MockUserRecord[] = [
{
id: 'usr_owner_01',
email: 'aravind@nealre.in',
name: 'Aravind',
role: 'owner',
organisation: 'Loyaly Retail Pvt Ltd',
password: 'admin123',
},
{
id: 'usr_owner_02',
email: 'aravind@nearle.in',
name: 'Aravind',
role: 'owner',
organisation: 'Loyaly Retail Pvt Ltd',
password: 'admin123',
},
{
id: 'usr_mgr_01',
email: 'priya.sharma@loyaly.ai',
name: 'Priya Sharma',
role: 'manager',
organisation: 'Loyaly Retail Pvt Ltd',
password: 'indiranagar@01',
},
{
id: 'usr_analyst_01',
email: 'analyst@loyaly.ai',
name: 'Rahul Menon',
role: 'analyst',
organisation: 'Loyaly Retail Pvt Ltd',
password: 'reports@2026',
},
];
/** Public projection — the record minus the credential. */
function toAuthUser(record: MockUserRecord): AuthUser {
// Field-by-field rather than a rest-spread that drops `password`: a spread
// silently carries anything added to the record later, and this projection
// is the boundary that keeps credentials out of every response.
return {
id: record.id,
email: record.email,
name: record.name,
role: record.role,
organisation: record.organisation,
};
}
export type CredentialCheck =
| {outcome: 'ok'; user: AuthUser}
| {outcome: 'unknown_email'}
| {outcome: 'wrong_password'};
/**
* The single credential-checking seam.
*
* Note the two distinct failure outcomes. That is a deliberate product choice
* — the brief asks for "wrong email" and "wrong password" to read differently
* — and it is worth knowing that it also makes the login form a user
* enumeration oracle: an attacker can discover which addresses have accounts.
* If that trade stops being acceptable, collapse both branches at the ROUTE
* (map them to one 'invalid_credentials' message) rather than here, so the
* distinction stays available to audit logging.
*/
export function verifyCredentials(
email: string,
password: string,
): CredentialCheck {
const record = USERS.find(
(u) => u.email.toLowerCase() === email.trim().toLowerCase(),
);
if (!record) return {outcome: 'unknown_email'};
if (record.password !== password) return {outcome: 'wrong_password'};
return {outcome: 'ok', user: toAuthUser(record)};
}
/** Used by the session endpoint to re-resolve a cookie subject to a user. */
export function findUserById(id: string): AuthUser | null {
const record = USERS.find((u) => u.id === id);
return record ? toAuthUser(record) : null;
}

View File

@@ -0,0 +1,172 @@
'use client';
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import {useRouter} from 'next/navigation';
import {authService} from '@/features/auth/services/authService';
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,
}: {
children: React.ReactNode;
initialSession: AuthSession | null;
}) {
const router = useRouter();
const [session, setSession] = useState<AuthSession | null>(initialSession);
const [status, setStatus] = useState<SessionStatus>(
initialSession ? 'authenticated' : 'unauthenticated',
);
const refresh = useCallback(async () => {
const next = await authService.currentSession();
setSession(next);
setStatus(next ? 'authenticated' : 'unauthenticated');
}, []);
/**
* 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) {
setSession(result.session);
setStatus('authenticated');
}
return result;
},
[],
);
const logout = useCallback(async () => {
await authService.logout();
setSession(null);
setStatus('unauthenticated');
/**
* Belt and braces on the client side of the door.
*
* The cookie is already gone server-side, which is what actually ends the
* session. This clears the *other* things a signed-in user leaves behind —
* a stored sidebar preference, a cached scope — so a shared machine does
* not hand the next person 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.clear();
window.sessionStorage.clear();
} 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]);
const value = useMemo<SessionValue>(
() => ({
status,
session,
user: session?.user ?? null,
isAuthenticated: status === 'authenticated',
login,
logout,
refresh,
}),
[status, session, login, logout, refresh],
);
return <SessionContext value={value}>{children}</SessionContext>;
}
export function useSession(): SessionValue {
const ctx = useContext(SessionContext);
if (!ctx) {
throw new Error('useSession must be used inside <SessionProvider>');
}
return ctx;
}

View File

@@ -0,0 +1,42 @@
import {postJson, getJson} from '@/shared/services/httpClient';
import type {
AuthSession,
LoginCredentials,
LoginError,
} from '@/features/auth/types/auth';
/**
* 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', {});
},
/** Null data means "no session" — a normal answer, not a failure. */
currentSession() {
return getJson<AuthSession | null>('/api/auth/session');
},
};

View 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;
},
};

View File

@@ -0,0 +1,31 @@
/**
* Where a signed-in user should land, from the proxy's `?next=` hint.
*
* ── Why this is shared rather than inlined ───────────────────────────────
* Two things redirect after a successful sign-in and they RACE: the login form
* (which knows the attempt just succeeded) and GuestGuard (which reacts to the
* session becoming authenticated). Measured: signing in from
* `/login?next=/settings/billing` landed on /dashboard, because the guard's
* effect fired first with a hardcoded destination and the form's redirect was
* replaced. Both now resolve through this function, so whichever wins, the
* user arrives at the same place.
*
* ── 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';
export function resolveRedirectTarget(next: string | null): string {
if (!next || !next.startsWith('/') || next.startsWith('//')) {
return DEFAULT_DESTINATION;
}
return next;
}

View File

@@ -0,0 +1,58 @@
import 'server-only';
import {cookies} from 'next/headers';
import {findUserById} from '@/features/auth/mock/users.mock';
import {
SESSION_COOKIE,
verifySessionToken,
} from '@/features/auth/services/sessionToken';
import type {AuthSession} from '@/features/auth/types/auth';
/**
* Resolve the session on the server, from the signed cookie.
*
* Used in two places:
* • the root layout, to SEED the client provider — so a full page load
* paints the signed-in shell immediately instead of flashing a spinner
* while the browser asks who it is
* • route handlers, to authorise a request before it touches data
*
* `import 'server-only'` is load-bearing: this module reads cookies and the
* user directory, and importing it from a client component would be a build
* error rather than a silent leak of the fixture into the browser bundle.
*/
export async function getServerSession(): Promise<AuthSession | null> {
const store = await cookies();
const payload = verifySessionToken(store.get(SESSION_COOKIE)?.value);
if (!payload) return null;
// Re-resolved from the directory rather than trusted off the token, so a
// role change or a deactivation takes effect on the next request instead of
// whenever the cookie happens to expire.
const user = findUserById(payload.sub);
if (!user) return null;
return {user, expiresAt: new Date(payload.exp * 1000).toISOString()};
}
/**
* Guard for route handlers that must not answer an anonymous request.
*
* Returns the session, or a ready-made 401 to return early with:
*
* const auth = await requireSession();
* if ('response' in auth) return auth.response;
* // auth.session is now available
*/
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'}},
),
};
}

View File

@@ -0,0 +1,144 @@
import {createHmac, timingSafeEqual} from 'node:crypto';
/**
* 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.
*/
const DEV_SECRET = 'loyaly-dev-secret-not-for-production';
/**
* Falls back to a constant in development so a fresh clone runs with no setup.
* In production a missing AUTH_SECRET is fatal rather than silently signing
* every session with a value that is checked into git.
*/
function secret(): string {
const fromEnv = process.env.AUTH_SECRET;
if (fromEnv) return fromEnv;
if (process.env.NODE_ENV === 'production') {
throw new Error(
'AUTH_SECRET is required in production — refusing to sign sessions with the development key.',
);
}
return DEV_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;
/** 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', secret()).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} : {}),
};
}

View File

@@ -0,0 +1,60 @@
/**
* 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.
*/
export type UserRole = 'owner' | 'manager' | 'analyst';
/** 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;
}
/** 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};

View File

@@ -1,78 +0,0 @@
'use client';
import {TabList, Tab} from '@astryxdesign/core/TabList';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Heading} from '@astryxdesign/core/Text';
import {IconButton} from '@astryxdesign/core/IconButton';
import {Icon} from '@astryxdesign/core/Icon';
import {ICONS} from '@/lib/icons';
import {useCopilot} from './CopilotProvider';
import type {CopilotTab} from './CopilotProvider';
import {AiTab} from './tabs/AiTab';
import {AnalyticsTab} from './tabs/AnalyticsTab';
import {ChatTab} from './tabs/ChatTab';
/**
* The Copilot body. Rendered identically inside the inline LayoutPanel and
* inside the slide-over — presentation differs, this does not. All of its
* state comes from CopilotProvider, so it is safe to unmount and remount.
*/
export function CopilotPanel({onDismiss}: {onDismiss?: () => void}) {
const {tab, setTab} = useCopilot();
return (
<VStack gap={0} height="100%">
<HStack
gap={2}
vAlign="center"
hAlign="between"
paddingInline={4}
paddingBlock={3}
>
<HStack gap={2} vAlign="center">
<Icon icon={ICONS.ai} size="sm" />
<Heading level={2}>Loyaly AI</Heading>
</HStack>
{onDismiss ? (
<IconButton
icon={<Icon icon={ICONS.panelClose} />}
label="Hide Loyaly AI"
variant="ghost"
size="sm"
onClick={onDismiss}
/>
) : null}
</HStack>
<TabList
value={tab}
onChange={(v) => setTab(v as CopilotTab)}
layout="fill"
size="sm"
hasDivider
>
<Tab value="ai" label="AI" icon={<Icon icon={ICONS.ai} size="sm" />} />
<Tab
value="analytics"
label="Analytics"
icon={<Icon icon={ICONS.analytics} size="sm" />}
/>
<Tab
value="chat"
label="Chat"
icon={<Icon icon={ICONS.chat} size="sm" />}
/>
</TabList>
{/* The chat tab owns its own scroll container (ChatLayout docks the
composer), so it must not be wrapped in a scrolling VStack. */}
{tab === 'chat' ? (
<ChatTab />
) : (
<VStack height="100%" isScrollable>
{tab === 'ai' ? <AiTab /> : <AnalyticsTab />}
</VStack>
)}
</VStack>
);
}

View File

@@ -1,102 +0,0 @@
'use client';
import {createContext, useCallback, useContext, useMemo, useState} from 'react';
/**
* All Copilot state lives here, and this provider is mounted in
* app/providers.tsx — ABOVE the route tree.
*
* Two things force that placement:
* 1. Route changes. The panel itself sits in (workspace)/layout.tsx, 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 LayoutPanel for a
* slide-over, which genuinely unmounts the panel. Holding state up here
* means presentation can change without losing a half-typed message.
*
* The panel is a pure view over this context. Nothing below it owns state.
*/
export type CopilotTab = 'ai' | 'analytics' | 'chat';
export interface ChatMessage {
id: string;
sender: 'user' | 'assistant';
text: string;
at: string;
}
interface CopilotValue {
tab: CopilotTab;
setTab: (t: CopilotTab) => void;
isOpen: boolean;
setIsOpen: (v: boolean) => void;
toggle: () => void;
/** Slide-over visibility, used below the laptop breakpoint. */
isSlideOverOpen: boolean;
setSlideOverOpen: (v: boolean) => void;
messages: ChatMessage[];
/** The in-progress composer value. Kept here so navigation never drops it. */
draft: string;
setDraft: (v: string) => void;
send: (text: string) => void;
isStreaming: boolean;
}
const CopilotContext = createContext<CopilotValue | null>(null);
export function CopilotProvider({children}: {children: React.ReactNode}) {
const [tab, setTab] = useState<CopilotTab>('ai');
const [isOpen, setIsOpen] = useState(true);
const [isSlideOverOpen, setSlideOverOpen] = useState(false);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [draft, setDraft] = useState('');
const [isStreaming] = useState(false);
const send = useCallback((text: string) => {
const trimmed = text.trim();
if (!trimmed) return;
setMessages((prev) => [
...prev,
{
id: `m${prev.length + 1}`,
sender: 'user',
text: trimmed,
// Timestamps are assigned on send (a user event), never during render —
// Date.now() in a render path is a hydration mismatch waiting to happen.
at: new Date().toISOString(),
},
]);
setDraft('');
}, []);
const toggle = useCallback(() => setIsOpen((v) => !v), []);
const value = useMemo(
() => ({
tab,
setTab,
isOpen,
setIsOpen,
toggle,
isSlideOverOpen,
setSlideOverOpen,
messages,
draft,
setDraft,
send,
isStreaming,
}),
[tab, isOpen, isSlideOverOpen, messages, draft, send, toggle, isStreaming],
);
return <CopilotContext value={value}>{children}</CopilotContext>;
}
export function useCopilot(): CopilotValue {
const ctx = useContext(CopilotContext);
if (!ctx) {
throw new Error('useCopilot must be used inside <CopilotProvider>');
}
return ctx;
}

View File

@@ -1,43 +0,0 @@
'use client';
import {MobileNav} from '@astryxdesign/core/MobileNav';
import {useBreakpoint} from '@/lib/breakpoints';
import {useCopilot} from './CopilotProvider';
import {CopilotPanel} from './CopilotPanel';
/**
* Tablet and mobile presentation of the Copilot.
*
* 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 drawer. MobileNav is the edge-anchored, full-height
* primitive this actually needs.
*
* It renders the SAME <CopilotPanel /> as the inline path — crossing the
* breakpoint changes where the panel lives, never what it contains or
* remembers, because all state sits in CopilotProvider above the route tree.
*/
export function CopilotSlideOver() {
const bp = useBreakpoint();
const {isSlideOverOpen, setSlideOverOpen} = useCopilot();
return (
<MobileNav
// Explicit, because MobileNav otherwise takes its id from AppShell's
// mobile context — which every MobileNav in the tree shares, so this
// drawer and the navigation drawer both rendered id="…" identical.
// A duplicate id makes the menu button's aria-controls resolve to
// whichever one the DOM happens to reach first.
id="copilot-slide-over"
isOpen={isSlideOverOpen}
onOpenChange={setSlideOverOpen}
side="end"
// Full-bleed on mobile, where a 380px drawer would leave nothing
// behind it worth looking at.
width={bp === 'mobile' ? undefined : 380}
label="Loyaly AI"
>
<CopilotPanel onDismiss={() => setSlideOverOpen(false)} />
</MobileNav>
);
}

View File

@@ -1,122 +0,0 @@
'use client';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Card} from '@astryxdesign/core/Card';
import {Text, Heading} from '@astryxdesign/core/Text';
import {Banner} from '@astryxdesign/core/Banner';
import {Button} from '@astryxdesign/core/Button';
import {Icon} from '@astryxdesign/core/Icon';
import {AsyncBoundary} from '@/components/data/AsyncBoundary';
import {SkeletonRows} from '@/components/patterns/LoadingState';
import {EmptyPanel} from '@/components/patterns/EmptyPanel';
import {useResource} from '@/lib/api/useResource';
import {endpoints} from '@/lib/api/client';
import {useWorkspace} from '@/components/shell/WorkspaceProvider';
import {ICONS} from '@/lib/icons';
import type {InsightSeverity} from '@/lib/api/contracts';
/**
* Reads the SAME briefing endpoint the dashboard does.
*
* This panel used to hold hardcoded Phase-1 copy, which was harmless while the
* dashboard had no narrative of its own. Now that it does, static copy here is
* actively wrong: the panel claimed "3 rewards expire in 48 hours" beside a
* dashboard saying one, from the same screen. Two AI surfaces disagreeing about
* the business is worse than one of them not existing.
*
* Scope comes from WorkspaceProvider, so the Copilot follows the store and
* period the merchant is looking at rather than reporting on something else.
*/
const BANNER_STATUS: Record<
InsightSeverity,
'error' | 'warning' | 'info' | 'success'
> = {
error: 'error',
warning: 'warning',
info: 'info',
success: 'success',
};
export function AiTab() {
const {storeId, range} = useWorkspace();
const briefing = useResource(endpoints.dashboardBriefing({range, storeId}));
return (
<VStack gap={4} padding={4}>
<AsyncBoundary resource={briefing} loading={<SkeletonRows count={6} />}>
{(b) => (
<VStack gap={4}>
<Card variant="muted">
<VStack gap={2}>
<HStack gap={2} vAlign="center">
<Icon icon={ICONS.ai} size="sm" />
<Heading level={3}>Summary</Heading>
</HStack>
<Text size="sm" color="secondary">
{b.summary || 'Not enough activity in this period to summarise.'}
</Text>
</VStack>
</Card>
<VStack gap={2}>
<Text size="sm" color="secondary">
Needs attention
</Text>
{b.alerts.length === 0 ? (
<EmptyPanel
icon="present"
title="All clear"
description="Nothing in this scope needs a decision right now."
/>
) : (
b.alerts.map((a) => (
<Banner
key={a.id}
status={BANNER_STATUS[a.severity]}
title={a.title}
description={a.body}
endContent={
a.action ? (
<Button
variant="secondary"
size="sm"
label={a.action.label}
href={a.action.href}
/>
) : undefined
}
/>
))
)}
</VStack>
<VStack gap={2}>
<Text size="sm" color="secondary">
Open tasks
</Text>
{b.tasks
.filter((t) => !t.isDone)
.map((t) => (
<Card key={t.id}>
<VStack gap={1.5}>
<HStack gap={2} vAlign="center" hAlign="between">
<Text weight="medium">{t.label}</Text>
<Text size="xsm" color="secondary" textWrap="nowrap">
{t.due}
</Text>
</HStack>
{t.detail ? (
<Text size="sm" color="secondary">
{t.detail}
</Text>
) : null}
</VStack>
</Card>
))}
</VStack>
</VStack>
)}
</AsyncBoundary>
</VStack>
);
}

View File

@@ -1,25 +0,0 @@
'use client';
import {VStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {EmptyPanel} from '@/components/patterns/EmptyPanel';
/**
* Placeholder until the chart primitives land in 1.7. It then hosts revenue
* forecast, customer growth and store/reward comparison at height={180} —
* the narrow-column variant of the same ChartCard used in the workspace.
*/
export function AnalyticsTab() {
return (
<VStack gap={4} padding={4}>
<Text size="sm" color="secondary">
Forecasts and comparisons, scoped to the current store and range.
</Text>
<EmptyPanel
icon="analytics"
title="Charts arrive with the analytics layer"
description="Revenue forecast, customer growth, store and reward comparison."
/>
</VStack>
);
}

View File

@@ -1,113 +0,0 @@
'use client';
import {
ChatComposer,
ChatComposerInput,
ChatDictationButton,
ChatLayout,
ChatMessage,
ChatMessageBubble,
ChatMessageList,
ChatMessageMetadata,
useChatDictation,
} from '@astryxdesign/core/Chat';
import {Avatar} from '@astryxdesign/core/Avatar';
import {Button} from '@astryxdesign/core/Button';
import {Timestamp} from '@astryxdesign/core/Timestamp';
import {VStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {useCopilot} from '../CopilotProvider';
/**
* Suggested prompts double as the empty state — an empty AI panel with a bare
* input tells a merchant nothing about what it can answer.
*/
const SUGGESTED = [
"Generate today's report",
'Compare stores',
'Suggest rewards',
'Analyze sales',
'Show best staff',
'Predict weekend traffic',
];
function SuggestedPrompts({onPick}: {onPick: (p: string) => void}) {
return (
<VStack gap={2} padding={4}>
<Text size="sm" color="secondary">
Ask about footfall, LYT redemption, staff or store performance.
</Text>
<VStack gap={1.5}>
{SUGGESTED.map((p) => (
<Button
key={p}
variant="secondary"
size="sm"
label={p}
onClick={() => onPick(p)}
/>
))}
</VStack>
</VStack>
);
}
export function ChatTab() {
const {messages, draft, setDraft, send, isStreaming} = useCopilot();
// Appends recognised speech to whatever is already typed rather than
// replacing it, so dictation composes with the keyboard.
const dictation = useChatDictation({
onResult: (text: string) => setDraft(draft ? `${draft} ${text}` : text),
});
return (
<ChatLayout
density="compact"
emptyState={<SuggestedPrompts onPick={send} />}
composer={
<ChatComposer
value={draft}
onChange={setDraft}
onSubmit={send}
placeholder="Ask about footfall, LYTs, staff…"
density="compact"
elevation="none"
isStopShown={isStreaming}
input={<ChatComposerInput maxRows={6} />}
// Renders nothing when SpeechRecognition is unsupported
// (isHiddenWhenUnsupported defaults true) — progressive, no polyfill.
sendActions={<ChatDictationButton dictation={dictation} size="sm" />}
/>
}
>
{messages.length > 0 ? (
<ChatMessageList isStreaming={isStreaming}>
{messages.map((m) => (
<ChatMessage
key={m.id}
sender={m.sender}
density="compact"
avatar={
m.sender === 'assistant' ? (
<Avatar name="Loyaly AI" size="xsm" tooltip={false} />
) : undefined
}
>
<ChatMessageBubble
variant={m.sender === 'assistant' ? 'ghost' : 'filled'}
metadata={
<ChatMessageMetadata
timestamp={<Timestamp value={m.at} />}
/>
}
>
{m.text}
</ChatMessageBubble>
</ChatMessage>
))}
</ChatMessageList>
) : null}
</ChatLayout>
);
}

View File

@@ -2,14 +2,14 @@
import {VStack} from '@astryxdesign/core/Layout';
import {Button} from '@astryxdesign/core/Button';
import {PanelCard} from '@/components/patterns/PanelCard';
import {ActivityFeed} from '@/components/patterns/ActivityItem';
import {SkeletonRows} from '@/components/patterns/LoadingState';
import {EmptyPanel} from '@/components/patterns/EmptyPanel';
import type {ActivityEntry, ActivityTone} from '@/components/patterns/ActivityItem';
import type {ActivityEvent, ActivityKind} from '@/lib/api/contracts';
import type {IconKey} from '@/lib/icons';
import type {Resource} from '@/lib/api/useResource';
import {PanelCard} from '@/shared/components/patterns/PanelCard';
import {ActivityFeed} from '@/shared/components/patterns/ActivityItem';
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import type {ActivityEntry, ActivityTone} from '@/shared/components/patterns/ActivityItem';
import type {ActivityEvent, ActivityKind} from '@/features/dashboard/types/dashboard';
import type {IconKey} from '@/shared/utils/icons';
import type {Resource} from '@/shared/hooks/useResource';
/**
* Each event kind gets its own glyph, so the feed can be skimmed by category

View File

@@ -1,15 +1,15 @@
'use client';
import {Grid} from '@astryxdesign/core/Grid';
import {AsyncBoundary} from '@/components/data/AsyncBoundary';
import {MetricCard} from '@/components/patterns/MetricCard';
import {SkeletonMetricGrid} from '@/components/patterns/LoadingState';
import {ICONS} from '@/lib/icons';
import {useBreakpoint, metricColumns} from '@/lib/breakpoints';
import {useCopilot} from '@/features/copilot/CopilotProvider';
import {formatCompact, formatInrCompact} from '@/lib/format';
import type {Kpi, KpiId} from '@/lib/api/contracts';
import type {Resource} from '@/lib/api/useResource';
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> = {
@@ -26,12 +26,12 @@ const KPI_ICON: Record<KpiId, IconType> = {
* 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 Copilot panel is
* the available width are the breakpoint and whether the Loyaly AI panel is
* taking 320380px of it, and both are already known here.
*/
export function useMetricColumns(): number {
const bp = useBreakpoint();
const {isOpen} = useCopilot();
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);

View File

@@ -4,13 +4,11 @@ import {
SegmentedControl,
SegmentedControlItem,
} from '@astryxdesign/core/SegmentedControl';
import {ChartCard} from '@/components/charts/ChartCard';
import {BarChartView} from '@/components/charts/BarChartView';
import {useResource} from '@/lib/api/useResource';
import {endpoints} from '@/lib/api/client';
import type {Scope} from '@/lib/api/client';
import type {Granularity, PeriodPoint} from '@/lib/api/contracts';
import {formatInrCompact} from '@/lib/format';
import {ChartCard} from '@/shared/components/charts/ChartCard';
import {BarChartView} from '@/shared/components/charts/BarChartView';
import {useDashboardPerformance} from '@/features/dashboard/hooks/useDashboard';
import type {Granularity} from '@/features/dashboard/types/dashboard';
import {formatInrCompact} from '@/shared/utils/format';
/**
* Weekly / monthly rollup.
@@ -20,17 +18,17 @@ import {formatInrCompact} from '@/lib/format';
* the weekly totals quietly stop matching the daily chart above them.
*/
export function PerformancePanel({
scope,
granularity,
onGranularityChange,
}: {
scope: Scope;
granularity: Granularity;
onGranularityChange: (g: Granularity) => void;
}) {
const resource = useResource<PeriodPoint[]>(
endpoints.dashboardPerformance(scope, granularity),
);
// Scope comes from the workspace via the hook rather than from a prop: the
// panel is always showing the same store and period as the page around it,
// and threading that through as an argument only created a way for them to
// disagree.
const resource = useDashboardPerformance(granularity);
return (
<ChartCard

View File

@@ -1,10 +1,10 @@
'use client';
import {ChartCard} from '@/components/charts/ChartCard';
import {BarChartView} from '@/components/charts/BarChartView';
import {formatCompact} from '@/lib/format';
import type {RewardUsagePoint} from '@/lib/api/contracts';
import type {Resource} from '@/lib/api/useResource';
import {ChartCard} from '@/shared/components/charts/ChartCard';
import {BarChartView} from '@/shared/components/charts/BarChartView';
import {formatCompact} from '@/shared/utils/format';
import type {RewardUsagePoint} from '@/features/dashboard/types/dashboard';
import type {Resource} from '@/shared/hooks/useResource';
/**
* Claimed vs used, per reward.

View File

@@ -5,12 +5,12 @@ import {Grid} from '@astryxdesign/core/Grid';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Badge} from '@astryxdesign/core/Badge';
import {Sparkline} from '@/components/charts/Sparkline';
import {ChartCard} from '@/components/charts/ChartCard';
import {StatPair, StatRow} from '@/components/patterns/StatPair';
import {formatCompact, formatInrCompact, formatPct} from '@/lib/format';
import type {StoreComparison as StoreComparisonRow} from '@/lib/api/contracts';
import type {Resource} from '@/lib/api/useResource';
import {Sparkline} from '@/shared/components/charts/Sparkline';
import {ChartCard} from '@/shared/components/charts/ChartCard';
import {StatPair, StatRow} from '@/shared/components/patterns/StatPair';
import {formatCompact, formatInrCompact, formatPct} from '@/shared/utils/format';
import type {StoreComparison as StoreComparisonRow} from '@/features/dashboard/types/dashboard';
import type {Resource} from '@/shared/hooks/useResource';
/**
* Small multiples, not a five-series chart.

View File

@@ -0,0 +1,58 @@
'use client';
import {dashboardRepository} from '@/features/dashboard/repositories/dashboardRepository';
import type {Granularity} from '@/features/dashboard/types/dashboard';
import {useResource} from '@/shared/hooks/useResource';
import {useScope} from '@/shared/hooks/useScope';
import type {Scope} from '@/shared/services/httpClient';
/**
* The dashboard's data access, one hook per panel.
*
* Components call these and get {status, data, error, refetch}; they never see
* a URL, a fetch or a scope argument. That is the whole point of the layering
* — swapping the repository for a GraphQL client, or useResource for TanStack
* Query, changes these files and nothing that renders.
*
* Each panel gets its own hook rather than one `useDashboardData()` returning
* everything: the store-detail page reuses four of them without pulling in the
* two it has no room for, and a panel that fails does not blank its neighbours.
*/
export function useDashboardKpis(scope?: Scope) {
const active = useScope();
return useResource(dashboardRepository.kpis(scope ?? active));
}
export function useDashboardTimeseries(scope?: Scope) {
const active = useScope();
return useResource(dashboardRepository.timeseries(scope ?? active));
}
export function useDashboardPeakHours(scope?: Scope) {
const active = useScope();
return useResource(dashboardRepository.peakHours(scope ?? active));
}
export function useDashboardActivity(scope?: Scope) {
const active = useScope();
return useResource(dashboardRepository.activity(scope ?? active));
}
export function useDashboardRewardUsage(scope?: Scope) {
const active = useScope();
return useResource(dashboardRepository.rewardUsage(scope ?? active));
}
export function useDashboardStoreComparison(scope?: Scope) {
const active = useScope();
return useResource(dashboardRepository.storeComparison(scope ?? active));
}
export function useDashboardPerformance(granularity: Granularity) {
return useResource(dashboardRepository.performance(useScope(), granularity));
}
export function useDashboardBriefing() {
return useResource(dashboardRepository.briefing(useScope()));
}

View File

@@ -1,13 +1,8 @@
import type {
Granularity,
PeriodPoint,
RangeKey,
RewardUsagePoint,
StoreComparison,
} from '@/lib/api/contracts';
import {createRng} from './rng';
import {buildTimeseries} from './dashboard';
import {STORE_SEED} from './stores';
import type {Granularity, PeriodPoint, RewardUsagePoint, StoreComparison} from '@/features/dashboard/types/dashboard';
import type {RangeKey} from '@/shared/types/api';
import {createRng} from '@/shared/mock/rng';
import {buildTimeseries} from './dashboard.mock';
import {STORE_SEED} from '@/features/stores/mock/stores.mock';
/**
* Per-store totals for the comparison small-multiples.

View File

@@ -1,14 +1,10 @@
import type {
DashboardBriefing,
DashboardTask,
Insight,
RangeKey,
} from '@/lib/api/contracts';
import {buildTimeseries} from './dashboard';
import {buildStoreComparison, buildRewardUsage} from './analytics';
import {buildStaffSummary} from './staff';
import {buildRewards} from './lyts';
import {storeName} from './stores';
import type {DashboardBriefing, DashboardTask, Insight} from '@/features/dashboard/types/dashboard';
import type {RangeKey} from '@/shared/types/api';
import {buildTimeseries} from './dashboard.mock';
import {buildStoreComparison, buildRewardUsage} from './analytics.mock';
import {buildStaffSummary} from '@/features/staff/mock/staff.mock';
import {buildRewards} from '@/features/lyts/mock/lyts.mock';
import {storeName} from '@/features/stores/mock/stores.mock';
/**
* The narrative half of the dashboard.

View File

@@ -1,12 +1,7 @@
import type {
ActivityEvent,
HourCell,
Kpi,
RangeKey,
TimePoint,
} from '@/lib/api/contracts';
import {createRng} from './rng';
import {storeScale} from './stores';
import type {ActivityEvent, HourCell, Kpi, TimePoint} from '@/features/dashboard/types/dashboard';
import type {RangeKey} from '@/shared/types/api';
import {createRng} from '@/shared/mock/rng';
import {storeScale} from '@/features/stores/mock/stores.mock';
export const RANGE_DAYS: Record<RangeKey, number> = {
'7d': 7,

View File

@@ -0,0 +1,51 @@
import {scopedEndpoint} from '@/shared/services/httpClient';
import type {Endpoint, Scope} from '@/shared/services/httpClient';
import type {
ActivityEvent,
DashboardBriefing,
Granularity,
HourCell,
Kpi,
PeriodPoint,
RewardUsagePoint,
StoreComparison,
TimePoint,
} from '@/features/dashboard/types/dashboard';
/**
* Every URL the dashboard knows, and the only place it knows them.
*
* This is the file a backend integration edits. Each method returns a typed
* Endpoint rather than data, so the transport stays declarative: useResource
* keys off the URL, which is what makes a store or range change refetch while
* an unrelated re-render does not.
*
* A repository never interprets. No formatting, no filtering, no defaults —
* those are the service's job, and keeping them out of here is what allows a
* REST backend to be swapped for GraphQL by changing this file alone.
*/
export const dashboardRepository = {
kpis: (scope: Scope): Endpoint<Kpi[]> =>
scopedEndpoint('/api/dashboard/kpis', scope),
briefing: (scope: Scope): Endpoint<DashboardBriefing> =>
scopedEndpoint('/api/dashboard/briefing', scope),
timeseries: (scope: Scope): Endpoint<TimePoint[]> =>
scopedEndpoint('/api/dashboard/timeseries', scope),
peakHours: (scope: Scope): Endpoint<HourCell[]> =>
scopedEndpoint('/api/dashboard/peak-hours', scope),
activity: (scope: Scope): Endpoint<ActivityEvent[]> =>
scopedEndpoint('/api/dashboard/activity', scope),
storeComparison: (scope: Scope): Endpoint<StoreComparison[]> =>
scopedEndpoint('/api/dashboard/store-comparison', scope),
rewardUsage: (scope: Scope): Endpoint<RewardUsagePoint[]> =>
scopedEndpoint('/api/dashboard/reward-usage', scope),
performance: (scope: Scope, granularity: Granularity): Endpoint<PeriodPoint[]> =>
scopedEndpoint('/api/dashboard/performance', scope, {granularity}),
};

View 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';
}

View File

@@ -0,0 +1,112 @@
/**
* 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.
*/
export type KpiId = 'visitors' | 'purchases' | 'revenue' | 'activeRewards';
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[];
}

View File

@@ -0,0 +1,100 @@
'use client';
import {Icon} from '@astryxdesign/core/Icon';
import {IconButton} from '@astryxdesign/core/IconButton';
import {HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
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,
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="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>
);
}

View 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 5664px 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 shadow-md',
'transition-colors duration-150',
'focus-within:border-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 text-primary placeholder:text-secondary',
'py-2 leading-6 overflow-y-auto',
].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>
);
}

View 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 justify-center items-center px-4 py-8 w-full max-w-2xl mx-auto overflow-y-auto"
>
<div className="w-full flex-1 flex flex-col items-center justify-center my-auto -mt-20 sm:-mt-28 py-4">
<EmptyState />
{/* 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>
);
}

View File

@@ -0,0 +1,70 @@
'use client';
import {useState, useEffect} from 'react';
import {motion} from 'framer-motion';
import {Heading, Text} from '@astryxdesign/core/Text';
import {BrandMark} from '@/shared/components/brand/BrandLogo';
const GREETINGS = [
'How can Loyaly AI help today?',
'What would you like to analyze today?',
'Need insights from your business?',
'What would you like to know?',
'Ready to optimize your stores?',
"Let's improve today's performance.",
'Ask anything about your business.',
'How can I help your team today?',
"Need help understanding today's numbers?",
'What should we explore today?',
];
const SUBTITLES = [
'Ask anything about sales, stores, staff or rewards.',
'Analyze your business with AI.',
'Get instant answers from your retail data.',
'Discover trends across all your stores.',
'Find opportunities to improve performance.',
'Your AI assistant for smarter retail decisions.',
];
export function EmptyState() {
const [greeting, setGreeting] = useState(GREETINGS[0]);
const [subtitle, setSubtitle] = useState(SUBTITLES[0]);
useEffect(() => {
const randomGreeting = GREETINGS[Math.floor(Math.random() * GREETINGS.length)];
const randomSubtitle = SUBTITLES[Math.floor(Math.random() * SUBTITLES.length)];
setGreeting(randomGreeting);
setSubtitle(randomSubtitle);
}, []);
return (
<div className="flex flex-col items-center text-center w-full max-w-xl mx-auto">
{/* 1. Official Loyaly Heart Logo (56px desktop, 48px mobile) */}
<div className="mb-6 flex justify-center">
<BrandMark size={56} priority />
</div>
{/* 2. Dynamic Heading & Subtitle */}
<motion.div
key={greeting}
initial={{opacity: 0, y: 8}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.25, ease: 'easeOut'}}
className="flex flex-col items-center text-center"
>
<div className="mb-4">
<Heading level={2} justify="center" className="text-xl sm:text-2xl font-semibold">
{greeting}
</Heading>
</div>
<div className="max-w-md mx-auto">
<Text type="supporting" justify="center" className="text-sm">
{subtitle}
</Text>
</div>
</motion.div>
</div>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -1,20 +1,20 @@
'use client';
import {IconButton} from '@astryxdesign/core/IconButton';
import {Icon} from '@astryxdesign/core/Icon';
import {ICONS} from '@/lib/icons';
import {useBreakpoint, isPanelInline} from '@/lib/breakpoints';
import {useCopilot} from './CopilotProvider';
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 Copilot's contents
* never notice which one is in play.
* slide-over. Both read and write the same provider, so the assistant's
* contents never notice which one is in play.
*/
export function CopilotToggle() {
export function LoyalyAiToggle() {
const bp = useBreakpoint();
const {isOpen, toggle, isSlideOverOpen, setSlideOverOpen} = useCopilot();
const {isOpen, toggle, isSlideOverOpen, setSlideOverOpen} = useLoyalyAi();
const inline = isPanelInline(bp);
const isShown = inline ? isOpen : isSlideOverOpen;
@@ -23,6 +23,7 @@ export function CopilotToggle() {
<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))}
/>

View File

@@ -0,0 +1,66 @@
'use client';
import {Avatar} from '@astryxdesign/core/Avatar';
import {
ChatMessage as ChatMessageRow,
ChatMessageBubble,
} from '@astryxdesign/core/Chat';
import {VStack} from '@astryxdesign/core/Layout';
import {MessageContent} from './MessageContent';
import {TypingIndicator} from './TypingIndicator';
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 ? (
<Avatar name="Loyaly AI" size="xsm" tooltip={false} />
) : undefined
}
>
<ChatMessageBubble variant={isAssistant ? 'ghost' : 'filled'}>
{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>
);
}

View File

@@ -0,0 +1,64 @@
'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 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':
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>
);
}
}

View 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>
);
}

Some files were not shown because too many files have changed in this diff Show More