update ui update and fix layout issue
This commit is contained in:
@@ -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
120
ARCHITECTURE.md
Normal 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
42
Dockerfile
Normal 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
56
nginx.conf
Normal 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
9
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 (
|
||||
|
||||
10
src/app/(public)/layout.tsx
Normal file
10
src/app/(public)/layout.tsx
Normal 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>;
|
||||
}
|
||||
19
src/app/(public)/login/page.tsx
Normal file
19
src/app/(public)/login/page.tsx
Normal 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 />;
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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 />}
|
||||
/>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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}>
|
||||
|
||||
117
src/app/api/auth/login/route.ts
Normal file
117
src/app/api/auth/login/route.ts
Normal 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'}});
|
||||
}
|
||||
29
src/app/api/auth/logout/route.ts
Normal file
29
src/app/api/auth/logout/route.ts
Normal 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'}},
|
||||
);
|
||||
}
|
||||
42
src/app/api/auth/session/route.ts
Normal file
42
src/app/api/auth/session/route.ts
Normal 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'}});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
'use client';
|
||||
|
||||
export {LoginSplit as LoginForm} from './LoginSplit';
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
236
src/features/auth/components/LoginCredentialsForm.tsx
Normal file
236
src/features/auth/components/LoginCredentialsForm.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {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>
|
||||
);
|
||||
}
|
||||
52
src/features/auth/components/LoginHeroPanel.tsx
Normal file
52
src/features/auth/components/LoginHeroPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
108
src/features/auth/components/LoginSplit.tsx
Normal file
108
src/features/auth/components/LoginSplit.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
43
src/features/auth/components/ProviderIcons.tsx
Normal file
43
src/features/auth/components/ProviderIcons.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
src/features/auth/guards/AuthGuard.tsx
Normal file
48
src/features/auth/guards/AuthGuard.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import {useEffect} from 'react';
|
||||
import {usePathname, useRouter} from 'next/navigation';
|
||||
import {Center} from '@astryxdesign/core/Center';
|
||||
import {Spinner} from '@astryxdesign/core/Spinner';
|
||||
import {useSession} from '@/features/auth/providers/SessionProvider';
|
||||
|
||||
/**
|
||||
* Client-side half of route protection. The server half is src/proxy.ts.
|
||||
*
|
||||
* ── Why both ──────────────────────────────────────────────────────────────
|
||||
* The proxy is the one that MATTERS: it refuses to serve the route at all, so
|
||||
* there is nothing to defeat by disabling JavaScript. But it only runs on
|
||||
* navigations the server sees. This guard covers what happens afterwards — a
|
||||
* session that expires while a tab sits open, a logout in another tab, a
|
||||
* client-side route change within the already-loaded app. Without it, the
|
||||
* workspace would keep rendering against an identity that no longer exists
|
||||
* until something happened to hit the network.
|
||||
*
|
||||
* Treat this as a correctness guard, never as the security boundary. Anything
|
||||
* that must not leak is fetched through a route handler, and route handlers
|
||||
* check the session themselves.
|
||||
*/
|
||||
export function AuthGuard({children}: {children: React.ReactNode}) {
|
||||
const {status} = useSession();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== 'unauthenticated') return;
|
||||
const next = pathname && pathname !== '/dashboard' ? `?next=${encodeURIComponent(pathname)}` : '';
|
||||
router.replace(`/login${next}`);
|
||||
}, [status, router, pathname]);
|
||||
|
||||
// 'loading' means the bootstrap request is still in flight. Rendering the
|
||||
// workspace here would paint an empty-looking shell and then swap it, and
|
||||
// rendering the redirect would fire it for users who ARE signed in.
|
||||
if (status !== 'authenticated') {
|
||||
return (
|
||||
<Center height="100vh" role="status" aria-label="Checking your session">
|
||||
<Spinner size="lg" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
45
src/features/auth/guards/GuestGuard.tsx
Normal file
45
src/features/auth/guards/GuestGuard.tsx
Normal 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}</>;
|
||||
}
|
||||
114
src/features/auth/hooks/useLoginForm.ts
Normal file
114
src/features/auth/hooks/useLoginForm.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
105
src/features/auth/mock/users.mock.ts
Normal file
105
src/features/auth/mock/users.mock.ts
Normal 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;
|
||||
}
|
||||
172
src/features/auth/providers/SessionProvider.tsx
Normal file
172
src/features/auth/providers/SessionProvider.tsx
Normal 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;
|
||||
}
|
||||
42
src/features/auth/repositories/authRepository.ts
Normal file
42
src/features/auth/repositories/authRepository.ts
Normal 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');
|
||||
},
|
||||
};
|
||||
99
src/features/auth/services/authService.ts
Normal file
99
src/features/auth/services/authService.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import {authRepository} from '@/features/auth/repositories/authRepository';
|
||||
import type {
|
||||
AuthSession,
|
||||
LoginCredentials,
|
||||
LoginError,
|
||||
LoginResult,
|
||||
} from '@/features/auth/types/auth';
|
||||
|
||||
/**
|
||||
* The domain layer for authentication.
|
||||
*
|
||||
* It owns the RULES — what counts as valid input, what an HTTP status means in
|
||||
* user terms, what "signed out" looks like — and it owns them independently of
|
||||
* both the transport below it and the React tree above it. Nothing here is
|
||||
* async-framework-specific, so it is directly unit-testable and it survives a
|
||||
* backend swap untouched.
|
||||
*/
|
||||
|
||||
const EMAIL_PATTERN = /^\S+@\S+\.\S+$/;
|
||||
|
||||
/**
|
||||
* Client-side validation, mirroring the server's.
|
||||
*
|
||||
* Duplicated on purpose, and the duplication is the point: this copy exists
|
||||
* for latency (no round trip to be told a field is blank), the server's copy
|
||||
* exists for correctness (a POST can arrive without ever passing through this
|
||||
* form). Neither is redundant, because they answer to different threats.
|
||||
*/
|
||||
export function validateCredentials(
|
||||
input: Pick<LoginCredentials, 'email' | 'password'>,
|
||||
): LoginError | null {
|
||||
if (!input.email.trim()) {
|
||||
return {field: 'email', message: 'Enter your email address.'};
|
||||
}
|
||||
if (!EMAIL_PATTERN.test(input.email.trim())) {
|
||||
return {field: 'email', message: 'Enter a valid email address.'};
|
||||
}
|
||||
if (!input.password) {
|
||||
return {field: 'password', message: 'Enter your password.'};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Narrow the server's free-form `field` back onto the union. */
|
||||
function toErrorField(field: string | undefined): LoginError['field'] {
|
||||
return field === 'email' || field === 'password' ? field : 'form';
|
||||
}
|
||||
|
||||
export const authService = {
|
||||
/**
|
||||
* Validate, then attempt. Returns a discriminated result — callers cannot
|
||||
* accidentally treat a failure as a success, and nothing throws, because a
|
||||
* wrong password is an expected outcome of logging in, not an exception.
|
||||
*/
|
||||
async login(credentials: LoginCredentials): Promise<LoginResult> {
|
||||
const invalid = validateCredentials(credentials);
|
||||
if (invalid) return {ok: false, error: invalid};
|
||||
|
||||
const res = await authRepository.login(credentials);
|
||||
|
||||
if (!res.ok || !res.data) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
field: toErrorField(res.field),
|
||||
message:
|
||||
res.message ??
|
||||
(res.status === 0
|
||||
? 'Could not reach the server. Check your connection.'
|
||||
: 'Sign-in failed. Please try again.'),
|
||||
},
|
||||
};
|
||||
}
|
||||
return {ok: true, session: res.data};
|
||||
},
|
||||
|
||||
/**
|
||||
* Ends the server session. Resolves even when the request fails — a user who
|
||||
* pressed Log out must never be left looking at a workspace because the
|
||||
* network blipped; the client clears its own state and redirects regardless,
|
||||
* and the cookie's own expiry is the backstop.
|
||||
*/
|
||||
async logout(): Promise<void> {
|
||||
try {
|
||||
await authRepository.logout();
|
||||
} catch {
|
||||
/* deliberately ignored — see above */
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Who the server says we are. `null` is a valid answer (signed out), so it
|
||||
* is returned as data rather than raised as an error.
|
||||
*/
|
||||
async currentSession(): Promise<AuthSession | null> {
|
||||
const res = await authRepository.currentSession();
|
||||
return res.ok ? (res.data ?? null) : null;
|
||||
},
|
||||
};
|
||||
31
src/features/auth/services/redirectTarget.ts
Normal file
31
src/features/auth/services/redirectTarget.ts
Normal 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;
|
||||
}
|
||||
58
src/features/auth/services/serverSession.ts
Normal file
58
src/features/auth/services/serverSession.ts
Normal 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'}},
|
||||
),
|
||||
};
|
||||
}
|
||||
144
src/features/auth/services/sessionToken.ts
Normal file
144
src/features/auth/services/sessionToken.ts
Normal 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} : {}),
|
||||
};
|
||||
}
|
||||
60
src/features/auth/types/auth.ts
Normal file
60
src/features/auth/types/auth.ts
Normal 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};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 320–380px 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);
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
58
src/features/dashboard/hooks/useDashboard.ts
Normal file
58
src/features/dashboard/hooks/useDashboard.ts
Normal 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()));
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
51
src/features/dashboard/repositories/dashboardRepository.ts
Normal file
51
src/features/dashboard/repositories/dashboardRepository.ts
Normal 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}),
|
||||
};
|
||||
28
src/features/dashboard/services/dashboardService.ts
Normal file
28
src/features/dashboard/services/dashboardService.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Domain rules for the dashboard.
|
||||
*
|
||||
* Small, but it is the right home for them: a greeting derived from a clock is
|
||||
* a rule about time of day, not a rendering concern, and having it in the page
|
||||
* meant the one non-obvious thing about it (which clock) lived next to JSX.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The header greeting, derived from the SERVER's clock.
|
||||
*
|
||||
* Never Date.now(): calling it during render is impure, disagrees between the
|
||||
* SSR and hydration passes, and reports the wrong time of day to anyone whose
|
||||
* device clock is off. The instant arrives on every response's `meta`, and
|
||||
* getHours() resolves it in the viewer's own zone — the hour the merchant is
|
||||
* actually living in.
|
||||
*
|
||||
* Before the first response there is no instant to reason about, so the
|
||||
* greeting is the time-independent one. Both strings are a single line, so the
|
||||
* swap costs no layout shift.
|
||||
*/
|
||||
export function greetingFor(generatedAt?: string): string {
|
||||
if (!generatedAt) return 'Welcome back';
|
||||
const hour = new Date(generatedAt).getHours();
|
||||
if (hour < 12) return 'Good morning';
|
||||
if (hour < 17) return 'Good afternoon';
|
||||
return 'Good evening';
|
||||
}
|
||||
112
src/features/dashboard/types/dashboard.ts
Normal file
112
src/features/dashboard/types/dashboard.ts
Normal 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[];
|
||||
}
|
||||
100
src/features/loyaly-ai/components/ChatHeader.tsx
Normal file
100
src/features/loyaly-ai/components/ChatHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
139
src/features/loyaly-ai/components/Composer.tsx
Normal file
139
src/features/loyaly-ai/components/Composer.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
'use client';
|
||||
|
||||
import {useEffect, useRef} from 'react';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {IconButton} from '@astryxdesign/core/IconButton';
|
||||
import {HStack} from '@astryxdesign/core/Layout';
|
||||
import {ICONS} from '@/shared/utils/icons';
|
||||
|
||||
/**
|
||||
* The composer: one pill, three zones.
|
||||
*
|
||||
* [ + ] Ask Loyaly AI about your business... [ mic ] [ ↑ ]
|
||||
*
|
||||
* ── Why this is hand-built and not Astryx's ChatComposer ─────────────────
|
||||
* ChatComposer is a multi-row surface: `headerActions` render in a row ABOVE
|
||||
* the input, `footerActions` in a row below. That is the right shape for a
|
||||
* desktop IDE assistant with model pickers and context chips, and the wrong
|
||||
* shape for the single 56–64px pill the brief specifies, where the attachment
|
||||
* button sits inline to the LEFT of the text. Bending it into one row would
|
||||
* have meant fighting its layout with utilities on every slot.
|
||||
*
|
||||
* What is NOT hand-built is anything that carries behaviour: the docking and
|
||||
* auto-scroll come from ChatLayout, which this is passed to as `composer`.
|
||||
*
|
||||
* ── Auto-grow ─────────────────────────────────────────────────────────────
|
||||
* The textarea grows with its content up to MAX_ROWS then scrolls, which is
|
||||
* why the height is a range rather than a number. Measured by resetting height
|
||||
* to `auto` before reading scrollHeight — without the reset, scrollHeight can
|
||||
* only ever grow, and the box never shrinks back after a deletion.
|
||||
*/
|
||||
|
||||
/** ~5 lines before the field starts scrolling instead of growing. */
|
||||
const MAX_HEIGHT_PX = 132;
|
||||
|
||||
const PILL = [
|
||||
'rounded-[28px] bg-card border border-border 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>
|
||||
);
|
||||
}
|
||||
126
src/features/loyaly-ai/components/Conversation.tsx
Normal file
126
src/features/loyaly-ai/components/Conversation.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {motion, AnimatePresence} from 'framer-motion';
|
||||
import {useLoyalyAi} from '@/features/loyaly-ai/providers/LoyalyAiProvider';
|
||||
import {Composer} from './Composer';
|
||||
import {EmptyState} from './EmptyState';
|
||||
import {SuggestionChips} from './SuggestionChips';
|
||||
import {MessageList} from './MessageList';
|
||||
|
||||
/**
|
||||
* ChatGPT & Gemini style two-stage chat experience:
|
||||
*
|
||||
* 1. STAGE 1 — Initial Minimal Landing:
|
||||
* - Vertically centered Loyaly logo mark, heading, subtitle, and composer.
|
||||
* - No suggestion chips shown initially to keep interface calm and minimal.
|
||||
*
|
||||
* 2. STAGE 2 — Input Focused / Typing:
|
||||
* - Focusing or typing in composer smoothly animates suggestion chips into view directly BELOW the composer.
|
||||
*
|
||||
* 3. Active Conversation Mode:
|
||||
* - First message sent removes landing elements and morphs composer to sticky bottom.
|
||||
*
|
||||
* 4. New Chat:
|
||||
* - Returns to STAGE 1 (minimal landing, chips hidden until focused again).
|
||||
*/
|
||||
export function Conversation() {
|
||||
const {conversation, draft, setDraft, send, stop, isStreaming} =
|
||||
useLoyalyAi();
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
const hasMessages = conversation.messages.length > 0;
|
||||
const showChips = isFocused || draft.trim().length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full w-full relative overflow-hidden bg-background">
|
||||
<AnimatePresence>
|
||||
{!hasMessages ? (
|
||||
<motion.div
|
||||
key="empty-landing-view"
|
||||
initial={{opacity: 0, scale: 0.98}}
|
||||
animate={{opacity: 1, scale: 1}}
|
||||
exit={{opacity: 0, scale: 0.96, transition: {duration: 0.2}}}
|
||||
transition={{duration: 0.3, ease: 'easeOut'}}
|
||||
className="flex-1 flex flex-col 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>
|
||||
);
|
||||
}
|
||||
70
src/features/loyaly-ai/components/EmptyState.tsx
Normal file
70
src/features/loyaly-ai/components/EmptyState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
src/features/loyaly-ai/components/HistoryDrawer.tsx
Normal file
80
src/features/loyaly-ai/components/HistoryDrawer.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {Item} from '@astryxdesign/core/Item';
|
||||
import {HStack, VStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Timestamp} from '@astryxdesign/core/Timestamp';
|
||||
import {useLoyalyAi} from '@/features/loyaly-ai/providers/LoyalyAiProvider';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {ICONS} from '@/shared/utils/icons';
|
||||
|
||||
/**
|
||||
* Past conversations, as an overlay INSIDE the panel.
|
||||
*
|
||||
* Not a second drawer off the side of the screen: the assistant already is a
|
||||
* drawer at most widths, and a drawer opening out of a drawer is how you end
|
||||
* up with two dismiss layers and a stack of Escape handlers that disagree.
|
||||
* Covering the conversation area instead keeps the header — with the same
|
||||
* History button, now toggled on — in place as the way back out.
|
||||
*
|
||||
* Absolutely positioned over the conversation rather than replacing it, so
|
||||
* the transcript is not unmounted: opening history and closing it again
|
||||
* returns to the same scroll position mid-conversation.
|
||||
*/
|
||||
export function HistoryDrawer() {
|
||||
const {history, openConversation, isHistoryOpen, setHistoryOpen} =
|
||||
useLoyalyAi();
|
||||
|
||||
if (!isHistoryOpen) return null;
|
||||
|
||||
return (
|
||||
<VStack
|
||||
gap={2}
|
||||
padding={3}
|
||||
width="100%"
|
||||
className="absolute inset-0 z-10 overflow-y-auto bg-surface"
|
||||
role="region"
|
||||
aria-label="Chat history"
|
||||
>
|
||||
<HStack hAlign="between" vAlign="center" paddingInline={1}>
|
||||
<Text size="sm" type="supporting">
|
||||
Recent chats
|
||||
</Text>
|
||||
{/* A text button rather than an IconButton: this closes a region, not
|
||||
the assistant, and an X here would be mistaken for the panel's own
|
||||
close in the header directly above it. */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
label="Done"
|
||||
onClick={() => setHistoryOpen(false)}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
{history.length === 0 ? (
|
||||
<EmptyPanel
|
||||
icon="chat"
|
||||
title="No conversations yet"
|
||||
description="Chats you start appear here for the rest of the session."
|
||||
/>
|
||||
) : (
|
||||
<VStack gap={0.5}>
|
||||
{history.map((c) => (
|
||||
<Item
|
||||
key={c.id}
|
||||
label={c.title}
|
||||
labelLines={1}
|
||||
description={<Timestamp value={c.updatedAt} />}
|
||||
density="balanced"
|
||||
className="rounded-lg cursor-pointer"
|
||||
startContent={<Icon icon={ICONS.chat} size="sm" />}
|
||||
onClick={() => openConversation(c.id)}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
30
src/features/loyaly-ai/components/LoyalyAiPanel.tsx
Normal file
30
src/features/loyaly-ai/components/LoyalyAiPanel.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {ChatHeader} from './ChatHeader';
|
||||
import {Conversation} from './Conversation';
|
||||
import {HistoryDrawer} from './HistoryDrawer';
|
||||
import {ResizeHandle} from './ResizeHandle';
|
||||
|
||||
/**
|
||||
* The assistant surface, identical inline and in the slide-over.
|
||||
*
|
||||
* Three children and no branching: a header, the conversation, and the history
|
||||
* overlay that covers the conversation when it is open. Everything it renders
|
||||
* reads from LoyalyAiProvider, so it is safe to unmount and remount when the
|
||||
* breakpoint changes presentation.
|
||||
*
|
||||
* `relative` on the frame is load-bearing — it is the positioning context
|
||||
* HistoryDrawer's `absolute inset-0` resolves against, which is what keeps the
|
||||
* overlay inside the panel instead of over the whole workspace.
|
||||
*/
|
||||
export function LoyalyAiPanel({onClose}: {onClose?: () => void}) {
|
||||
return (
|
||||
<VStack gap={0} height="100%" width="100%" className="relative">
|
||||
<ResizeHandle />
|
||||
<ChatHeader onClose={onClose} />
|
||||
<Conversation />
|
||||
<HistoryDrawer />
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
71
src/features/loyaly-ai/components/LoyalyAiSlideOver.tsx
Normal file
71
src/features/loyaly-ai/components/LoyalyAiSlideOver.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
import {MobileNav} from '@astryxdesign/core/MobileNav';
|
||||
import {useLoyalyAi} from '@/features/loyaly-ai/providers/LoyalyAiProvider';
|
||||
import {useBreakpoint} from '@/shared/hooks/useBreakpoint';
|
||||
import {LoyalyAiPanel} from './LoyalyAiPanel';
|
||||
|
||||
/**
|
||||
* Tablet and mobile presentation.
|
||||
*
|
||||
* MobileNav rather than Dialog: a Dialog is content-sized (height:
|
||||
* fit-content, maxHeight 480px) and ignores top/bottom insets, so it renders
|
||||
* as a floating card rather than a full-height surface. MobileNav is the
|
||||
* edge-anchored primitive this actually needs, and its native <dialog>
|
||||
* brings the focus trap, Escape and backdrop with it.
|
||||
*
|
||||
* It renders the SAME <LoyalyAiPanel /> as the inline path — crossing the
|
||||
* breakpoint changes where the assistant lives, never what it contains or
|
||||
* remembers, because all state sits in LoyalyAiProvider above the route tree.
|
||||
*/
|
||||
const SHEET = [
|
||||
// The composer must clear the home indicator on a notched phone. env() is
|
||||
// not expressible as a token and pb-safe is not in core Tailwind, so the
|
||||
// inset is an arbitrary property on the one surface that docks to the
|
||||
// bottom edge.
|
||||
'[&>div]:pb-[env(safe-area-inset-bottom)]',
|
||||
/*
|
||||
* Hide MobileNav's own header row.
|
||||
*
|
||||
* MobileNav always renders a close button after its `header` slot. With
|
||||
* ChatHeader inside, the sheet showed TWO X buttons stacked — one in
|
||||
* MobileNav's empty header row, one in ours — and two rows of chrome above
|
||||
* the conversation. ChatHeader is the assistant's header at every width, so
|
||||
* the drawer's own row is removed rather than duplicated.
|
||||
*/
|
||||
'[&>div>*:first-child]:hidden',
|
||||
].join(' ');
|
||||
|
||||
/**
|
||||
* Full-bleed on a phone.
|
||||
*
|
||||
* MobileNav's `width` is a MAX-width and it defaults to 320px, so passing
|
||||
* `undefined` on mobile — as this did before — left a 320px sheet with 70px of
|
||||
* dashboard showing beside it. A conversation is the whole task on a phone;
|
||||
* `max-w-none` lets the drawer's own `width: 100vw` fill the screen.
|
||||
*/
|
||||
const FULL_BLEED = '[&>div]:max-w-none';
|
||||
|
||||
export function LoyalyAiSlideOver() {
|
||||
const bp = useBreakpoint();
|
||||
const {isSlideOverOpen, setSlideOverOpen} = useLoyalyAi();
|
||||
|
||||
return (
|
||||
<MobileNav
|
||||
// Explicit, because MobileNav otherwise takes its id from AppShell's
|
||||
// mobile context — which every MobileNav in the tree shares, so this and
|
||||
// the navigation drawer would both render the same id, and the menu
|
||||
// button's aria-controls would resolve to whichever the DOM reached
|
||||
// first.
|
||||
id="loyaly-ai-slide-over"
|
||||
isOpen={isSlideOverOpen}
|
||||
onOpenChange={setSlideOverOpen}
|
||||
side="end"
|
||||
width={420}
|
||||
label="Loyaly AI"
|
||||
className={bp === 'mobile' ? `${SHEET} ${FULL_BLEED}` : SHEET}
|
||||
>
|
||||
<LoyalyAiPanel onClose={() => setSlideOverOpen(false)} />
|
||||
</MobileNav>
|
||||
);
|
||||
}
|
||||
@@ -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))}
|
||||
/>
|
||||
66
src/features/loyaly-ai/components/MessageBubble.tsx
Normal file
66
src/features/loyaly-ai/components/MessageBubble.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
src/features/loyaly-ai/components/MessageContent.tsx
Normal file
64
src/features/loyaly-ai/components/MessageContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
44
src/features/loyaly-ai/components/MessageList.tsx
Normal file
44
src/features/loyaly-ai/components/MessageList.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import {ChatMessageList} from '@astryxdesign/core/Chat';
|
||||
import {MessageBubble} from './MessageBubble';
|
||||
import type {ChatMessage} from '@/features/loyaly-ai/types/chat';
|
||||
|
||||
/**
|
||||
* The transcript.
|
||||
*
|
||||
* ── On vertical anchoring ────────────────────────────────────────────────
|
||||
* ChatMessageList renders a `flex: 1 1 0` spacer before its first message, so
|
||||
* a short conversation sits at the BOTTOM of the scroll area and grows upward
|
||||
* — Slack's behaviour rather than ChatGPT's. Measured: one short exchange sat
|
||||
* 561px down the panel with the whole upper half blank.
|
||||
*
|
||||
* TOP_ANCHORED collapses that spacer. It is safe here because Conversation
|
||||
* owns the scroll container: an earlier attempt with Astryx's ChatLayout
|
||||
* scrolled a short conversation clean out of view, because that layout's
|
||||
* auto-scroll assumed the spacer was filling the box.
|
||||
*
|
||||
* ChatMessageList is Astryx's own list primitive and it carries the
|
||||
* behaviour worth not reimplementing: it is an aria-live region, so a screen
|
||||
* reader announces an answer as it arrives, and `isStreaming` coordinates with
|
||||
* ChatLayout's auto-scroll so the view follows the text without fighting a
|
||||
* user who has scrolled up to re-read something.
|
||||
*/
|
||||
/** See the anchoring note above. */
|
||||
const TOP_ANCHORED = '[&>div>div:first-child]:hidden';
|
||||
|
||||
export function MessageList({
|
||||
messages,
|
||||
isStreaming,
|
||||
}: {
|
||||
messages: ChatMessage[];
|
||||
isStreaming: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ChatMessageList isStreaming={isStreaming} className={TOP_ANCHORED}>
|
||||
{messages.map((message) => (
|
||||
<MessageBubble key={message.id} message={message} />
|
||||
))}
|
||||
</ChatMessageList>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user