Commit 1b90df82 by krds-arun

feat(header,footer,widgets ,page sections): new components

parent 7357a58b
...@@ -15,13 +15,19 @@ html { ...@@ -15,13 +15,19 @@ html {
body { body {
background: var(--background); background: var(--background);
color: var(--foreground); color: var(--foreground);
font-feature-settings: "ss01" on, "cv11" on; font-feature-settings:
"ss01" on,
"cv11" on;
} }
.font-display { .font-display {
font-family: var(--font-display), ui-sans-serif, system-ui, sans-serif; font-family: var(--font-display), ui-sans-serif, system-ui, sans-serif;
} }
.font-serif {
font-family: var(--font-serif), "Georgia", ui-serif, serif;
}
@keyframes drift { @keyframes drift {
0%, 0%,
100% { 100% {
......
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Manrope, Syne } from "next/font/google"; import { Manrope, Playfair_Display, Syne } from "next/font/google";
import "./globals.css"; import "./globals.css";
...@@ -8,6 +8,11 @@ const syne = Syne({ ...@@ -8,6 +8,11 @@ const syne = Syne({
subsets: ["latin"], subsets: ["latin"],
}); });
const playfair = Playfair_Display({
variable: "--font-serif",
subsets: ["latin"],
});
const manrope = Manrope({ const manrope = Manrope({
variable: "--font-sans", variable: "--font-sans",
subsets: ["latin"], subsets: ["latin"],
...@@ -26,7 +31,7 @@ export default function RootLayout({ ...@@ -26,7 +31,7 @@ export default function RootLayout({
return ( return (
<html lang="en"> <html lang="en">
<body <body
className={`${manrope.className} ${syne.variable} min-h-screen antialiased`} className={`${manrope.className} ${syne.variable} ${playfair.variable} min-h-screen antialiased`}
> >
{children} {children}
</body> </body>
......
"use client";
import React from "react";
import { Header } from "@/components/layout/Header/Header";
import { InsightHeroBanner } from "@/components/shared/banners/InsightHeroBanner/InsightHeroBanner";
import { MobileAppCtaBanner } from "@/components/shared/banners/MobileAppCtaBanner/MobileAppCtaBanner";
import { Footer } from "@/components/layout/Footer/Footer";
import { LoginModal } from "@/components/widgets/LoginModal/LoginModal";
import { SupportModal } from "@/components/widgets/SupportModal/SupportModal";
import {
IconAudits,
IconCompliance,
IconDashboards,
IconIncident,
IconInspections,
IconOffline,
IconReports,
IconSecurity,
IconTraining,
} from "@/components/page-sections/CapabilitiesShowcaseSection/capabilityIcons";
import { CapabilitiesShowcaseSection } from "@/components/page-sections/CapabilitiesShowcaseSection/CapabilitiesShowcaseSection";
export default function Home() { export default function Home() {
const [loginOpen, setLoginOpen] = React.useState(false);
const [supportOpen, setSupportOpen] = React.useState(false);
return ( return (
<main className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-6 py-20"> <>
<div <Header
className="pointer-events-none absolute inset-0 animate-drift" supportCta={{
aria-hidden label: "Support",
style={{ onClick: () => setSupportOpen(true),
background: `
radial-gradient(ellipse 80% 50% at 50% -20%, var(--glow), transparent),
radial-gradient(ellipse 60% 40% at 100% 50%, rgba(90, 74, 58, 0.2), transparent),
radial-gradient(ellipse 50% 35% at 0% 80%, rgba(60, 52, 44, 0.35), transparent)
`,
}} }}
/> cta={{
<div label: "Login",
className="pointer-events-none absolute inset-0 opacity-[0.03]" onClick: () => setLoginOpen(true),
aria-hidden
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")`,
}} }}
/> />
<InsightHeroBanner
eyebrow="Enterprise Platform • 2026"
title={
<>
The <br />
Gateway
</>
}
subtitle="Assurance | Management Data | Compliance"
description="A comprehensive bespoke platform that replaces manual steps in business process flows — from audit and compliance to training and incident management."
links={[
{ label: "Assurance" },
{ label: "Management Data" },
{ label: "Compliance" },
]}
primaryCta={{ label: "Access Gateway", href: "#" }}
secondaryCta={{ label: "Explore modules", href: "#" }}
stats={[
{ value: "6+", label: "Modules" },
{ value: "iOS & Android", label: "Mobile" },
{ value: "100%", label: "Digital" },
]}
background="/image-443bcc33-80a0-465e-a40b-dbabfe7b2ff6.png"
/>
<MobileAppCtaBanner
eyebrow="Mobile app"
title="Gateway in your pocket"
description="Capture data, complete checklists and report incidents directly from the field — online or offline."
appStoreHref="https://apps.apple.com/"
googlePlayHref="https://play.google.com/store"
/>
<CapabilitiesShowcaseSection
title="Capabilities"
copyBlocks={[
{
title: "About the system:",
body: "A unified assurance workspace that connects teams, data, and workflows — designed for clarity in the field and control in the office.",
},
{
title: "Mobile application:",
body: "Purpose-built for frontline work with offline resilience, guided checklists, and instant escalation when it matters most.",
},
{
title: "Cyber security and data privacy:",
body: "Defense-in-depth controls, least-privilege access, and auditable trails so sensitive operational data stays protected end-to-end.",
},
]}
capabilities={[
{
id: "incident",
label: "Incident reporting",
icon: <IconIncident />,
href: "#incident",
},
{
id: "audits",
label: "Audits",
icon: <IconAudits />,
href: "#audits",
},
{
id: "inspections",
label: "Inspections",
icon: <IconInspections />,
href: "#inspections",
},
{
id: "compliance",
label: "Compliance",
icon: <IconCompliance />,
href: "#compliance",
},
{
id: "training",
label: "Training",
icon: <IconTraining />,
href: "#training",
},
{
id: "reports",
label: "Reports",
icon: <IconReports />,
href: "#reports",
},
{
id: "dashboards",
label: "Dashboards",
icon: <IconDashboards />,
href: "#dashboards",
},
{
id: "offline",
label: "Offline mode",
icon: <IconOffline />,
href: "#offline",
},
{
id: "security",
label: "Security",
icon: <IconSecurity />,
href: "#security",
},
]}
/>
<Footer />
<div className="relative z-10 mx-auto max-w-lg text-center"> <LoginModal
<p className="animate-fade-up text-xs font-medium uppercase tracking-[0.35em] text-[var(--muted)]"> open={loginOpen}
MAF Revamp onClose={() => setLoginOpen(false)}
</p> onMicrosoftSignIn={() => {
<h1 className="font-display animate-fade-up-delay mt-6 text-5xl font-semibold leading-[1.05] tracking-tight sm:text-6xl"> setLoginOpen(false);
Welcome }}
</h1> onContactSupport={() => {
<p className="animate-fade-up-delay-2 mt-6 text-lg leading-relaxed text-[var(--muted)]"> setLoginOpen(false);
You are on a clean frontend shell. Build screens and flows here when you are ready. setSupportOpen(true);
</p> }}
<p className="animate-fade-up-delay-2 mt-10 font-mono text-xs text-[var(--muted)]/70"> />
pnpm dev <SupportModal open={supportOpen} onClose={() => setSupportOpen(false)} />
</p> </>
</div>
</main>
); );
} }
{
"$schema": "https://biomejs.dev/schemas/2.4.8/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false,
"includes": ["**", "!!**/.next", "!!**/storybook-static", "!!**/coverage"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
},
"overrides": [
{
"includes": ["**/form-controls.css"],
"linter": {
"rules": {
"style": {
"noDescendingSpecificity": "off"
}
}
}
}
]
}
import type React from "react";
import "./app-store-badges.css";
export type AppStoreBadgesProps = {
appStoreHref: string;
googlePlayHref: string;
className?: string;
/** Opens store links in a new tab (recommended for external URLs) */
openInNewTab?: boolean;
};
function AppleGlyph({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden
>
<path d="M16.365 1.43c0 1.14-.493 2.096-1.104 2.542-.637.465-1.741.814-2.666.768-.126-1.109.484-2.24 1.115-2.865C13.391.95 14.54.494 15.55.27c.117 1.037-.32 2.04-.185 1.16zm4.23 15.613c-.04 3.504-3.078 4.686-4.67 4.78-1.46.085-3.22-.97-4.28-.97-1.08 0-2.77.94-4.55.94-2.28 0-4.35-1.94-4.35-5.8 0-3.06 1.17-6.23 2.62-8.32 1.2-1.78 2.58-3.35 4.43-3.35 1.38 0 2.38.88 3.65.88 1.22 0 1.96-.89 3.71-.89 1.33 0 2.73 1.22 3.73 2.9-3.29 1.8-2.76 6.48.22 7.73-.53 1.34-1.19 2.67-2.03 3.72z" />
</svg>
);
}
function PlayGlyph({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" aria-hidden>
<path
fill="currentColor"
d="M3 20.5V3.5L21 12 3 20.5z"
/>
</svg>
);
}
/**
* App Store and Google Play download links with branded-style affordances.
* Replace hrefs with real store URLs in production.
*/
export function AppStoreBadges({
appStoreHref,
googlePlayHref,
className = "",
openInNewTab = true,
}: AppStoreBadgesProps) {
const rel = openInNewTab ? "noopener noreferrer" : undefined;
const target = openInNewTab ? "_blank" : undefined;
return (
<div
className={`maf-app-badges ${className}`.trim()}
role="group"
aria-label="Download the mobile app"
>
<a
className="maf-app-badge maf-app-badge--apple"
href={appStoreHref}
target={target}
rel={rel}
>
<AppleGlyph className="maf-app-badge__glyph" />
<span className="maf-app-badge__text">
<span className="maf-app-badge__kicker">Download on the</span>
<span className="maf-app-badge__name">App Store</span>
</span>
</a>
<a
className="maf-app-badge maf-app-badge--google"
href={googlePlayHref}
target={target}
rel={rel}
>
<PlayGlyph className="maf-app-badge__glyph maf-app-badge__glyph--play" />
<span className="maf-app-badge__text">
<span className="maf-app-badge__kicker">Get it on</span>
<span className="maf-app-badge__name">Google Play</span>
</span>
</a>
</div>
);
}
.maf-app-badges {
display: flex;
flex-wrap: wrap;
align-items: stretch;
gap: 0.75rem;
}
.maf-app-badge {
display: inline-flex;
align-items: center;
gap: 0.65rem;
min-height: 3.25rem;
padding: 0.5rem 1rem 0.5rem 0.75rem;
border-radius: 0.65rem;
text-decoration: none;
color: rgba(255, 255, 255, 0.94);
background: linear-gradient(
165deg,
rgba(42, 42, 46, 0.98) 0%,
rgba(24, 24, 28, 0.98) 100%
);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow:
0 4px 14px rgba(0, 0, 0, 0.35),
inset 0 1px 0 rgba(255, 255, 255, 0.06);
transition:
transform 160ms ease,
box-shadow 160ms ease,
border-color 160ms ease,
background 160ms ease;
}
@media (prefers-reduced-motion: reduce) {
.maf-app-badge {
transition: none;
}
}
.maf-app-badge:hover {
transform: translateY(-2px);
border-color: rgba(196, 165, 116, 0.35);
box-shadow:
0 10px 28px rgba(0, 0, 0, 0.45),
0 0 0 1px rgba(196, 165, 116, 0.12);
}
.maf-app-badge:focus-visible {
outline: 2px solid var(--accent, #c4a574);
outline-offset: 3px;
}
.maf-app-badge__glyph {
width: 1.65rem;
height: 1.65rem;
flex-shrink: 0;
}
.maf-app-badge__glyph--play {
width: 1.5rem;
height: 1.5rem;
}
.maf-app-badge__text {
display: flex;
flex-direction: column;
align-items: flex-start;
line-height: 1.05;
text-align: left;
}
.maf-app-badge__kicker {
font-size: 0.5625rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.62);
}
.maf-app-badge__name {
font-size: 1.05rem;
font-weight: 600;
letter-spacing: 0.02em;
}
.maf-app-badge--google .maf-app-badge__glyph--play {
color: #00dc82;
}
import type { Meta, StoryObj } from "@storybook/nextjs-vite";
import {
IconAudits,
IconInspections,
} from "@/components/page-sections/CapabilitiesShowcaseSection/capabilityIcons";
import { FeatureTileCard } from "./FeatureTileCard";
const meta = {
title: "Base/FeatureTileCard",
component: FeatureTileCard,
parameters: {
layout: "centered",
},
} satisfies Meta<typeof FeatureTileCard>;
export default meta;
type Story = StoryObj<typeof meta>;
export const AsLink: Story = {
args: {
label: "Audits",
icon: <IconAudits />,
href: "#",
},
};
export const Static: Story = {
args: {
label: "Inspections",
icon: <IconInspections />,
},
};
import type React from "react";
import "./feature-tile-card.css";
export type FeatureTileCardProps = {
label: string;
icon: React.ReactNode;
/** When set, the tile renders as a link */
href?: string;
onClick?: () => void;
className?: string;
/** Extra context for assistive tech (not shown visually) */
description?: string;
};
/**
* Square feature tile with centered icon + label — used in tools / capability grids.
*/
export function FeatureTileCard({
label,
icon,
href,
onClick,
className = "",
description,
}: FeatureTileCardProps) {
const body = (
<>
<span className="maf-feature-tile__icon" aria-hidden>
{icon}
</span>
<span className="maf-feature-tile__label">{label}</span>
{description ? (
<span className="sr-only">{description}</span>
) : null}
</>
);
if (href) {
return (
<a
className={`maf-feature-tile ${className}`.trim()}
href={href}
>
{body}
</a>
);
}
if (onClick) {
return (
<button
type="button"
className={`maf-feature-tile maf-feature-tile--button ${className}`.trim()}
onClick={onClick}
>
{body}
</button>
);
}
return (
<div className={`maf-feature-tile maf-feature-tile--static ${className}`.trim()}>
{body}
</div>
);
}
.maf-feature-tile {
--tile-fg: #c4a574;
--tile-bg: linear-gradient(
165deg,
rgba(52, 44, 36, 0.98) 0%,
rgba(34, 28, 22, 0.99) 100%
);
--tile-border: rgba(196, 165, 116, 0.22);
--tile-shadow:
0 12px 28px rgba(17, 14, 10, 0.28),
0 2px 8px rgba(17, 14, 10, 0.12);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.85rem;
min-height: 9.5rem;
padding: 1.25rem 1rem;
border-radius: 0.65rem;
text-align: center;
text-decoration: none;
color: var(--tile-fg);
background: var(--tile-bg);
border: 1px solid var(--tile-border);
box-shadow: var(--tile-shadow);
transition:
transform 180ms ease,
box-shadow 180ms ease,
border-color 180ms ease;
}
@media (prefers-reduced-motion: reduce) {
.maf-feature-tile {
transition: none;
}
}
.maf-feature-tile:hover {
transform: translateY(-3px);
border-color: rgba(196, 165, 116, 0.42);
box-shadow:
0 18px 40px rgba(17, 14, 10, 0.35),
0 4px 12px rgba(17, 14, 10, 0.15);
}
.maf-feature-tile:focus-visible {
outline: 2px solid var(--accent, #c4a574);
outline-offset: 3px;
}
.maf-feature-tile--button {
cursor: pointer;
font: inherit;
width: 100%;
}
.maf-feature-tile--static {
cursor: default;
}
.maf-feature-tile--static:hover {
transform: none;
}
.maf-feature-tile__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 3.25rem;
height: 3.25rem;
color: var(--tile-fg);
}
.maf-feature-tile__icon svg {
width: 2.75rem;
height: 2.75rem;
stroke: currentColor;
fill: none;
stroke-width: 1.35;
stroke-linecap: round;
stroke-linejoin: round;
}
.maf-feature-tile__label {
font-size: 0.9375rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
line-height: 1.25;
max-width: 12rem;
}
.maf-feature-tile .sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
import type React from "react";
import "./section-eyebrow.css";
export type SectionEyebrowProps = {
children: React.ReactNode;
className?: string;
/** Visually hidden prefix for screen readers when the line is decorative */
srPrefix?: string;
};
/**
* Small caps label with a decorative accent line — use above section titles.
*/
export function SectionEyebrow({
children,
className = "",
srPrefix,
}: SectionEyebrowProps) {
return (
<p className={`maf-section-eyebrow ${className}`.trim()}>
<span className="maf-section-eyebrow__line" aria-hidden />
{srPrefix ? (
<span className="sr-only">{srPrefix} </span>
) : null}
<span className="maf-section-eyebrow__text">{children}</span>
</p>
);
}
.maf-section-eyebrow {
display: flex;
align-items: center;
gap: 0.65rem;
margin: 0 0 0.85rem;
padding: 0;
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--maf-eyebrow-fg, var(--accent, #c4a574));
}
.maf-section-eyebrow__line {
display: block;
width: 1.75rem;
height: 1px;
flex-shrink: 0;
background: linear-gradient(
90deg,
var(--maf-eyebrow-line, var(--accent, #c4a574)),
rgba(196, 165, 116, 0.25)
);
}
.maf-section-eyebrow__text {
font-family: var(--font-serif), Georgia, serif;
letter-spacing: 0.12em;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
import type { Meta, StoryObj } from '@storybook/nextjs-vite' import type { Meta, StoryObj } from "@storybook/nextjs-vite";
import { fn } from 'storybook/test' import { fn } from "storybook/test";
import type { FormDefinitionSchema } from '../types' import type { FormDefinitionSchema } from "../types";
import { FormRenderer } from './FormRenderer' import { FormRenderer } from "./FormRenderer";
const sampleSchema: FormDefinitionSchema = { const sampleSchema: FormDefinitionSchema = {
meta: { meta: {
formCode: 'MAF_OP_TECH_001', formCode: "MAF_OP_TECH_001",
sourceId: 'webform.webform.sample', sourceId: "webform.webform.sample",
title: 'Operational + Technical Checklist', title: "Operational + Technical Checklist",
categoryIds: ['operational_checklist', 'technical_checklist'], categoryIds: ["operational_checklist", "technical_checklist"],
version: 1, version: 1,
}, },
layout: [ layout: [
{ groupCode: 'operational', label: 'Operational Checklist', order: 1 }, { groupCode: "operational", label: "Operational Checklist", order: 1 },
{ groupCode: 'technical', label: 'Technical Checklist', order: 2 }, { groupCode: "technical", label: "Technical Checklist", order: 2 },
], ],
questions: [ questions: [
{ {
questionCode: 'op_general', questionCode: "op_general",
groupCode: 'operational', groupCode: "operational",
label: 'General observation', label: "General observation",
inputType: 'text', inputType: "text",
required: true, required: true,
questionOrder: 1, questionOrder: 1,
validation: {}, validation: {},
}, },
{ {
questionCode: 'op_radio_choice', questionCode: "op_radio_choice",
groupCode: 'operational', groupCode: "operational",
label: 'A separate laundry basket available to collect the dirty napkins for the laundry, tagged as unclean?', label:
inputType: 'radio', "A separate laundry basket available to collect the dirty napkins for the laundry, tagged as unclean?",
options: ['Yes', 'No', 'NA'], inputType: "radio",
options: ["Yes", "No", "NA"],
required: true, required: true,
questionOrder: 2, questionOrder: 2,
validation: { validation: {
...@@ -39,11 +40,12 @@ const sampleSchema: FormDefinitionSchema = { ...@@ -39,11 +40,12 @@ const sampleSchema: FormDefinitionSchema = {
}, },
}, },
{ {
questionCode: 'op_comment_enabled', questionCode: "op_comment_enabled",
groupCode: 'operational', groupCode: "operational",
label: 'Are there any arrangements made for the waste movement outside the food store to the main waste storage room with controls available?', label:
inputType: 'select', "Are there any arrangements made for the waste movement outside the food store to the main waste storage room with controls available?",
options: ['No', 'Yes'], inputType: "select",
options: ["No", "Yes"],
required: false, required: false,
questionOrder: 3, questionOrder: 3,
validation: { validation: {
...@@ -51,19 +53,19 @@ const sampleSchema: FormDefinitionSchema = { ...@@ -51,19 +53,19 @@ const sampleSchema: FormDefinitionSchema = {
}, },
}, },
{ {
questionCode: 'op_due_date', questionCode: "op_due_date",
groupCode: 'operational', groupCode: "operational",
label: 'Next inspection due date', label: "Next inspection due date",
inputType: 'date', inputType: "date",
required: true, required: true,
questionOrder: 4, questionOrder: 4,
validation: {}, validation: {},
}, },
{ {
questionCode: 'op_linked_file_enabled', questionCode: "op_linked_file_enabled",
groupCode: 'operational', groupCode: "operational",
label: 'Upload evidence (optional)', label: "Upload evidence (optional)",
inputType: 'checkbox', inputType: "checkbox",
required: false, required: false,
questionOrder: 5, questionOrder: 5,
validation: { validation: {
...@@ -71,20 +73,20 @@ const sampleSchema: FormDefinitionSchema = { ...@@ -71,20 +73,20 @@ const sampleSchema: FormDefinitionSchema = {
}, },
}, },
{ {
questionCode: 'tech_confirm', questionCode: "tech_confirm",
groupCode: 'technical', groupCode: "technical",
label: 'I confirm technical checklist completion', label: "I confirm technical checklist completion",
inputType: 'checkbox', inputType: "checkbox",
required: true, required: true,
questionOrder: 1, questionOrder: 1,
validation: {}, validation: {},
}, },
{ {
questionCode: 'tech_issues', questionCode: "tech_issues",
groupCode: 'technical', groupCode: "technical",
label: 'Select observed issue types', label: "Select observed issue types",
inputType: 'multiselect', inputType: "multiselect",
options: ['Mechanical', 'Electrical', 'Safety', 'Housekeeping'], options: ["Mechanical", "Electrical", "Safety", "Housekeeping"],
required: true, required: true,
questionOrder: 2, questionOrder: 2,
validation: { validation: {
...@@ -92,10 +94,10 @@ const sampleSchema: FormDefinitionSchema = { ...@@ -92,10 +94,10 @@ const sampleSchema: FormDefinitionSchema = {
}, },
}, },
{ {
questionCode: 'tech_notes', questionCode: "tech_notes",
groupCode: 'technical', groupCode: "technical",
label: 'Technical notes', label: "Technical notes",
inputType: 'textarea', inputType: "textarea",
required: false, required: false,
questionOrder: 3, questionOrder: 3,
validation: {}, validation: {},
...@@ -104,30 +106,29 @@ const sampleSchema: FormDefinitionSchema = { ...@@ -104,30 +106,29 @@ const sampleSchema: FormDefinitionSchema = {
hiddenFields: [], hiddenFields: [],
validation: { validation: {
requiredQuestions: [ requiredQuestions: [
'op_general', "op_general",
'op_radio_choice', "op_radio_choice",
'op_due_date', "op_due_date",
'tech_confirm', "tech_confirm",
'tech_issues', "tech_issues",
], ],
}, },
} };
const meta = { const meta = {
title: 'Forms/FormRenderer', title: "Forms/FormRenderer",
component: FormRenderer, component: FormRenderer,
parameters: { parameters: {
layout: 'fullscreen', layout: "fullscreen",
}, },
args: { args: {
onSubmit: fn(), onSubmit: fn(),
onTitleAction: fn(), onTitleAction: fn(),
schema: sampleSchema, schema: sampleSchema,
}, },
} satisfies Meta<typeof FormRenderer> } satisfies Meta<typeof FormRenderer>;
export default meta export default meta;
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>;
export const Default: Story = {}
export const Default: Story = {};
import type { ReactNode } from 'react' import type { ReactNode } from "react";
import './form-controls.css' import "./form-controls.css";
export type CheckboxFieldProps = { export type CheckboxFieldProps = {
id: string id: string;
checked?: boolean checked?: boolean;
label: ReactNode label: ReactNode;
disabled?: boolean disabled?: boolean;
onChange: (next: boolean) => void onChange: (next: boolean) => void;
} };
export function CheckboxField({ id, checked, label, disabled, onChange }: CheckboxFieldProps) { export function CheckboxField({
id,
checked,
label,
disabled,
onChange,
}: CheckboxFieldProps) {
return ( return (
<label className="maf-choice" data-checked={Boolean(checked)} htmlFor={id}> <label className="maf-choice" data-checked={Boolean(checked)} htmlFor={id}>
<input <input
...@@ -24,6 +30,5 @@ export function CheckboxField({ id, checked, label, disabled, onChange }: Checkb ...@@ -24,6 +30,5 @@ export function CheckboxField({ id, checked, label, disabled, onChange }: Checkb
<span className="maf-checkbox-ui" aria-hidden="true" /> <span className="maf-checkbox-ui" aria-hidden="true" />
<span className="maf-choice-text">{label}</span> <span className="maf-choice-text">{label}</span>
</label> </label>
) );
} }
export type FieldsetTabItem = { export type FieldsetTabItem = {
key: string key: string;
label: string label: string;
} total?: number;
answered?: number;
requiredRemaining?: number;
};
export type FieldsetTabsProps = { export type FieldsetTabsProps = {
tabs: FieldsetTabItem[] tabs: FieldsetTabItem[];
activeKey: string activeKey: string;
onChange: (key: string) => void onChange: (key: string) => void;
} };
export function FieldsetTabs({ tabs, activeKey, onChange }: FieldsetTabsProps) { export function FieldsetTabs({ tabs, activeKey, onChange }: FieldsetTabsProps) {
return ( return (
...@@ -28,25 +31,51 @@ export function FieldsetTabs({ tabs, activeKey, onChange }: FieldsetTabsProps) { ...@@ -28,25 +31,51 @@ export function FieldsetTabs({ tabs, activeKey, onChange }: FieldsetTabsProps) {
</select> </select>
</label> </label>
<div className="maf-fieldset-tabs" role="tablist" aria-label="Checklist groups"> <div
className="maf-fieldset-tabs"
role="tablist"
aria-label="Checklist groups"
>
{tabs.map((tab) => { {tabs.map((tab) => {
const active = tab.key === activeKey const active = tab.key === activeKey;
const answered = tab.answered ?? 0;
const total = tab.total ?? 0;
const progressPercent =
total > 0 ? Math.round((answered / total) * 100) : 0;
const requiredRemaining = tab.requiredRemaining ?? 0;
return ( return (
<button <button
key={tab.key} key={tab.key}
type="button" type="button"
className="maf-fieldset-tab" className="maf-fieldset-tab"
data-active={active} data-active={active}
data-error={requiredRemaining > 0}
role="tab" role="tab"
aria-selected={active} aria-selected={active}
onClick={() => onChange(tab.key)} onClick={() => onChange(tab.key)}
> >
<span className="maf-fieldset-tab-label">{tab.label}</span> <span className="maf-fieldset-tab-label">{tab.label}</span>
<span className="maf-fieldset-tab-meta">
<span className="maf-fieldset-tab-count">
{answered}/{total} done
</span>
{requiredRemaining > 0 ? (
<span className="maf-fieldset-tab-required">
{requiredRemaining} required left
</span>
) : (
<span className="maf-fieldset-tab-required">
All required done
</span>
)}
</span>
<span className="maf-fieldset-tab-progress" aria-hidden="true">
<span style={{ width: `${progressPercent}%` }} />
</span>
</button> </button>
) );
})} })}
</div> </div>
</div> </div>
) );
} }
export type FormActionsProps = { export type FormActionsProps = {
onReset: () => void onReset: () => void;
submitLabel?: string submitLabel?: string;
resetLabel?: string resetLabel?: string;
disabled?: boolean disabled?: boolean;
} };
export function FormActions({ export function FormActions({
onReset, onReset,
submitLabel = 'Submit', submitLabel = "Submit",
resetLabel = 'Reset', resetLabel = "Reset",
disabled, disabled,
}: FormActionsProps) { }: FormActionsProps) {
return ( return (
...@@ -16,10 +16,14 @@ export function FormActions({ ...@@ -16,10 +16,14 @@ export function FormActions({
<button className="btn-primary" type="submit" disabled={disabled}> <button className="btn-primary" type="submit" disabled={disabled}>
{submitLabel} {submitLabel}
</button> </button>
<button className="btn-ghost" type="button" onClick={onReset} disabled={disabled}> <button
className="btn-ghost"
type="button"
onClick={onReset}
disabled={disabled}
>
{resetLabel} {resetLabel}
</button> </button>
</div> </div>
) );
} }
import React, { type ReactNode } from 'react' import React, { type ReactNode } from "react";
import { ToggleField } from './ToggleField' import { ToggleField } from "./ToggleField";
export type FormGroupSectionProps = { export type FormGroupSectionProps = {
title: string title: string;
sectionIndex?: number sectionIndex?: number;
totalSections?: number totalSections?: number;
children: ReactNode children: ReactNode;
} };
export function FormGroupSection({ title, sectionIndex = 1, totalSections = 1, children }: FormGroupSectionProps) { export function FormGroupSection({
const [expanded, setExpanded] = React.useState(true) title,
sectionIndex = 1,
totalSections = 1,
children,
}: FormGroupSectionProps) {
const [expanded, setExpanded] = React.useState(true);
return ( return (
<section className="form-section"> <section className="form-section">
...@@ -32,7 +37,7 @@ export function FormGroupSection({ title, sectionIndex = 1, totalSections = 1, c ...@@ -32,7 +37,7 @@ export function FormGroupSection({ title, sectionIndex = 1, totalSections = 1, c
<span className="maf-rating-dot maf-rating-dot-na" /> <span className="maf-rating-dot maf-rating-dot-na" />
</div> </div>
<ToggleField <ToggleField
id={`group-toggle-${title.replace(/\s+/g, '-').toLowerCase()}`} id={`group-toggle-${title.replace(/\s+/g, "-").toLowerCase()}`}
checked={expanded} checked={expanded}
onChange={(next) => setExpanded(next)} onChange={(next) => setExpanded(next)}
onLabel="Hide" onLabel="Hide"
...@@ -42,6 +47,5 @@ export function FormGroupSection({ title, sectionIndex = 1, totalSections = 1, c ...@@ -42,6 +47,5 @@ export function FormGroupSection({ title, sectionIndex = 1, totalSections = 1, c
</div> </div>
{expanded ? children : null} {expanded ? children : null}
</section> </section>
) );
} }
export type FormTitleCardProps = { export type FormTitleCardProps = {
title: string title: string;
actionLabel?: string actionLabel?: string;
onAction?: () => void onAction?: () => void;
} };
export function FormTitleCard({ export function FormTitleCard({
title, title,
actionLabel = 'Action Plan', actionLabel = "Action Plan",
onAction, onAction,
}: FormTitleCardProps) { }: FormTitleCardProps) {
return ( return (
...@@ -21,6 +21,5 @@ export function FormTitleCard({ ...@@ -21,6 +21,5 @@ export function FormTitleCard({
</button> </button>
</div> </div>
</div> </div>
) );
} }
export function InlineFieldError({ error }: { error?: string }) { export function InlineFieldError({ error }: { error?: string }) {
if (!error) return null if (!error) return null;
return <div className="form-error visible">{error}</div> return <div className="form-error visible">{error}</div>;
} }
import React from 'react' import React from "react";
import './form-controls.css' import "./form-controls.css";
export type LinkedFileFieldProps = { export type LinkedFileFieldProps = {
id: string id: string;
files: File[] files: File[];
onChange: (next: File[]) => void onChange: (next: File[]) => void;
disabled?: boolean disabled?: boolean;
multiple?: boolean multiple?: boolean;
accept?: string accept?: string;
hint?: string hint?: string;
} };
export function LinkedFileField({ export function LinkedFileField({
id, id,
...@@ -19,62 +19,65 @@ export function LinkedFileField({ ...@@ -19,62 +19,65 @@ export function LinkedFileField({
disabled, disabled,
multiple = true, multiple = true,
accept, accept,
hint = 'Click to upload supporting files', hint = "Click to upload supporting files",
}: LinkedFileFieldProps) { }: LinkedFileFieldProps) {
const inputRef = React.useRef<HTMLInputElement | null>(null) const inputRef = React.useRef<HTMLInputElement | null>(null);
const [isDragging, setIsDragging] = React.useState(false) const [isDragging, setIsDragging] = React.useState(false);
const openFileDialog = () => { const openFileDialog = () => {
if (disabled) return if (disabled) return;
inputRef.current?.click() inputRef.current?.click();
} };
const applyFiles = (nextList: FileList | null | undefined) => { const applyFiles = (nextList: FileList | null | undefined) => {
if (!nextList) return if (!nextList) return;
const next = Array.from(nextList) const next = Array.from(nextList);
onChange(multiple ? next : next.slice(0, 1)) onChange(multiple ? next : next.slice(0, 1));
} };
const onDrop = (e: React.DragEvent) => { const onDrop = (e: React.DragEvent) => {
e.preventDefault() e.preventDefault();
e.stopPropagation() e.stopPropagation();
setIsDragging(false) setIsDragging(false);
applyFiles(e.dataTransfer?.files) applyFiles(e.dataTransfer?.files);
} };
const onDragOver = (e: React.DragEvent) => { const onDragOver = (e: React.DragEvent) => {
e.preventDefault() e.preventDefault();
e.stopPropagation() e.stopPropagation();
} };
const onRemoveAt = (idx: number) => { const onRemoveAt = (idx: number) => {
const next = files.filter((_, i) => i !== idx) const next = files.filter((_, i) => i !== idx);
onChange(next) onChange(next);
} };
return ( return (
<div> <div>
<div <button
type="button"
className="upload-placeholder" className="upload-placeholder"
role="button" disabled={disabled}
tabIndex={0}
aria-disabled={disabled}
onClick={openFileDialog} onClick={openFileDialog}
onKeyDown={(e) => {
if (disabled) return
if (e.key === 'Enter' || e.key === ' ') openFileDialog()
}}
onDrop={onDrop} onDrop={onDrop}
onDragOver={onDragOver} onDragOver={onDragOver}
onDragEnter={() => setIsDragging(true)} onDragEnter={() => setIsDragging(true)}
onDragLeave={() => setIsDragging(false)} onDragLeave={() => setIsDragging(false)}
data-dragging={isDragging} data-dragging={isDragging}
> >
<div style={{ fontWeight: 700, color: 'var(--text-1)', fontSize: 'var(--text-base)' }}> <div
{multiple ? 'Upload files' : 'Upload file'} style={{
fontWeight: 700,
color: "var(--text-1)",
fontSize: "var(--text-base)",
}}
>
{multiple ? "Upload files" : "Upload file"}
</div> </div>
<div style={{ fontSize: 'var(--text-sm)', color: 'var(--text-3)' }}>{hint}</div> <div style={{ fontSize: "var(--text-sm)", color: "var(--text-3)" }}>
{hint}
</div> </div>
</button>
<input <input
ref={inputRef} ref={inputRef}
...@@ -88,9 +91,12 @@ export function LinkedFileField({ ...@@ -88,9 +91,12 @@ export function LinkedFileField({
/> />
{files.length > 0 ? ( {files.length > 0 ? (
<div className="maf-file-list" aria-label="Selected files"> <section className="maf-file-list" aria-label="Selected files">
{files.map((f, idx) => ( {files.map((f, idx) => (
<div key={`${f.name}-${idx}`} className="maf-file-item"> <div
key={`${f.name}-${f.size}-${f.lastModified}`}
className="maf-file-item"
>
<div className="maf-file-name" title={f.name}> <div className="maf-file-name" title={f.name}>
{f.name} {f.name}
</div> </div>
...@@ -107,9 +113,8 @@ export function LinkedFileField({ ...@@ -107,9 +113,8 @@ export function LinkedFileField({
</button> </button>
</div> </div>
))} ))}
</div> </section>
) : null} ) : null}
</div> </div>
) );
} }
{
"folders": [
{
"path": "../../../../../.."
},
{
"path": "../../../../../../../../../Downloads/maf-mp-frontend-develop"
}
],
"settings": {}
}
export type OptionalCommentFieldProps = { export type OptionalCommentFieldProps = {
id: string id: string;
value?: string value?: string;
onChange: (next: string) => void onChange: (next: string) => void;
placeholder?: string placeholder?: string;
} };
export function OptionalCommentField({ export function OptionalCommentField({
id, id,
value, value,
onChange, onChange,
placeholder = 'Add an optional comment', placeholder = "Add an optional comment",
}: OptionalCommentFieldProps) { }: OptionalCommentFieldProps) {
return ( return (
<div> <div>
...@@ -19,12 +19,11 @@ export function OptionalCommentField({ ...@@ -19,12 +19,11 @@ export function OptionalCommentField({
<textarea <textarea
id={id} id={id}
className="form-input" className="form-input"
value={value ?? ''} value={value ?? ""}
placeholder={placeholder} placeholder={placeholder}
rows={4} rows={4}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
/> />
</div> </div>
) );
} }
import './form-controls.css' import "./form-controls.css";
export type RadioGroupFieldProps = { export type RadioGroupFieldProps = {
id: string id: string;
name: string name: string;
value?: string value?: string;
options: string[] options: string[];
disabled?: boolean disabled?: boolean;
variant?: 'default' | 'rating' variant?: "default" | "rating";
onChange: (next: string) => void onChange: (next: string) => void;
} };
const getRatingClass = (opt: string) => { const getRatingClass = (opt: string) => {
const key = opt.trim().toLowerCase() const key = opt.trim().toLowerCase();
if (key === 'yes') return 'maf-rating-dot-yes' if (key === "yes") return "maf-rating-dot-yes";
if (key === 'no') return 'maf-rating-dot-no' if (key === "no") return "maf-rating-dot-no";
if (key === 'na' || key === 'n/a') return 'maf-rating-dot-na' if (key === "na" || key === "n/a") return "maf-rating-dot-na";
return 'maf-rating-dot-default' return "maf-rating-dot-default";
} };
export function RadioGroupField({ export function RadioGroupField({
id, id,
...@@ -24,14 +24,14 @@ export function RadioGroupField({ ...@@ -24,14 +24,14 @@ export function RadioGroupField({
value, value,
options, options,
disabled, disabled,
variant = 'default', variant = "default",
onChange, onChange,
}: RadioGroupFieldProps) { }: RadioGroupFieldProps) {
if (variant === 'rating') { if (variant === "rating") {
return ( return (
<div className="maf-rating-group" role="radiogroup" aria-label={id}> <div className="maf-rating-group" role="radiogroup" aria-label={id}>
{options.map((opt) => { {options.map((opt) => {
const checked = value === opt const checked = value === opt;
return ( return (
<button <button
key={opt} key={opt}
...@@ -42,16 +42,16 @@ export function RadioGroupField({ ...@@ -42,16 +42,16 @@ export function RadioGroupField({
disabled={disabled} disabled={disabled}
onClick={() => onChange(opt)} onClick={() => onChange(opt)}
/> />
) );
})} })}
</div> </div>
) );
} }
return ( return (
<div className="maf-choice-group" role="radiogroup" aria-label={id}> <div className="maf-choice-group" role="radiogroup" aria-label={id}>
{options.map((opt) => { {options.map((opt) => {
const checked = value === opt const checked = value === opt;
return ( return (
<label key={opt} className="maf-choice" data-checked={checked}> <label key={opt} className="maf-choice" data-checked={checked}>
<input <input
...@@ -66,9 +66,8 @@ export function RadioGroupField({ ...@@ -66,9 +66,8 @@ export function RadioGroupField({
<span className="maf-radio-ui" aria-hidden="true" /> <span className="maf-radio-ui" aria-hidden="true" />
<span className="maf-choice-text">{opt}</span> <span className="maf-choice-text">{opt}</span>
</label> </label>
) );
})} })}
</div> </div>
) );
} }
import React from 'react' import React from "react";
export type SelectFieldProps = { export type SelectFieldProps = {
id: string id: string;
value?: string | string[] value?: string | string[];
options: string[] options: string[];
disabled?: boolean disabled?: boolean;
placeholder?: string placeholder?: string;
multiple?: boolean multiple?: boolean;
onChange: (next: string | string[]) => void onChange: (next: string | string[]) => void;
} };
export function SelectField({ export function SelectField({
id, id,
...@@ -19,48 +19,54 @@ export function SelectField({ ...@@ -19,48 +19,54 @@ export function SelectField({
multiple = false, multiple = false,
onChange, onChange,
}: SelectFieldProps) { }: SelectFieldProps) {
const [open, setOpen] = React.useState(false) const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState('') const [query, setQuery] = React.useState("");
const rootRef = React.useRef<HTMLDivElement | null>(null) const rootRef = React.useRef<HTMLDivElement | null>(null);
const searchInputRef = React.useRef<HTMLInputElement | null>(null);
React.useEffect(() => {
if (!multiple || !open) return;
searchInputRef.current?.focus();
}, [multiple, open]);
React.useEffect(() => { React.useEffect(() => {
if (!multiple) return if (!multiple) return;
const onDocClick = (e: MouseEvent) => { const onDocClick = (e: MouseEvent) => {
if (!rootRef.current) return if (!rootRef.current) return;
if (!rootRef.current.contains(e.target as Node)) { if (!rootRef.current.contains(e.target as Node)) {
setOpen(false) setOpen(false);
setQuery('') setQuery("");
}
} }
};
document.addEventListener('mousedown', onDocClick) document.addEventListener("mousedown", onDocClick);
return () => document.removeEventListener('mousedown', onDocClick) return () => document.removeEventListener("mousedown", onDocClick);
}, [multiple]) }, [multiple]);
const normalizedValue = multiple const normalizedValue = multiple
? Array.isArray(value) ? Array.isArray(value)
? value ? value
: [] : []
: typeof value === 'string' : typeof value === "string"
? value ? value
: '' : "";
if (multiple) { if (multiple) {
const selected = normalizedValue as string[] const selected = normalizedValue as string[];
const onSelectOption = (opt: string) => { const onSelectOption = (opt: string) => {
if (selected.includes(opt)) { if (selected.includes(opt)) {
setOpen(false) setOpen(false);
setQuery('') setQuery("");
return return;
}
onChange([...selected, opt])
setOpen(false)
setQuery('')
} }
onChange([...selected, opt]);
setOpen(false);
setQuery("");
};
const filteredOptions = options.filter((opt) => const filteredOptions = options.filter((opt) =>
opt.toLowerCase().includes(query.trim().toLowerCase()) opt.toLowerCase().includes(query.trim().toLowerCase()),
) );
return ( return (
<div className="maf-multiselect" ref={rootRef}> <div className="maf-multiselect" ref={rootRef}>
...@@ -74,7 +80,9 @@ export function SelectField({ ...@@ -74,7 +80,9 @@ export function SelectField({
> >
<div className="maf-multiselect-chips"> <div className="maf-multiselect-chips">
{selected.length === 0 ? ( {selected.length === 0 ? (
<span className="maf-multiselect-placeholder">{placeholder ?? 'Select options'}</span> <span className="maf-multiselect-placeholder">
{placeholder ?? "Select options"}
</span>
) : ( ) : (
selected.map((item) => ( selected.map((item) => (
<span className="maf-multiselect-chip" key={item}> <span className="maf-multiselect-chip" key={item}>
...@@ -84,14 +92,14 @@ export function SelectField({ ...@@ -84,14 +92,14 @@ export function SelectField({
className="maf-multiselect-chip-remove" className="maf-multiselect-chip-remove"
aria-label={`Remove ${item}`} aria-label={`Remove ${item}`}
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation();
onChange(selected.filter((v) => v !== item)) onChange(selected.filter((v) => v !== item));
}} }}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === "Enter" || e.key === " ") {
e.preventDefault() e.preventDefault();
e.stopPropagation() e.stopPropagation();
onChange(selected.filter((v) => v !== item)) onChange(selected.filter((v) => v !== item));
} }
}} }}
> >
...@@ -107,21 +115,25 @@ export function SelectField({ ...@@ -107,21 +115,25 @@ export function SelectField({
</button> </button>
{open ? ( {open ? (
<div className="maf-multiselect-menu" role="listbox" aria-multiselectable="true"> <div
className="maf-multiselect-menu"
role="listbox"
aria-multiselectable="true"
>
<input <input
ref={searchInputRef}
className="maf-multiselect-search" className="maf-multiselect-search"
type="text" type="text"
value={query} value={query}
placeholder="Search..." placeholder="Search..."
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
autoFocus
/> />
<div className="maf-multiselect-options"> <div className="maf-multiselect-options">
{filteredOptions.length === 0 ? ( {filteredOptions.length === 0 ? (
<div className="maf-multiselect-empty">No options found</div> <div className="maf-multiselect-empty">No options found</div>
) : ( ) : (
filteredOptions.map((opt) => { filteredOptions.map((opt) => {
const checked = selected.includes(opt) const checked = selected.includes(opt);
return ( return (
<button <button
key={opt} key={opt}
...@@ -132,14 +144,14 @@ export function SelectField({ ...@@ -132,14 +144,14 @@ export function SelectField({
> >
<span>{opt}</span> <span>{opt}</span>
</button> </button>
) );
}) })
)} )}
</div> </div>
</div> </div>
) : null} ) : null}
</div> </div>
) );
} }
return ( return (
...@@ -150,7 +162,7 @@ export function SelectField({ ...@@ -150,7 +162,7 @@ export function SelectField({
value={normalizedValue} value={normalizedValue}
disabled={disabled} disabled={disabled}
onChange={(e) => { onChange={(e) => {
onChange(e.target.value) onChange(e.target.value);
}} }}
> >
{placeholder ? <option value="">{placeholder}</option> : null} {placeholder ? <option value="">{placeholder}</option> : null}
...@@ -164,6 +176,5 @@ export function SelectField({ ...@@ -164,6 +176,5 @@ export function SelectField({
</span> </span>
</div> </div>
) );
} }
export type TextInputFieldProps = { export type TextInputFieldProps = {
id: string id: string;
value?: string value?: string;
disabled?: boolean disabled?: boolean;
placeholder?: string placeholder?: string;
onChange: (next: string) => void onChange: (next: string) => void;
} };
export function TextInputField({ export function TextInputField({
id, id,
...@@ -18,11 +18,10 @@ export function TextInputField({ ...@@ -18,11 +18,10 @@ export function TextInputField({
id={id} id={id}
className="form-input" className="form-input"
type="text" type="text"
value={value ?? ''} value={value ?? ""}
disabled={disabled} disabled={disabled}
placeholder={placeholder} placeholder={placeholder}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
/> />
) );
} }
export type TextareaFieldProps = { export type TextareaFieldProps = {
id: string id: string;
value?: string value?: string;
disabled?: boolean disabled?: boolean;
placeholder?: string placeholder?: string;
rows?: number rows?: number;
onChange: (next: string) => void onChange: (next: string) => void;
} };
export function TextareaField({ export function TextareaField({
id, id,
...@@ -19,12 +19,11 @@ export function TextareaField({ ...@@ -19,12 +19,11 @@ export function TextareaField({
<textarea <textarea
id={id} id={id}
className="form-input" className="form-input"
value={value ?? ''} value={value ?? ""}
disabled={disabled} disabled={disabled}
placeholder={placeholder} placeholder={placeholder}
rows={rows} rows={rows}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
/> />
) );
} }
export type ToggleFieldProps = { export type ToggleFieldProps = {
id: string id: string;
checked: boolean checked: boolean;
onChange: (next: boolean) => void onChange: (next: boolean) => void;
onLabel?: string onLabel?: string;
offLabel?: string offLabel?: string;
disabled?: boolean disabled?: boolean;
} };
export function ToggleField({ export function ToggleField({
id, id,
checked, checked,
onChange, onChange,
onLabel = 'Show', onLabel = "Show",
offLabel = 'Hide', offLabel = "Hide",
disabled, disabled,
}: ToggleFieldProps) { }: ToggleFieldProps) {
return ( return (
...@@ -29,6 +29,5 @@ export function ToggleField({ ...@@ -29,6 +29,5 @@ export function ToggleField({
<span className="maf-toggle-text">{checked ? onLabel : offLabel}</span> <span className="maf-toggle-text">{checked ? onLabel : offLabel}</span>
</span> </span>
</label> </label>
) );
} }
export type FormTypeEnum = 'audit' | 'inspection' | 'checklist' export type FormTypeEnum = "audit" | "inspection" | "checklist";
export type FormInputType = export type FormInputType =
| 'text' | "text"
| 'textarea' | "textarea"
| 'radio' | "radio"
| 'select' | "select"
| 'multiselect' | "multiselect"
| 'date' | "date"
| 'datetime' | "datetime"
| 'datetime-local' | "datetime-local"
| 'checkbox' | "checkbox"
| 'file' | "file"
| 'hidden' | "hidden"
| string | string;
export type FormQuestionValidation = { export type FormQuestionValidation = {
supportsComment?: boolean supportsComment?: boolean;
hasLinkedFile?: boolean hasLinkedFile?: boolean;
allowsMultiple?: boolean allowsMultiple?: boolean;
// Keep room for future flags without breaking strict typing. // Keep room for future flags without breaking strict typing.
[key: string]: unknown [key: string]: unknown;
} };
export type FormQuestion = { export type FormQuestion = {
questionCode: string questionCode: string;
groupCode?: string groupCode?: string;
label: string label: string;
inputType: FormInputType inputType: FormInputType;
required?: boolean required?: boolean;
questionOrder?: number questionOrder?: number;
options?: string[] options?: string[];
validation?: FormQuestionValidation validation?: FormQuestionValidation;
} };
export type FormGroupLayout = { export type FormGroupLayout = {
groupCode: string groupCode: string;
label: string label: string;
order?: number order?: number;
} };
export type FormDefinitionSchema = { export type FormDefinitionSchema = {
meta: { meta: {
formCode: string formCode: string;
sourceId?: string sourceId?: string;
title: string title: string;
categoryIds?: string[] categoryIds?: string[];
version?: number version?: number;
[key: string]: unknown [key: string]: unknown;
} };
layout: FormGroupLayout[] layout: FormGroupLayout[];
questions: FormQuestion[] questions: FormQuestion[];
hiddenFields?: unknown[] hiddenFields?: unknown[];
validation?: { validation?: {
requiredQuestions?: string[] requiredQuestions?: string[];
[key: string]: unknown [key: string]: unknown;
} };
} };
import type { Meta, StoryObj } from "@storybook/nextjs-vite";
import { Footer } from "./Footer";
const meta = {
title: "Layout/Footer",
component: Footer,
parameters: {
layout: "padded",
},
} satisfies Meta<typeof Footer>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Mobile: Story = {
args: {
quickLinks: [
{ label: "Download App", href: "#" },
{ label: "Modules", href: "#" },
{ label: "Platform", href: "#" },
],
supportLinks: [
{ label: "Support Center", href: "#" },
{ label: "FAQs", href: "#" },
{ label: "Technical Help", href: "#" },
],
legalLinks: [
{ label: "Privacy Policy", href: "#" },
{ label: "Terms of Use", href: "#" },
{ label: "Data Protection", href: "#" },
{ label: "Security Policy", href: "#" },
],
copyright: "Copyright © 2026 MAF Revamp",
},
parameters: {
viewport: { defaultViewport: "mobile1" },
},
};
export const Desktop: Story = {
args: {},
parameters: {
viewport: { defaultViewport: "desktop" },
},
};
import Image from "next/image";
import "./footer.css";
export type FooterLink = {
label: string;
href?: string;
};
export type FooterProps = {
logoSrc?: string;
brandLabel?: string;
quickLinks?: FooterLink[];
supportLinks?: FooterLink[];
legalLinks?: FooterLink[];
copyright?: string;
};
export function Footer({
logoSrc = "https://app-mafe-mafgateway-preprod-un-01.azurewebsites.net/themes/custom/enterprise_management_theme/images/app-logo.png",
brandLabel = "MAF Revamp",
quickLinks = [
{ label: "Download App", href: "#" },
{ label: "Modules", href: "#" },
{ label: "Platform", href: "#" },
],
supportLinks = [
{ label: "Support Center", href: "#" },
{ label: "FAQs", href: "#" },
{ label: "Technical Help", href: "#" },
],
legalLinks = [
{ label: "Privacy Policy", href: "#" },
{ label: "Terms of Use", href: "#" },
{ label: "Data Protection", href: "#" },
{ label: "Security Policy", href: "#" },
],
copyright = "Copyright © MAF Revamp",
}: FooterProps) {
return (
<footer className="landing-footer">
<div className="landing-footer__inner">
<div className="landing-footer__brandRow">
<span className="landing-footer__logoWrap" aria-hidden="true">
<Image
className="landing-footer__logo"
src={logoSrc}
alt={`${brandLabel} logo`}
fill
sizes="(max-width: 859px) 170px, 210px"
/>
</span>
</div>
<nav className="landing-footer__cols" aria-label="Footer links">
<div className="landing-footer__col">
<div className="landing-footer__heading">Quick Links</div>
<div className="landing-footer__links">
{quickLinks.map((l) => (
<a
key={l.label}
className="landing-footer__link"
href={l.href ?? "#"}
>
{l.label}
</a>
))}
</div>
</div>
<div className="landing-footer__col">
<div className="landing-footer__heading">Support</div>
<div className="landing-footer__links">
{supportLinks.map((l) => (
<a
key={l.label}
className="landing-footer__link"
href={l.href ?? "#"}
>
{l.label}
</a>
))}
</div>
</div>
<div className="landing-footer__col">
<div className="landing-footer__heading">Legal</div>
<div className="landing-footer__links">
{legalLinks.map((l) => (
<a
key={l.label}
className="landing-footer__link"
href={l.href ?? "#"}
>
{l.label}
</a>
))}
</div>
</div>
</nav>
<div className="landing-footer__bottom">{copyright}</div>
</div>
</footer>
);
}
.landing-footer {
--maf-brown: #6e5a44;
--maf-brown-soft: #a78a68;
--maf-ink: rgba(255, 255, 255, 0.92);
position: relative;
background: rgba(255, 255, 255, 0.98);
color: var(--maf-brown);
padding-top: 28px;
padding-bottom: 18px;
overflow: hidden;
}
.landing-footer__inner {
position: relative;
padding: 0 16px;
}
.landing-footer__brandRow {
display: flex;
align-items: center;
gap: 0;
margin-bottom: 18px;
}
.landing-footer__logoWrap {
position: relative;
width: 170px;
height: 48px;
display: block;
}
.landing-footer__logo {
object-fit: contain;
}
.landing-footer__cols {
display: grid;
grid-template-columns: 1fr;
gap: 18px;
padding-bottom: 14px;
border-bottom: 1px solid rgba(110, 90, 68, 0.35);
}
.landing-footer__heading {
font-weight: 900;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.12em;
margin-bottom: 10px;
text-shadow: none;
}
.landing-footer__links {
display: flex;
flex-direction: column;
gap: 10px;
}
.landing-footer__link {
text-decoration: none;
color: rgba(110, 90, 68, 0.95);
font-size: 0.95rem;
text-shadow: none;
}
.landing-footer__link:hover {
color: rgba(110, 90, 68, 1);
text-decoration: underline;
text-shadow: none;
}
.landing-footer__bottom {
margin-top: 14px;
font-size: 0.8rem;
color: rgba(110, 90, 68, 0.7);
}
@media (min-width: 860px) {
.landing-footer {
padding-top: 44px;
padding-bottom: 24px;
}
.landing-footer__inner {
padding: 0 22px;
}
.landing-footer__cols {
grid-template-columns: 1.05fr 1fr 1fr;
align-items: start;
gap: 22px;
}
.landing-footer__logoWrap {
width: 210px;
height: 56px;
}
}
import type { Meta, StoryObj } from "@storybook/nextjs-vite";
import { Header } from "./Header";
const meta = {
title: "Layout/Header",
component: Header,
parameters: {
layout: "padded",
},
} satisfies Meta<typeof Header>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Mobile: Story = {
args: {
navLinks: [
{ label: "Modules", href: "#" },
{ label: "Platform", href: "#" },
{ label: "Download", href: "#" },
],
supportCta: { label: "Support", href: "#" },
cta: { label: "Login", href: "#" },
},
parameters: {
viewport: { defaultViewport: "mobile1" },
},
};
export const Desktop: Story = {
args: {
navLinks: [
{ label: "Modules", href: "#" },
{ label: "Platform", href: "#" },
{ label: "Download", href: "#" },
],
supportCta: { label: "Support", href: "#" },
cta: { label: "Login", href: "#" },
},
parameters: {
viewport: { defaultViewport: "desktop" },
},
};
"use client";
import Image from "next/image";
import React from "react";
import "./header.css";
export type NavLink = {
label: string;
href?: string;
};
export type HeaderProps = {
logoSrc?: string;
brandLabel?: string;
navLinks?: NavLink[];
supportCta?: {
label: string;
href?: string;
onClick?: () => void;
};
cta?: {
label: string;
href?: string;
onClick?: () => void;
};
};
function SupportGlyph({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" aria-hidden>
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" strokeWidth="1.6" />
<path
d="M9.6 9.4a2.6 2.6 0 0 1 5 0c0 1.7-1.6 2.1-2.2 2.7-.5.5-.5 1-.5 1.9"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
<circle cx="12" cy="16.9" r="1" fill="currentColor" />
</svg>
);
}
export function Header({
logoSrc = "https://app-mafe-mafgateway-preprod-un-01.azurewebsites.net/themes/custom/enterprise_management_theme/images/app-logo.png",
brandLabel = "MAF Revamp",
navLinks = [
{ label: "Modules", href: "#" },
{ label: "Platform", href: "#" },
{ label: "Download", href: "#" },
],
supportCta = { label: "Support", href: "#" },
cta = { label: "Login", href: "#" },
}: HeaderProps) {
const [open, setOpen] = React.useState(false);
const panelId = "header-drawer";
return (
<header className="landing-header">
<div className="landing-header__bg" aria-hidden />
<div className="landing-header__inner">
<div className="landing-header__left">
<a className="landing-header__brand" href="/" aria-label={brandLabel}>
<span className="landing-header__logoWrap">
<Image
className="landing-header__logo"
src={logoSrc}
alt={`${brandLabel} logo`}
fill
sizes="(max-width: 859px) 150px, 190px"
priority
/>
</span>
<span className="landing-header__srOnly">{brandLabel}</span>
</a>
</div>
<div className="landing-header__center">
<nav
className="landing-header__nav landing-header__nav--desktop"
aria-label="Primary"
>
{navLinks.map((l) => (
<a
key={l.label}
className="landing-header__nav-link"
href={l.href ?? "#"}
>
{l.label}
</a>
))}
</nav>
</div>
<div className="landing-header__right">
{supportCta ? (
supportCta.onClick ? (
<button
type="button"
className="landing-header__cta landing-header__cta--desktop landing-header__cta--secondary landing-header__support landing-header__ctaButton"
onClick={supportCta.onClick}
>
<span className="landing-header__supportIcon" aria-hidden>
<SupportGlyph className="landing-header__supportIconSvg" />
</span>
<span className="landing-header__supportLabel">{supportCta.label}</span>
</button>
) : (
<a
className="landing-header__cta landing-header__cta--desktop landing-header__cta--secondary landing-header__support"
href={supportCta.href ?? "#"}
>
<span className="landing-header__supportIcon" aria-hidden>
<SupportGlyph className="landing-header__supportIconSvg" />
</span>
<span className="landing-header__supportLabel">{supportCta.label}</span>
</a>
)
) : null}
{cta ? (
cta.onClick ? (
<button
type="button"
className="landing-header__cta landing-header__cta--desktop landing-header__ctaButton"
onClick={cta.onClick}
>
{cta.label}
</button>
) : (
<a className="landing-header__cta landing-header__cta--desktop" href={cta.href ?? "#"}>
{cta.label}
</a>
)
) : null}
<button
className="landing-header__hamburger"
type="button"
aria-label={open ? "Close menu" : "Open menu"}
aria-controls={panelId}
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
<span />
<span />
<span />
</button>
</div>
</div>
{open ? (
<div
className="landing-header__drawer"
role="dialog"
aria-modal="true"
id={panelId}
>
<button
className="landing-header__drawer-close"
type="button"
aria-label="Close menu"
onClick={() => setOpen(false)}
>
×
</button>
<div className="landing-header__drawer-links">
{navLinks.map((l) => (
<a
key={l.label}
className="landing-header__drawer-link"
href={l.href ?? "#"}
onClick={() => setOpen(false)}
>
{l.label}
</a>
))}
</div>
{supportCta ? (
supportCta.onClick ? (
<button
type="button"
className="landing-header__drawer-link landing-header__drawer-link--support landing-header__drawer-button"
onClick={() => {
setOpen(false);
supportCta.onClick?.();
}}
>
<span className="landing-header__supportIcon" aria-hidden>
<SupportGlyph className="landing-header__supportIconSvg" />
</span>
<span className="landing-header__supportLabel">{supportCta.label}</span>
</button>
) : (
<a
className="landing-header__drawer-link landing-header__drawer-link--support"
href={supportCta.href ?? "#"}
onClick={() => setOpen(false)}
>
<span className="landing-header__supportIcon" aria-hidden>
<SupportGlyph className="landing-header__supportIconSvg" />
</span>
<span className="landing-header__supportLabel">{supportCta.label}</span>
</a>
)
) : null}
{cta ? (
cta.onClick ? (
<button
type="button"
className="landing-header__drawer-cta landing-header__drawer-button"
onClick={() => {
setOpen(false);
cta.onClick?.();
}}
>
{cta.label}
</button>
) : (
<a
className="landing-header__drawer-cta"
href={cta.href ?? "#"}
onClick={() => setOpen(false)}
>
{cta.label}
</a>
)
) : null}
</div>
) : null}
{open ? (
<button
className="landing-header__backdrop"
type="button"
aria-label="Close menu backdrop"
onClick={() => setOpen(false)}
/>
) : null}
</header>
);
}
.landing-header {
--maf-brown: #6e5a44;
--maf-brown-soft: #a78a68;
--maf-ink: rgba(255, 255, 255, 0.92);
position: sticky;
top: 0;
z-index: 20;
width: 100%;
color: var(--maf-brown);
}
.landing-header__bg {
position: absolute;
inset: 0;
background-image: url("/landing-header-bg.png");
background-size: cover;
background-position: center;
opacity: 0.14;
pointer-events: none;
}
.landing-header__inner {
position: relative;
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 12px;
padding: 14px 22px;
background: rgba(255, 255, 255, 0.96);
border-bottom: 1px solid rgba(110, 90, 68, 0.45);
box-shadow: 0 14px 50px rgba(110, 90, 68, 0.1);
backdrop-filter: blur(10px);
}
.landing-header__brand {
display: inline-flex;
align-items: center;
gap: 10px;
text-decoration: none;
color: inherit;
min-width: 0;
}
.landing-header__logoWrap {
position: relative;
width: 150px;
height: 44px;
display: block;
}
.landing-header__logo {
object-fit: contain;
}
.landing-header__srOnly {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.landing-header__right {
display: flex;
align-items: center;
gap: 10px;
}
.landing-header__support {
gap: 10px;
}
.landing-header__supportIcon {
width: 28px;
height: 28px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(110, 90, 68, 0.28);
background: rgba(110, 90, 68, 0.05);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6);
}
.landing-header__supportIconSvg {
width: 18px;
height: 18px;
color: rgba(110, 90, 68, 0.95);
}
.landing-header__supportLabel {
display: inline-flex;
align-items: center;
}
.landing-header__center {
display: flex;
justify-content: center;
align-items: center;
min-width: 0;
}
.landing-header__nav--desktop {
display: none;
}
.landing-header__cta--desktop {
display: none;
}
.landing-header__hamburger {
width: 42px;
height: 42px;
border-radius: 10px;
border: 1px solid rgba(110, 90, 68, 0.45);
background: rgba(255, 255, 255, 0.98);
color: var(--maf-brown);
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
box-shadow: 0 10px 26px rgba(110, 90, 68, 0.12);
}
.landing-header__hamburger span {
width: 18px;
height: 2px;
background: rgba(110, 90, 68, 0.92);
border-radius: 2px;
display: block;
}
.landing-header__backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
z-index: 19;
}
.landing-header__drawer {
position: fixed;
z-index: 21;
top: 0;
right: 0;
width: min(360px, 90vw);
height: 100vh;
background: rgba(255, 255, 255, 0.98);
border-left: 1px solid rgba(110, 90, 68, 0.35);
padding: 16px;
display: flex;
flex-direction: column;
gap: 14px;
box-shadow: -30px 0 60px rgba(110, 90, 68, 0.1);
}
.landing-header__drawer-close {
align-self: flex-end;
width: 40px;
height: 40px;
border-radius: 12px;
border: 1px solid rgba(110, 90, 68, 0.45);
background: rgba(110, 90, 68, 0.04);
color: var(--maf-brown);
font-size: 24px;
line-height: 1;
}
.landing-header__drawer-links {
display: flex;
flex-direction: column;
gap: 8px;
padding-top: 6px;
}
.landing-header__drawer-link {
padding: 12px 12px;
border-radius: 12px;
text-decoration: none;
color: rgba(110, 90, 68, 0.95);
border: 1px solid rgba(110, 90, 68, 0.25);
background: rgba(255, 255, 255, 0.7);
display: inline-flex;
align-items: center;
gap: 10px;
}
.landing-header__ctaButton,
.landing-header__drawer-button {
font: inherit;
cursor: pointer;
}
.landing-header__ctaButton {
border: 1px solid rgba(110, 90, 68, 0.45);
}
.landing-header__drawer-button {
width: 100%;
text-align: left;
}
.landing-header__drawer-link:hover {
background: rgba(110, 90, 68, 0.06);
color: rgba(110, 90, 68, 1);
box-shadow:
0 0 0 1px rgba(110, 90, 68, 0.16),
0 12px 30px rgba(110, 90, 68, 0.12);
}
.landing-header__drawer-link--support {
margin-top: 6px;
}
.landing-header__drawer-cta {
margin-top: auto;
padding: 12px 12px;
border-radius: 12px;
text-align: center;
text-decoration: none;
font-weight: 700;
color: var(--maf-brown);
background: rgba(255, 255, 255, 0.98);
border: 1px solid rgba(110, 90, 68, 0.45);
box-shadow: 0 18px 45px rgba(110, 90, 68, 0.12);
}
@media (min-width: 860px) {
.landing-header__logoWrap {
width: 190px;
height: 52px;
}
.landing-header__inner {
padding: 16px 32px;
}
.landing-header__nav--desktop {
display: flex;
align-items: center;
gap: 16px;
}
.landing-header__nav-link {
text-decoration: none;
color: rgba(110, 90, 68, 0.95);
font-weight: 700;
font-size: 0.95rem;
}
.landing-header__nav-link:hover {
color: rgba(110, 90, 68, 1);
text-shadow: none;
}
.landing-header__cta--desktop {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 10px 14px;
border-radius: 12px;
text-decoration: none;
color: var(--maf-brown);
font-weight: 800;
background: rgba(255, 255, 255, 0.98);
border: 1px solid rgba(110, 90, 68, 0.45);
box-shadow: 0 14px 50px rgba(110, 90, 68, 0.1);
}
.landing-header__cta--secondary {
font-weight: 800;
background: rgba(255, 255, 255, 0.92);
border-color: rgba(110, 90, 68, 0.28);
box-shadow: 0 10px 30px rgba(110, 90, 68, 0.08);
}
.landing-header__hamburger {
display: none;
}
}
import type { Meta, StoryObj } from "@storybook/nextjs-vite";
import {
IconAudits,
IconCompliance,
IconDashboards,
IconIncident,
IconInspections,
IconOffline,
IconReports,
IconSecurity,
IconTraining,
} from "./capabilityIcons";
import { CapabilitiesShowcaseSection } from "./CapabilitiesShowcaseSection";
const meta = {
title: "Page Sections/Capabilities Showcase",
component: CapabilitiesShowcaseSection,
parameters: {
layout: "fullscreen",
},
} satisfies Meta<typeof CapabilitiesShowcaseSection>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
title: "Capabilities",
copyBlocks: [
{
title: "About the system:",
body: "Select a tile to see how the capability works end-to-end — from field capture to dashboards and audit trails.",
},
],
capabilities: [
{
id: "incident",
label: "Incident reporting",
icon: <IconIncident />,
},
{
id: "audits",
label: "Audits",
icon: <IconAudits />,
},
{
id: "inspections",
label: "Inspections",
icon: <IconInspections />,
},
{
id: "compliance",
label: "Compliance",
icon: <IconCompliance />,
},
{
id: "training",
label: "Training",
icon: <IconTraining />,
},
{
id: "reports",
label: "Reports",
icon: <IconReports />,
},
{
id: "dashboards",
label: "Dashboards",
icon: <IconDashboards />,
},
{
id: "offline",
label: "Offline mode",
icon: <IconOffline />,
},
{
id: "security",
label: "Security",
icon: <IconSecurity />,
},
],
defaultActiveCapabilityId: "incident",
panelsByCapabilityId: {
incident: {
title: "Incident reporting",
copyBlocks: [
{
title: "Capture from the field:",
body: "Log incidents in seconds with guided prompts, attachments, and location context — even when connectivity is poor.",
},
{
title: "Immediate escalation:",
body: "Route high-severity events to the right owners with clear SLAs and a complete audit trail.",
},
],
},
audits: {
title: "Audits",
copyBlocks: [
{
title: "Standardized evidence:",
body: "Consistent checklists, structured findings, and repeatable workflows reduce variance across teams and sites.",
},
{
title: "Ready for review:",
body: "Exportable logs and a single source of truth simplify internal reviews and external reporting.",
},
],
},
inspections: {
title: "Inspections",
copyBlocks: [
{
title: "Guided inspections:",
body: "Step-by-step forms ensure completeness while keeping the flow fast for frontline users.",
},
{
title: "Actionable follow-ups:",
body: "Turn findings into tasks with ownership, due dates, and measurable outcomes.",
},
],
},
compliance: {
title: "Compliance",
copyBlocks: [
{
title: "Controls mapped to work:",
body: "Link requirements to actual field activity and evidence, not just documents and spreadsheets.",
},
{
title: "Always audit-ready:",
body: "Maintain traceability from control → activity → evidence → approval.",
},
],
},
training: {
title: "Training",
copyBlocks: [
{
title: "Targeted learning:",
body: "Deliver the right content to the right roles with progress tracking and completion evidence.",
},
{
title: "Compliance outcomes:",
body: "Tie training completion to readiness metrics across sites and teams.",
},
],
},
reports: {
title: "Reports",
copyBlocks: [
{
title: "Fast reporting:",
body: "Generate consistent outputs from structured data — fewer manual edits and fewer surprises.",
},
{
title: "Share with confidence:",
body: "Role-based access keeps sensitive operational details protected.",
},
],
},
dashboards: {
title: "Dashboards",
copyBlocks: [
{
title: "Operational visibility:",
body: "Track trends, hotspots, and performance across modules in one place.",
},
{
title: "Decision-ready metrics:",
body: "Move from lagging indicators to leading signals with clean, timely data.",
},
],
},
offline: {
title: "Offline mode",
copyBlocks: [
{
title: "Work without signal:",
body: "Complete forms, capture photos, and record notes offline — sync safely when the network returns.",
},
{
title: "Data integrity:",
body: "Conflict-safe syncing ensures field data remains consistent and traceable.",
},
],
},
security: {
title: "Security",
copyBlocks: [
{
title: "Least privilege by default:",
body: "Ensure people only see what they need — with role-based controls and reviewable access.",
},
{
title: "Audit trails built-in:",
body: "Every change is attributable and time-stamped, supporting internal governance and external scrutiny.",
},
],
},
},
},
};
"use client";
import type React from "react";
import { useMemo, useState } from "react";
import { FeatureTileCard } from "@/components/base/FeatureTileCard/FeatureTileCard";
import "./capabilities-showcase-section.css";
export type CapabilityItem = {
id: string;
label: string;
icon: React.ReactNode;
href?: string;
description?: string;
};
export type CapabilityCopyBlock = {
title: string;
body: string;
};
export type CapabilityPanel = {
title?: string;
copyBlocks: CapabilityCopyBlock[];
};
export type CapabilitiesShowcaseSectionProps = {
title: string;
copyBlocks?: CapabilityCopyBlock[];
capabilities: CapabilityItem[];
/**
* Optional interactive panels per capability. When provided, clicking a tile
* updates the narrative content (left column) to the selected capability panel.
*/
panelsByCapabilityId?: Record<string, CapabilityPanel>;
defaultActiveCapabilityId?: string;
activeCapabilityId?: string;
onActiveCapabilityChange?: (capabilityId: string) => void;
className?: string;
};
/**
* Light “Tools and features” layout: narrative column + responsive feature grid.
*/
export function CapabilitiesShowcaseSection({
title,
copyBlocks = [],
capabilities,
panelsByCapabilityId,
defaultActiveCapabilityId,
activeCapabilityId,
onActiveCapabilityChange,
className = "",
}: CapabilitiesShowcaseSectionProps) {
const firstCapabilityId = capabilities[0]?.id;
const initialActiveId = defaultActiveCapabilityId || firstCapabilityId || "";
const [uncontrolledActiveId, setUncontrolledActiveId] = useState(initialActiveId);
const selectedCapabilityId = activeCapabilityId ?? uncontrolledActiveId;
const activePanel = useMemo(() => {
if (!panelsByCapabilityId) return undefined;
return panelsByCapabilityId[selectedCapabilityId];
}, [panelsByCapabilityId, selectedCapabilityId]);
const effectiveTitle = activePanel?.title || title;
const effectiveCopyBlocks = activePanel?.copyBlocks?.length
? activePanel.copyBlocks
: copyBlocks;
return (
<section
className={`maf-capabilities-showcase ${className}`.trim()}
aria-labelledby="maf-capabilities-showcase-title"
>
<div className="maf-capabilities-showcase__inner">
<header className="maf-capabilities-showcase__header">
<h2
id="maf-capabilities-showcase-title"
className="maf-capabilities-showcase__page-title"
>
{effectiveTitle}
</h2>
</header>
<div className="maf-capabilities-showcase__grid">
<div className="maf-capabilities-showcase__copy">
{effectiveCopyBlocks.map((block) => (
<article key={block.title} className="maf-capabilities-showcase__block">
<h3 className="maf-capabilities-showcase__block-title">
{block.title}
</h3>
<p className="maf-capabilities-showcase__block-body">{block.body}</p>
</article>
))}
</div>
<div
className="maf-capabilities-showcase__tiles"
role="list"
aria-label="Capability highlights"
>
{capabilities.map((cap) => (
<div key={cap.id} role="listitem">
<FeatureTileCard
label={cap.label}
icon={cap.icon}
href={cap.href}
onClick={
cap.href
? undefined
: panelsByCapabilityId
? () => {
if (!activeCapabilityId) setUncontrolledActiveId(cap.id);
onActiveCapabilityChange?.(cap.id);
}
: undefined
}
className={
panelsByCapabilityId && cap.id === selectedCapabilityId
? "maf-feature-tile--active"
: ""
}
description={cap.description}
/>
</div>
))}
</div>
</div>
</div>
</section>
);
}
.maf-capabilities-showcase {
--cap-ink: #1a1714;
--cap-muted: #4a4540;
--cap-surface: #ece8e2;
--cap-accent: #9a7b45;
color: var(--cap-ink);
background: linear-gradient(180deg, #f6f3ee 0%, var(--cap-surface) 45%, #ebe6df 100%);
border-block: 1px solid rgba(26, 23, 20, 0.06);
}
.maf-capabilities-showcase__inner {
max-width: 1240px;
margin: 0 auto;
padding: clamp(2.5rem, 5vw, 3.75rem) 1.65rem clamp(3rem, 6vw, 4.5rem);
}
.maf-capabilities-showcase__header {
margin-bottom: clamp(1.75rem, 4vw, 2.5rem);
}
.maf-capabilities-showcase__page-title {
margin: 0;
font-size: clamp(1.65rem, 3vw, 2.1rem);
font-weight: 700;
letter-spacing: 0.02em;
color: var(--cap-ink);
}
.maf-capabilities-showcase__grid {
display: grid;
gap: clamp(2rem, 4vw, 3rem);
}
@media (min-width: 960px) {
.maf-capabilities-showcase__grid {
grid-template-columns: minmax(0, 1fr) minmax(0, 1.9fr);
align-items: start;
gap: clamp(2.25rem, 4vw, 3.5rem);
}
}
.maf-capabilities-showcase__copy {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.maf-capabilities-showcase__block {
margin: 0;
}
.maf-capabilities-showcase__block-title {
margin: 0 0 0.4rem;
font-size: 0.95rem;
font-weight: 700;
letter-spacing: 0.02em;
color: var(--cap-accent);
}
.maf-capabilities-showcase__block-body {
margin: 0;
font-size: 0.9375rem;
line-height: 1.65;
color: var(--cap-muted);
}
.maf-capabilities-showcase__tiles {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(9.5rem, 1fr));
gap: 1rem;
}
@media (min-width: 640px) {
.maf-capabilities-showcase__tiles {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1.15rem;
}
}
/* Active (selected) tile state for interactive panels */
.maf-feature-tile--active {
border-color: rgba(154, 123, 69, 0.65);
box-shadow:
0 18px 40px rgba(17, 14, 10, 0.38),
0 0 0 1px rgba(154, 123, 69, 0.22);
transform: translateY(-3px);
}
/* Active (selected) tile state for interactive panels */
.maf-feature-tile--active {
border-color: rgba(154, 123, 69, 0.65);
box-shadow:
0 18px 40px rgba(17, 14, 10, 0.38),
0 0 0 1px rgba(154, 123, 69, 0.22);
transform: translateY(-3px);
}
import type React from "react";
/** Stroke icons sized for `FeatureTileCard` (inherits color from tile). */
export function IconIncident({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" aria-hidden>
<path d="M12 3v3M6 9h12M7 9v10a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9" />
<path d="M9 14h6M9 17h4" />
</svg>
);
}
export function IconAudits({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" aria-hidden>
<path d="M9 11l2 2 4-4" />
<path d="M4 6h16v14H4z" />
<path d="M8 6V4h8v2" />
</svg>
);
}
export function IconInspections({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" aria-hidden>
<circle cx="11" cy="11" r="6" />
<path d="M20 20l-3.2-3.2" />
<path d="M11 8v6l3 2" />
</svg>
);
}
export function IconCompliance({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" aria-hidden>
<path d="M12 3l7 4v6c0 5-3.5 9-7 10-3.5-1-7-5-7-10V7z" />
<path d="M9 12l2 2 4-4" />
</svg>
);
}
export function IconTraining({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" aria-hidden>
<path d="M4 8l8-3 8 3v2l-8 3-8-3z" />
<path d="M4 10v6l8 3 8-3v-6" />
<path d="M12 13v7" />
</svg>
);
}
export function IconReports({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" aria-hidden>
<path d="M7 3h10v18H7z" />
<path d="M10 7h4M10 11h4M10 15h4" />
</svg>
);
}
export function IconDashboards({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" aria-hidden>
<path d="M4 20V10M12 20V4M20 20v-8" />
</svg>
);
}
export function IconOffline({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" aria-hidden>
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4" />
<circle cx="12" cy="12" r="3" />
</svg>
);
}
export function IconSecurity({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" aria-hidden>
<path d="M12 3l8 4v5c0 5-3.5 9-8 10-4.5-1-8-5-8-10V7z" />
<path d="M9 12l2 2 4-4" />
</svg>
);
}
import type { Meta, StoryObj } from "@storybook/nextjs-vite";
import { InsightHeroBanner } from "./InsightHeroBanner";
const meta = {
title: "Shared/Banners/InsightHeroBanner",
component: InsightHeroBanner,
parameters: {
layout: "padded",
},
} satisfies Meta<typeof InsightHeroBanner>;
export default meta;
type Story = StoryObj<typeof meta>;
export const LandingLoginBanner: Story = {
args: {
title: "Gateway",
subtitle: "Assurance | Management Data | Compliance",
eyebrow: "Enterprise Platform",
links: [
{ label: "Assurance" },
{ label: "Management Data" },
{ label: "Compliance" },
],
primaryCta: {
label: "Login",
href: "#",
},
background: "/image-443bcc33-80a0-465e-a40b-dbabfe7b2ff6.png",
},
};
export const WithLinksNoSubtitle: Story = {
args: {
title: "Incident Reporting",
eyebrow: "Overview",
links: [
{ label: "Overview" },
{ label: "Compliance" },
{ label: "Audit Logs" },
],
primaryCta: {
label: "Get Started",
href: "#",
},
subtitle: undefined,
background: "/image-443bcc33-80a0-465e-a40b-dbabfe7b2ff6.png",
},
};
export const MobilePreview: Story = {
args: {
title: "Gateway",
subtitle: "Assurance | Management Data | Compliance",
eyebrow: "Enterprise Platform",
links: [
{ label: "Assurance" },
{ label: "Management Data" },
{ label: "Compliance" },
],
primaryCta: {
label: "Login",
href: "#",
},
background: "/image-443bcc33-80a0-465e-a40b-dbabfe7b2ff6.png",
},
parameters: {
viewport: { defaultViewport: "mobile1" },
},
};
"use client";
import React from "react";
import "./insight-hero-banner.css";
export type InsightHeroBannerLink = {
label: string;
href?: string;
};
export type InsightHeroBannerCta = {
label: string;
href?: string;
onClick?: () => void;
};
export type InsightHeroBannerStat = {
label: string;
value: string;
};
export type InsightHeroBannerProps = {
title: React.ReactNode;
subtitle?: string;
description?: string;
eyebrow?: string;
links?: InsightHeroBannerLink[];
primaryCta?: InsightHeroBannerCta;
secondaryCta?: InsightHeroBannerCta;
stats?: InsightHeroBannerStat[];
/**
* Provide a CSS background value.
* Example:
* - `linear-gradient(...)`
* - `url('/img/bg.jpg')`
*/
background?: string;
className?: string;
};
export function InsightHeroBanner({
title,
subtitle,
description,
eyebrow,
links = [],
primaryCta,
secondaryCta,
stats = [],
background = "/image-443bcc33-80a0-465e-a40b-dbabfe7b2ff6.png",
className = "",
}: InsightHeroBannerProps) {
const normalizedBackgroundImage =
background.includes("gradient(") || background.startsWith("url(")
? background
: `url("${background}")`;
const titleForAriaLabel =
typeof title === "string" ? title : typeof title === "number" ? String(title) : "Hero banner";
const renderCta = (cta: InsightHeroBannerCta, variant: "primary" | "secondary") => {
const className = `maf-hero-banner__action maf-hero-banner__action--${variant}`;
return cta.href ? (
<a className={className} href={cta.href}>
{cta.label}
</a>
) : (
<button type="button" className={className} onClick={cta.onClick}>
{cta.label}
</button>
);
};
return (
<section
className={`maf-hero-banner ${className}`.trim()}
aria-label={titleForAriaLabel}
>
<div
className="maf-hero-banner__bg"
style={{ backgroundImage: normalizedBackgroundImage }}
aria-hidden
/>
<div className="maf-hero-banner__overlay" aria-hidden />
<div className="maf-hero-banner__content">
<div className="maf-hero-banner__left">
<div className="maf-hero-banner__topline">
{eyebrow ? (
<div className="maf-hero-banner__eyebrow">{eyebrow}</div>
) : null}
</div>
<h2 className="maf-hero-banner__title">{title}</h2>
{subtitle ? (
<p className="maf-hero-banner__subtitle">{subtitle}</p>
) : null}
{description ? (
<p className="maf-hero-banner__description">{description}</p>
) : null}
{primaryCta || secondaryCta ? (
<div className="maf-hero-banner__actions" aria-label="Primary actions">
{primaryCta ? renderCta(primaryCta, "primary") : null}
{secondaryCta ? renderCta(secondaryCta, "secondary") : null}
</div>
) : null}
{links.length ? (
<section
className="maf-hero-banner__links"
aria-label="Quick links"
>
{links.map((l, linkIndex) => (
<React.Fragment
key={l.href ? `href:${l.href}` : `span:${l.label}`}
>
{linkIndex > 0 ? (
<span className="maf-hero-banner__link-sep">|</span>
) : null}
{l.href ? (
<a className="maf-hero-banner__link" href={l.href}>
{l.label}
</a>
) : (
<span className="maf-hero-banner__link">{l.label}</span>
)}
</React.Fragment>
))}
</section>
) : null}
{stats.length ? (
<section className="maf-hero-banner__stats" aria-label="Highlights">
{stats.slice(0, 3).map((s) => (
<div key={`${s.label}:${s.value}`} className="maf-hero-banner__stat">
<div className="maf-hero-banner__statValue">{s.value}</div>
<div className="maf-hero-banner__statLabel">{s.label}</div>
</div>
))}
</section>
) : null}
</div>
</div>
</section>
);
}
.maf-hero-banner {
position: relative;
width: 100%;
overflow: hidden;
border: 0px solid rgba(255, 255, 255, 0.08);
min-height: clamp(520px, 72vh, 760px);
color: #ffffff;
background: #0f0f12;
--maf-hero-accent: #c0265d;
--maf-hero-accent2: #ffb3c8;
--maf-hero-ink: rgba(255, 255, 255, 0.92);
--maf-hero-muted: rgba(255, 255, 255, 0.72);
--maf-hero-line: rgba(255, 255, 255, 0.14);
--maf-hero-glass: rgba(10, 10, 14, 0.36);
--maf-hero-glass2: rgba(10, 10, 14, 0.22);
--maf-hero-shadow: 0 24px 60px rgba(0, 0, 0, 0.55);
--maf-hero-shadow-soft: 0 16px 40px rgba(0, 0, 0, 0.35);
}
.maf-hero-banner__bg {
position: absolute;
inset: 0;
background-size: cover;
background-position: center;
filter: saturate(0.9) contrast(1.06) brightness(0.94);
transform: scale(1.02);
}
.maf-hero-banner__overlay {
position: absolute;
inset: 0;
background:
radial-gradient(
1200px 600px at 18% 42%,
rgba(192, 38, 93, 0.25) 0%,
rgba(192, 38, 93, 0) 58%
),
radial-gradient(
900px 520px at 72% 30%,
rgba(255, 179, 200, 0.18) 0%,
rgba(255, 179, 200, 0) 55%
),
linear-gradient(
90deg,
rgba(0, 0, 0, 0.72) 0%,
rgba(0, 0, 0, 0.35) 48%,
rgba(0, 0, 0, 0.56) 100%
);
}
.maf-hero-banner::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0.32;
background-image:
linear-gradient(rgba(255, 255, 255, 0.06) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px);
background-size: 72px 72px, 72px 72px;
mix-blend-mode: overlay;
}
.maf-hero-banner::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0.18;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='220' height='220'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='220' height='220' filter='url(%23n)' opacity='.35'/%3E%3C/svg%3E");
mix-blend-mode: overlay;
}
.maf-hero-banner__content {
position: relative;
z-index: 1;
width: 100%;
max-width: 1240px;
margin: 0 auto;
padding: clamp(3.25rem, 6.5vh, 5rem) 1.65rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: clamp(1rem, 3vw, 2.25rem);
}
.maf-hero-banner__left {
min-width: 0;
flex: 1 1 auto;
max-width: 740px;
}
.maf-hero-banner__topline {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.9rem;
}
.maf-hero-banner__eyebrow {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.7rem;
border-radius: 999px;
background: rgba(10, 10, 14, 0.32);
border: 1px solid rgba(255, 255, 255, 0.18);
color: var(--maf-hero-ink);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
backdrop-filter: blur(10px);
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28);
}
.maf-hero-banner__title {
margin: 0;
font-size: clamp(2.6rem, 5.2vw, 4.2rem);
line-height: 1.02;
font-weight: 800;
letter-spacing: 0.02em;
text-transform: none;
color: rgba(255, 255, 255, 0.94);
text-shadow: 0 18px 60px rgba(0, 0, 0, 0.6);
}
.maf-hero-banner__subtitle {
margin: 0.45rem 0 0;
font-size: clamp(0.98rem, 1.15vw, 1.25rem);
color: rgba(255, 255, 255, 0.88);
line-height: 1.35;
max-width: 52ch;
}
.maf-hero-banner__description {
margin: 0.9rem 0 0;
font-size: 0.94rem;
line-height: 1.55;
color: var(--maf-hero-muted);
max-width: 62ch;
}
.maf-hero-banner__actions {
margin-top: 1.2rem;
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.maf-hero-banner__action {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
padding: 0 18px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.18);
color: #fff;
font-weight: 800;
text-decoration: none;
cursor: pointer;
transition:
transform 160ms ease,
box-shadow 160ms ease,
filter 160ms ease,
background 160ms ease;
white-space: nowrap;
}
.maf-hero-banner__action:hover {
transform: translateY(-1px);
filter: brightness(1.05);
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35);
}
.maf-hero-banner__action--primary {
background: linear-gradient(135deg, var(--maf-hero-accent) 0%, #8f1d46 55%, #7a1738 100%);
border-color: rgba(255, 255, 255, 0.12);
box-shadow:
0 18px 42px rgba(192, 38, 93, 0.22),
0 10px 22px rgba(0, 0, 0, 0.35);
}
.maf-hero-banner__action--secondary {
background: rgba(10, 10, 14, 0.22);
border-color: rgba(255, 255, 255, 0.24);
backdrop-filter: blur(10px);
}
.maf-hero-banner__action:focus-visible {
outline: none;
box-shadow:
0 0 0 3px rgba(192, 38, 93, 0.34),
0 0 0 6px rgba(255, 255, 255, 0.16);
}
.maf-hero-banner__links {
margin-top: 0.7rem;
font-size: 0.82rem;
color: rgba(255, 255, 255, 0.78);
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.55rem;
}
.maf-hero-banner__link {
color: rgba(255, 255, 255, 0.9);
text-decoration: none;
font-weight: 600;
}
.maf-hero-banner__link:hover {
text-decoration: underline;
}
.maf-hero-banner__link-sep {
opacity: 0.6;
}
.maf-hero-banner__stats {
margin-top: 1.35rem;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.85rem;
max-width: 560px;
}
.maf-hero-banner__stat {
padding: 0.95rem 1rem;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.16);
background: linear-gradient(
180deg,
rgba(10, 10, 14, 0.42) 0%,
rgba(10, 10, 14, 0.22) 100%
);
backdrop-filter: blur(12px);
box-shadow: var(--maf-hero-shadow-soft);
transition:
transform 180ms ease,
border-color 180ms ease,
background 180ms ease;
}
.maf-hero-banner__stat:hover {
transform: translateY(-2px);
border-color: rgba(255, 255, 255, 0.24);
background: linear-gradient(
180deg,
rgba(10, 10, 14, 0.48) 0%,
rgba(10, 10, 14, 0.26) 100%
);
}
.maf-hero-banner__statValue {
font-weight: 900;
letter-spacing: 0.02em;
color: rgba(255, 255, 255, 0.95);
font-size: 1rem;
}
.maf-hero-banner__statLabel {
margin-top: 0.2rem;
font-size: 0.7rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.65);
}
@media (max-width: 1024px) {
.maf-hero-banner__content {
padding: clamp(2.6rem, 6vh, 3.6rem) 1.25rem;
}
.maf-hero-banner__left {
max-width: 620px;
}
}
@media (max-width: 768px) {
.maf-hero-banner {
min-height: clamp(360px, 62vh, 560px);
}
.maf-hero-banner__content {
padding: clamp(2.1rem, 6vh, 3.1rem) 1.1rem;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 1rem;
}
.maf-hero-banner__title {
font-size: clamp(2.05rem, 7vw, 2.75rem);
}
.maf-hero-banner__subtitle {
font-size: 0.95rem;
}
.maf-hero-banner__topline {
margin-bottom: 0.75rem;
}
.maf-hero-banner__description {
font-size: 0.9rem;
}
.maf-hero-banner__stats {
grid-template-columns: 1fr;
max-width: 420px;
}
}
@media (max-width: 420px) {
.maf-hero-banner__content {
padding: 1.75rem 1rem;
}
.maf-hero-banner__action {
min-height: 42px;
padding: 0 16px;
border-radius: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.maf-hero-banner__action,
.maf-hero-banner__stat {
transition: none;
}
.maf-hero-banner__stat:hover,
.maf-hero-banner__action:hover {
transform: none;
}
}
import type { Meta, StoryObj } from "@storybook/nextjs-vite";
import { MobileAppCtaBanner } from "./MobileAppCtaBanner";
const meta = {
title: "Shared/Banners/MobileAppCtaBanner",
component: MobileAppCtaBanner,
parameters: {
layout: "fullscreen",
},
} satisfies Meta<typeof MobileAppCtaBanner>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
eyebrow: "Mobile app",
title: "Gateway in your pocket",
description:
"Capture data, complete checklists and report incidents directly from the field — online or offline.",
appStoreHref: "https://apps.apple.com/",
googlePlayHref: "https://play.google.com/store",
},
};
import type React from "react";
import { AppStoreBadges } from "@/components/base/AppStoreBadges/AppStoreBadges";
import { SectionEyebrow } from "@/components/base/SectionEyebrow/SectionEyebrow";
import "./mobile-app-cta-banner.css";
export type MobileAppCtaBannerProps = {
eyebrow: string;
title: React.ReactNode;
description: string;
appStoreHref: string;
googlePlayHref: string;
className?: string;
};
/**
* Dark split layout: editorial copy + app store CTAs (mobile gateway promo).
*/
export function MobileAppCtaBanner({
eyebrow,
title,
description,
appStoreHref,
googlePlayHref,
className = "",
}: MobileAppCtaBannerProps) {
return (
<section
className={`maf-mobile-app-cta ${className}`.trim()}
aria-labelledby="maf-mobile-app-cta-title"
>
<div className="maf-mobile-app-cta__inner">
<div className="maf-mobile-app-cta__copy">
<SectionEyebrow srPrefix="Section">{eyebrow}</SectionEyebrow>
<h2 id="maf-mobile-app-cta-title" className="maf-mobile-app-cta__title font-serif">
{title}
</h2>
<p className="maf-mobile-app-cta__desc">{description}</p>
</div>
<div className="maf-mobile-app-cta__actions">
<AppStoreBadges
appStoreHref={appStoreHref}
googlePlayHref={googlePlayHref}
/>
</div>
</div>
</section>
);
}
.maf-mobile-app-cta {
--maf-eyebrow-fg: #b8955c;
--maf-eyebrow-line: #b8955c;
position: relative;
overflow: hidden;
color: rgba(255, 255, 255, 0.92);
background:
radial-gradient(
900px 420px at 12% 20%,
rgba(196, 165, 116, 0.09) 0%,
transparent 55%
),
radial-gradient(
700px 380px at 88% 75%,
rgba(80, 64, 48, 0.45) 0%,
transparent 55%
),
linear-gradient(180deg, #0a0a0c 0%, #121014 50%, #0a0a0c 100%);
border-block: 1px solid rgba(255, 255, 255, 0.06);
}
.maf-mobile-app-cta::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0.35;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.8' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='180' height='180' filter='url(%23n)' opacity='.35'/%3E%3C/svg%3E");
mix-blend-mode: overlay;
}
.maf-mobile-app-cta__inner {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: clamp(1.75rem, 4vw, 2.75rem);
max-width: 1240px;
margin: 0 auto;
padding: clamp(2.75rem, 6vw, 4.25rem) 1.65rem;
}
@media (min-width: 900px) {
.maf-mobile-app-cta__inner {
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: clamp(2rem, 5vw, 4rem);
}
}
.maf-mobile-app-cta__copy {
flex: 1 1 auto;
min-width: 0;
max-width: 36rem;
}
.maf-mobile-app-cta__title {
margin: 0 0 1rem;
font-size: clamp(2rem, 4.2vw, 2.85rem);
font-weight: 500;
line-height: 1.12;
letter-spacing: 0.01em;
color: rgba(255, 255, 255, 0.98);
}
.maf-mobile-app-cta__desc {
margin: 0;
font-size: 1rem;
line-height: 1.65;
color: rgba(244, 240, 232, 0.72);
max-width: 34rem;
}
.maf-mobile-app-cta__actions {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: flex-start;
}
@media (min-width: 900px) {
.maf-mobile-app-cta__actions {
justify-content: flex-end;
}
}
import type { Meta, StoryObj } from "@storybook/nextjs-vite";
import { InsightCard } from "./InsightCard";
const meta = {
title: "Shared/Cards/InsightCard",
component: InsightCard,
parameters: {
layout: "padded",
},
} satisfies Meta<typeof InsightCard>;
export default meta;
type Story = StoryObj<typeof meta>;
export const LandingHeroCard: Story = {
args: {
layout: "stats",
title: "Incident Reporting",
headerLabel: "Total Incidents",
headerValue: 10689,
stats: [
{ label: "Completed", value: 9955 },
{ label: "Incomplete", value: 734 },
],
},
render: (args) => (
<div style={{ maxWidth: 740 }}>
<InsightCard {...args} />
</div>
),
};
export const DashboardSummaryCard: Story = {
args: {
layout: "stats",
title: "Audits",
headerLabel: "Total Audits",
headerValue: 10689,
stats: [
{ label: "Completed", value: 9955 },
{ label: "Incomplete", value: 734 },
],
},
render: (args) => (
<div style={{ maxWidth: 540 }}>
<InsightCard {...args} />
</div>
),
};
export const MobilePreview: Story = {
args: {
layout: "stats",
title: "Audits",
headerLabel: "Total Audits",
headerValue: 10689,
stats: [
{ label: "Completed", value: 9955 },
{ label: "Incomplete", value: 734 },
],
},
parameters: {
viewport: { defaultViewport: "mobile1" },
},
};
import type React from "react";
import "./insight-card.css";
type StatItem = {
label: string;
value: string | number;
};
type InsightCardProps = {
layout?: "hero" | "stats";
title: string;
icon?: React.ReactNode;
subtitle?: string;
headerLabel?: string;
headerValue?: string | number;
stats?: StatItem[];
className?: string;
};
export function InsightCard({
layout = "hero",
title,
icon,
subtitle,
headerLabel,
headerValue,
stats = [],
className = "",
}: InsightCardProps) {
if (layout === "stats") {
return (
<article
className={`maf-reusable-card maf-reusable-card--stats ${className}`.trim()}
>
<header className="maf-reusable-card__stats-head">
<span className="maf-reusable-card__stats-head-label">
{headerLabel || title}
</span>
{headerValue !== undefined ? (
<span className="maf-reusable-card__stats-head-value">
{headerValue}
</span>
) : null}
</header>
{stats.length > 0 ? (
<div className="maf-reusable-card__stats-grid">
{stats.map((item) => (
<div key={item.label} className="maf-reusable-card__stat-item">
<span className="maf-reusable-card__stat-label">
{item.label}
</span>
<span className="maf-reusable-card__stat-value">
{item.value}
</span>
</div>
))}
</div>
) : null}
</article>
);
}
return (
<article
className={`maf-reusable-card maf-reusable-card--hero ${className}`.trim()}
>
{icon ? <div className="maf-reusable-card__icon-wrap">{icon}</div> : null}
<div className="maf-reusable-card__hero-body">
<h3 className="maf-reusable-card__title">{title}</h3>
{subtitle ? (
<p className="maf-reusable-card__subtitle">{subtitle}</p>
) : null}
</div>
</article>
);
}
.maf-reusable-card {
--card-bg: #f4f4f4;
--card-bg-light: #f4f4f4;
--card-text: #8f8477;
--card-white: #ffffff;
--card-border: #d2c9bb;
--hero-bg: #6e5a44;
--hero-ink: rgba(255, 255, 255, 0.96);
--accent: #ac8e57;
border-radius: 8px;
border: 1px solid var(--card-border);
overflow: hidden;
box-shadow:
0 10px 24px rgba(17, 20, 24, 0.12),
0 2px 6px rgba(17, 20, 24, 0.08);
transition:
box-shadow 180ms ease,
transform 180ms ease;
}
.maf-reusable-card:hover {
box-shadow:
0 14px 30px rgba(17, 20, 24, 0.15),
0 4px 10px rgba(17, 20, 24, 0.1);
transform: translateY(-1px);
}
.maf-reusable-card--hero {
background: linear-gradient(
180deg,
rgba(110, 90, 68, 0.98),
rgba(110, 90, 68, 0.9)
);
color: var(--hero-ink);
padding: 1rem;
min-height: 104px;
display: flex;
align-items: center;
gap: 0.85rem;
}
.maf-reusable-card__icon-wrap {
width: 56px;
height: 56px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.14);
}
.maf-reusable-card__icon-wrap svg {
width: 42px;
height: 42px;
}
.maf-reusable-card__hero-body {
min-width: 0;
}
.maf-reusable-card__title {
margin: 0;
font-size: 1.75rem;
line-height: 1.1;
font-weight: 700;
letter-spacing: 0.01em;
text-transform: uppercase;
text-shadow: 0 8px 30px rgba(0, 0, 0, 0.18);
}
.maf-reusable-card__subtitle {
margin: 0.35rem 0 0;
color: rgba(255, 255, 255, 0.9);
font-size: 0.9rem;
line-height: 1.3;
}
.maf-reusable-card--stats {
background: var(--card-bg-light);
color: var(--card-text);
}
.maf-reusable-card__stats-head {
background: var(--card-bg);
color: #1e1b17;
padding: 0.7rem 0.95rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.maf-reusable-card__stats-head-label {
font-size: 1rem;
font-weight: 500;
}
.maf-reusable-card__stats-head-value {
font-size: 1.9rem;
line-height: 1;
font-weight: 700;
}
.maf-reusable-card__stats-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.8rem;
padding: 0.75rem 0.95rem 0.85rem;
}
.maf-reusable-card__stat-item {
display: grid;
gap: 0.15rem;
padding: 0.55rem 0.55rem;
border-radius: 10px;
background: rgba(110, 90, 68, 0.04);
}
.maf-reusable-card__stat-item:last-child {
justify-items: end;
text-align: right;
}
.maf-reusable-card__stat-label {
font-size: 0.95rem;
color: var(--accent);
}
.maf-reusable-card__stat-value {
font-size: 2rem;
font-weight: 700;
color: #212121;
line-height: 1;
}
@media (max-width: 1024px) {
.maf-reusable-card__title {
font-size: 1.45rem;
}
.maf-reusable-card__stats-head-value {
font-size: 1.6rem;
}
.maf-reusable-card__stat-value {
font-size: 1.6rem;
}
}
@media (max-width: 768px) {
.maf-reusable-card--hero {
min-height: 88px;
padding: 0.8rem;
gap: 0.65rem;
}
.maf-reusable-card__icon-wrap {
width: 44px;
height: 44px;
}
.maf-reusable-card__icon-wrap svg {
width: 32px;
height: 32px;
}
.maf-reusable-card__title {
font-size: 1.1rem;
}
.maf-reusable-card__subtitle {
font-size: 0.78rem;
}
.maf-reusable-card__stats-head {
padding: 0.6rem 0.72rem;
}
.maf-reusable-card__stats-head-label {
font-size: 0.82rem;
}
.maf-reusable-card__stats-head-value {
font-size: 1.25rem;
}
.maf-reusable-card__stats-grid {
gap: 0.55rem;
padding: 0.55rem 0.72rem 0.7rem;
}
.maf-reusable-card__stat-label {
font-size: 0.72rem;
}
.maf-reusable-card__stat-value {
font-size: 1.2rem;
}
}
import type { Meta, StoryObj } from "@storybook/nextjs-vite";
import { DashboardTrendChart } from "./DashboardTrendChart";
const meta = {
title: "Shared/Charts/DashboardTrendChart",
component: DashboardTrendChart,
parameters: {
layout: "padded",
},
} satisfies Meta<typeof DashboardTrendChart>;
export default meta;
type Story = StoryObj<typeof meta>;
const monthLabels = [
"JAN",
"FEB",
"MAR",
"APR",
"MAY",
"JUN",
"JUL",
"AUG",
"SEP",
"OCT",
"NOV",
"DEC",
];
export const Default: Story = {
args: {
title: "Audit Dashboard Progress",
labels: monthLabels,
completed: [4300, 3500, 2150, 0, 0, 0, 0, 0, 0, 0, 0, 0],
incomplete: [180, 190, 390, 0, 0, 0, 0, 0, 0, 0, 0, 0],
height: 280,
},
};
export const Mobile: Story = {
args: {
title: "Audit Dashboard Progress",
labels: monthLabels,
completed: [4300, 3500, 2150, 0, 0, 0, 0, 0, 0, 0, 0, 0],
incomplete: [180, 190, 390, 0, 0, 0, 0, 0, 0, 0, 0, 0],
height: 240,
},
parameters: {
viewport: { defaultViewport: "mobile1" },
},
};
"use client";
import React from "react";
import uPlot from "uplot";
import "uplot/dist/uPlot.min.css";
import "./dashboard-trend-chart.css";
type DashboardTrendChartProps = {
title?: string;
labels: string[];
completed: number[];
incomplete: number[];
height?: number;
showSeriesLabels?: boolean;
};
export function DashboardTrendChart({
title = "Audit Dashboard Progress",
labels,
completed,
incomplete,
height = 280,
showSeriesLabels = true,
}: DashboardTrendChartProps) {
const rootRef = React.useRef<HTMLDivElement | null>(null);
const chartRef = React.useRef<uPlot | null>(null);
const [containerWidth, setContainerWidth] = React.useState(0);
React.useEffect(() => {
const root = rootRef.current;
if (!root) return;
const resizeObserver = new ResizeObserver(() => {
setContainerWidth(root.clientWidth);
});
resizeObserver.observe(root);
setContainerWidth(root.clientWidth);
return () => {
resizeObserver.disconnect();
};
}, []);
React.useEffect(() => {
const root = rootRef.current;
if (!root) return;
if (!labels.length) return;
const x = labels.map((_, index) => index + 1);
const width = Math.max(containerWidth || root.clientWidth, 220);
const isSmallMobile = width <= 360;
const isMobile = width <= 768;
const minTickGap = isSmallMobile ? 4 : isMobile ? 2 : 1;
const responsiveHeight = isSmallMobile
? Math.max(178, height - 92)
: isMobile
? Math.max(214, height - 45)
: height;
const axisFont = isSmallMobile
? "10px sans-serif"
: isMobile
? "11px sans-serif"
: "12px sans-serif";
const formatYAxis = (value: number) => {
if (value >= 1000) return `${Math.round(value / 1000)}k`;
return String(Math.round(value));
};
const options: uPlot.Options = {
width,
height: responsiveHeight,
padding: isSmallMobile
? [8, 6, 20, 28]
: isMobile
? [10, 8, 24, 34]
: [16, 16, 30, 42],
title,
scales: {
x: { time: false },
},
axes: [
{
stroke: "#d7d7d7",
grid: { stroke: "#ededed", width: 1 },
size: isSmallMobile ? 22 : 26,
font: axisFont,
values: (_, ticks) =>
ticks.map((tick) => {
const index = Math.max(0, Number(tick) - 1);
if (index % minTickGap !== 0) return "";
return labels[index] || "";
}),
},
{
stroke: "#d7d7d7",
grid: { stroke: "#ededed", width: 1 },
size: isSmallMobile ? 26 : isMobile ? 34 : 42,
font: axisFont,
values: (_, ticks) => ticks.map((tick) => formatYAxis(Number(tick))),
},
],
series: [
{},
{
label: "Completed",
stroke: "#46b5dd",
width: isSmallMobile ? 1.5 : 2,
fill: "rgba(70, 181, 221, 0.08)",
points: {
size: isSmallMobile ? 2.5 : isMobile ? 3 : 4,
fill: "#ffffff",
stroke: "#46b5dd",
width: isSmallMobile ? 1.5 : 2,
},
},
{
label: "Incomplete",
stroke: "#da7ea7",
width: isSmallMobile ? 1.5 : 2,
points: {
size: isSmallMobile ? 2.5 : isMobile ? 3 : 4,
fill: "#ffffff",
stroke: "#da7ea7",
width: isSmallMobile ? 1.5 : 2,
},
},
],
legend: {
show: !isMobile,
},
};
chartRef.current?.destroy();
chartRef.current = new uPlot(options, [x, completed, incomplete], root);
return () => {
chartRef.current?.destroy();
chartRef.current = null;
};
}, [labels, completed, incomplete, height, title, containerWidth]);
const completedTotal = completed.reduce((sum, value) => sum + value, 0);
const incompleteTotal = incomplete.reduce((sum, value) => sum + value, 0);
return (
<div className="maf-dashboard-trend-chart-shell">
<div className="maf-dashboard-trend-chart" ref={rootRef} />
{showSeriesLabels ? (
<section
className="maf-dashboard-trend-chart-labels"
aria-label="Chart legend and totals"
>
<div className="maf-dashboard-trend-chart-label">
<span
className="maf-dashboard-trend-dot maf-dashboard-trend-dot--completed"
aria-hidden
/>
<span className="maf-dashboard-trend-label-text">Completed</span>
<span className="maf-dashboard-trend-label-value">
{completedTotal.toLocaleString()}
</span>
</div>
<div className="maf-dashboard-trend-chart-label">
<span
className="maf-dashboard-trend-dot maf-dashboard-trend-dot--incomplete"
aria-hidden
/>
<span className="maf-dashboard-trend-label-text">Incomplete</span>
<span className="maf-dashboard-trend-label-value">
{incompleteTotal.toLocaleString()}
</span>
</div>
</section>
) : null}
</div>
);
}
.maf-dashboard-trend-chart-shell {
width: 100%;
}
.maf-dashboard-trend-chart {
width: 100%;
background: #fff;
border: 1px solid #d9d9d9;
border-radius: 6px;
box-shadow:
0 8px 20px rgba(17, 20, 24, 0.1),
0 2px 6px rgba(17, 20, 24, 0.07);
padding: 0.5rem;
}
.maf-dashboard-trend-chart .u-title {
font-size: 1rem;
font-weight: 700;
color: #434343;
}
.maf-dashboard-trend-chart .u-legend {
font-size: 0.82rem;
display: flex;
flex-wrap: wrap;
gap: 0.35rem 0.6rem;
}
.maf-dashboard-trend-chart-labels {
margin-top: 0.45rem;
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.maf-dashboard-trend-chart-label {
display: inline-flex;
align-items: center;
gap: 0.32rem;
border: 1px solid #e5e5e5;
background: #fff;
border-radius: 999px;
padding: 0.2rem 0.52rem;
font-size: 0.75rem;
color: #4a4a4a;
}
.maf-dashboard-trend-dot {
width: 8px;
height: 8px;
border-radius: 999px;
display: inline-block;
flex-shrink: 0;
}
.maf-dashboard-trend-dot--completed {
background: #46b5dd;
}
.maf-dashboard-trend-dot--incomplete {
background: #da7ea7;
}
.maf-dashboard-trend-label-text {
font-weight: 600;
}
.maf-dashboard-trend-label-value {
font-weight: 700;
}
@media (max-width: 768px) {
.maf-dashboard-trend-chart {
padding: 0.28rem;
}
.maf-dashboard-trend-chart .u-title {
font-size: 0.8rem;
}
.maf-dashboard-trend-chart .u-legend {
font-size: 0.74rem;
gap: 0.25rem 0.45rem;
}
.maf-dashboard-trend-chart-labels {
gap: 0.35rem;
margin-top: 0.35rem;
}
.maf-dashboard-trend-chart-label {
font-size: 0.7rem;
padding: 0.18rem 0.45rem;
gap: 0.25rem;
}
}
@media (max-width: 420px) {
.maf-dashboard-trend-chart {
padding: 0.2rem;
}
.maf-dashboard-trend-chart .u-title {
font-size: 0.74rem;
}
.maf-dashboard-trend-chart .u-legend {
font-size: 0.68rem;
}
.maf-dashboard-trend-chart-labels {
display: grid;
grid-template-columns: 1fr;
gap: 0.28rem;
}
.maf-dashboard-trend-chart-label {
justify-content: space-between;
width: 100%;
font-size: 0.68rem;
padding: 0.2rem 0.42rem;
}
}
import type { Meta, StoryObj } from "@storybook/nextjs-vite";
import React from "react";
import { LoginModal } from "./LoginModal";
const meta = {
title: "Widgets/Login Modal",
component: LoginModal,
parameters: {
layout: "fullscreen",
},
} satisfies Meta<typeof LoginModal>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Open: Story = {
args: {
open: true,
onClose: () => {},
onMicrosoftSignIn: () => {},
onContactSupport: () => {},
},
};
"use client";
import { useMemo, useState } from "react";
import { ModalShell } from "@/components/widgets/ModalShell/ModalShell";
import "./login-modal.css";
export type LoginAccountType = "employee" | "contractor";
export type LoginModalProps = {
open: boolean;
onClose: () => void;
onContactSupport?: () => void;
onMicrosoftSignIn?: (accountType: LoginAccountType) => void;
className?: string;
};
function InfoGlyph({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" aria-hidden>
<circle
cx="12"
cy="12"
r="9"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
/>
<path
d="M12 10.8v5.4"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
<circle cx="12" cy="7.6" r="1" fill="currentColor" />
</svg>
);
}
function MicrosoftGlyph({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" aria-hidden>
<rect x="2" y="2" width="9.5" height="9.5" fill="#F25022" />
<rect x="12.5" y="2" width="9.5" height="9.5" fill="#7FBA00" />
<rect x="2" y="12.5" width="9.5" height="9.5" fill="#00A4EF" />
<rect x="12.5" y="12.5" width="9.5" height="9.5" fill="#FFB900" />
</svg>
);
}
export function LoginModal({
open,
onClose,
onContactSupport,
onMicrosoftSignIn,
className = "",
}: LoginModalProps) {
const [accountType, setAccountType] = useState<LoginAccountType>("employee");
const helperCopy = useMemo(() => {
if (accountType === "employee") {
return {
title: "Microsoft Azure Active Directory",
body: "MAF employees sign in using Microsoft Azure Active Directory. You'll be redirected to the Microsoft login page and returned here automatically.",
};
}
return {
title: "Contractor / Supplier access",
body: "Contractors and suppliers sign in with the credentials provided by your organization. If you don't have access yet, contact support.",
};
}, [accountType]);
return (
<ModalShell
open={open}
onClose={onClose}
brandLabel="MAF Gateway"
badge="M"
title="Welcome back"
className={`maf-login-modal ${className}`.trim()}
>
<p className="maf-login-modal__subtitle">Choose your account type to continue</p>
<div className="maf-login-modal__tabs" role="tablist" aria-label="Account type">
<button
type="button"
role="tab"
aria-selected={accountType === "employee"}
className={`maf-login-modal__tab ${accountType === "employee" ? "is-active" : ""}`.trim()}
onClick={() => setAccountType("employee")}
>
MAF Employee
</button>
<button
type="button"
role="tab"
aria-selected={accountType === "contractor"}
className={`maf-login-modal__tab ${accountType === "contractor" ? "is-active" : ""}`.trim()}
onClick={() => setAccountType("contractor")}
>
Contractor / Supplier
</button>
</div>
<section className="maf-login-modal__info" aria-label="Sign in information">
<span className="maf-login-modal__infoIcon" aria-hidden>
<InfoGlyph className="maf-login-modal__infoSvg" />
</span>
<p className="maf-login-modal__infoText">
<span className="maf-login-modal__infoLead">{helperCopy.title}</span> {helperCopy.body}
</p>
</section>
<button
type="button"
className="maf-login-modal__msButton"
onClick={() => onMicrosoftSignIn?.(accountType)}
>
<MicrosoftGlyph className="maf-login-modal__msGlyph" />
<span>Sign in with Microsoft</span>
</button>
<div className="maf-login-modal__security">
<span className="maf-login-modal__securityGlyph" aria-hidden>
</span>
<span>Secure redirect via Microsoft — your credentials never touch our servers</span>
</div>
<div className="maf-login-modal__footer">
<span className="maf-login-modal__footerMuted">Need help accessing your account?</span>
<button type="button" className="maf-login-modal__footerLink" onClick={onContactSupport}>
Contact support →
</button>
</div>
</ModalShell>
);
}
.maf-login-modal .maf-widget-modal__title {
margin-top: 0.35rem;
}
.maf-login-modal__subtitle {
margin: 0.55rem 0 1.1rem;
color: color-mix(in srgb, var(--foreground, #f4f0e8) 62%, transparent);
font-size: 0.95rem;
line-height: 1.5;
}
.maf-login-modal__tabs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.6rem;
padding: 0.45rem;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.maf-login-modal__tab {
border: 1px solid transparent;
background: transparent;
color: color-mix(in srgb, var(--foreground, #f4f0e8) 62%, transparent);
border-radius: 12px;
padding: 0.85rem 0.9rem;
font-weight: 700;
letter-spacing: 0.02em;
cursor: pointer;
}
.maf-login-modal__tab.is-active {
background: rgba(255, 255, 255, 0.06);
border-color: color-mix(in srgb, var(--accent, #c4a574) 30%, transparent);
color: rgba(255, 255, 255, 0.92);
box-shadow:
0 0 0 1px color-mix(in srgb, var(--accent, #c4a574) 12%, transparent),
0 18px 40px rgba(0, 0, 0, 0.25);
}
.maf-login-modal__tab:focus-visible {
outline: 2px solid var(--accent, #c4a574);
outline-offset: 3px;
}
.maf-login-modal__info {
margin-top: 1rem;
display: grid;
grid-template-columns: auto 1fr;
gap: 0.85rem;
align-items: start;
padding: 1rem 1rem;
border-radius: 16px;
border: 1px solid color-mix(in srgb, var(--accent, #c4a574) 20%, transparent);
background: rgba(255, 255, 255, 0.03);
}
.maf-login-modal__infoIcon {
width: 34px;
height: 34px;
border-radius: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
background: color-mix(in srgb, var(--accent, #c4a574) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--accent, #c4a574) 22%, transparent);
color: color-mix(in srgb, var(--accent, #c4a574) 92%, transparent);
}
.maf-login-modal__infoSvg {
width: 18px;
height: 18px;
}
.maf-login-modal__infoText {
margin: 0;
color: color-mix(in srgb, var(--foreground, #f4f0e8) 62%, transparent);
font-size: 0.92rem;
line-height: 1.6;
}
.maf-login-modal__infoLead {
font-weight: 800;
color: rgba(255, 255, 255, 0.92);
}
.maf-login-modal__msButton {
margin-top: 1rem;
width: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 0.95rem 1rem;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.92);
color: rgba(16, 15, 14, 0.92);
font-weight: 700;
cursor: pointer;
box-shadow:
0 18px 50px rgba(0, 0, 0, 0.35),
inset 0 1px 0 rgba(255, 255, 255, 0.6);
}
.maf-login-modal__msButton:hover {
background: rgba(255, 255, 255, 0.96);
}
.maf-login-modal__msButton:focus-visible {
outline: 2px solid var(--accent, #c4a574);
outline-offset: 3px;
}
.maf-login-modal__msGlyph {
width: 20px;
height: 20px;
}
.maf-login-modal__security {
margin-top: 0.85rem;
display: flex;
gap: 0.55rem;
align-items: center;
color: color-mix(in srgb, var(--foreground, #f4f0e8) 52%, transparent);
font-size: 0.8rem;
padding-bottom: 1.05rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.maf-login-modal__securityGlyph {
width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
color: color-mix(in srgb, var(--accent, #c4a574) 82%, transparent);
}
.maf-login-modal__footer {
margin-top: 1rem;
display: flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
flex-wrap: wrap;
color: color-mix(in srgb, var(--foreground, #f4f0e8) 56%, transparent);
font-size: 0.85rem;
}
.maf-login-modal__footerLink {
border: 0;
background: transparent;
color: color-mix(in srgb, var(--accent, #c4a574) 92%, transparent);
font-weight: 700;
cursor: pointer;
padding: 0.2rem 0.35rem;
border-radius: 10px;
}
.maf-login-modal__footerLink:hover {
background: color-mix(in srgb, var(--accent, #c4a574) 10%, transparent);
}
.maf-login-modal__footerLink:focus-visible {
outline: 2px solid var(--accent, #c4a574);
outline-offset: 3px;
}
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