Commit 30388722 by krds-arun

Initial commit

Made-with: Cursor
parents
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
*storybook.log
storybook-static
import type { StorybookConfig } from '@storybook/nextjs';
const config: StorybookConfig = {
"stories": [
"../stories/**/*.mdx",
"../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)"
],
"addons": [
"@storybook/addon-a11y",
"@storybook/addon-docs",
"@storybook/addon-onboarding"
],
"framework": "@storybook/nextjs",
"staticDirs": [
"../public"
]
};
export default config;
\ No newline at end of file
import type { Preview } from '@storybook/nextjs'
const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
};
export default preview;
\ No newline at end of file
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->
@AGENTS.md
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
File added
@import "tailwindcss";
/* Load original MAF-UIS CSS selectors so class-based markup matches pixel-for-pixel. */
@import "../../maf-uis/maf-ui/css/main.css";
:root {
/* Brand */
--maroon: #8b1538;
--maroon-deep: #6b0f2a;
--maroon-bright: #a91d47;
--gold: #c9a961;
--gold-light: #dfc07a;
--gold-pale: rgba(201, 169, 97, 0.12);
--gold-border: rgba(201, 169, 97, 0.2);
--gold-border-strong: rgba(201, 169, 97, 0.45);
/* Neutrals — professional contrast */
--ink: #0a0a0c;
--ink-2: #121214;
--ink-3: #1a1a1d;
--ink-4: #27272a;
--text-1: #fafafa;
--text-2: #a1a1aa;
--text-3: #71717a;
--white: rgba(255, 255, 255, 0.06);
--white-2: rgba(255, 255, 255, 0.1);
/* Surfaces (semantic) */
--surface-base: var(--ink);
--surface-raised: var(--ink-2);
--surface-overlay: var(--ink-3);
--border-subtle: var(--gold-border);
--border-strong: var(--gold-border-strong);
/* Status */
--status-new: #3b82f6;
--status-incomplete: #eab308;
--status-inprogress: #3b82f6;
--status-completed: #22c55e;
--status-published: #22c55e;
--status-active: #22c55e;
--status-blocked: #ef4444;
--status-draft: #94a3b8;
--notif-info: #3b82f6;
--notif-success: #22c55e;
--notif-warning: #f59e0b;
--notif-action: #8b1538;
/* Typography — MAF theme: professional scale & fonts */
--font-display: "Cormorant Garamond", Georgia, serif;
--font-sans: "Syne", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono: "JetBrains Mono", "SF Mono", Consolas, monospace;
/* Aliases used for Tailwind @theme token mapping (avoid self-references) */
--maf-font-sans: var(--font-sans);
--maf-font-mono: var(--font-mono);
--text-xs: 0.75rem; /* 12px */
--text-sm: 0.8125rem; /* 13px */
--text-base: 0.9375rem; /* 15px */
--text-md: 1rem; /* 16px */
--text-lg: 1.125rem; /* 18px */
--text-xl: 1.25rem; /* 20px */
--text-2xl: 1.5rem; /* 24px */
--text-3xl: 1.875rem; /* 30px */
--leading-tight: 1.25;
--leading-snug: 1.375;
--leading-normal: 1.5;
--leading-relaxed: 1.625;
--tracking-tight: -0.02em;
--tracking-normal: 0;
--tracking-wide: 0.05em;
--tracking-wider: 0.08em;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
/* Spacing scale (4px base) */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
--space-16: 64px;
/* Shadows */
--shadow-maroon: 0 8px 32px rgba(139, 21, 56, 0.4);
--shadow-deep: 0 24px 64px rgba(0, 0, 0, 0.6);
--shadow-focus: 0 0 0 3px rgba(201, 169, 97, 0.3);
--shadow-toast: 0 10px 40px rgba(0, 0, 0, 0.5);
/* Motion */
--ease: cubic-bezier(0.22, 1, 0.36, 1);
--ease-out: cubic-bezier(0.33, 1, 0.68, 1);
--t1: 0.12s;
--t2: 0.22s;
--t3: 0.35s;
/* Layout */
--header-height: 56px;
--sidebar-width: 240px;
--sidebar-width-collapsed: 72px;
--bottom-nav-height: 64px;
--compact-footer-height: 48px;
--notif-panel-width: 380px;
--notif-panel-max-height: 85vh;
--content-max-width: 1280px;
/* Radius */
--r-sm: 8px;
--r-md: 14px;
--r-lg: 20px;
--r-full: 9999px;
/* Touch targets */
--touch-min: 44px;
}
/* Light theme overrides (triggered by `data-theme="light"`) */
html[data-theme="light"] {
--ink: #f8f9fa;
--ink-2: #ffffff;
--ink-3: #f1f3f5;
--ink-4: #e9ecef;
--text-1: #1a1d21;
--text-2: #495057;
--text-3: #868e96;
--white: rgba(0, 0, 0, 0.04);
--white-2: rgba(0, 0, 0, 0.08);
--surface-base: #f8f9fa;
--surface-raised: #ffffff;
--surface-overlay: #ffffff;
--border-subtle: rgba(139, 21, 56, 0.15);
--border-strong: rgba(139, 21, 56, 0.35);
--gold-border: rgba(201, 169, 97, 0.35);
--gold-border-strong: rgba(201, 169, 97, 0.55);
--gold-pale: rgba(201, 169, 97, 0.12);
--shadow-deep: 0 24px 64px rgba(0, 0, 0, 0.08);
--shadow-toast: 0 10px 40px rgba(0, 0, 0, 0.12);
}
@theme inline {
--font-sans: var(--maf-font-sans);
--font-mono: var(--maf-font-mono);
/* Semantic colors */
--color-ink: var(--ink);
--color-surface-base: var(--surface-base);
--color-surface-raised: var(--surface-raised);
--color-surface-overlay: var(--surface-overlay);
--color-text-1: var(--text-1);
--color-text-2: var(--text-2);
--color-text-3: var(--text-3);
--color-gold: var(--gold);
--color-gold-border: var(--gold-border);
--color-gold-border-strong: var(--gold-border-strong);
--color-gold-pale: var(--gold-pale);
--color-maroon: var(--maroon);
--color-maroon-bright: var(--maroon-bright);
--color-border-subtle: var(--border-subtle);
--color-border-strong: var(--border-strong);
--color-status-new: var(--status-new);
--color-status-incomplete: var(--status-incomplete);
--color-status-inprogress: var(--status-inprogress);
--color-status-completed: var(--status-completed);
--color-status-active: var(--status-active);
--color-status-blocked: var(--status-blocked);
--color-status-draft: var(--status-draft);
}
html {
scroll-behavior: smooth;
font-size: 16px;
overflow-x: hidden;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* Basic MAF base (trimmed from MAF-UIS base.css) */
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: var(--font-sans);
font-size: var(--text-base);
font-weight: 500;
line-height: var(--leading-normal);
letter-spacing: 0.01em;
background: var(--ink);
color: var(--text-1);
min-height: 100vh;
padding-top: var(--header-height);
padding-bottom: var(--bottom-nav-height);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
overflow-x: hidden;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
:root[data-theme="light"] {
/* keep for older CSS selectors if needed later */
}
*:focus-visible {
outline: 2px solid var(--gold);
outline-offset: 2px;
box-shadow: none;
}
a:focus-visible,
button:focus-visible {
box-shadow: var(--shadow-focus);
}
/* Grain overlay (from MAF-UIS) */
html[data-theme="light"] body::before {
opacity: 0.15;
}
body::before {
content: '';
position: fixed;
inset: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.04'/%3E%3C/svg%3E");
pointer-events: none;
z-index: 9999;
opacity: 0.4;
}
'use client';
import { useRouter } from 'next/navigation';
import MafRequireAuth from '@/src/maf-ui/auth/MafRequireAuth';
import MafAppShell from '@/src/maf-ui/shell/MafAppShell';
import MafBadge from '@/src/maf-ui/components/MafBadge';
import MafButton from '@/src/maf-ui/components/MafButton';
import { MafCard } from '@/src/maf-ui/components/MafCard';
function viewToPath(view: string) {
switch (view) {
case 'modules':
return '/modules';
case 'incidents':
return '/incidents';
case 'permit':
return '/permit';
case 'settings':
return '/settings';
case 'profile':
return '/profile';
case 'notification-center':
return '/notification-center';
case 'welcome':
default:
return '/';
}
}
export default function IncidentsPage() {
const router = useRouter();
return (
<MafRequireAuth
fallbackAccountType="employee"
render={(user) => (
<MafAppShell user={user} activeView="incidents" onNavigate={(next) => router.push(viewToPath(next))}>
<div className="w-full max-w-[980px] mx-auto px-3 md:px-6 py-6">
<MafCard className="p-6 md:p-8">
<div className="flex items-start justify-between gap-4">
<div>
<h1
className="text-[var(--text-3xl)] leading-[var(--leading-tight)] tracking-[var(--tracking-tight)] font-semibold text-text-1"
style={{ fontFamily: 'var(--font-display)' }}
>
Incidents
</h1>
<p className="text-text-2 text-sm leading-[var(--leading-relaxed)] mt-2">
Placeholder list view that confirms cards/buttons/badges styling on the shell.
</p>
</div>
<MafBadge tone="warning">3 pending</MafBadge>
</div>
<div className="mt-6 space-y-3">
<div className="rounded-[var(--r-md)] border border-gold-border bg-[var(--ink-2)] p-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-text-1 font-semibold">Incident #INC-2024-001</div>
<div className="text-text-3 text-xs uppercase tracking-[0.06em] mt-1">
Open · Requires review
</div>
</div>
<MafBadge tone="info">1 day</MafBadge>
</div>
<div className="mt-3 flex gap-3 flex-wrap">
<MafButton variant="primary" size="small" onClick={() => {}}>
Review
</MafButton>
<MafButton variant="ghost" size="small" onClick={() => {}}>
Assign
</MafButton>
</div>
</div>
<div className="rounded-[var(--r-md)] border border-gold-border bg-[var(--ink-2)] p-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-text-1 font-semibold">Incident #INC-2024-014</div>
<div className="text-text-3 text-xs uppercase tracking-[0.06em] mt-1">
Open · Pending approval
</div>
</div>
<MafBadge tone="draft">Draft</MafBadge>
</div>
<div className="mt-3 flex gap-3 flex-wrap">
<MafButton variant="primary" size="small" onClick={() => {}}>
Approve
</MafButton>
<MafButton variant="ghost" size="small" onClick={() => {}}>
View
</MafButton>
</div>
</div>
</div>
</MafCard>
</div>
</MafAppShell>
)}
/>
);
}
import type { Metadata } from "next";
import { Cormorant_Garamond, JetBrains_Mono, Syne } from "next/font/google";
import "./globals.css";
const cormorant = Cormorant_Garamond({
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
variable: "--font-maf-display",
});
const syne = Syne({
subsets: ["latin"],
weight: ["400", "500", "600", "700", "800"],
variable: "--font-maf-sans",
});
const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
weight: ["400", "500"],
variable: "--font-maf-mono",
});
export const metadata: Metadata = {
title: "MAF Revamp",
description: "MAF-UIS styled frontend",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="en"
data-theme="dark"
className={`${cormorant.variable} ${syne.variable} ${jetbrainsMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
</html>
);
}
'use client';
import { useRouter } from 'next/navigation';
import MafRequireAuth from '@/src/maf-ui/auth/MafRequireAuth';
import MafAppShell from '@/src/maf-ui/shell/MafAppShell';
import MafButton from '@/src/maf-ui/components/MafButton';
import MafBadge from '@/src/maf-ui/components/MafBadge';
import MafTabs from '@/src/maf-ui/components/MafTabs';
import { MafCard } from '@/src/maf-ui/components/MafCard';
function viewToPath(view: string) {
switch (view) {
case 'modules':
return '/modules';
case 'incidents':
return '/incidents';
case 'permit':
return '/permit';
case 'settings':
return '/settings';
case 'profile':
return '/profile';
case 'notification-center':
return '/notification-center';
case 'welcome':
default:
return '/';
}
}
export default function ModulesPage() {
const router = useRouter();
return (
<MafRequireAuth
fallbackAccountType="employee"
render={(user) => (
<MafAppShell user={user} activeView="modules" onNavigate={(next) => router.push(viewToPath(next))}>
<div className="w-full max-w-[980px] mx-auto px-3 md:px-6 py-6">
<MafCard className="p-6 md:p-8">
<div className="flex items-start justify-between gap-4">
<div>
<h1
className="text-[var(--text-3xl)] leading-[var(--leading-tight)] tracking-[var(--tracking-tight)] font-semibold text-text-1"
style={{ fontFamily: 'var(--font-display)' }}
>
Modules
</h1>
<p className="text-text-2 text-sm leading-[var(--leading-relaxed)] mt-2">
Component kit + shell validation on a real Next route.
</p>
</div>
<MafBadge tone="info">New</MafBadge>
</div>
<div className="mt-6">
<MafTabs
defaultActiveKey="overview"
items={[
{
key: 'overview',
label: 'Overview',
panel: (
<div className="text-text-2 text-sm leading-[var(--leading-relaxed)]">
The Modules page uses <b>MAF shell</b> + <b>MAF tabs</b> + <b>MAF buttons</b>.
<div className="mt-4 flex flex-wrap gap-3">
<MafButton variant="primary" onClick={() => {}}>
Primary action
</MafButton>
<MafButton variant="ghost" onClick={() => {}}>
Secondary
</MafButton>
</div>
</div>
),
},
{
key: 'rules',
label: 'Rules',
panel: <div className="text-text-2 text-sm">Rules configuration placeholder.</div>,
},
{
key: 'approvals',
label: 'Approvals',
panel: (
<div className="text-text-2 text-sm">
Approval workflow placeholder. Use <MafBadge tone="success">Ready</MafBadge> to represent status.
</div>
),
},
]}
/>
</div>
</MafCard>
</div>
</MafAppShell>
)}
/>
);
}
'use client';
import { useRouter } from 'next/navigation';
import MafRequireAuth from '@/src/maf-ui/auth/MafRequireAuth';
import MafAppShell from '@/src/maf-ui/shell/MafAppShell';
import MafBadge from '@/src/maf-ui/components/MafBadge';
import MafButton from '@/src/maf-ui/components/MafButton';
import { MafCard } from '@/src/maf-ui/components/MafCard';
import MafNotificationCenter from '@/src/maf-ui/shell/MafNotificationCenter';
function viewToPath(view: string) {
switch (view) {
case 'modules':
return '/modules';
case 'incidents':
return '/incidents';
case 'permit':
return '/permit';
case 'settings':
return '/settings';
case 'profile':
return '/profile';
case 'notification-center':
return '/notification-center';
case 'welcome':
default:
return '/';
}
}
export default function NotificationCenterPage() {
const router = useRouter();
return (
<MafRequireAuth
fallbackAccountType="employee"
render={(user) => (
<MafAppShell
user={user}
activeView="notification-center"
onNavigate={(next) => router.push(viewToPath(next))}
>
<div className="w-full max-w-[980px] mx-auto px-3 md:px-6 py-6">
<MafCard className="p-6 md:p-8">
<MafNotificationCenter
open
variant="page"
unreadCount={0}
onUnreadCountChange={() => {}}
onClose={() => {}}
onNavigate={(link) => {
const view = (() => {
switch (link) {
case '#incidents':
return 'incidents';
case '#inspection':
return 'modules';
case '#admin':
return 'modules';
case '#profile':
return 'profile';
case '#settings':
return 'settings';
default:
return 'modules';
}
})();
router.push(viewToPath(view));
}}
/>
</MafCard>
</div>
</MafAppShell>
)}
/>
);
}
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import MafGatewayLanding from '@/src/maf-ui/landing/MafGatewayLanding';
import useAuth from '@/src/maf-ui/auth/useAuth';
import type { MafUser } from '@/src/maf-ui/shell/MafUserMenu';
export default function Home() {
const router = useRouter();
const { status, user, login } = useAuth();
useEffect(() => {
if (status === 'authed' && user) {
router.replace('/modules');
}
}, [status, user, router]);
if (status === 'authed') {
return null;
}
const handleLogin = (u: MafUser) => {
login({
email: `${u.name}@company.com`,
accountType: u.role === 'Admin' ? 'employee' : 'contractor',
});
router.replace('/modules');
};
return <MafGatewayLanding onLoginSuccess={handleLogin} />;
}
'use client';
import { useRouter } from 'next/navigation';
import MafRequireAuth from '@/src/maf-ui/auth/MafRequireAuth';
import MafAppShell from '@/src/maf-ui/shell/MafAppShell';
import MafButton from '@/src/maf-ui/components/MafButton';
import { MafCard } from '@/src/maf-ui/components/MafCard';
function viewToPath(view: string) {
switch (view) {
case 'modules':
return '/modules';
case 'incidents':
return '/incidents';
case 'permit':
return '/permit';
case 'settings':
return '/settings';
case 'profile':
return '/profile';
case 'notification-center':
return '/notification-center';
case 'welcome':
default:
return '/';
}
}
export default function PermitPage() {
const router = useRouter();
return (
<MafRequireAuth
fallbackAccountType="employee"
render={(user) => (
<MafAppShell user={user} activeView="permit" onNavigate={(next) => router.push(viewToPath(next))}>
<div className="w-full max-w-[980px] mx-auto px-3 md:px-6 py-6">
<MafCard className="p-6 md:p-8">
<h1
className="text-[var(--text-3xl)] leading-[var(--leading-tight)] tracking-[var(--tracking-tight)] font-semibold text-text-1"
style={{ fontFamily: 'var(--font-display)' }}
>
Permit
</h1>
<p className="text-text-2 text-sm leading-[var(--leading-relaxed)] mt-2">
Placeholder route to confirm shell layout across navigation.
</p>
<div className="mt-6">
<MafButton variant="primary" onClick={() => {}}>
Create permit
</MafButton>
</div>
</MafCard>
</div>
</MafAppShell>
)}
/>
);
}
'use client';
import { useRouter } from 'next/navigation';
import MafRequireAuth from '@/src/maf-ui/auth/MafRequireAuth';
import MafAppShell from '@/src/maf-ui/shell/MafAppShell';
import MafButton from '@/src/maf-ui/components/MafButton';
import { MafCard } from '@/src/maf-ui/components/MafCard';
function viewToPath(view: string) {
switch (view) {
case 'modules':
return '/modules';
case 'incidents':
return '/incidents';
case 'permit':
return '/permit';
case 'settings':
return '/settings';
case 'profile':
return '/profile';
case 'notification-center':
return '/notification-center';
case 'welcome':
default:
return '/';
}
}
export default function ProfilePage() {
const router = useRouter();
return (
<MafRequireAuth
fallbackAccountType="employee"
render={(user) => (
<MafAppShell user={user} activeView="profile" onNavigate={(next) => router.push(viewToPath(next))}>
<div className="w-full max-w-[980px] mx-auto px-3 md:px-6 py-6">
<MafCard className="p-6 md:p-8">
<h1
className="text-[var(--text-3xl)] leading-[var(--leading-tight)] tracking-[var(--tracking-tight)] font-semibold text-text-1"
style={{ fontFamily: 'var(--font-display)' }}
>
Profile
</h1>
<p className="text-text-2 text-sm leading-[var(--leading-relaxed)] mt-2">
Placeholder route to validate header + user menu + shell spacing.
</p>
<div className="mt-6">
<MafButton variant="primary" onClick={() => {}}>
Edit profile
</MafButton>
</div>
</MafCard>
</div>
</MafAppShell>
)}
/>
);
}
'use client';
import { useRouter } from 'next/navigation';
import MafRequireAuth from '@/src/maf-ui/auth/MafRequireAuth';
import MafAppShell from '@/src/maf-ui/shell/MafAppShell';
import MafButton from '@/src/maf-ui/components/MafButton';
import { MafCard } from '@/src/maf-ui/components/MafCard';
import MafBadge from '@/src/maf-ui/components/MafBadge';
function viewToPath(view: string) {
switch (view) {
case 'modules':
return '/modules';
case 'incidents':
return '/incidents';
case 'permit':
return '/permit';
case 'settings':
return '/settings';
case 'profile':
return '/profile';
case 'notification-center':
return '/notification-center';
case 'welcome':
default:
return '/';
}
}
export default function SettingsPage() {
const router = useRouter();
return (
<MafRequireAuth
fallbackAccountType="employee"
render={(user) => (
<MafAppShell user={user} activeView="settings" onNavigate={(next) => router.push(viewToPath(next))}>
<div className="w-full max-w-[980px] mx-auto px-3 md:px-6 py-6">
<MafCard className="p-6 md:p-8">
<div className="flex items-start justify-between gap-4">
<div>
<h1
className="text-[var(--text-3xl)] leading-[var(--leading-tight)] tracking-[var(--tracking-tight)] font-semibold text-text-1"
style={{ fontFamily: 'var(--font-display)' }}
>
Settings
</h1>
<p className="text-text-2 text-sm leading-[var(--leading-relaxed)] mt-2">
Placeholder page for settings UI and navigation validation.
</p>
</div>
<MafBadge tone="draft">Demo</MafBadge>
</div>
<div className="mt-6 flex gap-3 flex-wrap">
<MafButton variant="primary" onClick={() => {}}>
Save changes
</MafButton>
<MafButton variant="ghost" onClick={() => {}}>
Reset
</MafButton>
</div>
</MafCard>
</div>
</MafAppShell>
)}
/>
);
}
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
{
"name": "maf-revamp-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
"dependencies": {
"next": "16.2.0",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.0",
"tailwindcss": "^4",
"typescript": "^5",
"storybook": "^10.3.1",
"@storybook/nextjs": "^10.3.1",
"@storybook/addon-a11y": "^10.3.1",
"@storybook/addon-docs": "^10.3.1",
"@storybook/addon-onboarding": "^10.3.1"
}
}
This source diff could not be displayed because it is too large. You can view the blob instead.
ignoredBuiltDependencies:
- sharp
- unrs-resolver
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
\ No newline at end of file
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
\ No newline at end of file
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
\ No newline at end of file
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
\ No newline at end of file
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
\ No newline at end of file
// MAF-styled login dialog (mock auth).
'use client';
import { FormEvent, useEffect, useMemo, useRef, useState } from 'react';
import MafButton from '@/src/maf-ui/components/MafButton';
import { MafCard } from '@/src/maf-ui/components/MafCard';
import type { MafUser } from '@/src/maf-ui/shell/MafUserMenu';
export default function MafLoginModal({
open,
defaultAccountType = 'employee',
onClose,
onSuccess,
}: {
open: boolean;
defaultAccountType?: 'employee' | 'contractor';
onClose?: () => void;
onSuccess: (user: MafUser) => void;
}) {
const [accountType, setAccountType] = useState<'employee' | 'contractor'>(defaultAccountType);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const emailRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (!open) return;
setAccountType(defaultAccountType);
setError(null);
setLoading(false);
// focus first field for accessibility.
setTimeout(() => emailRef.current?.focus(), 0);
}, [open, defaultAccountType]);
useEffect(() => {
if (!open) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose?.();
};
document.addEventListener('keydown', onKeyDown);
// Prevent background scroll.
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', onKeyDown);
document.body.style.overflow = prev;
};
}, [open, onClose]);
const tabDefs = useMemo(
() => [
{ key: 'employee' as const, label: 'MAF Employee' },
{ key: 'contractor' as const, label: 'Contractor / Supplier' },
],
[]
);
if (!open) return null;
const submit = async (e: FormEvent) => {
e.preventDefault();
if (!email.trim()) {
setError('Please enter your email.');
return;
}
if (!password.trim()) {
setError('Password is required.');
return;
}
setLoading(true);
setError(null);
// Mock latency to mimic UX.
await new Promise((r) => setTimeout(r, 600));
const base = (email.split('@')[0] || email).replace(/[^a-zA-Z0-9]/g, '');
const initials = (base.slice(0, 2).toUpperCase() || 'MAF').padEnd(2, 'F');
const user: MafUser = {
initials,
name: email.includes('@') ? email.split('@')[0] : email,
role: accountType === 'employee' ? 'Admin' : 'Contractor',
};
setLoading(false);
onSuccess(user);
onClose?.();
};
return (
<div
className="fixed inset-0 z-[2000] bg-black/60 flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
aria-label="Login"
>
<div className="absolute inset-0" onClick={onClose} aria-hidden="true" />
<MafCard className="relative w-full max-w-[560px] p-5 md:p-7">
<div className="flex items-start justify-between gap-4 mb-4">
<div>
<h2
className="text-[1.65rem] leading-tight font-semibold text-text-1"
style={{ fontFamily: 'var(--font-display)' }}
>
Welcome back
</h2>
<p className="text-text-2 text-sm mt-1 font-medium">Choose your account type to continue</p>
</div>
<button
type="button"
className="w-10 h-10 rounded-[var(--r-sm)] border border-gold-border bg-white/0 hover:bg-white/5 text-text-2"
aria-label="Close login dialog"
onClick={onClose}
>
×
</button>
</div>
<div className="mb-4 flex gap-3" role="tablist" aria-label="Login type">
{tabDefs.map((t) => {
const active = t.key === accountType;
return (
<button
key={t.key}
type="button"
role="tab"
aria-selected={active}
className={[
'px-4 py-2 rounded-[var(--r-sm)] text-sm font-semibold border transition-colors',
active ? 'bg-gold-pale border-gold-border-strong text-gold' : 'bg-surface-raised border-gold-border text-text-2 hover:text-text-1',
].join(' ')}
onClick={() => setAccountType(t.key)}
>
{t.label}
</button>
);
})}
</div>
<form onSubmit={submit} className="space-y-4" aria-label="Sign in form">
<div className="space-y-2">
<label className="block text-text-3 text-xs uppercase tracking-[0.08em] font-semibold" htmlFor="loginEmail">
Company Email
</label>
<input
ref={emailRef}
id="loginEmail"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@company.com"
autoComplete="email"
className="w-full h-[44px] px-3 rounded-[var(--r-sm)] border border-gold-border bg-surface-raised text-text-1 outline-none focus:border-gold-border-strong"
/>
</div>
<div className="space-y-2">
<label className="block text-text-3 text-xs uppercase tracking-[0.08em] font-semibold" htmlFor="loginPassword">
Password
</label>
<input
id="loginPassword"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Your password"
autoComplete="current-password"
className="w-full h-[44px] px-3 rounded-[var(--r-sm)] border border-gold-border bg-surface-raised text-text-1 outline-none focus:border-gold-border-strong"
/>
</div>
{error && (
<div className="text-red-400 text-sm font-semibold" role="alert">
{error}
</div>
)}
<div className="flex items-center justify-between gap-3">
<label className="flex items-center gap-2 text-text-2 text-sm select-none">
<input type="checkbox" className="accent-[#C9A961]" />
Remember me
</label>
<button
type="button"
className="text-gold text-sm font-semibold hover:opacity-90"
onClick={() => setError('Password reset is mocked in this demo.')}
>
Forgot password?
</button>
</div>
<div className="pt-2">
<MafButton variant="primary" size="medium" loading={loading} disabled={loading} className="w-full">
{loading ? 'Signing in...' : 'Log In'}
</MafButton>
</div>
<div className="text-text-2 text-sm pt-1">
Need help accessing your account?{' '}
<button type="button" className="text-gold font-semibold hover:opacity-90" onClick={() => setError('Support is mocked.')}>
Contact support →
</button>
</div>
</form>
</MafCard>
</div>
);
}
'use client';
import { ReactNode } from 'react';
import useAuth from '@/src/maf-ui/auth/useAuth';
import MafLoginModal from '@/src/maf-ui/auth/MafLoginModal';
import type { MafUser } from '@/src/maf-ui/shell/MafUserMenu';
export default function MafRequireAuth({
render,
fallbackAccountType,
}: {
render: (user: MafUser) => ReactNode;
fallbackAccountType?: 'employee' | 'contractor';
}) {
const { status, user, login } = useAuth();
if (status === 'loading') {
// Keep layout stable while auth state initializes.
return null;
}
if (!user) {
return (
<MafLoginModal
open
defaultAccountType={fallbackAccountType ?? 'employee'}
onClose={() => {}}
onSuccess={(u) => {
// Ensure storage is set via hook login (we only need to route after).
login({
email: `${u.name}@company.com`,
accountType: u.role === 'Admin' ? 'employee' : 'contractor',
});
}}
/>
);
}
return <>{render(user)}</>;
}
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { MafUser } from '@/src/maf-ui/shell/MafUserMenu';
export type AuthStatus = 'loading' | 'guest' | 'authed';
const STORAGE_USER_KEY = 'maf_user';
function safeParseUser(raw: string | null): MafUser | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as MafUser;
if (!parsed?.initials || !parsed?.name || !parsed?.role) return null;
return parsed;
} catch {
return null;
}
}
function makeInitials(email: string) {
const base = (email.split('@')[0] || email).replace(/[^a-zA-Z0-9]/g, '');
const a = base.slice(0, 2).toUpperCase();
return a.length ? a : 'MAF';
}
export default function useAuth() {
const [status, setStatus] = useState<AuthStatus>('loading');
const [user, setUser] = useState<MafUser | null>(null);
useEffect(() => {
const u = safeParseUser(localStorage.getItem(STORAGE_USER_KEY));
if (u) {
setUser(u);
setStatus('authed');
} else {
setUser(null);
setStatus('guest');
}
}, []);
const logout = useCallback(() => {
try {
localStorage.removeItem(STORAGE_USER_KEY);
} catch {
// ignore
}
setUser(null);
setStatus('guest');
}, []);
const login = useCallback((params: { email: string; accountType: 'employee' | 'contractor' }) => {
const email = params.email.trim();
const initials = makeInitials(email);
const nextUser: MafUser = {
initials,
name: email.includes('@') ? email.split('@')[0] : email,
role: params.accountType === 'employee' ? 'Admin' : 'Contractor',
};
try {
localStorage.setItem(STORAGE_USER_KEY, JSON.stringify(nextUser));
} catch {
// ignore
}
setUser(nextUser);
setStatus('authed');
}, []);
const api = useMemo(
() => ({
status,
user,
authed: status === 'authed',
guest: status === 'guest',
login,
logout,
}),
[status, user, login, logout]
);
return api;
}
import { HTMLAttributes } from 'react';
export type MafBadgeTone = 'info' | 'success' | 'warning' | 'action' | 'draft';
export type MafBadgeProps = {
tone?: MafBadgeTone;
} & HTMLAttributes<HTMLSpanElement>;
function toneClasses(tone: MafBadgeTone) {
switch (tone) {
case 'success':
return 'bg-[rgba(34,197,94,0.12)] border border-[rgba(34,197,94,0.25)] text-[#22c55e]';
case 'warning':
return 'bg-[rgba(245,158,11,0.12)] border border-[rgba(245,158,11,0.25)] text-[#f59e0b]';
case 'action':
return 'bg-[rgba(139,21,56,0.18)] border border-[rgba(201,169,97,0.25)] text-[#dfc07a]';
case 'draft':
return 'bg-[rgba(148,163,184,0.12)] border border-[rgba(148,163,184,0.25)] text-[#94a3b8]';
case 'info':
default:
return 'bg-[rgba(59,130,246,0.12)] border border-[rgba(59,130,246,0.25)] text-[#3b82f6]';
}
}
export default function MafBadge({ tone = 'info', className, ...rest }: MafBadgeProps) {
return (
<span
className={[
'inline-flex items-center px-3 min-h-[28px] rounded-[var(--r-sm)] text-xs font-semibold border',
'tracking-[0.03em]',
toneClasses(tone),
className ?? '',
].join(' ')}
{...rest}
/>
);
}
import { ButtonHTMLAttributes, ReactNode } from 'react';
export type MafButtonVariant = 'primary' | 'secondary' | 'ghost';
export type MafButtonSize = 'small' | 'medium' | 'large';
export type MafButtonProps = {
variant?: MafButtonVariant;
size?: MafButtonSize;
leftIcon?: ReactNode;
rightIcon?: ReactNode;
loading?: boolean;
} & ButtonHTMLAttributes<HTMLButtonElement>;
function sizeClasses(size: MafButtonSize) {
switch (size) {
case 'small':
return 'min-h-[44px] px-4 text-sm';
case 'large':
return 'min-h-[44px] px-6 text-base';
case 'medium':
default:
return 'min-h-[44px] px-5 text-sm';
}
}
function variantClasses(variant: MafButtonVariant) {
switch (variant) {
case 'primary':
return 'bg-gradient-to-r from-maroon to-maroon-bright text-white border-none shadow-[var(--shadow-maroon)]';
case 'ghost':
return 'bg-transparent text-gold border border-gold-border hover:bg-gold-pale hover:border-gold-border-strong';
case 'secondary':
default:
return 'bg-surface-raised text-text-1 border border-gold-border hover:bg-surface-overlay hover:border-gold-border-strong';
}
}
export default function MafButton({
variant = 'primary',
size = 'medium',
leftIcon,
rightIcon,
loading,
disabled,
className,
children,
...rest
}: MafButtonProps) {
const isDisabled = disabled || loading;
return (
<button
type="button"
disabled={isDisabled}
className={[
'inline-flex items-center justify-center gap-2 rounded-[var(--r-sm)] font-semibold transition-colors',
sizeClasses(size),
variantClasses(variant),
isDisabled ? 'opacity-80 cursor-not-allowed' : 'cursor-pointer',
className ?? '',
].join(' ')}
{...rest}
>
{loading ? (
<span className="inline-flex items-center justify-center w-5 h-5 rounded-full border-2 border-white/40 border-t-white animate-spin" aria-label="Loading" />
) : (
<>
{leftIcon}
<span>{children}</span>
{rightIcon}
</>
)}
</button>
);
}
import { HTMLAttributes, ReactNode } from 'react';
export type MafSurfaceProps = {
as?: 'div' | 'section' | 'article';
children: ReactNode;
} & HTMLAttributes<HTMLDivElement>;
export function MafSurface({ as = 'div', children, className, ...rest }: MafSurfaceProps) {
// Use `any` for the polymorphic element to avoid `JSX.IntrinsicElements` typing issues.
const Component = as as any;
return (
<Component
{...rest}
className={[
'rounded-[var(--r-md)] border border-gold-border bg-surface-raised text-text-1',
className ?? '',
].join(' ')}
>
{children}
</Component>
);
}
export function MafCard({
children,
className,
...rest
}: {
children: ReactNode;
className?: string;
} & HTMLAttributes<HTMLDivElement>) {
return (
<MafSurface className={className ?? ''} {...rest}>
{children}
</MafSurface>
);
}
'use client';
import { ReactNode, useMemo, useState } from 'react';
export type MafTabItem = {
key: string;
label: string;
panel: ReactNode;
};
export default function MafTabs({
items,
defaultActiveKey,
}: {
items: MafTabItem[];
defaultActiveKey?: string;
}) {
const initial = useMemo(() => {
if (defaultActiveKey && items.some((i) => i.key === defaultActiveKey)) return defaultActiveKey;
return items[0]?.key;
}, [defaultActiveKey, items]);
const [activeKey, setActiveKey] = useState<string | undefined>(initial);
return (
<div className="w-full">
<div className="flex gap-[4px] overflow-x-auto pb-1">
{items.map((it) => {
const active = it.key === activeKey;
return (
<button
key={it.key}
type="button"
className={[
'flex-shrink-0 px-4 py-2 rounded-[var(--r-sm)] border transition-colors text-xs font-semibold',
active ? 'bg-gold-pale border-gold-border-strong text-gold' : 'bg-surface-raised border-gold-border text-text-2 hover:text-text-1 hover:border-gold-border-strong',
].join(' ')}
aria-pressed={active}
onClick={() => setActiveKey(it.key)}
>
{it.label}
</button>
);
})}
</div>
<div className="mt-4">{items.find((i) => i.key === activeKey)?.panel}</div>
</div>
);
}
'use client';
import type { MafUser } from '@/src/maf-ui/shell/MafUserMenu';
import MafGat1Landing from './gat1/MafGat1Landing';
type Props = {
onLoginSuccess: (user: MafUser) => void;
};
export default function MafGatewayLanding({ onLoginSuccess }: Props) {
return <MafGat1Landing onLoginSuccess={onLoginSuccess} />;
}
const __MAF_GATEWAY_LEGACY__ = String.raw`
'use client';
import type { MafUser } from '@/src/maf-ui/shell/MafUserMenu';
import MafGat1Landing from './gat1/MafGat1Landing';
type Props = {
onLoginSuccess: (user: MafUser) => void;
};
export default function MafGatewayLanding({ onLoginSuccess }: Props) {
return <MafGat1Landing onLoginSuccess={onLoginSuccess} />;
}
/*
'use client';
import type { MafUser } from '@/src/maf-ui/shell/MafUserMenu';
import MafGat1Landing from './gat1/MafGat1Landing';
type Props = {
onLoginSuccess: (user: MafUser) => void;
};
export default function MafGatewayLanding({ onLoginSuccess }: Props) {
return <MafGat1Landing onLoginSuccess={onLoginSuccess} />;
}
// Marketing/landing experience (MAF-UIS styled) + login entry point.
'use client';
import { useMemo, useState } from 'react';
import MafThemeToggle from '@/src/maf-ui/shell/MafThemeToggle';
import MafLoginModal from '@/src/maf-ui/auth/MafLoginModal';
import MafButton from '@/src/maf-ui/components/MafButton';
import { MafCard } from '@/src/maf-ui/components/MafCard';
import type { MafUser } from '@/src/maf-ui/shell/MafUserMenu';
function Logo() {
return (
<div className="flex items-center gap-2">
<div className="w-9 h-9 rounded-[var(--r-sm)] bg-gradient-to-br from-maroon to-maroon-bright shadow-[var(--shadow-maroon)] flex items-center justify-center relative overflow-hidden">
<span className="text-white font-bold text-sm" style={{ fontFamily: 'var(--font-display)' }}>
M
</span>
</div>
<div className="hidden sm:block">
<div className="text-sm font-bold tracking-wide text-text-1 leading-tight">MAF Gateway</div>
<div className="text-[11px] uppercase tracking-[0.08em] text-text-3 font-semibold leading-tight">
Enterprise Platform
</div>
</div>
</div>
);
}
type MafGatewayLandingProps = {
onLoginSuccess: (user: MafUser) => void;
};
export default function MafGatewayLanding({ onLoginSuccess }: MafGatewayLandingProps) {
const [loginOpen, setLoginOpen] = useState(false);
const moduleCards = useMemo(
() => [
{ title: 'Audit Module', desc: 'HSE compliance audits with instant reporting and follow-up actions.' },
{ title: 'Inspection Module', desc: 'Templates for daily/weekly inspections integrated into workflows.' },
{ title: 'Checklist Module', desc: 'Digital replacement for paper checklists with complete audit trails.' },
{ title: 'Suggestion Scheme', desc: 'Central idea database tied to incentives and awards.' },
{ title: 'Training Module', desc: 'Manage training lifecycles: calendar, exams, feedback and certification.' },
{ title: 'Incident Reporting', desc: 'Real-time incident capture with rich media and escalation.' },
],
[]
);
const capabilities = useMemo(
() => [
{ title: 'Mobile Application', desc: 'Native experiences for online and offline field work.' },
{ title: 'Security & Privacy', desc: 'Enterprise-grade access controls and security posture.' },
{ title: 'Data Management', desc: 'Structured data capture with consistent reporting.' },
{ title: 'Enhanced Productivity', desc: 'Replace manual steps and improve operational throughput.' },
],
[]
);
return (
<div className="min-h-screen">
{/* Top navbar */}
<header className="fixed top-0 left-0 right-0 z-[1500] h-[72px] bg-[rgba(9,9,11,0.92)] backdrop-blur-[20px] border-b border-gold-border flex items-center justify-between px-4 md:px-8">
<a
href="#"
onClick={(e) => e.preventDefault()}
className="no-underline text-text-1"
aria-label="MAF Gateway home"
>
<Logo />
</a>
<div className="flex items-center gap-3">
<MafThemeToggle />
<MafButton
variant="primary"
size="small"
onClick={() => setLoginOpen(true)}
>
Login
</MafButton>
</div>
</header>
{/* Hero */}
<main className="pt-[72px]">
<section
className="relative overflow-hidden"
aria-label="Gateway hero"
>
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,_rgba(201,169,97,0.08)_0%,_transparent_60%)] pointer-events-none" />
<div className="absolute -top-40 -left-40 w-[680px] h-[680px] rounded-full bg-[rgba(139,21,56,0.20)] blur-[40px] pointer-events-none" />
<div className="absolute -bottom-60 -right-60 w-[680px] h-[680px] rounded-full bg-[rgba(201,169,97,0.15)] blur-[50px] pointer-events-none" />
<div className="relative max-w-[1100px] mx-auto px-4 md:px-8 py-14 md:py-20">
<div className="max-w-[720px]">
<div className="text-xs uppercase tracking-[0.08em] font-semibold text-text-3 mb-3">
Enterprise Platform · 2026
</div>
<h1 className="text-[3rem] md:text-[3.75rem] font-semibold leading-tight" style={{ fontFamily: 'var(--font-display)' }}>
The <span className="text-gold">Gateway</span>
</h1>
<p className="text-text-2 text-base md:text-lg leading-[1.7] mt-4">
A comprehensive bespoke platform that replaces manual steps in business process flows — from audit and compliance to training and incident management.
</p>
<div className="mt-8 flex flex-col sm:flex-row gap-3">
<MafButton variant="primary" size="large" onClick={() => setLoginOpen(true)}>
Access Gateway
</MafButton>
<MafButton
variant="ghost"
size="large"
onClick={() => document.getElementById('maf-modules')?.scrollIntoView({ behavior: 'smooth' })}
>
Explore modules
</MafButton>
</div>
<div className="mt-10 grid grid-cols-3 gap-3 max-w-[620px]">
<div className="rounded-[var(--r-md)] border border-gold-border bg-surface-raised p-4">
<div className="text-gold font-extrabold text-2xl">6+</div>
<div className="text-text-3 text-[11px] uppercase tracking-[0.06em] font-semibold mt-1">
Modules
</div>
</div>
<div className="rounded-[var(--r-md)] border border-gold-border bg-surface-raised p-4">
<div className="text-gold font-extrabold text-2xl">iOS</div>
<div className="text-text-3 text-[11px] uppercase tracking-[0.06em] font-semibold mt-1">
& Android
</div>
</div>
<div className="rounded-[var(--r-md)] border border-gold-border bg-surface-raised p-4">
<div className="text-gold font-extrabold text-2xl">100%</div>
<div className="text-text-3 text-[11px] uppercase tracking-[0.06em] font-semibold mt-1">
Digital
</div>
</div>
</div>
</div>
</div>
</section>
{/* Modules */}
<section id="maf-modules" className="max-w-[1100px] mx-auto px-4 md:px-8 py-12 md:py-16">
<div className="flex items-end justify-between gap-4 flex-wrap mb-6">
<div>
<div className="text-xs uppercase tracking-[0.08em] font-semibold text-text-3 mb-2">
Gateway Modules
</div>
<h2 className="text-[1.9rem] md:text-[2.25rem] leading-tight font-semibold" style={{ fontFamily: 'var(--font-display)' }}>
Everything in one platform
</h2>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{moduleCards.map((c) => (
<MafCard key={c.title} className="p-5 hover:border-gold-border-strong transition-colors">
<div className="h-10 w-10 rounded-[var(--r-sm)] border border-gold-border bg-gold-pale/40 flex items-center justify-center mb-3">
<span className="text-gold font-black">M</span>
</div>
<h3 className="text-text-1 font-semibold text-[1rem] leading-tight">{c.title}</h3>
<p className="text-text-2 text-sm mt-2 leading-[1.6]">{c.desc}</p>
</MafCard>
))}
</div>
</section>
{/* Platform capabilities */}
<section className="max-w-[1100px] mx-auto px-4 md:px-8 pb-14 md:pb-18">
<div className="mb-6">
<div className="text-xs uppercase tracking-[0.08em] font-semibold text-text-3 mb-2">
Platform Capabilities
</div>
<h2 className="text-[1.9rem] md:text-[2.25rem] leading-tight font-semibold" style={{ fontFamily: 'var(--font-display)' }}>
Built for the real world
</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{capabilities.map((c) => (
<MafCard key={c.title} className="p-5">
<h3 className="text-text-1 font-semibold text-[1rem] leading-tight">{c.title}</h3>
<p className="text-text-2 text-sm mt-2 leading-[1.6]">{c.desc}</p>
</MafCard>
))}
</div>
</section>
{/* Download */}
<section className="max-w-[1100px] mx-auto px-4 md:px-8 pb-10">
<MafCard className="p-7 md:p-10 bg-surface-raised">
<div className="flex items-start justify-between gap-6 flex-wrap">
<div>
<div className="text-xs uppercase tracking-[0.08em] font-semibold text-text-3 mb-2">Mobile App</div>
<h2 className="text-[1.9rem] md:text-[2.25rem] leading-tight font-semibold" style={{ fontFamily: 'var(--font-display)' }}>
Gateway in your pocket
</h2>
<p className="text-text-2 text-sm mt-2 leading-[1.7] max-w-[640px]">
Capture data, complete checklists and report incidents directly from the field — online or offline.
</p>
</div>
<div className="flex gap-3 flex-wrap">
<button
type="button"
className="min-h-[48px] px-4 rounded-[var(--r-md)] border border-gold-border bg-white/0 hover:bg-white/5 text-text-1 font-semibold"
>
App Store
</button>
<button
type="button"
className="min-h-[48px] px-4 rounded-[var(--r-md)] border border-gold-border bg-white/0 hover:bg-white/5 text-text-1 font-semibold"
>
Google Play
</button>
</div>
</div>
</MafCard>
</section>
{/* Footer */}
<footer className="border-t border-gold-border bg-surface-raised/10">
<div className="max-w-[1100px] mx-auto px-4 md:px-8 py-10 grid grid-cols-1 md:grid-cols-3 gap-8">
<div>
<div className="flex items-center gap-2">
<div className="w-9 h-9 rounded-[var(--r-sm)] bg-gradient-to-br from-maroon to-maroon-bright shadow-[var(--shadow-maroon)] flex items-center justify-center relative overflow-hidden">
<span className="text-white font-bold text-sm" style={{ fontFamily: 'var(--font-display)' }}>
M
</span>
</div>
<div>
<div className="text-sm font-bold tracking-wide text-text-1">Gateway</div>
<div className="text-[11px] uppercase tracking-[0.08em] text-text-3 font-semibold">© 2020–2026</div>
</div>
</div>
<p className="text-text-2 text-sm mt-3 leading-[1.7]">
A comprehensive bespoke platform designed to enhance business quality, efficiency, and productivity through digital transformation.
</p>
</div>
<div>
<div className="text-xs uppercase tracking-[0.08em] font-semibold text-text-3 mb-3">Quick Links</div>
<ul className="space-y-2">
<li>
<a className="text-text-2 hover:text-gold" href="#" onClick={(e) => { e.preventDefault(); setLoginOpen(true); }}>
Login
</a>
</li>
<li>
<a className="text-text-2 hover:text-gold" href="#maf-modules" onClick={(e) => { e.preventDefault(); document.getElementById('maf-modules')?.scrollIntoView({ behavior: 'smooth' }); }}>
Modules
</a>
</li>
<li>
<a className="text-text-2 hover:text-gold" href="#" onClick={(e) => { e.preventDefault(); }}>
Support Center
</a>
</li>
</ul>
</div>
<div>
<div className="text-xs uppercase tracking-[0.08em] font-semibold text-text-3 mb-3">Legal</div>
<ul className="space-y-2">
<li>
<a className="text-text-2 hover:text-gold" href="#" onClick={(e) => e.preventDefault()}>
Privacy Policy
</a>
</li>
<li>
<a className="text-text-2 hover:text-gold" href="#" onClick={(e) => e.preventDefault()}>
Terms of Use
</a>
</li>
</ul>
</div>
</div>
</footer>
</main>
<MafLoginModal
open={loginOpen}
onClose={() => setLoginOpen(false)}
onSuccess={(user) => onLoginSuccess(user)}
/>
</div>
);
}
`;
'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { MafUser } from '@/src/maf-ui/shell/MafUserMenu';
type Props = {
onLoginSuccess: (user: MafUser) => void;
};
type Theme = 'light' | 'dark';
type LoginView = 'login' | 'forgot-request' | 'forgot-sent' | '2fa';
type AccountType = 'employee' | 'contractor';
function getFocusable(container: HTMLElement) {
return Array.from(
container.querySelectorAll<HTMLElement>(
'button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"])'
)
).filter((n) => n.offsetParent !== null && !n.hasAttribute('disabled'));
}
function focusFirst(container: HTMLElement | null) {
if (!container) return;
const focusable = getFocusable(container);
focusable[0]?.focus?.();
}
function useLockBodyScroll(locked: boolean) {
useEffect(() => {
if (!locked) return;
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = prev;
};
}, [locked]);
}
export default function MafGat1Landing({ onLoginSuccess }: Props) {
const navRef = useRef<HTMLElement | null>(null);
const modulesRef = useRef<HTMLElement | null>(null);
const mainRef = useRef<HTMLElement | null>(null);
const drawerRef = useRef<HTMLElement | null>(null);
const drawerLastFocusRef = useRef<HTMLElement | null>(null);
const loginModalRef = useRef<HTMLDivElement | null>(null);
const loginLastFocusRef = useRef<HTMLElement | null>(null);
const supportModalRef = useRef<HTMLDivElement | null>(null);
const supportLastFocusRef = useRef<HTMLElement | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [loginOpen, setLoginOpen] = useState(false);
const [supportOpen, setSupportOpen] = useState(false);
const [theme, setTheme] = useState<Theme>(() => {
if (typeof window === 'undefined') return 'dark';
const KEY = 'maf_theme';
try {
const stored = localStorage.getItem(KEY) || 'dark';
return stored === 'light' ? 'light' : 'dark';
} catch {
return 'dark';
}
});
// Login flow state
const [loginView, setLoginView] = useState<LoginView>('login');
const [accountType, setAccountType] = useState<AccountType>('employee');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [rememberMe, setRememberMe] = useState(true);
const [emailErrorVisible, setEmailErrorVisible] = useState(false);
const [passErrorVisible, setPassErrorVisible] = useState(false);
const [signingIn, setSigningIn] = useState(false);
const [azureRedirecting, setAzureRedirecting] = useState(false);
const [alertMsg, setAlertMsg] = useState('');
const [forgotBusy, setForgotBusy] = useState(false);
const [forgotEmailSent, setForgotEmailSent] = useState('');
const [resendSeconds, setResendSeconds] = useState(0);
const [otp, setOtp] = useState<string[]>(Array(6).fill(''));
const otpRefs = useRef<Array<HTMLInputElement | null>>([]);
const [otpErrorVisible, setOtpErrorVisible] = useState(false);
const [twofaBusy, setTwofaBusy] = useState(false);
const [twofaAlert, setTwofaAlert] = useState('');
const [openFaqIndex, setOpenFaqIndex] = useState<number | null>(null);
useLockBodyScroll(drawerOpen || loginOpen || supportOpen);
// Keep html[data-theme] in sync with React state.
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
}, [theme]);
const toggleTheme = () => {
const next: Theme = theme === 'light' ? 'dark' : 'light';
setTheme(next);
try {
localStorage.setItem('maf_theme', next);
} catch {
// ignore
}
document.documentElement.setAttribute('data-theme', next);
};
// Navbar scroll behavior
useEffect(() => {
const el = navRef.current;
if (!el) return;
let raf = 0;
const onScroll = () => {
cancelAnimationFrame(raf);
raf = window.requestAnimationFrame(() => {
el.classList.toggle('scrolled', window.scrollY > 10);
});
};
window.addEventListener('scroll', onScroll, { passive: true });
onScroll();
return () => {
cancelAnimationFrame(raf);
window.removeEventListener('scroll', onScroll);
};
}, []);
// Scroll reveal behavior
useEffect(() => {
const els = Array.from(document.querySelectorAll<HTMLElement>('.reveal'));
if (!els.length) return;
if (!('IntersectionObserver' in window)) {
els.forEach((e) => e.classList.add('visible'));
return;
}
const obs = new IntersectionObserver(
(entries) => {
entries.forEach((en) => {
if (en.isIntersecting) {
(en.target as HTMLElement).classList.add('visible');
obs.unobserve(en.target);
}
});
},
{ threshold: 0.12 }
);
els.forEach((e) => obs.observe(e));
return () => obs.disconnect();
}, []);
// Close on Escape
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
setDrawerOpen(false);
setLoginOpen(false);
setSupportOpen(false);
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, []);
// Focus trap (drawer)
useEffect(() => {
if (!drawerOpen) return;
const container = drawerRef.current;
if (!container) return;
drawerLastFocusRef.current = document.activeElement as HTMLElement | null;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
const focusable = getFocusable(container);
if (!focusable.length) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
container.addEventListener('keydown', onKeyDown);
// #region initial focus
window.setTimeout(() => focusFirst(container), 50);
// #endregion
return () => {
container.removeEventListener('keydown', onKeyDown);
drawerLastFocusRef.current?.focus?.();
};
}, [drawerOpen]);
// Focus trap (login/support)
useEffect(() => {
const activeContainer = loginOpen ? loginModalRef.current : supportOpen ? supportModalRef.current : null;
if (!activeContainer) return;
const lastFocus = document.activeElement as HTMLElement | null;
if (loginOpen) loginLastFocusRef.current = lastFocus;
if (supportOpen) supportLastFocusRef.current = lastFocus;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
const focusable = getFocusable(activeContainer);
if (!focusable.length) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
activeContainer.addEventListener('keydown', onKeyDown);
window.setTimeout(() => {
const closeBtn = activeContainer.querySelector<HTMLElement>('.modal-close');
(closeBtn || activeContainer).focus?.();
}, 60);
return () => {
activeContainer.removeEventListener('keydown', onKeyDown);
if (loginOpen) loginLastFocusRef.current?.focus?.();
if (supportOpen) supportLastFocusRef.current?.focus?.();
};
}, [loginOpen, supportOpen]);
const resetAuthState = () => {
setLoginView('login');
setAccountType('employee');
setEmail('');
setPassword('');
setRememberMe(true);
setEmailErrorVisible(false);
setPassErrorVisible(false);
setSigningIn(false);
setAzureRedirecting(false);
setAlertMsg('');
setForgotBusy(false);
setForgotEmailSent('');
setResendSeconds(0);
setOtp(Array(6).fill(''));
setOtpErrorVisible(false);
setTwofaBusy(false);
setTwofaAlert('');
setOpenFaqIndex(null);
};
const openLogin = () => {
setDrawerOpen(false);
setSupportOpen(false);
setLoginOpen(true);
setLoginView('login');
setAccountType('employee');
setEmailErrorVisible(false);
setPassErrorVisible(false);
setOtpErrorVisible(false);
setOtp(Array(6).fill(''));
setTwofaAlert('');
setForgotEmailSent('');
};
const closeLogin = () => {
setLoginOpen(false);
setLoginView('login');
setOpenFaqIndex(null);
setOtpErrorVisible(false);
setOtp(Array(6).fill(''));
setSigningIn(false);
setAzureRedirecting(false);
setTwofaBusy(false);
setTwofaAlert('');
// Keep email/password (closer to a real modal) - but safe to reset in resetAuthState if desired.
};
const openSupport = () => {
setDrawerOpen(false);
setLoginOpen(false);
setSupportOpen(true);
setOpenFaqIndex(null);
};
const closeSupport = () => {
setSupportOpen(false);
setOpenFaqIndex(null);
};
const showAuthView = (view: LoginView) => {
setLoginView(view);
};
// OTP resend cooldown timer
useEffect(() => {
if (loginView !== 'forgot-sent') return;
if (resendSeconds <= 0) return;
const id = window.setInterval(() => setResendSeconds((s) => Math.max(0, s - 1)), 1000);
return () => window.clearInterval(id);
}, [loginView, resendSeconds]);
useEffect(() => {
// Focus otp0 when switching to 2FA
if (loginView !== '2fa') return;
window.setTimeout(() => otpRefs.current[0]?.focus?.(), 0);
}, [loginView]);
const switchTab = (type: AccountType) => {
setAccountType(type);
setAlertMsg('');
setEmailErrorVisible(false);
setPassErrorVisible(false);
};
const handleAzureLogin = () => {
if (azureRedirecting) return;
setAzureRedirecting(true);
window.setTimeout(() => {
setAzureRedirecting(false);
// Simulate returning from Azure: authenticate as employee.
const user: MafUser = {
initials: 'MAF',
name: 'employee',
role: 'Admin',
};
onLoginSuccess(user);
closeLogin();
}, 1400);
};
const handleContractorLogin = (e: React.FormEvent) => {
e.preventDefault();
setAlertMsg('');
const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const emailOk = emailRe.test(email.trim());
const passOk = !!password.trim();
setEmailErrorVisible(!emailOk);
setPassErrorVisible(!passOk);
if (!emailOk || !passOk) return;
setSigningIn(true);
window.setTimeout(() => {
setSigningIn(false);
showAuthView('2fa');
setOtpErrorVisible(false);
setOtp(Array(6).fill(''));
}, 1400);
};
const handleForgotSubmit = (e: React.FormEvent) => {
e.preventDefault();
setAlertMsg('');
const form = e.target as HTMLFormElement | null;
const inp = (form?.querySelector<HTMLInputElement>('#forgotEmailInput') || document.getElementById('forgotEmailInput')) as HTMLInputElement | null;
const err = document.getElementById('forgotEmailError') as HTMLElement | null;
if (!inp || !err) return;
inp.classList.remove('error');
err.classList.remove('visible');
const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const val = inp.value.trim();
if (!emailRe.test(val)) {
inp.classList.add('error');
err.classList.add('visible');
inp.focus();
return;
}
setForgotBusy(true);
const btn = document.getElementById('forgotSubmitBtn') as HTMLButtonElement | null;
const spinner = document.getElementById('forgotSpinner') as HTMLElement | null;
const label = document.getElementById('forgotSubmitLabel') as HTMLElement | null;
if (btn) btn.disabled = true;
if (spinner) spinner.style.display = 'block';
if (label) label.textContent = 'Sending…';
window.setTimeout(() => {
if (btn) btn.disabled = false;
if (spinner) spinner.style.display = 'none';
if (label) label.textContent = 'Send reset link';
setForgotEmailSent(val);
setForgotBusy(false);
showAuthView('forgot-sent');
setResendSeconds(60);
}, 1200);
};
const handleResend = () => {
if (resendSeconds > 0) return;
setResendSeconds(60);
};
const handleOtpChange = (idx: number, val: string) => {
const d = val.replace(/\D/g, '').slice(-1);
setOtp((prev) => {
const next = [...prev];
next[idx] = d;
return next;
});
setOtpErrorVisible(false);
if (d) otpRefs.current[idx + 1]?.focus?.();
};
const handleOtpKeyDown = (idx: number, e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key !== 'Backspace') return;
if (otp[idx]) return;
const prevIdx = idx - 1;
const prevEl = otpRefs.current[prevIdx];
if (prevEl) {
prevEl.focus();
setOtp((prev) => {
const next = [...prev];
next[prevIdx] = '';
return next;
});
}
};
const handleOtpPaste = (idx: number, e: React.ClipboardEvent<HTMLInputElement>) => {
e.preventDefault();
const pasted = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, 6);
if (!pasted) return;
const digits = pasted.split('');
setOtp((prev) => {
const next = [...prev];
digits.forEach((d, i) => {
const at = idx + i;
if (at < next.length) next[at] = d;
});
return next;
});
const focusIdx = Math.min(idx + digits.length, 5);
otpRefs.current[focusIdx]?.focus?.();
};
const handleTwoFaSubmit = (e: React.FormEvent) => {
e.preventDefault();
const code = otp.join('');
setOtpErrorVisible(false);
setTwofaAlert('');
if (code.length !== 6) {
setOtpErrorVisible(true);
return;
}
setTwofaBusy(true);
window.setTimeout(() => {
setTwofaBusy(false);
const base = (email.split('@')[0] || email).replace(/[^a-zA-Z0-9]/g, '');
const initials = (base.slice(0, 2).toUpperCase() || 'MAF').padEnd(2, 'F');
const user: MafUser = {
initials,
name: email.includes('@') ? email.split('@')[0] : email || 'user',
role: accountType === 'employee' ? 'Admin' : 'Contractor',
};
onLoginSuccess(user);
closeLogin();
}, 1000);
};
const moduleCards = useMemo(
() => [
{
num: '01',
title: 'Audit Module',
desc: 'HSE and compliance audits with instant report generation and circulation. Photograph support and integrated tracking for follow-up actions.',
icon: (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
),
delay: 1,
},
{
num: '02',
title: 'Inspection Module',
desc: 'HSE, Quality and Operational inspection templates — daily, weekly, monthly and annual — fully integrated with the audit workflow.',
icon: (
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
),
delay: 2,
},
{
num: '03',
title: 'Checklist Module',
desc: 'Digital replacement for all paper-based operator and technical team checklists. Ensures consistency, accuracy, and a full audit trail.',
icon: (
<svg viewBox="0 0 24 24" aria-hidden="true">
<polyline points="9 11 12 14 22 4" />
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
</svg>
),
delay: 3,
},
{
num: '04',
title: 'Suggestion Scheme',
desc: 'Central database for employee innovation. Tied to award and incentive schemes to drive engagement and measurable business improvement.',
icon: (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M9 18h6" />
<path d="M10 22h4" />
<path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14" />
</svg>
),
delay: 1,
},
{
num: '05',
title: 'Training Module',
desc: 'Full training lifecycle management — calendar, exam compilation, trainee feedback and certification tracking, completely digitalized.',
icon: (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
<line x1="8" y1="7" x2="14" y2="7" />
<line x1="8" y1="11" x2="14" y2="11" />
</svg>
),
delay: 2,
},
{
num: '06',
title: 'Incident Reporting',
desc: 'Real-time incident capture with rich media support. Enables immediate response, documentation, and follow-up for HSE and operational events.',
icon: (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
</svg>
),
delay: 3,
},
],
[]
);
const capabilities = useMemo(
() => [
{
title: 'Mobile Application',
badge: { label: 'iOS & Android', className: 'available' },
delay: 1,
icon: (
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="5" y="2" width="14" height="20" rx="2" ry="2" />
<path d="M12 18h.01" />
</svg>
),
desc: 'Native iOS and Android app for live workplace data capture. Supports both online and offline working — syncs automatically when reconnected.',
},
{
title: 'Security & Privacy',
badge: { label: 'Security Certified', className: 'secure' },
delay: 2,
icon: (
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
),
desc: 'Subjected to rigorous cyber security testing. Comprehensive access controls with full Data Protection compliance per company policy.',
},
{
title: 'Data Management',
badge: { label: 'Rich Media', className: 'secure' },
delay: 3,
icon: (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M3 3v18h18" />
<path d="M18 17V9" />
<path d="M13 17V5" />
<path d="M8 17v-3" />
</svg>
),
desc: 'Customized forms and templates with rich media capabilities. Captures critical data in a consistent, orderly manner to enhance business quality.',
},
{
title: 'Enhanced Productivity',
badge: { label: 'Paperless', className: 'available' },
delay: 4,
icon: (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M13 2 3 14h9l-1 8 10-12h-9l1-8z" />
</svg>
),
desc: 'Eliminates paper-based processes and manual steps entirely, delivering measurable gains in efficiency and operational throughput.',
},
],
[]
);
return (
<div>
<a
href="#main"
className="skip-link"
onClick={(e) => {
e.preventDefault();
mainRef.current?.focus?.();
}}
>
Skip to main content
</a>
{/* NAVBAR */}
<nav
className="navbar"
id="navbar"
aria-label="Main navigation"
ref={(el) => {
navRef.current = el;
}}
>
<a href="#" className="logo" aria-label="MAF Gateway home" onClick={(e) => e.preventDefault()}>
<div className="logo-mark">
<span>M</span>
</div>
<div className="logo-text-wrap">
<span className="logo-ar">مـاجـد الـفـطـيـم</span>
<span className="logo-en">Majid Al Futtaim</span>
</div>
</a>
<div className="nav-links" role="list">
<a href="#modules" className="nav-link" role="listitem">
Modules
</a>
<a href="#platform" className="nav-link" role="listitem">
Platform
</a>
<a href="#download" className="nav-link" role="listitem">
Download App
</a>
</div>
<div className="nav-right">
<button
type="button"
className="theme-toggle"
id="themeToggleGat1"
aria-label={theme === 'light' ? 'Switch to dark theme' : 'Switch to light theme'}
title={theme === 'light' ? 'Dark mode' : 'Light mode'}
onClick={toggleTheme}
>
<span className="icon-dark" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
</span>
<span className="icon-light" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="5" />
<line x1="12" y1="1" x2="12" y2="3" />
<line x1="12" y1="21" x2="12" y2="23" />
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
<line x1="1" y1="12" x2="3" y2="12" />
<line x1="21" y1="12" x2="23" y2="12" />
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
</svg>
</span>
</button>
<div className="status-pill" aria-label="System status: All systems operational">
<div className="status-dot" />
All Systems Operational
</div>
<button type="button" className="btn-ghost nav-btn-support" onClick={openSupport} aria-label="Open support center">
<svg width="14" height="14" viewBox="0 0 24 24" stroke="currentColor" fill="none" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10" />
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
<path d="M12 17h.01" />
</svg>
<span className="nav-btn-support-text">Support</span>
</button>
<button type="button" className="btn-primary nav-btn-login" onClick={openLogin} aria-label="Log in to Gateway">
Login
</button>
<button
type="button"
className={`nav-toggle ${drawerOpen ? 'open' : ''}`}
id="navToggle"
aria-label="Open navigation menu"
aria-expanded={drawerOpen}
aria-controls="navDrawer"
onClick={() => setDrawerOpen((v) => !v)}
>
<span />
<span />
<span />
</button>
</div>
</nav>
{/* MOBILE DRAWER */}
<div
className={`nav-backdrop ${drawerOpen ? 'open' : ''}`}
id="navBackdrop"
aria-hidden="true"
onClick={() => setDrawerOpen(false)}
/>
<aside
ref={(el) => {
drawerRef.current = el;
}}
className={`nav-drawer ${drawerOpen ? 'open' : ''}`}
id="navDrawer"
role="dialog"
aria-modal="true"
aria-label="Navigation menu"
aria-hidden={!drawerOpen}
>
<div className="nav-drawer-top">
<span className="nav-drawer-label">Navigation</span>
<button className="drawer-close" onClick={() => setDrawerOpen(false)} aria-label="Close menu">
×
</button>
</div>
<nav className="nav-drawer-links" aria-label="Mobile navigation">
<a
href="#modules"
className="drawer-link"
onClick={() => setDrawerOpen(false)}
>
<svg viewBox="0 0 24 24">
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
<rect x="14" y="14" width="7" height="7" rx="1" />
</svg>
Modules
</a>
<a href="#platform" className="drawer-link" onClick={() => setDrawerOpen(false)}>
<svg viewBox="0 0 24 24">
<path d="M12 2L2 7l10 5 10-5-10-5z" />
<path d="M2 17l10 5 10-5" />
<path d="M2 12l10 5 10-5" />
</svg>
Platform
</a>
<a href="#download" className="drawer-link" onClick={() => setDrawerOpen(false)}>
<svg viewBox="0 0 24 24">
<rect x="5" y="2" width="14" height="20" rx="2" />
<path d="M12 18h.01" />
</svg>
Download App
</a>
</nav>
<div className="nav-drawer-footer">
<button
className="btn-ghost"
onClick={() => {
setDrawerOpen(false);
setSupportOpen(true);
setLoginOpen(false);
}}
>
Support Center
</button>
<button
className="btn-primary"
onClick={() => {
setDrawerOpen(false);
openLogin();
}}
>
Login to Gateway
</button>
</div>
</aside>
{/* MAIN */}
<main
id="main"
tabIndex={-1}
ref={(el) => {
mainRef.current = el;
}}
>
{/* HERO */}
<section className="hero" aria-label="Hero — MAF Gateway">
<div className="hero-bg" />
<div className="hero-slab" aria-hidden="true" />
<div className="hero-glow" aria-hidden="true" />
<div className="hero-glow-2" aria-hidden="true" />
<div className="hero-grid" aria-hidden="true" />
<div className="hero-inner">
<div className="hero-eyebrow">
<div className="hero-eyebrow-dot" aria-hidden="true" />
Enterprise Platform · 2026
</div>
<h1 className="hero-title">
<span className="hero-title-line">The</span>
<span className="hero-title-line accent">Gateway</span>
</h1>
<p className="hero-sub">
A comprehensive bespoke platform that replaces manual steps in business process flows — from audit and compliance to training and incident management.
</p>
<div className="hero-actions">
<button className="hero-btn-main" onClick={openLogin} aria-label="Access Gateway — open login">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4" />
<polyline points="10 17 15 12 10 7" />
<line x1="15" y1="12" x2="3" y2="12" />
</svg>
Access Gateway
</button>
<button
className="hero-btn-sec"
onClick={() => modulesRef.current?.scrollIntoView({ behavior: 'smooth' })}
>
Explore modules
</button>
</div>
<div className="hero-stats" aria-label="Platform statistics">
<div className="hero-stat">
<div className="hero-stat-num">6+</div>
<div className="hero-stat-label">Modules</div>
</div>
<div className="hero-stat">
<div className="hero-stat-num">iOS &amp; Android</div>
<div className="hero-stat-label">Mobile</div>
</div>
<div className="hero-stat">
<div className="hero-stat-num">100%</div>
<div className="hero-stat-label">Digital</div>
</div>
</div>
</div>
</section>
{/* MODULES */}
<section
className="section modules-section"
id="modules"
aria-labelledby="modules-title"
ref={(el) => {
modulesRef.current = el;
}}
>
<div className="section-inner">
<div className="modules-header reveal">
<div className="section-tag">Gateway Modules</div>
<h2 className="section-title" id="modules-title">
Everything in one platform
</h2>
<p className="section-desc">
Six fully integrated modules that cover every facet of operational assurance, compliance, and team management.
</p>
</div>
<div className="bento" role="list">
{moduleCards.map((c) => (
<div key={c.num} className={`bento-card reveal reveal-delay-${c.delay}`} role="listitem">
<div className="bento-card-accent" />
<div className="bento-num">{c.num}</div>
<div className="bento-icon" aria-hidden="true">
{c.icon}
</div>
<h3 className="bento-title">{c.title}</h3>
<p className="bento-desc">{c.desc}</p>
<span
className="bento-arrow"
role="button"
tabIndex={0}
aria-label={`Learn more about ${c.title}`}
onClick={openLogin}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') openLogin();
}}
>
Learn more{' '}
<svg viewBox="0 0 24 24" aria-hidden="true">
<line x1="5" y1="12" x2="19" y2="12" />
<polyline points="12 5 19 12 12 19" />
</svg>
</span>
</div>
))}
</div>
</div>
</section>
{/* PLATFORM */}
<section className="section platform-section" id="platform" aria-labelledby="platform-title">
<div className="section-inner">
<div className="reveal">
<div className="section-tag">Platform Capabilities</div>
<h2 className="section-title" id="platform-title">
Built for the real world
</h2>
<p className="section-desc">
Enterprise-grade security, offline-capable mobile apps, and rich data management tools — designed for the pace of modern operations.
</p>
</div>
<div className="capabilities-grid">
{capabilities.map((c) => (
<div key={c.title} className={`cap-card reveal reveal-delay-${c.delay}`}>
<div className="cap-card-icon" aria-hidden="true">
{c.icon}
</div>
<h3 className="cap-card-title">{c.title}</h3>
<p className="cap-card-desc">{c.desc}</p>
<span className={`cap-badge ${c.badge.className}`}>{c.badge.label}</span>
</div>
))}
</div>
</div>
</section>
{/* DOWNLOAD */}
<section className="section download-section" id="download" aria-labelledby="download-title">
<div className="section-inner">
<div className="download-inner">
<div className="download-content reveal">
<div className="section-tag">Mobile App</div>
<h2 className="section-title" id="download-title">
Gateway in your pocket
</h2>
<p className="section-desc" style={{ marginBottom: 0 }}>
Capture data, complete checklists and report incidents directly from the field — online or offline.
</p>
</div>
<div className="download-stores reveal reveal-delay-2">
<a href="#" className="store-btn" aria-label="Download on the App Store for iOS" onClick={(e) => e.preventDefault()}>
<svg width="28" height="28" viewBox="0 0 384 512" fill="currentColor" aria-hidden="true">
<path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z" />
</svg>
<div className="store-btn-text">
<span className="store-btn-sub">Download on the</span>
<span className="store-btn-name">App Store</span>
</div>
</a>
<a href="#" className="store-btn" aria-label="Get it on Google Play for Android" onClick={(e) => e.preventDefault()}>
<svg width="28" height="28" viewBox="0 0 512 512" fill="currentColor" aria-hidden="true">
<path d="M325.3 234.3L104.6 13l280.8 161.2-60.1 60.1zM47 0C34 6.8 25.3 19.2 25.3 35.3v441.3c0 16.1 8.7 28.5 21.7 35.3l256.6-256L47 0zm425.2 225.6l-58.9-34.1-65.7 64.5 65.7 64.5 60.1-34.1c18-14.3 18-46.5-1.2-60.8zM104.6 499l280.8-161.2-60.1-60.1L104.6 499z" />
</svg>
<div className="store-btn-text">
<span className="store-btn-sub">Get it on</span>
<span className="store-btn-name">Google Play</span>
</div>
</a>
</div>
</div>
</div>
</section>
</main>
{/* FOOTER */}
<footer className="footer">
<div className="footer-inner">
<div className="footer-grid">
<div className="footer-brand">
<div className="footer-brand-logo">
<div className="logo-mark" style={{ width: 36, height: 36, minWidth: 36 }}>
<span style={{ fontSize: '1.2rem' }}>M</span>
</div>
<div className="logo-text-wrap">
<span className="logo-ar">مـاجـد الـفـطـيـم</span>
<span className="logo-en">Majid Al Futtaim</span>
</div>
</div>
<p className="footer-brand-desc">
Gateway© is a comprehensive bespoke platform designed to enhance business quality, efficiency, and productivity through digital transformation.
</p>
</div>
<div>
<div className="footer-col-title">Quick Links</div>
<ul className="footer-links">
<li>
<a
href="#"
onClick={(e) => {
e.preventDefault();
openLogin();
}}
>
Login
</a>
</li>
<li>
<a
href="#"
onClick={(e) => {
e.preventDefault();
openSupport();
}}
>
Support Center
</a>
</li>
<li>
<a href="#download">Download App</a>
</li>
<li>
<a href="#modules">Modules</a>
</li>
</ul>
</div>
<div>
<div className="footer-col-title">Support</div>
<ul className="footer-links">
<li>
<a href="mailto:support@mafgateway.com">Email Support</a>
</li>
<li>
<a
href="#"
onClick={(e) => {
e.preventDefault();
openSupport();
}}
>
Reset Password
</a>
</li>
<li>
<a
href="#"
onClick={(e) => {
e.preventDefault();
openSupport();
}}
>
FAQs
</a>
</li>
<li>
<a
href="#"
onClick={(e) => {
e.preventDefault();
openSupport();
}}
>
Technical Help
</a>
</li>
</ul>
</div>
<div>
<div className="footer-col-title">Legal</div>
<ul className="footer-links">
<li>
<a href="#">Privacy Policy</a>
</li>
<li>
<a href="#">Terms of Use</a>
</li>
<li>
<a href="#">Data Protection</a>
</li>
<li>
<a href="#">Security Policy</a>
</li>
</ul>
</div>
</div>
<div className="footer-divider" />
<div className="footer-bottom">
<span>Gateway© 2020–2026. All rights reserved · Majid Al Futtaim</span>
<div className="footer-bottom-right">
<a href="#">Privacy</a>
<a href="#">Terms</a>
<a href="#">Cookies</a>
</div>
</div>
</div>
</footer>
{/* LOGIN MODAL */}
<div
className={`modal-overlay ${loginOpen ? 'open' : ''}`}
id="loginModal"
role="dialog"
aria-modal="true"
aria-labelledby="loginTitle"
tabIndex={-1}
onClick={(e) => {
if (e.target === e.currentTarget) closeLogin();
}}
>
<div className="modal" ref={loginModalRef}>
<div className="modal-topbar">
<div className="modal-logo">
<div className="modal-logo-mark">
<span>M</span>
</div>
<span className="modal-logo-name">MAF Gateway</span>
</div>
<button className="modal-close" onClick={closeLogin} aria-label="Close login dialog" type="button">
×
</button>
</div>
<div className="modal-body">
{/* Step 1: login */}
<div
className={`auth-view ${loginView === 'login' ? 'active' : ''}`}
id="auth-login"
role="region"
aria-label="Sign in"
aria-hidden={loginView !== 'login'}
>
<h2 id="loginTitle" style={{ fontFamily: 'Cormorant Garamond, serif', fontSize: '1.65rem', fontWeight: 600, marginBottom: '.35rem' }}>
Welcome back
</h2>
<p style={{ fontSize: '.875rem', color: 'var(--text-2)', marginBottom: '1.75rem', fontWeight: 400 }}>
Choose your account type to continue
</p>
<div className="login-tabs" role="tablist">
<button
type="button"
className={`login-tab ${accountType === 'employee' ? 'active' : ''}`}
id="tab-employee"
role="tab"
aria-selected={accountType === 'employee'}
aria-controls="panel-employee"
onClick={() => switchTab('employee')}
>
MAF Employee
</button>
<button
type="button"
className={`login-tab ${accountType === 'contractor' ? 'active' : ''}`}
id="tab-contractor"
role="tab"
aria-selected={accountType === 'contractor'}
aria-controls="panel-contractor"
onClick={() => switchTab('contractor')}
>
Contractor / Supplier
</button>
</div>
<div className={`azure-panel ${accountType === 'employee' ? 'active' : ''}`} id="panel-employee" role="tabpanel" aria-labelledby="tab-employee">
<div className="azure-info-box">
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<p>
MAF employees sign in using <strong>Microsoft Azure Active Directory</strong>. You&apos;ll be redirected to the Microsoft login page and returned here automatically.
</p>
</div>
<button className="ms-btn" id="msBtn" onClick={handleAzureLogin} aria-label="Sign in with Microsoft" disabled={azureRedirecting} type="button">
<svg className="ms-logo" viewBox="0 0 21 21" fill="none" aria-hidden="true">
<rect width="10" height="10" fill="#F25022" />
<rect x="11" width="10" height="10" fill="#7FBA00" />
<rect y="11" width="10" height="10" fill="#00A4EF" />
<rect x="11" y="11" width="10" height="10" fill="#FFB900" />
</svg>
<span id="msBtnLabel">{azureRedirecting ? 'Redirecting…' : 'Sign in with Microsoft'}</span>
</button>
<div className="redirect-note">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
</svg>
Secure redirect via Microsoft — your credentials never touch our servers
</div>
</div>
<div className={`contractor-panel ${accountType === 'contractor' ? 'active' : ''}`} id="panel-contractor" role="tabpanel" aria-labelledby="tab-contractor">
<form id="contractorForm" onSubmit={handleContractorLogin} noValidate>
<div className="form-group">
<label className="form-label" htmlFor="emailInput">
Company Email
</label>
<input
type="email"
id="emailInput"
className="form-input"
placeholder="you@company.com"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<p className="form-error" id="emailError">
Please enter a valid email address.
</p>
</div>
<div className="form-group">
<label className="form-label" htmlFor="passInput">
Password
</label>
<input
type="password"
id="passInput"
className="form-input"
placeholder="Your password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<p className="form-error" id="passError">
Password is required.
</p>
</div>
<div className="form-row">
<label className="remember-row">
<input type="checkbox" className="check-input" id="rememberMe" checked={rememberMe} onChange={(e) => setRememberMe(e.target.checked)} />
<span className="check-label">Remember me</span>
</label>
<button type="button" className="forgot-btn" onClick={() => setLoginView('forgot-request')}>
Forgot password?
</button>
</div>
<button type="submit" className="submit-btn" id="submitBtn" disabled={signingIn}>
<div className="spinner" id="submitSpinner" style={{ display: signingIn ? 'block' : 'none' }} />
<span id="submitLabel">{signingIn ? 'Signing in…' : 'Log In'}</span>
</button>
<div className="form-alert" id="formAlert" role="alert">
{alertMsg}
</div>
</form>
</div>
<div className="modal-divider" />
<p className="modal-footer-note">
Need help accessing your account?{' '}
<a
href="#"
onClick={(e) => {
e.preventDefault();
closeLogin();
openSupport();
}}
>
Contact support →
</a>
</p>
</div>
{/* Step 2: forgot */}
<div className={`auth-view ${loginView === 'forgot-request' ? 'active' : ''}`} id="auth-forgot-request" role="region" aria-label="Reset password" aria-hidden={loginView !== 'forgot-request'}>
<button type="button" className="auth-back" onClick={() => setLoginView('login')} aria-label="Back to sign in">
<svg viewBox="0 0 24 24" aria-hidden="true">
<polyline points="15 18 9 12 15 6" />
</svg>
Back to sign in
</button>
<div className="forgot-illustration" aria-hidden="true">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
<polyline points="22,6 12,13 2,6" />
</svg>
</div>
<div className="forgot-header">
<h2>Reset your password</h2>
<p>Enter the email address linked to your account. We'll send you a secure link to create a new password.</p>
</div>
<form id="forgotForm" onSubmit={handleForgotSubmit} noValidate>
<div className="form-group">
<label className="form-label" htmlFor="forgotEmailInput">
Email address
</label>
<input type="email" id="forgotEmailInput" className="form-input" placeholder="you@company.com" autoComplete="email" required defaultValue={email} />
<p className="form-error" id="forgotEmailError">
Please enter a valid email address.
</p>
</div>
<button type="submit" className="submit-btn" id="forgotSubmitBtn" disabled={forgotBusy}>
<div className="spinner" id="forgotSpinner" style={{ display: forgotBusy ? 'block' : 'none' }} />
<span id="forgotSubmitLabel">{forgotBusy ? 'Sending…' : 'Send reset link'}</span>
</button>
<div className="form-alert" id="forgotFormAlert" role="alert" />
</form>
</div>
{/* Step 3: forgot-sent */}
<div className={`auth-view ${loginView === 'forgot-sent' ? 'active' : ''}`} id="auth-forgot-sent" role="region" aria-label="Check your email" aria-hidden={loginView !== 'forgot-sent'}>
<button type="button" className="auth-back" onClick={() => setLoginView('login')} aria-label="Back to sign in">
<svg viewBox="0 0 24 24" aria-hidden="true">
<polyline points="15 18 9 12 15 6" />
</svg>
Back to sign in
</button>
<div className="forgot-sent">
<div className="forgot-illustration" aria-hidden="true">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
<polyline points="22 4 12 14.01 9 11.01" />
</svg>
</div>
<h3>Check your email</h3>
<p className="sent-email" id="forgotSentEmail">
{forgotEmailSent}
</p>
<p>
We&apos;ve sent a password reset link to this address. Click the link in the email to set a new password. The link expires in 1 hour.
</p>
<div className="resend-row">
<button type="button" className="resend-btn" id="resendBtn" onClick={handleResend} disabled={resendSeconds > 0}>
Resend email
</button>
<span className="resend-timer" id="resendTimer" aria-live="polite">
{resendSeconds > 0 ? `Resend in ${resendSeconds}s` : ''}
</span>
</div>
</div>
</div>
{/* Step 4: 2FA */}
<div className={`auth-view ${loginView === '2fa' ? 'active' : ''}`} id="auth-2fa" role="region" aria-label="Two-factor verification" aria-hidden={loginView !== '2fa'}>
<button type="button" className="auth-back" onClick={() => setLoginView('login')} aria-label="Back to sign in">
<svg viewBox="0 0 24 24" aria-hidden="true">
<polyline points="15 18 9 12 15 6" />
</svg>
Back to sign in
</button>
<div className="twofa-header">
<div className="twofa-badge">
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
Two-factor authentication
</div>
<h2>Verify it's you</h2>
<p>Enter the 6-digit code from your authenticator app. If you don&apos;t have the app, we can send a code to your email.</p>
</div>
<form id="twofaForm" onSubmit={handleTwoFaSubmit} noValidate>
<div className="otp-row" role="group" aria-label="Verification code">
{Array.from({ length: 6 }).map((_, i) => (
<input
key={i}
type="text"
className={`otp-input ${otp[i] ? 'filled' : ''} ${otpErrorVisible && !otp[i] ? 'error' : ''}`}
id={`otp${i}`}
inputMode="numeric"
maxLength={1}
pattern="[0-9]"
aria-label={`Digit ${i + 1}`}
data-idx={i}
value={otp[i] || ''}
ref={(el) => {
otpRefs.current[i] = el;
}}
onChange={(e) => handleOtpChange(i, e.target.value)}
onKeyDown={(e) => handleOtpKeyDown(i, e)}
onPaste={(e) => handleOtpPaste(i, e)}
autoComplete="one-time-code"
/>
))}
</div>
<p className={`form-error ${otpErrorVisible ? 'visible' : ''}`} id="otpError" style={{ textAlign: 'center', marginTop: '-0.75rem', marginBottom: '1rem' }}>
Please enter the full 6-digit code.
</p>
<button type="submit" className="submit-btn" id="twofaSubmitBtn" disabled={twofaBusy}>
<div className="spinner" id="twofaSpinner" style={{ display: twofaBusy ? 'block' : 'none' }} />
<span id="twofaSubmitLabel">{twofaBusy ? 'Verifying…' : 'Verify & continue'}</span>
</button>
<div className="twofa-alt">
<button
type="button"
className="link-btn"
onClick={() => setTwofaAlert('Verification code sent to your email. Check your inbox.')}
>
Send code to my email instead
</button>
</div>
<div className="form-alert" id="twofaFormAlert" role="alert">
{twofaAlert}
</div>
</form>
</div>
</div>
</div>
</div>
{/* SUPPORT MODAL */}
<div
className={`modal-overlay ${supportOpen ? 'open' : ''}`}
id="supportModal"
role="dialog"
aria-modal="true"
aria-labelledby="supportTitle"
tabIndex={-1}
onClick={(e) => {
if (e.target === e.currentTarget) closeSupport();
}}
>
<div className="modal modal-support" ref={supportModalRef}>
<div className="modal-topbar">
<div className="modal-logo">
<div className="modal-logo-mark">
<span>M</span>
</div>
<span className="modal-logo-name">Support Center</span>
</div>
<button className="modal-close" onClick={closeSupport} aria-label="Close support dialog" type="button">
×
</button>
</div>
<div className="modal-body">
<div className="support-header">
<h2 id="supportTitle">How can we help?</h2>
<p>Find quick answers or reach our team directly.</p>
</div>
<div className="faq-list" role="list">
{[
{
title: 'I forgot my password — how do I reset it?',
body: 'For MAF employees, passwords are managed through Microsoft. Visit https://passwordreset.microsoftonline.com or contact your IT helpdesk. For contractors, use the "Forgot password?" link on the login form.',
},
{
title: 'Which login type should I choose?',
body: 'Use MAF Employee if you have a @maf.ae or @mafjgroup.com email address. Use Contractor / Supplier if you were issued credentials directly by the MAF Gateway team.',
},
{
title: 'The app won&apos;t load or is showing an error.',
body: 'Try refreshing the page or clearing your browser cache. For persistent issues, please email our support team with a screenshot of the error message and the browser/device you are using.',
},
{
title: 'How do I download the mobile app?',
body: 'Search "MAF Gateway" on the Apple App Store or Google Play Store, or visit the Download section for direct links.',
},
].map((it, idx) => {
const open = openFaqIndex === idx;
return (
<div key={it.title} className={`faq-item ${open ? 'open' : ''}`} role="listitem">
<button
type="button"
className="faq-btn"
aria-expanded={open}
onClick={() => setOpenFaqIndex((cur) => (cur === idx ? null : idx))}
>
{it.title}
<svg viewBox="0 0 24 24" aria-hidden="true">
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
<div className="faq-answer">
<p>{it.body}</p>
</div>
</div>
);
})}
</div>
<div className="support-contact">
<div className="support-contact-icon">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
<polyline points="22,6 12,13 2,6" />
</svg>
</div>
<div className="support-contact-text">
<div className="support-contact-label">Email Support · Responds within 24h</div>
<div className="support-contact-val">
<a href="mailto:support@mafgateway.com">support@mafgateway.com</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
// MAF-UIS app shell: fixed header, responsive sidebar/drawer, and mobile bottom nav.
'use client';
import { ReactNode, useMemo, useState } from 'react';
import MafBottomNav, { MafBottomNavItem } from './MafBottomNav';
import MafDrawer, { MafDrawerItem } from './MafDrawer';
import MafHeader from './MafHeader';
import MafNotificationCenter from './MafNotificationCenter';
import MafSidebar, { MafNavItem } from './MafSidebar';
import MafUserMenu, { MafUser } from './MafUserMenu';
export type MafAppShellProps = {
children: ReactNode;
user: MafUser;
activeView?: string;
onNavigate?: (view: string) => void;
};
function Icon({ children }: { children: ReactNode }) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{children}
</svg>
);
}
export default function MafAppShell({ children, user, activeView = 'welcome', onNavigate }: MafAppShellProps) {
const [drawerOpen, setDrawerOpen] = useState(false);
const [notifOpen, setNotifOpen] = useState(false);
const [notifUnreadCount, setNotifUnreadCount] = useState(0);
const linkToView = (link: string) => {
// Map notification anchors used by MAF-UIS into our Next route "view" strings.
switch (link) {
case '#incidents':
return 'incidents';
case '#inspection':
return 'modules';
case '#admin':
return 'modules';
case '#profile':
return 'profile';
case '#settings':
return 'settings';
case '#notification-center':
return 'notification-center';
default:
return 'modules';
}
};
const navItems: MafNavItem[] = useMemo(
() => [
{
view: 'welcome',
label: 'Home',
icon: (
<Icon>
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</Icon>
),
},
{
view: 'modules',
label: 'Modules',
icon: (
<Icon>
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="14" y="14" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
</Icon>
),
},
{
view: 'incidents',
label: 'Incidents',
icon: (
<Icon>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</Icon>
),
},
{
view: 'permit',
label: 'Permit',
icon: (
<Icon>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</Icon>
),
},
{
view: 'settings',
label: 'Settings',
icon: (
<Icon>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
</Icon>
),
},
],
[]
);
const drawerItems: MafDrawerItem[] = useMemo(
() =>
navItems.map((x) => ({
view: x.view,
label: x.label,
icon: x.icon,
})),
[navItems]
);
const bottomItems: MafBottomNavItem[] = useMemo(
() => [
{
view: 'welcome',
label: 'Home',
icon: (
<Icon>
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</Icon>
),
},
{
view: 'modules',
label: 'Modules',
icon: (
<Icon>
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="14" y="14" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
</Icon>
),
},
{
view: 'incidents',
label: 'Incidents',
icon: (
<Icon>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</Icon>
),
},
{
view: 'notification-center',
label: 'Alerts',
icon: (
<Icon>
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
</Icon>
),
},
{
view: 'profile',
label: 'Profile',
icon: (
<Icon>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</Icon>
),
},
],
[]
);
return (
<div className="min-h-screen">
<MafSidebar items={navItems} activeView={activeView} onNavigate={onNavigate} />
<MafHeader
onOpenDrawer={() => setDrawerOpen(true)}
drawerOpen={drawerOpen}
user={user}
activeView={activeView}
onNavigate={onNavigate}
notifOpen={notifOpen}
notifUnreadCount={notifUnreadCount}
onToggleNotifications={() => setNotifOpen((v) => !v)}
/>
<MafDrawer
open={drawerOpen}
items={drawerItems}
activeView={activeView}
onClose={() => setDrawerOpen(false)}
onNavigate={onNavigate}
/>
<main
id="main-content"
tabIndex={-1}
className="relative flex-1 md:pl-[var(--sidebar-width)]"
>
{children}
</main>
<MafBottomNav items={bottomItems} activeView={activeView} onNavigate={onNavigate} />
<MafNotificationCenter
open={notifOpen}
onClose={() => setNotifOpen(false)}
unreadCount={notifUnreadCount}
onUnreadCountChange={setNotifUnreadCount}
onNavigate={(link) => {
const view = linkToView(link);
onNavigate?.(view);
}}
variant="dropdown"
/>
</div>
);
}
// Mobile bottom navigation.
'use client';
import { ReactNode } from 'react';
export type MafBottomNavItem = {
view: string;
label: string;
icon?: ReactNode;
};
export default function MafBottomNav({
items,
activeView,
onNavigate,
}: {
items: MafBottomNavItem[];
activeView?: string;
onNavigate?: (view: string) => void;
}) {
return (
<nav
className="md:hidden fixed bottom-0 left-0 right-0 z-[998] h-[var(--bottom-nav-height)] bg-surface-raised border-t border-gold-border"
aria-label="Primary actions"
>
<div className="h-full flex items-stretch justify-around px-2">
{items.map((it) => {
const active = activeView && it.view === activeView;
return (
<a
key={it.view}
href="#"
className={[
'flex flex-col items-center justify-center flex-1 gap-1 rounded-[var(--r-sm)] transition-colors',
active ? 'text-maroon bg-gold-pale/40' : 'text-text-2 hover:text-text-1',
].join(' ')}
aria-current={active ? 'page' : undefined}
onClick={(e) => {
e.preventDefault();
onNavigate?.(it.view);
}}
>
<div className="w-6 h-6 text-gold">{it.icon}</div>
<span className="text-[11px] font-semibold">{it.label}</span>
</a>
);
})}
</div>
</nav>
);
}
// Mobile right drawer navigation.
'use client';
import { ReactNode, useEffect, useRef } from 'react';
export type MafDrawerItem = {
view: string;
label: string;
icon?: ReactNode;
};
export default function MafDrawer({
open,
items,
onClose,
activeView,
onNavigate,
}: {
open: boolean;
items: MafDrawerItem[];
onClose: () => void;
activeView?: string;
onNavigate?: (view: string) => void;
}) {
const closeBtnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
if (!open) return;
// Move focus to the close control for keyboard users.
closeBtnRef.current?.focus();
}, [open]);
useEffect(() => {
if (!open) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [open, onClose]);
return (
<>
<div
className={[
'fixed inset-0 z-[1000] bg-black/0 transition-opacity',
open ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none',
].join(' ')}
id="mafDrawerBackdrop"
aria-hidden="true"
onClick={onClose}
/>
<aside
id="mafDrawer"
className={[
'fixed top-0 right-0 bottom-0 z-[1001] border-l border-gold-border bg-surface-raised transition-transform',
open ? 'translate-x-0' : 'translate-x-full',
'w-[min(320px,90vw)]',
].join(' ')}
role="dialog"
aria-modal="true"
aria-label="Navigation menu"
aria-hidden={!open}
>
<div className="flex items-center justify-between px-4 py-3 border-b border-gold-border">
<span className="text-text-3 text-xs uppercase tracking-[0.12em] font-semibold">Navigation</span>
<button
type="button"
ref={closeBtnRef}
className="w-10 h-10 flex items-center justify-center rounded-[var(--r-sm)] hover:bg-white/5 transition-colors text-text-2"
aria-label="Close menu"
onClick={onClose}
>
×
</button>
</div>
<nav className="px-3 py-3 flex flex-col gap-1" aria-label="Mobile navigation">
{items.map((it) => {
const active = activeView && it.view === activeView;
return (
<a
key={it.view}
href="#"
className={[
'drawer-item flex items-center gap-3 px-3 py-2 rounded-[var(--r-sm)] text-sm font-semibold transition-colors',
active ? 'bg-gold-pale text-maroon' : 'text-text-2 hover:bg-white/5 hover:text-text-1',
].join(' ')}
aria-current={active ? 'page' : undefined}
onClick={(e) => {
e.preventDefault();
onNavigate?.(it.view);
onClose();
}}
>
<span className="w-9 h-9 rounded-[var(--r-sm)] border border-gold-border bg-white/0 flex items-center justify-center text-gold">
{it.icon}
</span>
{it.label}
</a>
);
})}
</nav>
<div className="px-3 pb-4 pt-2 border-t border-gold-border mt-auto">
<a
href="#"
className="w-full flex items-center justify-center min-h-12 rounded-[var(--r-sm)] border border-gold-border bg-white/0 text-gold hover:bg-white/5 font-semibold text-sm"
onClick={(e) => {
e.preventDefault();
onClose();
}}
>
Support Center
</a>
<a
href="#"
className="w-full flex items-center justify-center min-h-12 rounded-[var(--r-sm)] mt-2 bg-gradient-to-r from-maroon to-maroon-bright text-white font-semibold text-sm hover:opacity-95"
onClick={(e) => {
e.preventDefault();
onClose();
}}
>
Login to Gateway
</a>
</div>
</aside>
</>
);
}
// Fixed header (logo, search, actions).
'use client';
import MafThemeToggle from './MafThemeToggle';
import MafUserMenu, { MafUser } from './MafUserMenu';
import { ReactNode } from 'react';
function IconWrap({ children }: { children: ReactNode }) {
return (
<button
type="button"
className="w-[44px] h-[44px] rounded-[var(--r-sm)] flex items-center justify-center text-text-2 hover:bg-gold-pale hover:text-gold transition-colors"
>
{children}
</button>
);
}
export default function MafHeader({
onOpenDrawer,
drawerOpen,
user,
activeView,
onNavigate,
notifOpen,
notifUnreadCount,
onToggleNotifications,
}: {
onOpenDrawer: () => void;
drawerOpen: boolean;
user: MafUser;
activeView?: string;
onNavigate?: (view: string) => void;
notifOpen?: boolean;
notifUnreadCount?: number;
onToggleNotifications?: () => void;
}) {
return (
<header
className="fixed top-0 left-0 right-0 md:left-[var(--sidebar-width)] z-[1000] h-[var(--header-height)] bg-surface-overlay/90 backdrop-blur-[20px] border-b border-gold-border flex items-center justify-between gap-3 px-3"
style={{
paddingTop: `max(0.35rem, env(safe-area-inset-top))`,
paddingBottom: `max(0.35rem, env(safe-area-inset-bottom))`,
paddingLeft: `max(0.75rem, env(safe-area-inset-left))`,
paddingRight: `max(0.75rem, env(safe-area-inset-right))`,
}}
>
<div className="flex items-center gap-3 min-w-0">
<button
type="button"
className="md:hidden w-[44px] h-[44px] min-w-[44px] min-h-[44px] rounded-[var(--r-sm)] bg-[var(--white)] border border-gold-border flex items-center justify-center text-text-1"
aria-label="Open menu"
aria-expanded={drawerOpen}
aria-controls="mafDrawer"
aria-haspopup="dialog"
onClick={onOpenDrawer}
>
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
<a
href="#"
className="flex items-center gap-2 text-text-1 no-underline"
onClick={(e) => {
e.preventDefault();
onNavigate?.('welcome');
}}
>
<div
className="w-9 h-9 rounded-[var(--r-sm)] bg-gradient-to-br from-maroon to-maroon-bright flex items-center justify-center shadow-[var(--shadow-maroon)] relative overflow-hidden"
aria-hidden="true"
>
<span
className="text-white font-bold text-sm"
style={{ fontFamily: 'var(--font-display)' }}
>
M
</span>
</div>
<span className="hidden sm:block text-sm font-bold tracking-wide text-text-1">MAF Gateway</span>
</a>
</div>
<div className="flex-1 hidden lg:flex items-center justify-center px-6 min-w-0">
<div className="flex items-center gap-2 w-full max-w-[520px] bg-[var(--ink-3)]/60 border border-gold-border rounded-[var(--r-sm)] px-3 h-[44px]">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
type="search"
placeholder="Search modules…"
className="w-full bg-transparent outline-none text-text-1 placeholder:text-text-3 text-sm"
aria-label="Search modules and pages"
/>
</div>
</div>
<div className="flex items-center gap-2 min-w-0">
<MafThemeToggle />
<button
type="button"
className="icon-btn notif-trigger-wrap"
id="notifTrigger"
aria-label="Notifications"
aria-expanded={notifOpen ?? false}
aria-haspopup="true"
title="Notifications"
onClick={() => onToggleNotifications?.()}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
</svg>
<span className="badge" id="notifBadge" style={{ display: (notifUnreadCount ?? 0) ? '' : 'none' }}>
{(notifUnreadCount ?? 0) > 99 ? '99+' : notifUnreadCount ?? 0}
</span>
</button>
<MafUserMenu
user={user}
onProfile={() => onNavigate?.('profile')}
onSettings={() => onNavigate?.('settings')}
onSignOut={() => {
// Placeholder for sign out flow.
}}
/>
</div>
</header>
);
}
'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
export type MafNotification = {
id: string;
type: 'info' | 'success' | 'warning' | 'action';
title: string;
meta: string;
time: string;
timeKey: 'today' | 'yesterday' | 'older';
read: boolean;
link: string;
};
type MafNotificationCenterProps = {
open: boolean;
onClose: () => void;
unreadCount: number;
onUnreadCountChange: (next: number) => void;
onNavigate: (link: string) => void;
variant?: 'dropdown' | 'page';
};
const STORAGE_KEY = 'maf_notif_read';
const BASE_NOTIFICATIONS = [
{ id: '1', type: 'action', title: 'Incident #INC-2024-001 assigned to you', meta: 'Requires review by 15 Apr 2024', time: '2m ago', timeKey: 'today', read: false, link: '#incidents' },
{ id: '2', type: 'success', title: 'User added successfully', meta: 'Ahmed.AlAlawi@maf.ae is now active', time: '1h ago', timeKey: 'today', read: true, link: '#users' },
{ id: '3', type: 'info', title: 'New audit due', meta: 'PMCOE Fire Safety — due 29 Mar', time: 'Yesterday', timeKey: 'yesterday', read: false, link: '#inspection' },
{ id: '4', type: 'warning', title: 'Pending approval', meta: 'Contractor registration awaiting your approval', time: '2 days ago', timeKey: 'older', read: false, link: '#admin' },
{ id: '5', type: 'info', title: 'System maintenance', meta: 'Scheduled 22:00–23:00 GMT, 10 Apr', time: '3 days ago', timeKey: 'older', read: true, link: '#' },
] as const;
function getReadSet(): Set<string> {
try {
const s = sessionStorage.getItem(STORAGE_KEY);
return s ? new Set(JSON.parse(s) as string[]) : new Set();
} catch {
return new Set();
}
}
function setReadSet(ids: Set<string>) {
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify([...ids]));
} catch {
// ignore
}
}
function iconFor(type: MafNotification['type']) {
switch (type) {
case 'success':
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
<polyline points="22 4 12 14.01 9 11.01" />
</svg>
);
case 'warning':
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
);
case 'action':
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
</svg>
);
case 'info':
default:
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
);
}
}
function escapeText(s: string) {
return s;
}
function groupByTime(notifs: MafNotification[]) {
const groups: Record<'today' | 'yesterday' | 'older', MafNotification[]> = { today: [], yesterday: [], older: [] };
for (const n of notifs) groups[n.timeKey].push(n);
return groups;
}
export default function MafNotificationCenter({
open,
onClose,
unreadCount,
onUnreadCountChange,
onNavigate,
variant = 'dropdown',
}: MafNotificationCenterProps) {
const panelRef = useRef<HTMLDivElement | null>(null);
const [readIds, setReadIds] = useState<string[]>(() => Array.from(getReadSet()));
const readSet = useMemo(() => new Set(readIds), [readIds]);
const notifications: MafNotification[] = useMemo(() => {
return BASE_NOTIFICATIONS.map((n) => ({
...n,
read: readSet.has(n.id) || n.read,
})) as MafNotification[];
}, [readSet]);
const computedUnreadCount = useMemo(() => notifications.filter((n) => !n.read).length, [notifications]);
useEffect(() => {
// Keep parent badge in sync (parent also passes unreadCount; we reconcile to avoid stale UI).
if (computedUnreadCount !== unreadCount) onUnreadCountChange(computedUnreadCount);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [computedUnreadCount]);
const markRead = (id: string) => {
setReadIds((prev) => {
if (prev.includes(id)) return prev;
const next = [...prev, id];
setReadSet(new Set(next));
return next;
});
};
const markAllRead = () => {
const ids = BASE_NOTIFICATIONS.map((n) => n.id);
setReadIds(() => {
setReadSet(new Set(ids));
return ids;
});
};
const groups = useMemo(() => groupByTime(notifications), [notifications]);
useEffect(() => {
if (!open) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', onKeyDown);
// Focus first item.
window.setTimeout(() => {
const first = panelRef.current?.querySelector<HTMLElement>('.notif-item, .notif-panel-actions button');
first?.focus?.();
}, 30);
return () => document.removeEventListener('keydown', onKeyDown);
}, [open, onClose]);
if (variant === 'page') {
return (
<div className="w-full">
<div className="notif-panel-header">
<span className="notif-panel-title">Notifications</span>
<div className="notif-panel-actions">
<button type="button" onClick={markAllRead}>
Mark all read
</button>
</div>
</div>
<div className="notif-list" id="notifListFull">
{(['today', 'yesterday', 'older'] as const).map((key) => {
const label = key === 'today' ? 'Today' : key === 'yesterday' ? 'Yesterday' : 'Older';
const items = groups[key];
if (!items.length) return null;
return (
<div key={key}>
<div className="notif-group-label">{label}</div>
{items.map((n) => {
const unreadClass = n.read ? '' : ' unread';
const gotoLabel = n.link === '#' ? 'View' : 'Go to page';
return (
<div
key={n.id}
className={`notif-item${unreadClass}`}
role="button"
tabIndex={0}
onClick={() => {
if (!n.read) markRead(n.id);
onNavigate(n.link);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (!n.read) markRead(n.id);
onNavigate(n.link);
}
}}
>
<span className={`notif-item-icon ${n.type}`} aria-hidden="true">
{iconFor(n.type)}
</span>
<span className="notif-item-body">
<span className="notif-item-title">{escapeText(n.title)}</span>
<span className="notif-item-meta">{escapeText(n.meta)}</span>
<span className="notif-item-actions">
<a
href={n.link}
className="notif-goto"
onClick={(e) => {
e.preventDefault();
if (!n.read) markRead(n.id);
onNavigate(n.link);
}}
>
{gotoLabel}
</a>
<button
type="button"
className="notif-forward"
onClick={(e) => {
e.stopPropagation();
if (typeof (window as any).App?.showToast === 'function') {
(window as any).App.showToast('Forward: choose a user to escalate this notification', '', 3500);
}
}}
title="Forward to another user"
>
Forward
</button>
</span>
</span>
<span className="notif-item-time">{escapeText(n.time)}</span>
</div>
);
})}
</div>
);
})}
{notifications.length === 0 ? (
<div className="notif-empty">
<p>No notifications yet</p>
</div>
) : null}
</div>
<div className="notif-panel-footer">
<p className="notif-panel-hint">Click a notification to go to the associated page. Use Forward to escalate to another user.</p>
<a
href="#notification-center"
onClick={(e) => {
e.preventDefault();
onClose();
}}
>
View all notifications
</a>
</div>
</div>
);
}
return (
<>
<div
className={`notif-panel-backdrop ${open ? 'open' : ''}`}
id="notifPanelBackdrop"
aria-hidden={!open}
onClick={onClose}
/>
<div
ref={panelRef}
className={`notif-panel ${open ? 'open' : ''}`}
id="notifPanel"
role="dialog"
aria-label="Notifications"
aria-hidden={!open}
>
<div className="notif-panel-header">
<span className="notif-panel-title">Notifications</span>
<div className="notif-panel-actions">
<button type="button" onClick={markAllRead}>
Mark all read
</button>
</div>
</div>
<div className="notif-list" id="notifList">
{notifications.length === 0 ? (
<div className="notif-empty">
<p>No notifications yet</p>
<p>We&apos;ll show updates here</p>
</div>
) : (
(['today', 'yesterday', 'older'] as const).map((key) => {
const label = key === 'today' ? 'Today' : key === 'yesterday' ? 'Yesterday' : 'Older';
const items = groups[key];
if (!items.length) return null;
return (
<div key={key}>
<div className="notif-group-label">{label}</div>
{items.map((n) => {
const unreadClass = n.read ? '' : ' unread';
const gotoLabel = n.link === '#' ? 'View' : 'Go to page';
return (
<div
key={n.id}
className={`notif-item${unreadClass}`}
tabIndex={0}
role="button"
onClick={() => {
if (!n.read) markRead(n.id);
onNavigate(n.link);
onClose();
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (!n.read) markRead(n.id);
onNavigate(n.link);
onClose();
}
}}
>
<span className={`notif-item-icon ${n.type}`} aria-hidden="true">
{iconFor(n.type)}
</span>
<span className="notif-item-body">
<span className="notif-item-title">{escapeText(n.title)}</span>
<span className="notif-item-meta">{escapeText(n.meta)}</span>
<span className="notif-item-actions">
<a
href={n.link}
className="notif-goto"
onClick={(e) => {
e.preventDefault();
if (!n.read) markRead(n.id);
onNavigate(n.link);
onClose();
}}
>
{gotoLabel}
</a>
<button
type="button"
className="notif-forward"
onClick={(e) => {
e.stopPropagation();
if (typeof (window as any).App?.showToast === 'function') {
(window as any).App.showToast('Forward: choose a user to escalate this notification', '', 3500);
}
}}
title="Forward to another user"
>
Forward
</button>
</span>
</span>
<span className="notif-item-time">{escapeText(n.time)}</span>
</div>
);
})}
</div>
);
})
)}
</div>
<div className="notif-panel-footer">
<p className="notif-panel-hint">Click a notification to go to the associated page. Use Forward to escalate to another user.</p>
<a
href="#notification-center"
onClick={(e) => {
e.preventDefault();
onClose();
}}
>
View all notifications
</a>
</div>
</div>
</>
);
}
// Desktop sidebar navigation.
'use client';
import { ReactNode } from 'react';
export type MafNavItem = {
view: string;
label: string;
icon?: ReactNode;
};
export default function MafSidebar({
items,
activeView,
onNavigate,
}: {
items: MafNavItem[];
activeView?: string;
onNavigate?: (view: string) => void;
}) {
return (
<nav
className="hidden md:flex fixed top-0 left-0 bottom-0 z-[999] w-[var(--sidebar-width)] bg-surface-raised border-r border-gold-border pt-[var(--header-height)] overflow-y-auto"
aria-label="Main navigation"
>
<div className="w-full px-3 pb-3">
<div className="h-[var(--header-height)] flex items-center">
<div className="text-text-3 text-xs uppercase tracking-[0.08em] font-semibold px-2">
Navigation
</div>
</div>
<div className="flex flex-col gap-1">
{items.map((it) => {
const active = activeView && it.view === activeView;
return (
<a
key={it.view}
href="#"
className={[
'flex items-center gap-3 px-3 py-2 rounded-[var(--r-sm)] text-sm font-semibold transition-colors',
active ? 'bg-gold-pale text-maroon' : 'text-text-2 hover:bg-white/5 hover:text-text-1',
].join(' ')}
aria-current={active ? 'page' : undefined}
onClick={(e) => {
e.preventDefault();
onNavigate?.(it.view);
}}
>
<span className="w-9 h-9 rounded-[var(--r-sm)] border border-gold-border bg-white/0 flex items-center justify-center text-gold">
{it.icon}
</span>
<span className="whitespace-nowrap">{it.label}</span>
</a>
);
})}
</div>
</div>
</nav>
);
}
// MAF theme toggle (dark-first) with persistence via localStorage.
// This component is client-only because it touches `window`/`document`.
'use client';
import { useEffect, useMemo, useState } from 'react';
type MafTheme = 'light' | 'dark';
const STORAGE_KEY = 'maf_theme';
function getStoredTheme(): MafTheme {
try {
const v = localStorage.getItem(STORAGE_KEY);
return v === 'light' ? 'light' : 'dark';
} catch {
return 'dark';
}
}
export default function MafThemeToggle() {
const [theme, setTheme] = useState<MafTheme>(() => {
if (typeof window === 'undefined') return 'dark';
const v = localStorage.getItem(STORAGE_KEY);
return v === 'light' ? 'light' : 'dark';
});
useEffect(() => {
// Keep html[data-theme] in sync with persisted theme.
document.documentElement.setAttribute('data-theme', theme === 'light' ? 'light' : 'dark');
}, []);
const nextTheme: MafTheme = useMemo(() => (theme === 'light' ? 'dark' : 'light'), [theme]);
return (
<button
type="button"
id="themeToggle"
className="theme-toggle"
aria-label={theme === 'light' ? 'Switch to dark theme' : 'Switch to light theme'}
title={theme === 'light' ? 'Dark mode' : 'Light mode'}
aria-pressed={theme === 'light'}
onClick={() => {
const t = nextTheme;
setTheme(t);
try {
localStorage.setItem(STORAGE_KEY, t);
} catch {
// ignore
}
document.documentElement.setAttribute('data-theme', t);
}}
>
<span className="icon-dark" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
</span>
<span className="icon-light" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="5" />
<line x1="12" y1="1" x2="12" y2="3" />
<line x1="12" y1="21" x2="12" y2="23" />
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
<line x1="1" y1="12" x2="3" y2="12" />
<line x1="21" y1="12" x2="23" y2="12" />
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
</svg>
</span>
</button>
);
}
// User dropdown menu anchored in the header.
'use client';
import { useEffect, useId, useMemo, useRef, useState } from 'react';
export type MafUser = {
initials: string;
name: string;
role: string;
};
export type MafUserMenuProps = {
user: MafUser;
onProfile?: () => void;
onSettings?: () => void;
onSignOut?: () => void;
};
export default function MafUserMenu({ user, onProfile, onSettings, onSignOut }: MafUserMenuProps) {
const [open, setOpen] = useState(false);
const id = useId();
const triggerRef = useRef<HTMLButtonElement | null>(null);
const wrapRef = useRef<HTMLDivElement | null>(null);
const menuId = useMemo(() => `userMenu-${id.replace(/:/g, '')}`, [id]);
useEffect(() => {
if (!open) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setOpen(false);
triggerRef.current?.focus();
}
};
const onPointerDown = (e: PointerEvent) => {
const el = e.target as Node | null;
if (!el) return;
if (!wrapRef.current) return;
if (!wrapRef.current.contains(el)) {
setOpen(false);
}
};
document.addEventListener('keydown', onKeyDown);
document.addEventListener('pointerdown', onPointerDown);
return () => {
document.removeEventListener('keydown', onKeyDown);
document.removeEventListener('pointerdown', onPointerDown);
};
}, [open]);
return (
<div ref={wrapRef} className="relative">
<button
ref={triggerRef}
type="button"
className="flex items-center gap-2 px-2.5 min-h-[44px] rounded-full border border-gold-border bg-white/0 hover:bg-white/5 transition-colors"
aria-label="User menu"
aria-haspopup="menu"
aria-expanded={open}
aria-controls={menuId}
onClick={() => setOpen((v) => !v)}
>
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-maroon to-maroon-bright flex items-center justify-center text-white text-xs font-bold">
{user.initials}
</div>
<div className="text-left hidden sm:block">
<span className="block text-text-2 text-sm font-semibold leading-4">{user.name}</span>
<span className="block text-text-3 text-[11px] uppercase tracking-[0.08em] font-medium leading-3">
{user.role}
</span>
</div>
</button>
{open && (
<>
<div
className="fixed inset-0 z-[1000]"
aria-hidden="true"
/>
<div
id={menuId}
role="menu"
aria-label="User menu"
className="absolute right-0 mt-2 z-[1001] min-w-[220px] rounded-[var(--r-md)] border border-gold-border bg-surface-raised shadow-[var(--shadow-deep)] py-2"
>
<button
type="button"
role="menuitem"
className="w-full flex items-center gap-3 px-4 py-2 text-text-2 hover:bg-white/5 hover:text-text-1 transition-colors text-sm"
onClick={() => {
setOpen(false);
onProfile?.();
}}
>
<svg viewBox="0 0 24 24" className="w-4 h-4" aria-hidden="true">
<path
d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle cx="12" cy="7" r="4" fill="none" stroke="currentColor" strokeWidth="2" />
</svg>
Profile
</button>
<button
type="button"
role="menuitem"
className="w-full flex items-center gap-3 px-4 py-2 text-text-2 hover:bg-white/5 hover:text-text-1 transition-colors text-sm"
onClick={() => {
setOpen(false);
onSettings?.();
}}
>
<svg viewBox="0 0 24 24" className="w-4 h-4" aria-hidden="true">
<circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" strokeWidth="2" />
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"
fill="none"
stroke="currentColor"
strokeWidth="2"
/>
</svg>
Settings
</button>
<div className="h-px bg-gold-border my-1" role="separator" />
<button
type="button"
role="menuitem"
className="w-full flex items-center gap-3 px-4 py-2 text-text-3 hover:bg-white/5 hover:text-red-400 transition-colors text-sm"
onClick={() => {
setOpen(false);
onSignOut?.();
}}
>
<svg viewBox="0 0 24 24" className="w-4 h-4" aria-hidden="true">
<path
d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<polyline
points="16 17 21 12 16 7"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<line x1="21" y1="12" x2="9" y2="12" fill="none" stroke="currentColor" strokeWidth="2" />
</svg>
Sign out
</button>
</div>
</>
)}
</div>
);
}
import type { Meta, StoryObj } from '@storybook/nextjs';
import MafBadge from '@/src/maf-ui/components/MafBadge';
const meta = {
title: 'MAF/Badge',
component: MafBadge,
tags: ['autodocs'],
parameters: {
layout: 'centered',
},
args: {
children: 'Badge',
},
} satisfies Meta<typeof MafBadge>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Info: Story = { args: { tone: 'info', children: 'Info' } };
export const Success: Story = { args: { tone: 'success', children: 'Success' } };
export const Warning: Story = { args: { tone: 'warning', children: 'Warning' } };
export const Action: Story = { args: { tone: 'action', children: 'Action' } };
export const Draft: Story = { args: { tone: 'draft', children: 'Draft' } };
import type { Meta, StoryObj } from '@storybook/nextjs';
import MafBottomNav, { MafBottomNavItem } from '@/src/maf-ui/shell/MafBottomNav';
const meta = {
title: 'MAF/Shell/BottomNav',
component: MafBottomNav,
tags: ['autodocs'],
parameters: { layout: 'fullscreen' },
} satisfies Meta<typeof MafBottomNav>;
export default meta;
type Story = StoryObj<typeof meta>;
const icon = (
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path d="M12 5v14M5 12h14" />
</svg>
);
const items: MafBottomNavItem[] = [
{ view: 'welcome', label: 'Home', icon },
{ view: 'modules', label: 'Modules', icon },
{ view: 'incidents', label: 'Incidents', icon },
{ view: 'notification-center', label: 'Alerts', icon },
{ view: 'profile', label: 'Profile', icon },
];
export const ActiveIncidents: Story = {
args: {
items,
activeView: 'incidents',
onNavigate: () => {},
},
};
import type { Meta, StoryObj } from '@storybook/nextjs';
import { fn } from 'storybook/test';
import { createElement } from 'react';
import MafButton from '@/src/maf-ui/components/MafButton';
const meta = {
title: 'MAF/Button',
component: MafButton,
tags: ['autodocs'],
parameters: {
layout: 'centered',
},
args: {
onClick: fn(),
children: 'Button',
disabled: false,
},
} satisfies Meta<typeof MafButton>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Primary: Story = { args: { variant: 'primary', children: 'Primary' } };
export const Secondary: Story = { args: { variant: 'secondary', children: 'Secondary' } };
export const Ghost: Story = { args: { variant: 'ghost', children: 'Ghost' } };
export const Sizes: Story = {
render: (args) =>
createElement(
'div',
{ className: 'flex flex-col gap-3 items-center' },
createElement(MafButton, { ...args, size: 'small', variant: 'primary' }, 'Small'),
createElement(MafButton, { ...args, size: 'medium', variant: 'primary' }, 'Medium'),
createElement(MafButton, { ...args, size: 'large', variant: 'primary' }, 'Large')
),
args: { onClick: fn() },
};
import './button.css';
export interface ButtonProps {
/** Is this the principal call to action on the page? */
primary?: boolean;
/** What background color to use */
backgroundColor?: string;
/** How large should the button be? */
size?: 'small' | 'medium' | 'large';
/** Button contents */
label: string;
/** Optional click handler */
onClick?: () => void;
}
/** Primary UI component for user interaction */
export const Button = ({
primary = false,
size = 'medium',
backgroundColor,
label,
...props
}: ButtonProps) => {
const mode = primary ? 'storybook-button--primary' : 'storybook-button--secondary';
return (
<button
type="button"
className={['storybook-button', `storybook-button--${size}`, mode].join(' ')}
{...props}
>
{label}
<style jsx>{`
button {
background-color: ${backgroundColor};
}
`}</style>
</button>
);
};
import type { Meta, StoryObj } from '@storybook/nextjs';
import { MafCard } from '@/src/maf-ui/components/MafCard';
const meta = {
title: 'MAF/Card',
component: MafCard,
tags: ['autodocs'],
parameters: {
layout: 'centered',
},
} satisfies Meta<typeof MafCard>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
children: 'Card',
},
render: () => (
<MafCard className="p-6 md:p-8">
<div className="font-semibold text-text-1">Card</div>
<div className="mt-2 text-text-2 text-sm leading-[var(--leading-relaxed)]">
Default surface styling for content containers.
</div>
</MafCard>
),
};
export const WithBorderOverride: Story = {
args: {
children: 'Custom border',
},
render: () => (
<MafCard className="p-6 md:p-8 border-gold-border-strong">
<div className="font-semibold text-text-1">Custom border</div>
<div className="mt-2 text-text-2 text-sm">Useful for emphasis states.</div>
</MafCard>
),
};
import { Meta } from "@storybook/addon-docs/blocks";
import Image from "next/image";
import Github from "./assets/github.svg";
import Discord from "./assets/discord.svg";
import Youtube from "./assets/youtube.svg";
import Tutorials from "./assets/tutorials.svg";
import Styling from "./assets/styling.png";
import Context from "./assets/context.png";
import Assets from "./assets/assets.png";
import Docs from "./assets/docs.png";
import Share from "./assets/share.png";
import FigmaPlugin from "./assets/figma-plugin.png";
import Testing from "./assets/testing.png";
import Accessibility from "./assets/accessibility.png";
import Theming from "./assets/theming.png";
import AddonLibrary from "./assets/addon-library.png";
export const RightArrow = () => <svg
viewBox="0 0 14 14"
width="8px"
height="14px"
style={{
marginLeft: '4px',
display: 'inline-block',
shapeRendering: 'inherit',
verticalAlign: 'middle',
fill: 'currentColor',
'path fill': 'currentColor'
}}
>
<path d="m11.1 7.35-5.5 5.5a.5.5 0 0 1-.7-.7L10.04 7 4.9 1.85a.5.5 0 1 1 .7-.7l5.5 5.5c.2.2.2.5 0 .7Z" />
</svg>
<Meta title="Configure your project" />
<div className="sb-container">
<div className='sb-section-title'>
# Configure your project
Because Storybook works separately from your app, you'll need to configure it for your specific stack and setup. Below, explore guides for configuring Storybook with popular frameworks and tools. If you get stuck, learn how you can ask for help from our community.
</div>
<div className="sb-section">
<div className="sb-section-item">
<Image
src={Styling}
alt="A wall of logos representing different styling technologies"
width={0}
height={0}
style={{ width: '100%', height: 'auto' }}
/>
<h4 className="sb-section-item-heading">Add styling and CSS</h4>
<p className="sb-section-item-paragraph">Like with web applications, there are many ways to include CSS within Storybook. Learn more about setting up styling within Storybook.</p>
<a
className="sb-action-link"
href="https://storybook.js.org/docs/configure/styling-and-css/?renderer=react&ref=configure"
target="_blank"
>Learn more<RightArrow /></a>
</div>
<div className="sb-section-item">
<Image
width={0}
height={0}
style={{ width: '100%', height: 'auto' }}
src={Context}
alt="An abstraction representing the composition of data for a component"
/>
<h4 className="sb-section-item-heading">Provide context and mocking</h4>
<p className="sb-section-item-paragraph">Often when a story doesn't render, it's because your component is expecting a specific environment or context (like a theme provider) to be available.</p>
<a
className="sb-action-link"
href="https://storybook.js.org/docs/writing-stories/decorators/?renderer=react&ref=configure#context-for-mocking"
target="_blank"
>Learn more<RightArrow /></a>
</div>
<div className="sb-section-item">
<Image
width={0}
height={0}
style={{ width: '100%', height: 'auto' }}
src={Assets}
alt="A representation of typography and image assets"
/>
<div>
<h4 className="sb-section-item-heading">Load assets and resources</h4>
<p className="sb-section-item-paragraph">To link static files (like fonts) to your projects and stories, use the
`staticDirs` configuration option to specify folders to load when
starting Storybook.</p>
<a
className="sb-action-link"
href="https://storybook.js.org/docs/configure/images-and-assets/?renderer=react&ref=configure"
target="_blank"
>Learn more<RightArrow /></a>
</div>
</div>
</div>
</div>
<div className="sb-container">
<div className='sb-section-title'>
# Do more with Storybook
Now that you know the basics, let's explore other parts of Storybook that will improve your experience. This list is just to get you started. You can customise Storybook in many ways to fit your needs.
</div>
<div className="sb-section">
<div className="sb-features-grid">
<div className="sb-grid-item">
<Image
width={0}
height={0}
style={{ width: '100%', height: 'auto' }}
src={Docs}
alt="A screenshot showing the autodocs tag being set, pointing a docs page being generated"
/>
<h4 className="sb-section-item-heading">Autodocs</h4>
<p className="sb-section-item-paragraph">Auto-generate living,
interactive reference documentation from your components and stories.</p>
<a
className="sb-action-link"
href="https://storybook.js.org/docs/writing-docs/autodocs/?renderer=react&ref=configure"
target="_blank"
>Learn more<RightArrow /></a>
</div>
<div className="sb-grid-item">
<Image
width={0}
height={0}
style={{ width: '100%', height: 'auto' }}
src={Share}
alt="A browser window showing a Storybook being published to a chromatic.com URL"
/>
<h4 className="sb-section-item-heading">Publish to Chromatic</h4>
<p className="sb-section-item-paragraph">Publish your Storybook to review and collaborate with your entire team.</p>
<a
className="sb-action-link"
href="https://storybook.js.org/docs/sharing/publish-storybook/?renderer=react&ref=configure#publish-storybook-with-chromatic"
target="_blank"
>Learn more<RightArrow /></a>
</div>
<div className="sb-grid-item">
<Image
width={0}
height={0}
style={{ width: '100%', height: 'auto' }}
src={FigmaPlugin}
alt="Windows showing the Storybook plugin in Figma"
/>
<h4 className="sb-section-item-heading">Figma Plugin</h4>
<p className="sb-section-item-paragraph">Embed your stories into Figma to cross-reference the design and live
implementation in one place.</p>
<a
className="sb-action-link"
href="https://storybook.js.org/docs/sharing/design-integrations/?renderer=react&ref=configure#embed-storybook-in-figma-with-the-plugin"
target="_blank"
>Learn more<RightArrow /></a>
</div>
<div className="sb-grid-item">
<Image
width={0}
height={0}
style={{ width: '100%', height: 'auto' }}
src={Testing}
alt="Screenshot of tests passing and failing"
/>
<h4 className="sb-section-item-heading">Testing</h4>
<p className="sb-section-item-paragraph">Use stories to test a component in all its variations, no matter how
complex.</p>
<a
className="sb-action-link"
href="https://storybook.js.org/docs/writing-tests/?renderer=react&ref=configure"
target="_blank"
>Learn more<RightArrow /></a>
</div>
<div className="sb-grid-item">
<Image
width={0}
height={0}
style={{ width: '100%', height: 'auto' }}
src={Accessibility}
alt="Screenshot of accessibility tests passing and failing"
/>
<h4 className="sb-section-item-heading">Accessibility</h4>
<p className="sb-section-item-paragraph">Automatically test your components for a11y issues as you develop.</p>
<a
className="sb-action-link"
href="https://storybook.js.org/docs/writing-tests/accessibility-testing/?renderer=react&ref=configure"
target="_blank"
>Learn more<RightArrow /></a>
</div>
<div className="sb-grid-item">
<Image
width={0}
height={0}
style={{ width: '100%', height: 'auto' }}
src={Theming}
alt="Screenshot of Storybook in light and dark mode"
/>
<h4 className="sb-section-item-heading">Theming</h4>
<p className="sb-section-item-paragraph">Theme Storybook's UI to personalize it to your project.</p>
<a
className="sb-action-link"
href="https://storybook.js.org/docs/configure/theming/?renderer=react&ref=configure"
target="_blank"
>Learn more<RightArrow /></a>
</div>
</div>
</div>
</div>
<div className='sb-addon'>
<div className='sb-addon-text'>
<h4>Addons</h4>
<p className="sb-section-item-paragraph">Integrate your tools with Storybook to connect workflows.</p>
<a
className="sb-action-link"
href="https://storybook.js.org/addons/?ref=configure"
target="_blank"
>Discover all addons<RightArrow /></a>
</div>
<div className='sb-addon-img'>
<Image
width={650}
height={347}
src={AddonLibrary}
alt="Integrate your tools with Storybook to connect workflows."
/>
</div>
</div>
<div className="sb-section sb-socials">
<div className="sb-section-item">
<Image
width={32}
height={32}
layout="fixed"
src={Github}
alt="Github logo"
className="sb-explore-image"
/>
Join our contributors building the future of UI development.
<a
className="sb-action-link"
href="https://github.com/storybookjs/storybook"
target="_blank"
>Star on GitHub<RightArrow /></a>
</div>
<div className="sb-section-item">
<Image
width={33}
height={32}
layout="fixed"
src={Discord}
alt="Discord logo"
className="sb-explore-image"
/>
<div>
Get support and chat with frontend developers.
<a
className="sb-action-link"
href="https://discord.gg/storybook"
target="_blank"
>Join Discord server<RightArrow /></a>
</div>
</div>
<div className="sb-section-item">
<Image
width={32}
height={32}
layout="fixed"
src={Youtube}
alt="Youtube logo"
className="sb-explore-image"
/>
<div>
Watch tutorials, feature previews and interviews.
<a
className="sb-action-link"
href="https://www.youtube.com/@chromaticui"
target="_blank"
>Watch on YouTube<RightArrow /></a>
</div>
</div>
<div className="sb-section-item">
<Image
width={33}
height={32}
layout="fixed"
src={Tutorials}
alt="A book"
className="sb-explore-image"
/>
<p>Follow guided walkthroughs on for key workflows.</p>
<a
className="sb-action-link"
href="https://storybook.js.org/tutorials/?ref=configure"
target="_blank"
>Discover tutorials<RightArrow /></a>
</div>
</div>
<style>
{`
.sb-container {
margin-bottom: 48px;
}
.sb-section {
width: 100%;
display: flex;
flex-direction: row;
gap: 20px;
}
img {
object-fit: cover;
}
.sb-section-title {
margin-bottom: 32px;
}
.sb-section a:not(h1 a, h2 a, h3 a) {
font-size: 14px;
}
.sb-section-item, .sb-grid-item {
flex: 1;
display: flex;
flex-direction: column;
}
.sb-section-item-heading {
padding-top: 20px !important;
padding-bottom: 5px !important;
margin: 0 !important;
}
.sb-section-item-paragraph {
margin: 0;
padding-bottom: 10px;
}
.sb-action-link {
text-decoration: none;
}
.sb-action-link:hover, .sb-action-link:focus {
text-decoration: underline;
text-decoration-thickness: 0.03125rem;
text-underline-offset: 0.11em;
}
.sb-chevron {
margin-left: 5px;
}
.sb-features-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-gap: 32px 20px;
}
.sb-socials {
display: grid;
grid-template-columns: repeat(4, 1fr);
}
.sb-socials p {
margin-bottom: 10px;
}
.sb-explore-image {
max-height: 32px;
align-self: flex-start;
}
.sb-addon {
width: 100%;
display: flex;
align-items: center;
position: relative;
background-color: #EEF3F8;
border-radius: 5px;
border: 1px solid rgba(0, 0, 0, 0.05);
background: #EEF3F8;
height: 180px;
margin-bottom: 48px;
overflow: hidden;
}
.sb-addon-text {
padding-left: 48px;
max-width: 240px;
}
.sb-addon-text h4 {
padding-top: 0px;
}
.sb-addon-img {
position: absolute;
left: 345px;
top: 0;
height: 100%;
width: 200%;
overflow: hidden;
}
.sb-addon-img img {
width: 650px;
transform: rotate(-15deg);
margin-left: 40px;
margin-top: -72px;
box-shadow: 0 0 1px rgba(255, 255, 255, 0);
backface-visibility: hidden;
}
@media screen and (max-width: 800px) {
.sb-addon-img {
left: 300px;
}
}
@media screen and (max-width: 600px) {
.sb-section {
flex-direction: column;
}
.sb-features-grid {
grid-template-columns: repeat(1, 1fr);
}
.sb-socials {
grid-template-columns: repeat(2, 1fr);
}
.sb-addon {
height: 280px;
align-items: flex-start;
padding-top: 32px;
overflow: hidden;
}
.sb-addon-text {
padding-left: 24px;
}
.sb-addon-img {
right: 0;
left: 0;
top: 130px;
bottom: 0;
overflow: hidden;
height: auto;
width: 124%;
}
.sb-addon-img img {
width: 1200px;
transform: rotate(-12deg);
margin-left: 0;
margin-top: 48px;
margin-bottom: -40px;
margin-left: -24px;
}
}
`}
</style>
import type { Meta, StoryObj } from '@storybook/nextjs';
import { useState } from 'react';
import MafDrawer, { MafDrawerItem } from '@/src/maf-ui/shell/MafDrawer';
const meta = {
title: 'MAF/Shell/Drawer',
component: MafDrawer,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
},
} satisfies Meta<typeof MafDrawer>;
export default meta;
type Story = StoryObj<typeof meta>;
const icon = (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
);
const items: MafDrawerItem[] = [
{ view: 'welcome', label: 'Home', icon },
{ view: 'modules', label: 'Modules', icon },
{ view: 'incidents', label: 'Incidents', icon },
];
export const Open: Story = {
args: {
open: true,
items,
activeView: 'modules',
onClose: () => {},
onNavigate: () => {},
},
render: () => {
const [open, setOpen] = useState(true);
return <MafDrawer open={open} items={items} activeView="modules" onClose={() => setOpen(false)} onNavigate={() => {}} />;
},
};
export const Closed: Story = {
args: {
open: false,
items,
activeView: 'modules',
onClose: () => {},
onNavigate: () => {},
},
render: () => <MafDrawer open={false} items={items} activeView="modules" onClose={() => {}} onNavigate={() => {}} />,
};
import type { Meta, StoryObj } from '@storybook/nextjs';
import MafTabs from '@/src/maf-ui/components/MafTabs';
import { MafCard } from '@/src/maf-ui/components/MafCard';
const meta = {
title: 'MAF/Tabs (Legacy)',
component: MafTabs,
tags: ['autodocs'],
parameters: {
layout: 'centered',
},
} satisfies Meta<typeof MafTabs>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
items: [],
defaultActiveKey: 'overview',
},
render: () => (
<div className="w-full max-w-[720px] p-6">
<MafCard className="p-4 md:p-6">
<MafTabs
defaultActiveKey="overview"
items={[
{
key: 'overview',
label: 'Overview',
panel: <div className="text-text-2 text-sm">Overview content goes here.</div>,
},
{
key: 'activity',
label: 'Activity',
panel: <div className="text-text-2 text-sm">Activity content goes here.</div>,
},
{
key: 'settings',
label: 'Settings',
panel: <div className="text-text-2 text-sm">Settings content goes here.</div>,
},
]}
/>
</MafCard>
</div>
),
};
import { Button } from './Button';
import './header.css';
type User = {
name: string;
};
export interface HeaderProps {
user?: User;
onLogin?: () => void;
onLogout?: () => void;
onCreateAccount?: () => void;
}
export const Header = ({ user, onLogin, onLogout, onCreateAccount }: HeaderProps) => (
<header>
<div className="storybook-header">
<div>
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<g fill="none" fillRule="evenodd">
<path
d="M10 0h12a10 10 0 0110 10v12a10 10 0 01-10 10H10A10 10 0 010 22V10A10 10 0 0110 0z"
fill="#FFF"
/>
<path
d="M5.3 10.6l10.4 6v11.1l-10.4-6v-11zm11.4-6.2l9.7 5.5-9.7 5.6V4.4z"
fill="#555AB9"
/>
<path
d="M27.2 10.6v11.2l-10.5 6V16.5l10.5-6zM15.7 4.4v11L6 10l9.7-5.5z"
fill="#91BAF8"
/>
</g>
</svg>
<h1>Acme</h1>
</div>
<div>
{user ? (
<>
<span className="welcome">
Welcome, <b>{user.name}</b>!
</span>
<Button size="small" onClick={onLogout} label="Log out" />
</>
) : (
<>
<Button size="small" onClick={onLogin} label="Log in" />
<Button primary size="small" onClick={onCreateAccount} label="Sign up" />
</>
)}
</div>
</div>
</header>
);
import type { Meta, StoryObj } from '@storybook/nextjs';
import MafLoginModal from '@/src/maf-ui/auth/MafLoginModal';
import type { MafUser } from '@/src/maf-ui/shell/MafUserMenu';
const meta = {
title: 'MAF/Auth/LoginModal',
component: MafLoginModal,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
},
} satisfies Meta<typeof MafLoginModal>;
export default meta;
type Story = StoryObj<typeof meta>;
const onSuccess = (u: MafUser) => {
// no-op for Storybook
void u;
};
export const EmployeeOpen: Story = {
args: {
open: true,
defaultAccountType: 'employee',
onClose: () => {},
onSuccess,
},
render: () => <MafLoginModal open defaultAccountType="employee" onClose={() => {}} onSuccess={onSuccess} />,
};
export const ContractorOpen: Story = {
args: {
open: true,
defaultAccountType: 'contractor',
onClose: () => {},
onSuccess,
},
render: () => <MafLoginModal open defaultAccountType="contractor" onClose={() => {}} onSuccess={onSuccess} />,
};
import type { Meta, StoryObj } from '@storybook/nextjs';
import MafNotificationCenter from '@/src/maf-ui/shell/MafNotificationCenter';
import { useEffect } from 'react';
const meta = {
title: 'MAF/NotificationCenter',
component: MafNotificationCenter,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
},
} satisfies Meta<typeof MafNotificationCenter>;
export default meta;
type Story = StoryObj<typeof meta>;
function NotifSetup({ readIds }: { readIds: string[] }) {
useEffect(() => {
try {
sessionStorage.setItem('maf_notif_read', JSON.stringify(readIds));
} catch {
// ignore
}
// Ensure we're in a known theme.
document.documentElement.setAttribute('data-theme', 'dark');
}, [readIds]);
return null;
}
export const DropdownWithUnread: Story = {
args: {
open: true,
variant: 'dropdown',
unreadCount: 0,
onUnreadCountChange: () => {},
onClose: () => {},
onNavigate: () => {},
},
render: () => {
// Mark only one notification as read to demonstrate badge count.
const readIds = ['2', '5'];
return (
<>
<NotifSetup readIds={readIds} />
<div className="p-6">
<MafNotificationCenter
open
variant="dropdown"
unreadCount={0}
onUnreadCountChange={() => {}}
onClose={() => {}}
onNavigate={() => {}}
/>
</div>
</>
);
},
};
export const PageVariant: Story = {
args: {
open: true,
variant: 'page',
unreadCount: 0,
onUnreadCountChange: () => {},
onClose: () => {},
onNavigate: () => {},
},
render: () => {
const readIds: string[] = [];
return (
<>
<NotifSetup readIds={readIds} />
<div className="p-6 max-w-[980px] mx-auto">
<MafNotificationCenter
open
variant="page"
unreadCount={0}
onUnreadCountChange={() => {}}
onClose={() => {}}
onNavigate={() => {}}
/>
</div>
</>
);
},
};
import type { Meta, StoryObj } from '@storybook/nextjs';
import { useMemo, useState } from 'react';
import MafAppShell from '@/src/maf-ui/shell/MafAppShell';
const meta = {
title: 'MAF/AppShell',
component: MafAppShell,
parameters: {
layout: 'fullscreen',
},
} satisfies Meta<typeof MafAppShell>;
export default meta;
type Story = StoryObj<typeof meta>;
export const ShellWelcome: Story = {
args: {
user: {
initials: 'NL',
name: 'Nino Lester Cruz',
role: 'Admin',
},
activeView: 'welcome',
children: null,
},
render: () => {
const user = useMemo(
() => ({
initials: 'NL',
name: 'Nino Lester Cruz',
role: 'Admin',
}),
[]
);
const [view, setView] = useState<
'welcome' | 'modules' | 'incidents' | 'permit' | 'settings' | 'profile' | 'notification-center'
>('welcome');
return (
<MafAppShell user={user} activeView={view} onNavigate={(next) => setView(next as any)}>
<div className="w-full max-w-[980px] mx-auto px-3 md:px-6 py-6">
<section className="rounded-[var(--r-lg)] border border-gold-border bg-surface-raised p-6 md:p-8">
<h1
className="text-[var(--text-2xl)] leading-[var(--leading-tight)] tracking-[var(--tracking-tight)] font-semibold text-text-1 mb-3"
style={{ fontFamily: 'var(--font-display)' }}
>
Storybook shell validation
</h1>
<p className="text-text-2 text-sm leading-[var(--leading-relaxed)]">
Theme toggle, sidebar, drawer, and bottom nav should match the MAF-UIS layout.
</p>
<div className="mt-4 flex gap-3 flex-wrap">
<button
type="button"
className="rounded-[var(--r-sm)] border border-gold-border bg-white/0 hover:bg-white/5 px-4 py-2 text-text-1 font-semibold text-sm"
onClick={() => setView('welcome')}
>
Welcome
</button>
<button
type="button"
className="rounded-[var(--r-sm)] border border-gold-border bg-white/0 hover:bg-white/5 px-4 py-2 text-text-1 font-semibold text-sm"
onClick={() => setView('modules')}
>
Modules
</button>
<button
type="button"
className="rounded-[var(--r-sm)] border border-gold-border bg-white/0 hover:bg-white/5 px-4 py-2 text-text-1 font-semibold text-sm"
onClick={() => setView('incidents')}
>
Incidents
</button>
</div>
</section>
</div>
</MafAppShell>
);
},
};
import React from 'react';
import { Header } from './Header';
import './page.css';
type User = {
name: string;
};
export const Page: React.FC = () => {
const [user, setUser] = React.useState<User>();
return (
<article>
<Header
user={user}
onLogin={() => setUser({ name: 'Jane Doe' })}
onLogout={() => setUser(undefined)}
onCreateAccount={() => setUser({ name: 'Jane Doe' })}
/>
<section className="storybook-page">
<h2>Pages in Storybook</h2>
<p>
We recommend building UIs with a{' '}
<a href="https://componentdriven.org" target="_blank" rel="noopener noreferrer">
<strong>component-driven</strong>
</a>{' '}
process starting with atomic components and ending with pages.
</p>
<p>
Render pages with mock data. This makes it easy to build and review page states without
needing to navigate to them in your app. Here are some handy patterns for managing page
data in Storybook:
</p>
<ul>
<li>
Use a higher-level connected component. Storybook helps you compose such data from the
"args" of child component stories
</li>
<li>
Assemble data in the page component from your services. You can mock these services out
using Storybook.
</li>
</ul>
<p>
Get a guided tutorial on component-driven development at{' '}
<a href="https://storybook.js.org/tutorials/" target="_blank" rel="noopener noreferrer">
Storybook tutorials
</a>
. Read more in the{' '}
<a href="https://storybook.js.org/docs" target="_blank" rel="noopener noreferrer">
docs
</a>
.
</p>
<div className="tip-wrapper">
<span className="tip">Tip</span> Adjust the width of the canvas with the{' '}
<svg width="10" height="10" viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg">
<g fill="none" fillRule="evenodd">
<path
d="M1.5 5.2h4.8c.3 0 .5.2.5.4v5.1c-.1.2-.3.3-.4.3H1.4a.5.5 0 01-.5-.4V5.7c0-.3.2-.5.5-.5zm0-2.1h6.9c.3 0 .5.2.5.4v7a.5.5 0 01-1 0V4H1.5a.5.5 0 010-1zm0-2.1h9c.3 0 .5.2.5.4v9.1a.5.5 0 01-1 0V2H1.5a.5.5 0 010-1zm4.3 5.2H2V10h3.8V6.2z"
id="a"
fill="#999"
/>
</g>
</svg>
Viewports addon in the toolbar
</div>
</section>
</article>
);
};
import type { Meta, StoryObj } from '@storybook/nextjs';
import MafSidebar, { MafNavItem } from '@/src/maf-ui/shell/MafSidebar';
const meta = {
title: 'MAF/Shell/Sidebar',
component: MafSidebar,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
},
} satisfies Meta<typeof MafSidebar>;
export default meta;
type Story = StoryObj<typeof meta>;
const icon = (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path d="M12 6v6l4 2" />
<circle cx="12" cy="12" r="10" />
</svg>
);
const items: MafNavItem[] = [
{ view: 'welcome', label: 'Home', icon },
{ view: 'modules', label: 'Modules', icon },
{ view: 'incidents', label: 'Incidents', icon },
{ view: 'permit', label: 'Permit', icon },
{ view: 'settings', label: 'Settings', icon },
{ view: 'profile', label: 'Profile', icon },
];
export const ActiveModules: Story = {
args: {
items,
activeView: 'modules',
onNavigate: () => {},
},
render: () => <MafSidebar items={items} activeView="modules" onNavigate={() => {}} />,
};
import type { Meta, StoryObj } from '@storybook/nextjs';
import MafTabs from '@/src/maf-ui/components/MafTabs';
import { MafCard } from '@/src/maf-ui/components/MafCard';
const meta = {
title: 'MAF/Tabs',
component: MafTabs,
tags: ['autodocs'],
parameters: {
layout: 'centered',
},
} satisfies Meta<typeof MafTabs>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
items: [],
defaultActiveKey: 'overview',
},
render: () => (
<div className="w-full max-w-[720px] p-6">
<MafCard className="p-4 md:p-6">
<MafTabs
defaultActiveKey="overview"
items={[
{
key: 'overview',
label: 'Overview',
panel: <div className="text-text-2 text-sm">Overview content.</div>,
},
{
key: 'activity',
label: 'Activity',
panel: <div className="text-text-2 text-sm">Activity content.</div>,
},
{
key: 'settings',
label: 'Settings',
panel: <div className="text-text-2 text-sm">Settings content.</div>,
},
]}
/>
</MafCard>
</div>
),
};
import type { Meta, StoryObj } from '@storybook/nextjs';
import { useEffect } from 'react';
import MafThemeToggle from '@/src/maf-ui/shell/MafThemeToggle';
const meta = {
title: 'MAF/ThemeToggle',
component: MafThemeToggle,
tags: ['autodocs'],
parameters: {
layout: 'centered',
},
} satisfies Meta<typeof MafThemeToggle>;
export default meta;
type Story = StoryObj<typeof meta>;
function ThemeSetup({ theme }: { theme: 'light' | 'dark' }) {
useEffect(() => {
try {
localStorage.setItem('maf_theme', theme);
} catch {
// ignore
}
document.documentElement.setAttribute('data-theme', theme);
}, [theme]);
return <MafThemeToggle />;
}
export const Dark: Story = {
render: () => <ThemeSetup theme="dark" />,
};
export const Light: Story = {
render: () => <ThemeSetup theme="light" />,
};
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="none" viewBox="0 0 48 48"><title>Accessibility</title><circle cx="24.334" cy="24" r="24" fill="#A849FF" fill-opacity=".3"/><path fill="#A470D5" fill-rule="evenodd" d="M27.8609 11.585C27.8609 9.59506 26.2497 7.99023 24.2519 7.99023C22.254 7.99023 20.6429 9.65925 20.6429 11.585C20.6429 13.575 22.254 15.1799 24.2519 15.1799C26.2497 15.1799 27.8609 13.575 27.8609 11.585ZM21.8922 22.6473C21.8467 23.9096 21.7901 25.4788 21.5897 26.2771C20.9853 29.0462 17.7348 36.3314 17.3325 37.2275C17.1891 37.4923 17.1077 37.7955 17.1077 38.1178C17.1077 39.1519 17.946 39.9902 18.9802 39.9902C19.6587 39.9902 20.253 39.6293 20.5814 39.0889L20.6429 38.9874L24.2841 31.22C24.2841 31.22 27.5529 37.9214 27.9238 38.6591C28.2948 39.3967 28.8709 39.9902 29.7168 39.9902C30.751 39.9902 31.5893 39.1519 31.5893 38.1178C31.5893 37.7951 31.3639 37.2265 31.3639 37.2265C30.9581 36.3258 27.698 29.0452 27.0938 26.2771C26.8975 25.4948 26.847 23.9722 26.8056 22.7236C26.7927 22.333 26.7806 21.9693 26.7653 21.6634C26.7008 21.214 27.0231 20.8289 27.4097 20.7005L35.3366 18.3253C36.3033 18.0685 36.8834 16.9773 36.6256 16.0144C36.3678 15.0515 35.2722 14.4737 34.3055 14.7305C34.3055 14.7305 26.8619 17.1057 24.2841 17.1057C21.7062 17.1057 14.456 14.7947 14.456 14.7947C13.4893 14.5379 12.3937 14.9873 12.0715 15.9502C11.7493 16.9131 12.3293 18.0044 13.3604 18.3253L21.2873 20.7005C21.674 20.8289 21.9318 21.214 21.9318 21.6634C21.9174 21.9493 21.9053 22.2857 21.8922 22.6473Z" clip-rule="evenodd"/></svg>
\ No newline at end of file
<svg xmlns="http://www.w3.org/2000/svg" width="33" height="32" fill="none" viewBox="0 0 33 32"><g clip-path="url(#clip0_10031_177575)"><mask id="mask0_10031_177575" style="mask-type:luminance" width="33" height="25" x="0" y="4" maskUnits="userSpaceOnUse"><path fill="#fff" d="M32.5034 4.00195H0.503906V28.7758H32.5034V4.00195Z"/></mask><g mask="url(#mask0_10031_177575)"><path fill="#5865F2" d="M27.5928 6.20817C25.5533 5.27289 23.3662 4.58382 21.0794 4.18916C21.0378 4.18154 20.9962 4.20057 20.9747 4.23864C20.6935 4.73863 20.3819 5.3909 20.1637 5.90358C17.7042 5.53558 15.2573 5.53558 12.8481 5.90358C12.6299 5.37951 12.307 4.73863 12.0245 4.23864C12.003 4.20184 11.9614 4.18281 11.9198 4.18916C9.63431 4.58255 7.44721 5.27163 5.40641 6.20817C5.38874 6.21578 5.3736 6.22848 5.36355 6.24497C1.21508 12.439 0.078646 18.4809 0.636144 24.4478C0.638667 24.477 0.655064 24.5049 0.677768 24.5227C3.41481 26.5315 6.06609 27.7511 8.66815 28.5594C8.70979 28.5721 8.75392 28.5569 8.78042 28.5226C9.39594 27.6826 9.94461 26.7968 10.4151 25.8653C10.4428 25.8107 10.4163 25.746 10.3596 25.7244C9.48927 25.3945 8.66058 24.9922 7.86343 24.5354C7.80038 24.4986 7.79533 24.4084 7.85333 24.3653C8.02108 24.2397 8.18888 24.109 8.34906 23.977C8.37804 23.9529 8.41842 23.9478 8.45249 23.963C13.6894 26.3526 19.359 26.3526 24.5341 23.963C24.5682 23.9465 24.6086 23.9516 24.6388 23.9757C24.799 24.1077 24.9668 24.2397 25.1358 24.3653C25.1938 24.4084 25.19 24.4986 25.127 24.5354C24.3298 25.0011 23.5011 25.3945 22.6296 25.7232C22.5728 25.7447 22.5476 25.8107 22.5754 25.8653C23.0559 26.7955 23.6046 27.6812 24.2087 28.5213C24.234 28.5569 24.2794 28.5721 24.321 28.5594C26.9357 27.7511 29.5869 26.5315 32.324 24.5227C32.348 24.5049 32.3631 24.4783 32.3656 24.4491C33.0328 17.5506 31.2481 11.5584 27.6344 6.24623C27.6256 6.22848 27.6105 6.21578 27.5928 6.20817ZM11.1971 20.8146C9.62043 20.8146 8.32129 19.3679 8.32129 17.5913C8.32129 15.8146 9.59523 14.368 11.1971 14.368C12.8115 14.368 14.0981 15.8273 14.0729 17.5913C14.0729 19.3679 12.7989 20.8146 11.1971 20.8146ZM21.8299 20.8146C20.2533 20.8146 18.9541 19.3679 18.9541 17.5913C18.9541 15.8146 20.228 14.368 21.8299 14.368C23.4444 14.368 24.7309 15.8273 24.7057 17.5913C24.7057 19.3679 23.4444 20.8146 21.8299 20.8146Z"/></g></g><defs><clipPath id="clip0_10031_177575"><rect width="32" height="32" fill="#fff" transform="translate(0.5)"/></clipPath></defs></svg>
\ No newline at end of file
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none" viewBox="0 0 32 32"><path fill="#161614" d="M16.0001 0C7.16466 0 0 7.17472 0 16.0256C0 23.1061 4.58452 29.1131 10.9419 31.2322C11.7415 31.3805 12.0351 30.8845 12.0351 30.4613C12.0351 30.0791 12.0202 28.8167 12.0133 27.4776C7.56209 28.447 6.62283 25.5868 6.62283 25.5868C5.89499 23.7345 4.8463 23.2419 4.8463 23.2419C3.39461 22.2473 4.95573 22.2678 4.95573 22.2678C6.56242 22.3808 7.40842 23.9192 7.40842 23.9192C8.83547 26.3691 11.1514 25.6609 12.0645 25.2514C12.2081 24.2156 12.6227 23.5087 13.0803 23.1085C9.52648 22.7032 5.7906 21.3291 5.7906 15.1886C5.7906 13.4389 6.41563 12.0094 7.43916 10.8871C7.27303 10.4834 6.72537 8.85349 7.59415 6.64609C7.59415 6.64609 8.93774 6.21539 11.9953 8.28877C13.2716 7.9337 14.6404 7.75563 16.0001 7.74953C17.3599 7.75563 18.7297 7.9337 20.0084 8.28877C23.0623 6.21539 24.404 6.64609 24.404 6.64609C25.2749 8.85349 24.727 10.4834 24.5608 10.8871C25.5868 12.0094 26.2075 13.4389 26.2075 15.1886C26.2075 21.3437 22.4645 22.699 18.9017 23.0957C19.4756 23.593 19.9869 24.5683 19.9869 26.0634C19.9869 28.2077 19.9684 29.9334 19.9684 30.4613C19.9684 30.8877 20.2564 31.3874 21.0674 31.2301C27.4213 29.1086 32 23.1037 32 16.0256C32 7.17472 24.8364 0 16.0001 0ZM5.99257 22.8288C5.95733 22.9084 5.83227 22.9322 5.71834 22.8776C5.60229 22.8253 5.53711 22.7168 5.57474 22.6369C5.60918 22.5549 5.7345 22.5321 5.85029 22.587C5.9666 22.6393 6.03284 22.7489 5.99257 22.8288ZM6.7796 23.5321C6.70329 23.603 6.55412 23.5701 6.45291 23.4581C6.34825 23.3464 6.32864 23.197 6.40601 23.125C6.4847 23.0542 6.62937 23.0874 6.73429 23.1991C6.83895 23.3121 6.85935 23.4605 6.7796 23.5321ZM7.31953 24.4321C7.2215 24.5003 7.0612 24.4363 6.96211 24.2938C6.86407 24.1513 6.86407 23.9804 6.96422 23.9119C7.06358 23.8435 7.2215 23.905 7.32191 24.0465C7.41968 24.1914 7.41968 24.3623 7.31953 24.4321ZM8.23267 25.4743C8.14497 25.5712 7.95818 25.5452 7.82146 25.413C7.68156 25.2838 7.64261 25.1004 7.73058 25.0035C7.81934 24.9064 8.00719 24.9337 8.14497 25.0648C8.28381 25.1938 8.3262 25.3785 8.23267 25.4743ZM9.41281 25.8262C9.37413 25.9517 9.19423 26.0088 9.013 25.9554C8.83203 25.9005 8.7136 25.7535 8.75016 25.6266C8.78778 25.5003 8.96848 25.4408 9.15104 25.4979C9.33174 25.5526 9.45044 25.6985 9.41281 25.8262ZM10.7559 25.9754C10.7604 26.1076 10.6067 26.2172 10.4165 26.2196C10.2252 26.2238 10.0704 26.1169 10.0683 25.9868C10.0683 25.8534 10.2185 25.7448 10.4098 25.7416C10.6001 25.7379 10.7559 25.8441 10.7559 25.9754ZM12.0753 25.9248C12.0981 26.0537 11.9658 26.1862 11.7769 26.2215C11.5912 26.2554 11.4192 26.1758 11.3957 26.0479C11.3726 25.9157 11.5072 25.7833 11.6927 25.7491C11.8819 25.7162 12.0512 25.7937 12.0753 25.9248Z"/></svg>
\ No newline at end of file
<svg xmlns="http://www.w3.org/2000/svg" width="33" height="32" fill="none" viewBox="0 0 33 32"><g clip-path="url(#clip0_10031_177597)"><path fill="#B7F0EF" fill-rule="evenodd" d="M17 7.87059C17 6.48214 17.9812 5.28722 19.3431 5.01709L29.5249 2.99755C31.3238 2.64076 33 4.01717 33 5.85105V22.1344C33 23.5229 32.0188 24.7178 30.6569 24.9879L20.4751 27.0074C18.6762 27.3642 17 25.9878 17 24.1539L17 7.87059Z" clip-rule="evenodd" opacity=".7"/><path fill="#87E6E5" fill-rule="evenodd" d="M1 5.85245C1 4.01857 2.67623 2.64215 4.47507 2.99895L14.6569 5.01848C16.0188 5.28861 17 6.48354 17 7.87198V24.1553C17 25.9892 15.3238 27.3656 13.5249 27.0088L3.34311 24.9893C1.98119 24.7192 1 23.5242 1 22.1358V5.85245Z" clip-rule="evenodd"/><path fill="#61C1FD" fill-rule="evenodd" d="M15.543 5.71289C15.543 5.71289 16.8157 5.96289 17.4002 6.57653C17.9847 7.19016 18.4521 9.03107 18.4521 9.03107C18.4521 9.03107 18.4521 25.1106 18.4521 26.9629C18.4521 28.8152 19.3775 31.4174 19.3775 31.4174L17.4002 28.8947L16.2575 31.4174C16.2575 31.4174 15.543 29.0765 15.543 27.122C15.543 25.1674 15.543 5.71289 15.543 5.71289Z" clip-rule="evenodd"/></g><defs><clipPath id="clip0_10031_177597"><rect width="32" height="32" fill="#fff" transform="translate(0.5)"/></clipPath></defs></svg>
\ No newline at end of file
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none" viewBox="0 0 32 32"><path fill="#ED1D24" d="M31.3313 8.44657C30.9633 7.08998 29.8791 6.02172 28.5022 5.65916C26.0067 5.00026 16 5.00026 16 5.00026C16 5.00026 5.99333 5.00026 3.4978 5.65916C2.12102 6.02172 1.03665 7.08998 0.668678 8.44657C0 10.9053 0 16.0353 0 16.0353C0 16.0353 0 21.1652 0.668678 23.6242C1.03665 24.9806 2.12102 26.0489 3.4978 26.4116C5.99333 27.0703 16 27.0703 16 27.0703C16 27.0703 26.0067 27.0703 28.5022 26.4116C29.8791 26.0489 30.9633 24.9806 31.3313 23.6242C32 21.1652 32 16.0353 32 16.0353C32 16.0353 32 10.9053 31.3313 8.44657Z"/><path fill="#fff" d="M12.7266 20.6934L21.0902 16.036L12.7266 11.3781V20.6934Z"/></svg>
\ No newline at end of file
.storybook-button {
display: inline-block;
cursor: pointer;
border: 0;
border-radius: 3em;
font-weight: 700;
line-height: 1;
font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
.storybook-button--primary {
background-color: #555ab9;
color: white;
}
.storybook-button--secondary {
box-shadow: rgba(0, 0, 0, 0.15) 0px 0px 0px 1px inset;
background-color: transparent;
color: #333;
}
.storybook-button--small {
padding: 10px 16px;
font-size: 12px;
}
.storybook-button--medium {
padding: 11px 20px;
font-size: 14px;
}
.storybook-button--large {
padding: 12px 24px;
font-size: 16px;
}
.storybook-header {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
padding: 15px 20px;
font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
.storybook-header svg {
display: inline-block;
vertical-align: top;
}
.storybook-header h1 {
display: inline-block;
vertical-align: top;
margin: 6px 0 6px 10px;
font-weight: 700;
font-size: 20px;
line-height: 1;
}
.storybook-header button + button {
margin-left: 10px;
}
.storybook-header .welcome {
margin-right: 10px;
color: #333;
font-size: 14px;
}
.storybook-page {
margin: 0 auto;
padding: 48px 20px;
max-width: 600px;
color: #333;
font-size: 14px;
line-height: 24px;
font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
.storybook-page h2 {
display: inline-block;
vertical-align: top;
margin: 0 0 4px;
font-weight: 700;
font-size: 32px;
line-height: 1;
}
.storybook-page p {
margin: 1em 0;
}
.storybook-page a {
color: inherit;
}
.storybook-page ul {
margin: 1em 0;
padding-left: 30px;
}
.storybook-page li {
margin-bottom: 8px;
}
.storybook-page .tip {
display: inline-block;
vertical-align: top;
margin-right: 10px;
border-radius: 1em;
background: #e7fdd8;
padding: 4px 12px;
color: #357a14;
font-weight: 700;
font-size: 11px;
line-height: 12px;
}
.storybook-page .tip-wrapper {
margin-top: 40px;
margin-bottom: 40px;
font-size: 13px;
line-height: 20px;
}
.storybook-page .tip-wrapper svg {
display: inline-block;
vertical-align: top;
margin-top: 3px;
margin-right: 4px;
width: 12px;
height: 12px;
}
.storybook-page .tip-wrapper svg path {
fill: #1ea7fd;
}
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment