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 |
|
| | 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 |
|
| Top | 32px (`paddingBlock={8}`) | title never touches the header |
|
||||||
| Bottom | 48px (`className="pb-12"`) | last card is never flush to the fold |
|
| 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 |
|
| 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",
|
"next": "^16.3.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"recharts": "^3.10.1"
|
"recharts": "^3.10.1",
|
||||||
|
"server-only": "^0.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@astryxdesign/cli": "^0.2.0",
|
"@astryxdesign/cli": "^0.2.0",
|
||||||
@@ -7050,6 +7051,12 @@
|
|||||||
"semver": "bin/semver.js"
|
"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": {
|
"node_modules/set-function-length": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||||
|
|||||||
@@ -20,7 +20,8 @@
|
|||||||
"next": "^16.3.0",
|
"next": "^16.3.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"recharts": "^3.10.1"
|
"recharts": "^3.10.1",
|
||||||
|
"server-only": "^0.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@astryxdesign/cli": "^0.2.0",
|
"@astryxdesign/cli": "^0.2.0",
|
||||||
|
|||||||
@@ -16,22 +16,22 @@ import {Card} from '@astryxdesign/core/Card';
|
|||||||
import {Heading, Text} from '@astryxdesign/core/Text';
|
import {Heading, Text} from '@astryxdesign/core/Text';
|
||||||
import {Button} from '@astryxdesign/core/Button';
|
import {Button} from '@astryxdesign/core/Button';
|
||||||
import {Badge} from '@astryxdesign/core/Badge';
|
import {Badge} from '@astryxdesign/core/Badge';
|
||||||
import {useResource} from '@/lib/api/useResource';
|
import {useResource} from '@/shared/hooks/useResource';
|
||||||
import {endpoints} from '@/lib/api/client';
|
import {dashboardRepository} from '@/features/dashboard/repositories/dashboardRepository';
|
||||||
import {ChartCard} from '@/components/charts/ChartCard';
|
import {ChartCard} from '@/shared/components/charts/ChartCard';
|
||||||
import {LineChartView} from '@/components/charts/LineChartView';
|
import {LineChartView} from '@/shared/components/charts/LineChartView';
|
||||||
import {AreaChartView} from '@/components/charts/AreaChartView';
|
import {AreaChartView} from '@/shared/components/charts/AreaChartView';
|
||||||
import {BarChartView} from '@/components/charts/BarChartView';
|
import {BarChartView} from '@/shared/components/charts/BarChartView';
|
||||||
import {Sparkline} from '@/components/charts/Sparkline';
|
import {Sparkline} from '@/shared/components/charts/Sparkline';
|
||||||
import {HeatmapGrid} from '@/components/charts/HeatmapGrid';
|
import {HeatmapGrid} from '@/shared/components/charts/HeatmapGrid';
|
||||||
import type {HourCell, Kpi, TimePoint} from '@/lib/api/contracts';
|
import type {HourCell, Kpi, TimePoint} from '@/features/dashboard/types/dashboard';
|
||||||
import {
|
import {
|
||||||
formatCompact,
|
formatCompact,
|
||||||
formatDayLabel,
|
formatDayLabel,
|
||||||
formatDelta,
|
formatDelta,
|
||||||
formatInrCompact,
|
formatInrCompact,
|
||||||
formatPct,
|
formatPct,
|
||||||
} from '@/lib/format';
|
} from '@/shared/utils/format';
|
||||||
|
|
||||||
type Forced = '' | 'empty' | 'error' | 'delay';
|
type Forced = '' | 'empty' | 'error' | 'delay';
|
||||||
|
|
||||||
@@ -52,11 +52,11 @@ export default function ChartsGallery() {
|
|||||||
({...e, params: {...e.params, ...extra}}) as never as T;
|
({...e, params: {...e.params, ...extra}}) as never as T;
|
||||||
|
|
||||||
const series = useResource<TimePoint[]>(
|
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[]>(
|
const peak = useResource<HourCell[]>(
|
||||||
withExtra(endpoints.dashboardPeakHours(scope)),
|
withExtra(dashboardRepository.peakHours(scope)),
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
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';
|
'use client';
|
||||||
|
|
||||||
import {VStack} from '@astryxdesign/core/Layout';
|
import {VStack} from '@astryxdesign/core/Layout';
|
||||||
import {PageHeader} from '@/components/primitives/PageHeader';
|
import {PageHeader} from '@/shared/components/primitives/PageHeader';
|
||||||
import {ScopeControls} from '@/components/scope/ScopeControls';
|
import {ScopeControls} from '@/shared/components/scope/ScopeControls';
|
||||||
import {ActivityTimeline} from '@/features/dashboard/ActivityTimeline';
|
import {ActivityTimeline} from '@/features/dashboard/components/ActivityTimeline';
|
||||||
import {useResource} from '@/lib/api/useResource';
|
import {useDashboardActivity} from '@/features/dashboard/hooks/useDashboard';
|
||||||
import {endpoints} from '@/lib/api/client';
|
import {useScopeLabel} from '@/features/stores/hooks/useStoreDirectory';
|
||||||
import {useWorkspace} from '@/components/shell/WorkspaceProvider';
|
|
||||||
import {storeName} from '@/lib/mock/stores';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The complete event history — where the dashboard's "View all" leads.
|
* 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.
|
* uncapped: one feed implementation, two budgets.
|
||||||
*/
|
*/
|
||||||
export default function ActivityPage() {
|
export default function ActivityPage() {
|
||||||
const {storeId, range} = useWorkspace();
|
const activity = useDashboardActivity();
|
||||||
const activity = useResource(endpoints.dashboardActivity({range, storeId}));
|
const scopeLabel = useScopeLabel();
|
||||||
|
|
||||||
const scopeLabel = storeId === 'all' ? 'all stores' : storeName(storeId);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VStack gap={5}>
|
<VStack gap={5}>
|
||||||
|
|||||||
@@ -5,30 +5,37 @@ import {VStack} from '@astryxdesign/core/Layout';
|
|||||||
import {Grid} from '@astryxdesign/core/Grid';
|
import {Grid} from '@astryxdesign/core/Grid';
|
||||||
import {Button} from '@astryxdesign/core/Button';
|
import {Button} from '@astryxdesign/core/Button';
|
||||||
import {Icon} from '@astryxdesign/core/Icon';
|
import {Icon} from '@astryxdesign/core/Icon';
|
||||||
import {PageHeader} from '@/components/primitives/PageHeader';
|
import {PageHeader} from '@/shared/components/primitives/PageHeader';
|
||||||
import {ScopeControls} from '@/components/scope/ScopeControls';
|
import {ScopeControls} from '@/shared/components/scope/ScopeControls';
|
||||||
import {ICONS} from '@/lib/icons';
|
import {ICONS} from '@/shared/utils/icons';
|
||||||
import {ChartCard} from '@/components/charts/ChartCard';
|
import {ChartCard} from '@/shared/components/charts/ChartCard';
|
||||||
import {AreaChartView} from '@/components/charts/AreaChartView';
|
import {AreaChartView} from '@/shared/components/charts/AreaChartView';
|
||||||
import {LineChartView} from '@/components/charts/LineChartView';
|
import {LineChartView} from '@/shared/components/charts/LineChartView';
|
||||||
import {BarChartView} from '@/components/charts/BarChartView';
|
import {BarChartView} from '@/shared/components/charts/BarChartView';
|
||||||
import {HeatmapGrid} from '@/components/charts/HeatmapGrid';
|
import {HeatmapGrid} from '@/shared/components/charts/HeatmapGrid';
|
||||||
import {KpiRow} from '@/features/dashboard/KpiRow';
|
import {KpiRow} from '@/features/dashboard/components/KpiRow';
|
||||||
import {ActivityTimeline} from '@/features/dashboard/ActivityTimeline';
|
import {ActivityTimeline} from '@/features/dashboard/components/ActivityTimeline';
|
||||||
import {RewardUsageChart} from '@/features/dashboard/RewardUsageChart';
|
import {RewardUsageChart} from '@/features/dashboard/components/RewardUsageChart';
|
||||||
import {StoreComparisonPanel} from '@/features/dashboard/StoreComparison';
|
import {StoreComparisonPanel} from '@/features/dashboard/components/StoreComparison';
|
||||||
import {PerformancePanel} from '@/features/dashboard/PerformancePanel';
|
import {PerformancePanel} from '@/features/dashboard/components/PerformancePanel';
|
||||||
import {useResource} from '@/lib/api/useResource';
|
import {
|
||||||
import {endpoints} from '@/lib/api/client';
|
useDashboardActivity,
|
||||||
import {useWorkspace} from '@/components/shell/WorkspaceProvider';
|
useDashboardKpis,
|
||||||
import {storeName} from '@/lib/mock/stores';
|
useDashboardPeakHours,
|
||||||
import type {Granularity} from '@/lib/api/contracts';
|
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 {
|
import {
|
||||||
formatCompact,
|
formatCompact,
|
||||||
formatDayLabel,
|
formatDayLabel,
|
||||||
formatInrCompact,
|
formatInrCompact,
|
||||||
formatPct,
|
formatPct,
|
||||||
} from '@/lib/format';
|
} from '@/shared/utils/format';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An analytics workspace. Charts are the subject, not evidence for a to-do list.
|
* 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,
|
* Operational widgets — quick actions, tasks, the AI briefing, top store/reward,
|
||||||
* staff status — deliberately do NOT live here. The briefing endpoint they were
|
* 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
|
* class of content belongs: a panel you open to be told what to do, beside a
|
||||||
* dashboard you read to work it out yourself.
|
* dashboard you read to work it out yourself.
|
||||||
*
|
*
|
||||||
@@ -52,75 +59,35 @@ import {
|
|||||||
* charts drawn from it, so they cannot disagree.
|
* 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() {
|
export default function DashboardPage() {
|
||||||
const {storeId, range} = useWorkspace();
|
const {storeId} = useWorkspace();
|
||||||
const [granularity, setGranularity] = useState<Granularity>('weekly');
|
const [granularity, setGranularity] = useState<Granularity>('weekly');
|
||||||
// On by default: store comparison has always been part of the all-stores
|
// On by default: store comparison has always been part of the all-stores
|
||||||
// dashboard, so Compare starts pressed and switches the panel off, rather
|
// 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.
|
// than hiding a panel that used to be there until someone finds the button.
|
||||||
const [isComparing, setIsComparing] = useState(true);
|
const [isComparing, setIsComparing] = useState(true);
|
||||||
|
|
||||||
const scope = {range, storeId};
|
const kpis = useDashboardKpis();
|
||||||
|
const series = useDashboardTimeseries();
|
||||||
const kpis = useResource(endpoints.dashboardKpis(scope));
|
const peak = useDashboardPeakHours();
|
||||||
const series = useResource(endpoints.dashboardTimeseries(scope));
|
const activity = useDashboardActivity();
|
||||||
const peak = useResource(endpoints.dashboardPeakHours(scope));
|
const rewards = useDashboardRewardUsage();
|
||||||
const activity = useResource(endpoints.dashboardActivity(scope));
|
const comparison = useDashboardStoreComparison();
|
||||||
const rewards = useResource(endpoints.dashboardRewardUsage(scope));
|
|
||||||
const comparison = useResource(endpoints.dashboardStoreComparison(scope));
|
|
||||||
|
|
||||||
const isAllStores = storeId === 'all';
|
const isAllStores = storeId === 'all';
|
||||||
const scopeLabel = isAllStores ? 'all stores' : storeName(storeId);
|
const scopeLabel = useScopeLabel();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VStack gap={5}>
|
<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
|
<PageHeader
|
||||||
eyebrow={greetingFor(kpis.meta?.generatedAt)}
|
eyebrow={greetingFor(kpis.meta?.generatedAt)}
|
||||||
title="Dashboard"
|
title="Dashboard"
|
||||||
description={`Business performance across ${scopeLabel}.`}
|
description={`Business performance across ${scopeLabel}.`}
|
||||||
controls={
|
controls={<ScopeControls />}
|
||||||
<>
|
|
||||||
<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)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 3 — headline numbers. */}
|
{/* 3 — headline numbers. */}
|
||||||
<KpiRow resource={kpis} />
|
<KpiRow resource={kpis} />
|
||||||
@@ -209,7 +176,6 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
{/* 7 — period rollup. */}
|
{/* 7 — period rollup. */}
|
||||||
<PerformancePanel
|
<PerformancePanel
|
||||||
scope={scope}
|
|
||||||
granularity={granularity}
|
granularity={granularity}
|
||||||
onGranularityChange={setGranularity}
|
onGranularityChange={setGranularity}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,160 +1,16 @@
|
|||||||
'use client';
|
import {ProtectedLayout} from '@/shared/layouts/ProtectedLayout';
|
||||||
|
|
||||||
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';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
* Deliberately thin: the composition lives in ProtectedLayout, so this file
|
||||||
* changes, so /dashboard → /staff swaps only {children}. Everything else here —
|
* never needs editing again and the shell can be reused (tests, a future
|
||||||
* nav, scroll position, the Copilot — is untouched.
|
* embedded view) without a route existing for it.
|
||||||
*
|
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
export default function WorkspaceLayout({
|
export default function WorkspaceRouteLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return <ProtectedLayout>{children}</ProtectedLayout>;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,31 +3,33 @@
|
|||||||
import {VStack} from '@astryxdesign/core/Layout';
|
import {VStack} from '@astryxdesign/core/Layout';
|
||||||
import {Grid} from '@astryxdesign/core/Grid';
|
import {Grid} from '@astryxdesign/core/Grid';
|
||||||
import {Text} from '@astryxdesign/core/Text';
|
import {Text} from '@astryxdesign/core/Text';
|
||||||
import {PageHeader} from '@/components/primitives/PageHeader';
|
import {PageHeader} from '@/shared/components/primitives/PageHeader';
|
||||||
import {MetricCard} from '@/components/patterns/MetricCard';
|
import {MetricCard} from '@/shared/components/patterns/MetricCard';
|
||||||
import {SkeletonMetricGrid} from '@/components/patterns/LoadingState';
|
import {SkeletonMetricGrid} from '@/shared/components/patterns/LoadingState';
|
||||||
import {AsyncBoundary} from '@/components/data/AsyncBoundary';
|
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
|
||||||
import {ChartCard} from '@/components/charts/ChartCard';
|
import {ChartCard} from '@/shared/components/charts/ChartCard';
|
||||||
import {AreaChartView} from '@/components/charts/AreaChartView';
|
import {AreaChartView} from '@/shared/components/charts/AreaChartView';
|
||||||
import {LineChartView} from '@/components/charts/LineChartView';
|
import {LineChartView} from '@/shared/components/charts/LineChartView';
|
||||||
import {BarChartView} from '@/components/charts/BarChartView';
|
import {BarChartView} from '@/shared/components/charts/BarChartView';
|
||||||
import {RewardGrid} from '@/features/lyts/RewardGrid';
|
import {RewardGrid} from '@/features/lyts/components/RewardGrid';
|
||||||
import {ExpiryAlerts} from '@/features/lyts/ExpiryAlerts';
|
import {ExpiryAlerts} from '@/features/lyts/components/ExpiryAlerts';
|
||||||
import {RewardPerformanceTable} from '@/features/lyts/RewardPerformanceTable';
|
import {RewardPerformanceTable} from '@/features/lyts/components/RewardPerformanceTable';
|
||||||
import {ActivityTimeline} from '@/features/dashboard/ActivityTimeline';
|
import {ActivityTimeline} from '@/features/dashboard/components/ActivityTimeline';
|
||||||
import {useMetricColumns} from '@/features/dashboard/KpiRow';
|
import {useMetricColumns} from '@/features/dashboard/components/KpiRow';
|
||||||
import {useResource} from '@/lib/api/useResource';
|
import {
|
||||||
import {endpoints} from '@/lib/api/client';
|
useLytsActivity,
|
||||||
import {useWorkspace} from '@/components/shell/WorkspaceProvider';
|
useRedemptions,
|
||||||
import {ScopeControls} from '@/components/scope/ScopeControls';
|
useRewards,
|
||||||
import {ICONS} from '@/lib/icons';
|
} from '@/features/lyts/hooks/useLyts';
|
||||||
import {usageRate} from '@/features/lyts/reward-status';
|
import {ScopeControls} from '@/shared/components/scope/ScopeControls';
|
||||||
|
import {ICONS} from '@/shared/utils/icons';
|
||||||
|
import {usageRate} from '@/features/lyts/services/lytsService';
|
||||||
import {
|
import {
|
||||||
formatCompact,
|
formatCompact,
|
||||||
formatDayLabel,
|
formatDayLabel,
|
||||||
formatLyt,
|
formatLyt,
|
||||||
formatPct,
|
formatPct,
|
||||||
} from '@/lib/format';
|
} from '@/shared/utils/format';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The LYT programme.
|
* The LYT programme.
|
||||||
@@ -40,12 +42,9 @@ import {
|
|||||||
* rupee liability — which is why they lead.
|
* rupee liability — which is why they lead.
|
||||||
*/
|
*/
|
||||||
export default function LytsPage() {
|
export default function LytsPage() {
|
||||||
const {storeId, range} = useWorkspace();
|
const rewards = useRewards();
|
||||||
const scope = {range, storeId};
|
const redemptions = useRedemptions();
|
||||||
|
const activity = useLytsActivity();
|
||||||
const rewards = useResource(endpoints.lytsRewards(scope));
|
|
||||||
const redemptions = useResource(endpoints.lytsRedemptions(scope));
|
|
||||||
const activity = useResource(endpoints.lytsActivity(scope));
|
|
||||||
|
|
||||||
const columns = useMetricColumns();
|
const columns = useMetricColumns();
|
||||||
|
|
||||||
@@ -63,7 +62,7 @@ export default function LytsPage() {
|
|||||||
return (
|
return (
|
||||||
<VStack gap={5}>
|
<VStack gap={5}>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Lyts"
|
title="Lytsup"
|
||||||
description="Reward performance, redemption and outstanding LYT liability. 1 LYT = ₹1."
|
description="Reward performance, redemption and outstanding LYT liability. 1 LYT = ₹1."
|
||||||
controls={<ScopeControls />}
|
controls={<ScopeControls />}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {SettingsPage} from '@/features/settings/SettingsPage';
|
import {SettingsPage} from '@/features/settings/components/SettingsPage';
|
||||||
import {ApiWebhooksManager} from '@/features/settings/ApiWebhooksManager';
|
import {ApiWebhooksManager} from '@/features/settings/components/ApiWebhooksManager';
|
||||||
|
|
||||||
export default function ApiSettingsPage() {
|
export default function ApiSettingsPage() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {SettingsPage} from '@/features/settings/SettingsPage';
|
import {SettingsPage} from '@/features/settings/components/SettingsPage';
|
||||||
import {BillingOverview} from '@/features/settings/BillingOverview';
|
import {BillingOverview} from '@/features/settings/components/BillingOverview';
|
||||||
|
|
||||||
export default function SettingsBillingPage() {
|
export default function SettingsBillingPage() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {SettingsPage} from '@/features/settings/SettingsPage';
|
import {SettingsPage} from '@/features/settings/components/SettingsPage';
|
||||||
import {IntegrationsGrid} from '@/features/settings/IntegrationsGrid';
|
import {IntegrationsGrid} from '@/features/settings/components/IntegrationsGrid';
|
||||||
|
|
||||||
export default function IntegrationsSettingsPage() {
|
export default function IntegrationsSettingsPage() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import {SideNav, SideNavItem, SideNavSection} from '@astryxdesign/core/SideNav';
|
|||||||
import {DropdownMenu} from '@astryxdesign/core/DropdownMenu';
|
import {DropdownMenu} from '@astryxdesign/core/DropdownMenu';
|
||||||
import {Icon} from '@astryxdesign/core/Icon';
|
import {Icon} from '@astryxdesign/core/Icon';
|
||||||
import {useRouter} from 'next/navigation';
|
import {useRouter} from 'next/navigation';
|
||||||
import {useBreakpoint} from '@/lib/breakpoints';
|
import {useBreakpoint} from '@/shared/hooks/useBreakpoint';
|
||||||
import {SETTINGS_NAV, isSettingsActive} from '@/features/settings/settings-nav';
|
import {SETTINGS_NAV, isSettingsActive} from '@/features/settings/config/settingsNav';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Settings gets its own sub-navigation.
|
* Settings gets its own sub-navigation.
|
||||||
@@ -97,7 +97,7 @@ export default function SettingsLayout({
|
|||||||
bottom edge (measured: scrollWidth 272 in a 240px panel).
|
bottom edge (measured: scrollWidth 272 in a 240px panel).
|
||||||
*/}
|
*/}
|
||||||
<SideNav className="w-full">
|
<SideNav className="w-full">
|
||||||
<SideNavSection title="Settings" isHeaderHidden>
|
<SideNavSection title="Settings">
|
||||||
{SETTINGS_NAV.map((s) => (
|
{SETTINGS_NAV.map((s) => (
|
||||||
<SideNavItem
|
<SideNavItem
|
||||||
key={s.href}
|
key={s.href}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {SettingsPage} from '@/features/settings/SettingsPage';
|
import {SettingsPage} from '@/features/settings/components/SettingsPage';
|
||||||
import {NotificationsForm} from '@/features/settings/NotificationsForm';
|
import {NotificationsForm} from '@/features/settings/components/NotificationsForm';
|
||||||
|
|
||||||
export default function NotificationsSettingsPage() {
|
export default function NotificationsSettingsPage() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {SettingsPage} from '@/features/settings/SettingsPage';
|
import {SettingsPage} from '@/features/settings/components/SettingsPage';
|
||||||
import {BusinessForm} from '@/features/settings/BusinessForm';
|
import {BusinessForm} from '@/features/settings/components/BusinessForm';
|
||||||
|
|
||||||
export default function BusinessSettingsPage() {
|
export default function BusinessSettingsPage() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {SettingsPage} from '@/features/settings/SettingsPage';
|
import {SettingsPage} from '@/features/settings/components/SettingsPage';
|
||||||
import {PreferencesForm} from '@/features/settings/PreferencesForm';
|
import {PreferencesForm} from '@/features/settings/components/PreferencesForm';
|
||||||
|
|
||||||
export default function PreferencesSettingsPage() {
|
export default function PreferencesSettingsPage() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
import {SettingsPage} from '@/features/settings/SettingsPage';
|
import {SettingsPage} from '@/features/settings/components/SettingsPage';
|
||||||
import {ProfileForm} from '@/features/settings/ProfileForm';
|
import {ProfileForm} from '@/features/settings/components/ProfileForm';
|
||||||
import {readProfile} from '@/lib/mock/settings';
|
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 (
|
return (
|
||||||
<SettingsPage
|
<SettingsPage
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {SettingsPage} from '@/features/settings/SettingsPage';
|
import {SettingsPage} from '@/features/settings/components/SettingsPage';
|
||||||
import {RoleMatrix} from '@/features/settings/RoleMatrix';
|
import {RoleMatrix} from '@/features/settings/components/RoleMatrix';
|
||||||
|
|
||||||
export default function RolesSettingsPage() {
|
export default function RolesSettingsPage() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {SettingsPage} from '@/features/settings/SettingsPage';
|
import {SettingsPage} from '@/features/settings/components/SettingsPage';
|
||||||
import {SecurityManager} from '@/features/settings/SecurityManager';
|
import {SecurityManager} from '@/features/settings/components/SecurityManager';
|
||||||
|
|
||||||
export default function SecuritySettingsPage() {
|
export default function SecuritySettingsPage() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {SettingsPage} from '@/features/settings/SettingsPage';
|
import {SettingsPage} from '@/features/settings/components/SettingsPage';
|
||||||
import {StoreManagement} from '@/features/settings/StoreManagement';
|
import {StoreManagement} from '@/features/settings/components/StoreManagement';
|
||||||
|
|
||||||
export default function StoreSettingsPage() {
|
export default function StoreSettingsPage() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {SettingsPage} from '@/features/settings/SettingsPage';
|
import {SettingsPage} from '@/features/settings/components/SettingsPage';
|
||||||
import {TeamManagement} from '@/features/settings/TeamManagement';
|
import {TeamManagement} from '@/features/settings/components/TeamManagement';
|
||||||
|
|
||||||
export default function TeamSettingsPage() {
|
export default function TeamSettingsPage() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,19 +2,21 @@
|
|||||||
|
|
||||||
import {VStack} from '@astryxdesign/core/Layout';
|
import {VStack} from '@astryxdesign/core/Layout';
|
||||||
import {Grid} from '@astryxdesign/core/Grid';
|
import {Grid} from '@astryxdesign/core/Grid';
|
||||||
import {PageHeader} from '@/components/primitives/PageHeader';
|
import {PageHeader} from '@/shared/components/primitives/PageHeader';
|
||||||
import {ChartCard} from '@/components/charts/ChartCard';
|
import {ChartCard} from '@/shared/components/charts/ChartCard';
|
||||||
import {BarChartView} from '@/components/charts/BarChartView';
|
import {BarChartView} from '@/shared/components/charts/BarChartView';
|
||||||
import {StaffKpis} from '@/features/staff/StaffKpis';
|
import {StaffKpis} from '@/features/staff/components/StaffKpis';
|
||||||
import {StaffGrid} from '@/features/staff/StaffGrid';
|
import {StaffGrid} from '@/features/staff/components/StaffGrid';
|
||||||
import {AttendanceChart} from '@/features/staff/AttendanceChart';
|
import {AttendanceChart} from '@/features/staff/components/AttendanceChart';
|
||||||
import {Leaderboard} from '@/features/staff/Leaderboard';
|
import {Leaderboard} from '@/features/staff/components/Leaderboard';
|
||||||
import {useResource} from '@/lib/api/useResource';
|
import {
|
||||||
import {endpoints} from '@/lib/api/client';
|
useStaffAttendance,
|
||||||
import {useWorkspace} from '@/components/shell/WorkspaceProvider';
|
useStaffList,
|
||||||
import {ScopeControls} from '@/components/scope/ScopeControls';
|
useStaffSummary,
|
||||||
import {storeName} from '@/lib/mock/stores';
|
} from '@/features/staff/hooks/useStaff';
|
||||||
import {formatCompact} from '@/lib/format';
|
import {useScopeLabel} from '@/features/stores/hooks/useStoreDirectory';
|
||||||
|
import {ScopeControls} from '@/shared/components/scope/ScopeControls';
|
||||||
|
import {formatCompact} from '@/shared/utils/format';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The team.
|
* The team.
|
||||||
@@ -23,18 +25,10 @@ import {formatCompact} from '@/lib/format';
|
|||||||
* morning is "who is in today", and only then "who is performing".
|
* morning is "who is in today", and only then "who is performing".
|
||||||
*/
|
*/
|
||||||
export default function StaffPage() {
|
export default function StaffPage() {
|
||||||
const {storeId, range} = useWorkspace();
|
const summary = useStaffSummary();
|
||||||
const scope = {range, storeId};
|
const staff = useStaffList();
|
||||||
|
const attendance = useStaffAttendance();
|
||||||
const summary = useResource(endpoints.staffSummary(scope), {
|
const scopeLabel = useScopeLabel();
|
||||||
// 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);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VStack gap={5}>
|
<VStack gap={5}>
|
||||||
|
|||||||
@@ -9,29 +9,30 @@ import {
|
|||||||
BreadcrumbItem,
|
BreadcrumbItem,
|
||||||
Breadcrumbs,
|
Breadcrumbs,
|
||||||
} from '@astryxdesign/core/Breadcrumbs';
|
} from '@astryxdesign/core/Breadcrumbs';
|
||||||
import {PageHeader} from '@/components/primitives/PageHeader';
|
import {PageHeader} from '@/shared/components/primitives/PageHeader';
|
||||||
import {MetricCard} from '@/components/patterns/MetricCard';
|
import {MetricCard} from '@/shared/components/patterns/MetricCard';
|
||||||
import {ChartCard} from '@/components/charts/ChartCard';
|
import {ChartCard} from '@/shared/components/charts/ChartCard';
|
||||||
import {AreaChartView} from '@/components/charts/AreaChartView';
|
import {AreaChartView} from '@/shared/components/charts/AreaChartView';
|
||||||
import {BarChartView} from '@/components/charts/BarChartView';
|
import {BarChartView} from '@/shared/components/charts/BarChartView';
|
||||||
import {LineChartView} from '@/components/charts/LineChartView';
|
import {LineChartView} from '@/shared/components/charts/LineChartView';
|
||||||
import {HeatmapGrid} from '@/components/charts/HeatmapGrid';
|
import {HeatmapGrid} from '@/shared/components/charts/HeatmapGrid';
|
||||||
import {AsyncBoundary} from '@/components/data/AsyncBoundary';
|
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
|
||||||
import {SkeletonMetricGrid} from '@/components/patterns/LoadingState';
|
import {SkeletonMetricGrid} from '@/shared/components/patterns/LoadingState';
|
||||||
import {ActivityTimeline} from '@/features/dashboard/ActivityTimeline';
|
import {ActivityTimeline} from '@/features/dashboard/components/ActivityTimeline';
|
||||||
import {useMetricColumns} from '@/features/dashboard/KpiRow';
|
import {useMetricColumns} from '@/features/dashboard/components/KpiRow';
|
||||||
import {useResource} from '@/lib/api/useResource';
|
import {useResource} from '@/shared/hooks/useResource';
|
||||||
import {endpoints} from '@/lib/api/client';
|
import {storeRepository} from '@/features/stores/repositories/storeRepository';
|
||||||
import {useWorkspace} from '@/components/shell/WorkspaceProvider';
|
import {dashboardRepository} from '@/features/dashboard/repositories/dashboardRepository';
|
||||||
import {ScopeControls} from '@/components/scope/ScopeControls';
|
import {useWorkspace} from '@/shared/providers/WorkspaceProvider';
|
||||||
import {ICONS} from '@/lib/icons';
|
import {ScopeControls} from '@/shared/components/scope/ScopeControls';
|
||||||
|
import {ICONS} from '@/shared/utils/icons';
|
||||||
import {
|
import {
|
||||||
formatCompact,
|
formatCompact,
|
||||||
formatDayLabel,
|
formatDayLabel,
|
||||||
formatInrCompact,
|
formatInrCompact,
|
||||||
formatPct,
|
formatPct,
|
||||||
} from '@/lib/format';
|
} from '@/shared/utils/format';
|
||||||
import type {StoreStatus} from '@/lib/api/contracts';
|
import type {StoreStatus} from '@/features/stores/types/store';
|
||||||
|
|
||||||
const STATUS: Record<
|
const STATUS: Record<
|
||||||
StoreStatus,
|
StoreStatus,
|
||||||
@@ -59,13 +60,13 @@ export default function StoreDetailPage({
|
|||||||
const {range} = useWorkspace();
|
const {range} = useWorkspace();
|
||||||
const scope = {range, storeId};
|
const scope = {range, storeId};
|
||||||
|
|
||||||
const store = useResource(endpoints.store(scope, storeId), {
|
const store = useResource(storeRepository.byId(scope, storeId), {
|
||||||
isEmpty: () => false,
|
isEmpty: () => false,
|
||||||
});
|
});
|
||||||
const kpis = useResource(endpoints.dashboardKpis(scope));
|
const kpis = useResource(dashboardRepository.kpis(scope));
|
||||||
const series = useResource(endpoints.dashboardTimeseries(scope));
|
const series = useResource(dashboardRepository.timeseries(scope));
|
||||||
const peak = useResource(endpoints.dashboardPeakHours(scope));
|
const peak = useResource(dashboardRepository.peakHours(scope));
|
||||||
const activity = useResource(endpoints.dashboardActivity(scope));
|
const activity = useResource(dashboardRepository.activity(scope));
|
||||||
|
|
||||||
const columns = useMetricColumns();
|
const columns = useMetricColumns();
|
||||||
const name = store.data?.name ?? 'Store';
|
const name = store.data?.name ?? 'Store';
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import {VStack} from '@astryxdesign/core/Layout';
|
import {VStack} from '@astryxdesign/core/Layout';
|
||||||
import {PageHeader} from '@/components/primitives/PageHeader';
|
import {PageHeader} from '@/shared/components/primitives/PageHeader';
|
||||||
import {StoreGrid} from '@/features/stores/StoreGrid';
|
import {StoreGrid} from '@/features/stores/components/StoreGrid';
|
||||||
import {useResource} from '@/lib/api/useResource';
|
import {useStoreList} from '@/features/stores/hooks/useStores';
|
||||||
import {endpoints} from '@/lib/api/client';
|
|
||||||
import {
|
import {
|
||||||
RANGE_LABELS,
|
RANGE_LABELS,
|
||||||
useWorkspace,
|
useWorkspace,
|
||||||
} from '@/components/shell/WorkspaceProvider';
|
} from '@/shared/providers/WorkspaceProvider';
|
||||||
import {ScopeControls} from '@/components/scope/ScopeControls';
|
import {ScopeControls} from '@/shared/components/scope/ScopeControls';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The store roster.
|
* The store roster.
|
||||||
@@ -21,7 +20,7 @@ import {ScopeControls} from '@/components/scope/ScopeControls';
|
|||||||
*/
|
*/
|
||||||
export default function StoresPage() {
|
export default function StoresPage() {
|
||||||
const {range} = useWorkspace();
|
const {range} = useWorkspace();
|
||||||
const stores = useResource(endpoints.stores({range, storeId: 'all'}));
|
const stores = useStoreList();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VStack gap={5}>
|
<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 type {NextRequest} from 'next/server';
|
||||||
import {ok, parseQuery, simulate, wantsEmpty} from '@/lib/api/server';
|
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
|
||||||
import {buildActivity} from '@/lib/mock/dashboard';
|
import {buildActivity} from '@/features/dashboard/mock/dashboard.mock';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
|
const denied = await requireApiSession();
|
||||||
|
if (denied) return denied;
|
||||||
const q = parseQuery(req);
|
const q = parseQuery(req);
|
||||||
const simulated = await simulate(q);
|
const simulated = await simulate(q);
|
||||||
if (simulated) return simulated;
|
if (simulated) return simulated;
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import type {NextRequest} from 'next/server';
|
import type {NextRequest} from 'next/server';
|
||||||
import {ok, parseQuery, simulate, wantsEmpty} from '@/lib/api/server';
|
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
|
||||||
import {buildBriefing} from '@/lib/mock/briefing';
|
import {buildBriefing} from '@/features/dashboard/mock/briefing.mock';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
const EMPTY = {summary: '', alerts: [], tasks: []};
|
const EMPTY = {summary: '', alerts: [], tasks: []};
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
|
const denied = await requireApiSession();
|
||||||
|
if (denied) return denied;
|
||||||
const q = parseQuery(req);
|
const q = parseQuery(req);
|
||||||
const simulated = await simulate(q);
|
const simulated = await simulate(q);
|
||||||
if (simulated) return simulated;
|
if (simulated) return simulated;
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import type {NextRequest} from 'next/server';
|
import type {NextRequest} from 'next/server';
|
||||||
import {ok, parseQuery, simulate, wantsEmpty} from '@/lib/api/server';
|
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
|
||||||
import {buildKpis} from '@/lib/mock/dashboard';
|
import {buildKpis} from '@/features/dashboard/mock/dashboard.mock';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
|
const denied = await requireApiSession();
|
||||||
|
if (denied) return denied;
|
||||||
const q = parseQuery(req);
|
const q = parseQuery(req);
|
||||||
const simulated = await simulate(q);
|
const simulated = await simulate(q);
|
||||||
if (simulated) return simulated;
|
if (simulated) return simulated;
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import type {NextRequest} from 'next/server';
|
import type {NextRequest} from 'next/server';
|
||||||
import {ok, parseQuery, simulate, wantsEmpty} from '@/lib/api/server';
|
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
|
||||||
import {buildPeakHours} from '@/lib/mock/dashboard';
|
import {buildPeakHours} from '@/features/dashboard/mock/dashboard.mock';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
|
const denied = await requireApiSession();
|
||||||
|
if (denied) return denied;
|
||||||
const q = parseQuery(req);
|
const q = parseQuery(req);
|
||||||
const simulated = await simulate(q);
|
const simulated = await simulate(q);
|
||||||
if (simulated) return simulated;
|
if (simulated) return simulated;
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import type {NextRequest} from 'next/server';
|
import type {NextRequest} from 'next/server';
|
||||||
import type {Granularity} from '@/lib/api/contracts';
|
import type {Granularity} from '@/features/dashboard/types/dashboard';
|
||||||
import {ok, parseQuery, simulate, wantsEmpty} from '@/lib/api/server';
|
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
|
||||||
import {buildPeriodPerformance} from '@/lib/mock/analytics';
|
import {buildPeriodPerformance} from '@/features/dashboard/mock/analytics.mock';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
|
const denied = await requireApiSession();
|
||||||
|
if (denied) return denied;
|
||||||
const q = parseQuery(req);
|
const q = parseQuery(req);
|
||||||
const simulated = await simulate(q);
|
const simulated = await simulate(q);
|
||||||
if (simulated) return simulated;
|
if (simulated) return simulated;
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import type {NextRequest} from 'next/server';
|
import type {NextRequest} from 'next/server';
|
||||||
import {ok, parseQuery, simulate, wantsEmpty} from '@/lib/api/server';
|
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
|
||||||
import {buildRewardUsage} from '@/lib/mock/analytics';
|
import {buildRewardUsage} from '@/features/dashboard/mock/analytics.mock';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
|
const denied = await requireApiSession();
|
||||||
|
if (denied) return denied;
|
||||||
const q = parseQuery(req);
|
const q = parseQuery(req);
|
||||||
const simulated = await simulate(q);
|
const simulated = await simulate(q);
|
||||||
if (simulated) return simulated;
|
if (simulated) return simulated;
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import type {NextRequest} from 'next/server';
|
import type {NextRequest} from 'next/server';
|
||||||
import {ok, parseQuery, simulate, wantsEmpty} from '@/lib/api/server';
|
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
|
||||||
import {buildStoreComparison} from '@/lib/mock/analytics';
|
import {buildStoreComparison} from '@/features/dashboard/mock/analytics.mock';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
|
const denied = await requireApiSession();
|
||||||
|
if (denied) return denied;
|
||||||
const q = parseQuery(req);
|
const q = parseQuery(req);
|
||||||
const simulated = await simulate(q);
|
const simulated = await simulate(q);
|
||||||
if (simulated) return simulated;
|
if (simulated) return simulated;
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import type {NextRequest} from 'next/server';
|
import type {NextRequest} from 'next/server';
|
||||||
import {ok, parseQuery, simulate, wantsEmpty} from '@/lib/api/server';
|
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
|
||||||
import {buildTimeseries} from '@/lib/mock/dashboard';
|
import {buildTimeseries} from '@/features/dashboard/mock/dashboard.mock';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
|
const denied = await requireApiSession();
|
||||||
|
if (denied) return denied;
|
||||||
const q = parseQuery(req);
|
const q = parseQuery(req);
|
||||||
const simulated = await simulate(q);
|
const simulated = await simulate(q);
|
||||||
if (simulated) return simulated;
|
if (simulated) return simulated;
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import type {NextRequest} from 'next/server';
|
import type {NextRequest} from 'next/server';
|
||||||
import {ok, parseQuery, simulate, wantsEmpty} from '@/lib/api/server';
|
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
|
||||||
import {buildLytActivity} from '@/lib/mock/lyts';
|
import {buildLytActivity} from '@/features/lyts/mock/lyts.mock';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
|
const denied = await requireApiSession();
|
||||||
|
if (denied) return denied;
|
||||||
const q = parseQuery(req);
|
const q = parseQuery(req);
|
||||||
const simulated = await simulate(q);
|
const simulated = await simulate(q);
|
||||||
if (simulated) return simulated;
|
if (simulated) return simulated;
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import type {NextRequest} from 'next/server';
|
import type {NextRequest} from 'next/server';
|
||||||
import {ok, parseQuery, simulate, wantsEmpty} from '@/lib/api/server';
|
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
|
||||||
import {buildRedemptions} from '@/lib/mock/lyts';
|
import {buildRedemptions} from '@/features/lyts/mock/lyts.mock';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
|
const denied = await requireApiSession();
|
||||||
|
if (denied) return denied;
|
||||||
const q = parseQuery(req);
|
const q = parseQuery(req);
|
||||||
const simulated = await simulate(q);
|
const simulated = await simulate(q);
|
||||||
if (simulated) return simulated;
|
if (simulated) return simulated;
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import type {NextRequest} from 'next/server';
|
import type {NextRequest} from 'next/server';
|
||||||
import {ok, parseQuery, simulate, wantsEmpty} from '@/lib/api/server';
|
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
|
||||||
import {buildRewards} from '@/lib/mock/lyts';
|
import {buildRewards} from '@/features/lyts/mock/lyts.mock';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
|
const denied = await requireApiSession();
|
||||||
|
if (denied) return denied;
|
||||||
const q = parseQuery(req);
|
const q = parseQuery(req);
|
||||||
const simulated = await simulate(q);
|
const simulated = await simulate(q);
|
||||||
if (simulated) return simulated;
|
if (simulated) return simulated;
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import type {NextRequest} from 'next/server';
|
import type {NextRequest} from 'next/server';
|
||||||
import {ok, parseQuery, simulate} from '@/lib/api/server';
|
import {ok, parseQuery, requireApiSession, simulate} from '@/shared/services/apiRoute';
|
||||||
import {readProfile} from '@/lib/mock/settings';
|
import {readProfile} from '@/features/settings/mock/settings.mock';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
|
const denied = await requireApiSession();
|
||||||
|
if (denied) return denied;
|
||||||
const q = parseQuery(req);
|
const q = parseQuery(req);
|
||||||
const simulated = await simulate(q);
|
const simulated = await simulate(q);
|
||||||
if (simulated) return simulated;
|
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.
|
* which is enough for the form to exercise its success path honestly.
|
||||||
*/
|
*/
|
||||||
export async function PATCH(req: NextRequest) {
|
export async function PATCH(req: NextRequest) {
|
||||||
|
const denied = await requireApiSession();
|
||||||
|
if (denied) return denied;
|
||||||
const q = parseQuery(req);
|
const q = parseQuery(req);
|
||||||
const simulated = await simulate(q);
|
const simulated = await simulate(q);
|
||||||
if (simulated) return simulated;
|
if (simulated) return simulated;
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import type {NextRequest} from 'next/server';
|
import type {NextRequest} from 'next/server';
|
||||||
import {ok, parseQuery, simulate, wantsEmpty} from '@/lib/api/server';
|
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
|
||||||
import {buildAttendance} from '@/lib/mock/staff';
|
import {buildAttendance} from '@/features/staff/mock/staff.mock';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
|
const denied = await requireApiSession();
|
||||||
|
if (denied) return denied;
|
||||||
const q = parseQuery(req);
|
const q = parseQuery(req);
|
||||||
const simulated = await simulate(q);
|
const simulated = await simulate(q);
|
||||||
if (simulated) return simulated;
|
if (simulated) return simulated;
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import type {NextRequest} from 'next/server';
|
import type {NextRequest} from 'next/server';
|
||||||
import {ok, parseQuery, simulate, wantsEmpty} from '@/lib/api/server';
|
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
|
||||||
import {buildStaff} from '@/lib/mock/staff';
|
import {buildStaff} from '@/features/staff/mock/staff.mock';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
|
const denied = await requireApiSession();
|
||||||
|
if (denied) return denied;
|
||||||
const q = parseQuery(req);
|
const q = parseQuery(req);
|
||||||
const simulated = await simulate(q);
|
const simulated = await simulate(q);
|
||||||
if (simulated) return simulated;
|
if (simulated) return simulated;
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import type {NextRequest} from 'next/server';
|
import type {NextRequest} from 'next/server';
|
||||||
import {ok, parseQuery, simulate} from '@/lib/api/server';
|
import {ok, parseQuery, requireApiSession, simulate} from '@/shared/services/apiRoute';
|
||||||
import {buildStaffSummary} from '@/lib/mock/staff';
|
import {buildStaffSummary} from '@/features/staff/mock/staff.mock';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
|
const denied = await requireApiSession();
|
||||||
|
if (denied) return denied;
|
||||||
const q = parseQuery(req);
|
const q = parseQuery(req);
|
||||||
const simulated = await simulate(q);
|
const simulated = await simulate(q);
|
||||||
if (simulated) return simulated;
|
if (simulated) return simulated;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type {NextRequest} from 'next/server';
|
import type {NextRequest} from 'next/server';
|
||||||
import {fail, ok, parseQuery, simulate} from '@/lib/api/server';
|
import {fail, ok, parseQuery, requireApiSession, simulate} from '@/shared/services/apiRoute';
|
||||||
import {buildStore} from '@/lib/mock/stores';
|
import {buildStore} from '@/features/stores/mock/stores.mock';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
@@ -8,6 +8,8 @@ export async function GET(
|
|||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
{params}: {params: Promise<{storeId: string}>},
|
{params}: {params: Promise<{storeId: string}>},
|
||||||
) {
|
) {
|
||||||
|
const denied = await requireApiSession();
|
||||||
|
if (denied) return denied;
|
||||||
const q = parseQuery(req);
|
const q = parseQuery(req);
|
||||||
const simulated = await simulate(q);
|
const simulated = await simulate(q);
|
||||||
if (simulated) return simulated;
|
if (simulated) return simulated;
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import type {NextRequest} from 'next/server';
|
import type {NextRequest} from 'next/server';
|
||||||
import {ok, parseQuery, simulate, wantsEmpty} from '@/lib/api/server';
|
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
|
||||||
import {buildStores} from '@/lib/mock/stores';
|
import {buildStores} from '@/features/stores/mock/stores.mock';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
|
const denied = await requireApiSession();
|
||||||
|
if (denied) return denied;
|
||||||
const q = parseQuery(req);
|
const q = parseQuery(req);
|
||||||
const simulated = await simulate(q);
|
const simulated = await simulate(q);
|
||||||
if (simulated) return simulated;
|
if (simulated) return simulated;
|
||||||
|
|||||||
@@ -30,6 +30,34 @@ html {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@layer components {
|
@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
|
/* Kill switch for our own CSS transitions. Framer Motion is handled by
|
||||||
<MotionConfig reducedMotion="user">, recharts by useChartMotion(). */
|
<MotionConfig reducedMotion="user">, recharts by useChartMotion(). */
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type {Metadata, Viewport} from 'next';
|
import type {Metadata, Viewport} from 'next';
|
||||||
import {Sora, Inter} from 'next/font/google';
|
import {Sora, Inter} from 'next/font/google';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
import {getServerSession} from '@/features/auth/services/serverSession';
|
||||||
import {Providers} from './providers';
|
import {Providers} from './providers';
|
||||||
|
|
||||||
const sora = Sora({
|
const sora = Sora({
|
||||||
@@ -43,7 +44,19 @@ export const viewport: Viewport = {
|
|||||||
themeColor: '#000000',
|
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 (
|
return (
|
||||||
// The theme attributes are server-rendered so the @scope rules in
|
// The theme attributes are server-rendered so the @scope rules in
|
||||||
// loyaly.css match on the very first byte — no flash from stock defaults.
|
// 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
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
<body>
|
<body>
|
||||||
<Providers>{children}</Providers>
|
<Providers initialSession={session}>{children}</Providers>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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 {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 {LinkProvider} from '@astryxdesign/core/Link';
|
||||||
import {ToastViewport} from '@astryxdesign/core/Toast';
|
import {ToastViewport} from '@astryxdesign/core/Toast';
|
||||||
import {loyalyTheme} from '@/theme';
|
import {loyalyTheme} from '@/theme';
|
||||||
import {WorkspaceProvider} from '@/components/shell/WorkspaceProvider';
|
import {WorkspaceProvider} from '@/shared/providers/WorkspaceProvider';
|
||||||
import {CopilotProvider} from '@/features/copilot/CopilotProvider';
|
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
|
* @/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 also re-attaches the Lucide icon registry that `theme build` drops;
|
||||||
* <Theme> registers it during render, on both the SSR and hydration passes.
|
* <Theme> registers it during render, on both the SSR and hydration passes.
|
||||||
*
|
*
|
||||||
* WorkspaceProvider and CopilotProvider sit here, ABOVE the route tree, so
|
* WorkspaceProvider and LoyalyAiProvider sit here, ABOVE the route tree, so
|
||||||
* query scope and Copilot state outlive both navigation and the responsive
|
* query scope and Loyaly AI state outlive both navigation and the responsive
|
||||||
* swap between the inline panel and the slide-over.
|
* 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 (
|
return (
|
||||||
<Theme theme={loyalyTheme} mode="dark">
|
<Theme theme={loyalyTheme} mode="dark">
|
||||||
<LinkProvider component={NextLink}>
|
<LinkProvider component={NextLink}>
|
||||||
{/* One line, and every motion.* in the app honours prefers-reduced-motion. */}
|
{/* One line, and every motion.* in the app honours prefers-reduced-motion. */}
|
||||||
<MotionConfig reducedMotion="user">
|
<MotionConfig reducedMotion="user">
|
||||||
|
{/* Above the route tree AND outside (workspace), because /login
|
||||||
|
establishes the session that the workspace then reads. */}
|
||||||
|
<SessionProvider initialSession={initialSession}>
|
||||||
<WorkspaceProvider>
|
<WorkspaceProvider>
|
||||||
<CopilotProvider>{children}</CopilotProvider>
|
<LoyalyAiProvider>{children}</LoyalyAiProvider>
|
||||||
</WorkspaceProvider>
|
</WorkspaceProvider>
|
||||||
|
</SessionProvider>
|
||||||
{/* Mounted once at the root: useToast() positions, stacks and
|
{/* Mounted once at the root: useToast() positions, stacks and
|
||||||
expires notifications into this viewport from anywhere. */}
|
expires notifications into this viewport from anywhere. */}
|
||||||
<ToastViewport />
|
<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 {VStack} from '@astryxdesign/core/Layout';
|
||||||
import {Button} from '@astryxdesign/core/Button';
|
import {Button} from '@astryxdesign/core/Button';
|
||||||
import {PanelCard} from '@/components/patterns/PanelCard';
|
import {PanelCard} from '@/shared/components/patterns/PanelCard';
|
||||||
import {ActivityFeed} from '@/components/patterns/ActivityItem';
|
import {ActivityFeed} from '@/shared/components/patterns/ActivityItem';
|
||||||
import {SkeletonRows} from '@/components/patterns/LoadingState';
|
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
|
||||||
import {EmptyPanel} from '@/components/patterns/EmptyPanel';
|
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||||
import type {ActivityEntry, ActivityTone} from '@/components/patterns/ActivityItem';
|
import type {ActivityEntry, ActivityTone} from '@/shared/components/patterns/ActivityItem';
|
||||||
import type {ActivityEvent, ActivityKind} from '@/lib/api/contracts';
|
import type {ActivityEvent, ActivityKind} from '@/features/dashboard/types/dashboard';
|
||||||
import type {IconKey} from '@/lib/icons';
|
import type {IconKey} from '@/shared/utils/icons';
|
||||||
import type {Resource} from '@/lib/api/useResource';
|
import type {Resource} from '@/shared/hooks/useResource';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Each event kind gets its own glyph, so the feed can be skimmed by category
|
* Each event kind gets its own glyph, so the feed can be skimmed by category
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import {Grid} from '@astryxdesign/core/Grid';
|
import {Grid} from '@astryxdesign/core/Grid';
|
||||||
import {AsyncBoundary} from '@/components/data/AsyncBoundary';
|
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
|
||||||
import {MetricCard} from '@/components/patterns/MetricCard';
|
import {MetricCard} from '@/shared/components/patterns/MetricCard';
|
||||||
import {SkeletonMetricGrid} from '@/components/patterns/LoadingState';
|
import {SkeletonMetricGrid} from '@/shared/components/patterns/LoadingState';
|
||||||
import {ICONS} from '@/lib/icons';
|
import {ICONS} from '@/shared/utils/icons';
|
||||||
import {useBreakpoint, metricColumns} from '@/lib/breakpoints';
|
import {useBreakpoint, metricColumns} from '@/shared/hooks/useBreakpoint';
|
||||||
import {useCopilot} from '@/features/copilot/CopilotProvider';
|
import {useLoyalyAi} from '@/features/loyaly-ai/providers/LoyalyAiProvider';
|
||||||
import {formatCompact, formatInrCompact} from '@/lib/format';
|
import {formatCompact, formatInrCompact} from '@/shared/utils/format';
|
||||||
import type {Kpi, KpiId} from '@/lib/api/contracts';
|
import type {Kpi, KpiId} from '@/features/dashboard/types/dashboard';
|
||||||
import type {Resource} from '@/lib/api/useResource';
|
import type {Resource} from '@/shared/hooks/useResource';
|
||||||
import type {IconType} from '@astryxdesign/core/Icon';
|
import type {IconType} from '@astryxdesign/core/Icon';
|
||||||
|
|
||||||
const KPI_ICON: Record<KpiId, IconType> = {
|
const KPI_ICON: Record<KpiId, IconType> = {
|
||||||
@@ -26,12 +26,12 @@ const KPI_ICON: Record<KpiId, IconType> = {
|
|||||||
* card drops to an orphan row.
|
* card drops to an orphan row.
|
||||||
*
|
*
|
||||||
* So the count is computed rather than inferred. The two things that decide
|
* 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.
|
* taking 320–380px of it, and both are already known here.
|
||||||
*/
|
*/
|
||||||
export function useMetricColumns(): number {
|
export function useMetricColumns(): number {
|
||||||
const bp = useBreakpoint();
|
const bp = useBreakpoint();
|
||||||
const {isOpen} = useCopilot();
|
const {isOpen} = useLoyalyAi();
|
||||||
// The rule itself lives in lib/breakpoints alongside every other layout
|
// The rule itself lives in lib/breakpoints alongside every other layout
|
||||||
// threshold, so a breakpoint change is one edit rather than a hunt.
|
// threshold, so a breakpoint change is one edit rather than a hunt.
|
||||||
return metricColumns(bp, isOpen);
|
return metricColumns(bp, isOpen);
|
||||||
@@ -4,13 +4,11 @@ import {
|
|||||||
SegmentedControl,
|
SegmentedControl,
|
||||||
SegmentedControlItem,
|
SegmentedControlItem,
|
||||||
} from '@astryxdesign/core/SegmentedControl';
|
} from '@astryxdesign/core/SegmentedControl';
|
||||||
import {ChartCard} from '@/components/charts/ChartCard';
|
import {ChartCard} from '@/shared/components/charts/ChartCard';
|
||||||
import {BarChartView} from '@/components/charts/BarChartView';
|
import {BarChartView} from '@/shared/components/charts/BarChartView';
|
||||||
import {useResource} from '@/lib/api/useResource';
|
import {useDashboardPerformance} from '@/features/dashboard/hooks/useDashboard';
|
||||||
import {endpoints} from '@/lib/api/client';
|
import type {Granularity} from '@/features/dashboard/types/dashboard';
|
||||||
import type {Scope} from '@/lib/api/client';
|
import {formatInrCompact} from '@/shared/utils/format';
|
||||||
import type {Granularity, PeriodPoint} from '@/lib/api/contracts';
|
|
||||||
import {formatInrCompact} from '@/lib/format';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Weekly / monthly rollup.
|
* Weekly / monthly rollup.
|
||||||
@@ -20,17 +18,17 @@ import {formatInrCompact} from '@/lib/format';
|
|||||||
* the weekly totals quietly stop matching the daily chart above them.
|
* the weekly totals quietly stop matching the daily chart above them.
|
||||||
*/
|
*/
|
||||||
export function PerformancePanel({
|
export function PerformancePanel({
|
||||||
scope,
|
|
||||||
granularity,
|
granularity,
|
||||||
onGranularityChange,
|
onGranularityChange,
|
||||||
}: {
|
}: {
|
||||||
scope: Scope;
|
|
||||||
granularity: Granularity;
|
granularity: Granularity;
|
||||||
onGranularityChange: (g: Granularity) => void;
|
onGranularityChange: (g: Granularity) => void;
|
||||||
}) {
|
}) {
|
||||||
const resource = useResource<PeriodPoint[]>(
|
// Scope comes from the workspace via the hook rather than from a prop: the
|
||||||
endpoints.dashboardPerformance(scope, granularity),
|
// 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 (
|
return (
|
||||||
<ChartCard
|
<ChartCard
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import {ChartCard} from '@/components/charts/ChartCard';
|
import {ChartCard} from '@/shared/components/charts/ChartCard';
|
||||||
import {BarChartView} from '@/components/charts/BarChartView';
|
import {BarChartView} from '@/shared/components/charts/BarChartView';
|
||||||
import {formatCompact} from '@/lib/format';
|
import {formatCompact} from '@/shared/utils/format';
|
||||||
import type {RewardUsagePoint} from '@/lib/api/contracts';
|
import type {RewardUsagePoint} from '@/features/dashboard/types/dashboard';
|
||||||
import type {Resource} from '@/lib/api/useResource';
|
import type {Resource} from '@/shared/hooks/useResource';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Claimed vs used, per reward.
|
* Claimed vs used, per reward.
|
||||||
@@ -5,12 +5,12 @@ import {Grid} from '@astryxdesign/core/Grid';
|
|||||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||||
import {Text} from '@astryxdesign/core/Text';
|
import {Text} from '@astryxdesign/core/Text';
|
||||||
import {Badge} from '@astryxdesign/core/Badge';
|
import {Badge} from '@astryxdesign/core/Badge';
|
||||||
import {Sparkline} from '@/components/charts/Sparkline';
|
import {Sparkline} from '@/shared/components/charts/Sparkline';
|
||||||
import {ChartCard} from '@/components/charts/ChartCard';
|
import {ChartCard} from '@/shared/components/charts/ChartCard';
|
||||||
import {StatPair, StatRow} from '@/components/patterns/StatPair';
|
import {StatPair, StatRow} from '@/shared/components/patterns/StatPair';
|
||||||
import {formatCompact, formatInrCompact, formatPct} from '@/lib/format';
|
import {formatCompact, formatInrCompact, formatPct} from '@/shared/utils/format';
|
||||||
import type {StoreComparison as StoreComparisonRow} from '@/lib/api/contracts';
|
import type {StoreComparison as StoreComparisonRow} from '@/features/dashboard/types/dashboard';
|
||||||
import type {Resource} from '@/lib/api/useResource';
|
import type {Resource} from '@/shared/hooks/useResource';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Small multiples, not a five-series chart.
|
* 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 {
|
import type {Granularity, PeriodPoint, RewardUsagePoint, StoreComparison} from '@/features/dashboard/types/dashboard';
|
||||||
Granularity,
|
import type {RangeKey} from '@/shared/types/api';
|
||||||
PeriodPoint,
|
import {createRng} from '@/shared/mock/rng';
|
||||||
RangeKey,
|
import {buildTimeseries} from './dashboard.mock';
|
||||||
RewardUsagePoint,
|
import {STORE_SEED} from '@/features/stores/mock/stores.mock';
|
||||||
StoreComparison,
|
|
||||||
} from '@/lib/api/contracts';
|
|
||||||
import {createRng} from './rng';
|
|
||||||
import {buildTimeseries} from './dashboard';
|
|
||||||
import {STORE_SEED} from './stores';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-store totals for the comparison small-multiples.
|
* Per-store totals for the comparison small-multiples.
|
||||||
@@ -1,14 +1,10 @@
|
|||||||
import type {
|
import type {DashboardBriefing, DashboardTask, Insight} from '@/features/dashboard/types/dashboard';
|
||||||
DashboardBriefing,
|
import type {RangeKey} from '@/shared/types/api';
|
||||||
DashboardTask,
|
import {buildTimeseries} from './dashboard.mock';
|
||||||
Insight,
|
import {buildStoreComparison, buildRewardUsage} from './analytics.mock';
|
||||||
RangeKey,
|
import {buildStaffSummary} from '@/features/staff/mock/staff.mock';
|
||||||
} from '@/lib/api/contracts';
|
import {buildRewards} from '@/features/lyts/mock/lyts.mock';
|
||||||
import {buildTimeseries} from './dashboard';
|
import {storeName} from '@/features/stores/mock/stores.mock';
|
||||||
import {buildStoreComparison, buildRewardUsage} from './analytics';
|
|
||||||
import {buildStaffSummary} from './staff';
|
|
||||||
import {buildRewards} from './lyts';
|
|
||||||
import {storeName} from './stores';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The narrative half of the dashboard.
|
* The narrative half of the dashboard.
|
||||||
@@ -1,12 +1,7 @@
|
|||||||
import type {
|
import type {ActivityEvent, HourCell, Kpi, TimePoint} from '@/features/dashboard/types/dashboard';
|
||||||
ActivityEvent,
|
import type {RangeKey} from '@/shared/types/api';
|
||||||
HourCell,
|
import {createRng} from '@/shared/mock/rng';
|
||||||
Kpi,
|
import {storeScale} from '@/features/stores/mock/stores.mock';
|
||||||
RangeKey,
|
|
||||||
TimePoint,
|
|
||||||
} from '@/lib/api/contracts';
|
|
||||||
import {createRng} from './rng';
|
|
||||||
import {storeScale} from './stores';
|
|
||||||
|
|
||||||
export const RANGE_DAYS: Record<RangeKey, number> = {
|
export const RANGE_DAYS: Record<RangeKey, number> = {
|
||||||
'7d': 7,
|
'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';
|
'use client';
|
||||||
|
|
||||||
import {IconButton} from '@astryxdesign/core/IconButton';
|
|
||||||
import {Icon} from '@astryxdesign/core/Icon';
|
import {Icon} from '@astryxdesign/core/Icon';
|
||||||
import {ICONS} from '@/lib/icons';
|
import {IconButton} from '@astryxdesign/core/IconButton';
|
||||||
import {useBreakpoint, isPanelInline} from '@/lib/breakpoints';
|
import {useLoyalyAi} from '@/features/loyaly-ai/providers/LoyalyAiProvider';
|
||||||
import {useCopilot} from './CopilotProvider';
|
import {isPanelInline, useBreakpoint} from '@/shared/hooks/useBreakpoint';
|
||||||
|
import {ICONS} from '@/shared/utils/icons';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One control, two behaviours: above the laptop breakpoint it collapses the
|
* One control, two behaviours: above the laptop breakpoint it collapses the
|
||||||
* inline panel to give the workspace full width; below it, it opens 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
|
* slide-over. Both read and write the same provider, so the assistant's
|
||||||
* never notice which one is in play.
|
* contents never notice which one is in play.
|
||||||
*/
|
*/
|
||||||
export function CopilotToggle() {
|
export function LoyalyAiToggle() {
|
||||||
const bp = useBreakpoint();
|
const bp = useBreakpoint();
|
||||||
const {isOpen, toggle, isSlideOverOpen, setSlideOverOpen} = useCopilot();
|
const {isOpen, toggle, isSlideOverOpen, setSlideOverOpen} = useLoyalyAi();
|
||||||
const inline = isPanelInline(bp);
|
const inline = isPanelInline(bp);
|
||||||
|
|
||||||
const isShown = inline ? isOpen : isSlideOverOpen;
|
const isShown = inline ? isOpen : isSlideOverOpen;
|
||||||
@@ -23,6 +23,7 @@ export function CopilotToggle() {
|
|||||||
<IconButton
|
<IconButton
|
||||||
icon={<Icon icon={isShown ? ICONS.panelClose : ICONS.panelOpen} />}
|
icon={<Icon icon={isShown ? ICONS.panelClose : ICONS.panelOpen} />}
|
||||||
label={isShown ? 'Hide Loyaly AI' : 'Show Loyaly AI'}
|
label={isShown ? 'Hide Loyaly AI' : 'Show Loyaly AI'}
|
||||||
|
tooltip={isShown ? 'Hide Loyaly AI' : 'Show Loyaly AI'}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => (inline ? toggle() : setSlideOverOpen(!isSlideOverOpen))}
|
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