Commit 1b90df82 by krds-arun

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

parent 7357a58b
......@@ -15,13 +15,19 @@ html {
body {
background: var(--background);
color: var(--foreground);
font-feature-settings: "ss01" on, "cv11" on;
font-feature-settings:
"ss01" on,
"cv11" on;
}
.font-display {
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 {
0%,
100% {
......
import type { Metadata } from "next";
import { Manrope, Syne } from "next/font/google";
import { Manrope, Playfair_Display, Syne } from "next/font/google";
import "./globals.css";
......@@ -8,6 +8,11 @@ const syne = Syne({
subsets: ["latin"],
});
const playfair = Playfair_Display({
variable: "--font-serif",
subsets: ["latin"],
});
const manrope = Manrope({
variable: "--font-sans",
subsets: ["latin"],
......@@ -26,7 +31,7 @@ export default function RootLayout({
return (
<html lang="en">
<body
className={`${manrope.className} ${syne.variable} min-h-screen antialiased`}
className={`${manrope.className} ${syne.variable} ${playfair.variable} min-h-screen antialiased`}
>
{children}
</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() {
const [loginOpen, setLoginOpen] = React.useState(false);
const [supportOpen, setSupportOpen] = React.useState(false);
return (
<main className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-6 py-20">
<div
className="pointer-events-none absolute inset-0 animate-drift"
aria-hidden
style={{
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)
`,
<>
<Header
supportCta={{
label: "Support",
onClick: () => setSupportOpen(true),
}}
/>
<div
className="pointer-events-none absolute inset-0 opacity-[0.03]"
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")`,
cta={{
label: "Login",
onClick: () => setLoginOpen(true),
}}
/>
<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">
<p className="animate-fade-up text-xs font-medium uppercase tracking-[0.35em] text-[var(--muted)]">
MAF Revamp
</p>
<h1 className="font-display animate-fade-up-delay mt-6 text-5xl font-semibold leading-[1.05] tracking-tight sm:text-6xl">
Welcome
</h1>
<p className="animate-fade-up-delay-2 mt-6 text-lg leading-relaxed text-[var(--muted)]">
You are on a clean frontend shell. Build screens and flows here when you are ready.
</p>
<p className="animate-fade-up-delay-2 mt-10 font-mono text-xs text-[var(--muted)]/70">
pnpm dev
</p>
</div>
</main>
<LoginModal
open={loginOpen}
onClose={() => setLoginOpen(false)}
onMicrosoftSignIn={() => {
setLoginOpen(false);
}}
onContactSupport={() => {
setLoginOpen(false);
setSupportOpen(true);
}}
/>
<SupportModal open={supportOpen} onClose={() => setSupportOpen(false)} />
</>
);
}
{
"$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 { fn } from 'storybook/test'
import type { Meta, StoryObj } from "@storybook/nextjs-vite";
import { fn } from "storybook/test";
import type { FormDefinitionSchema } from '../types'
import { FormRenderer } from './FormRenderer'
import type { FormDefinitionSchema } from "../types";
import { FormRenderer } from "./FormRenderer";
const sampleSchema: FormDefinitionSchema = {
meta: {
formCode: 'MAF_OP_TECH_001',
sourceId: 'webform.webform.sample',
title: 'Operational + Technical Checklist',
categoryIds: ['operational_checklist', 'technical_checklist'],
formCode: "MAF_OP_TECH_001",
sourceId: "webform.webform.sample",
title: "Operational + Technical Checklist",
categoryIds: ["operational_checklist", "technical_checklist"],
version: 1,
},
layout: [
{ groupCode: 'operational', label: 'Operational Checklist', order: 1 },
{ groupCode: 'technical', label: 'Technical Checklist', order: 2 },
{ groupCode: "operational", label: "Operational Checklist", order: 1 },
{ groupCode: "technical", label: "Technical Checklist", order: 2 },
],
questions: [
{
questionCode: 'op_general',
groupCode: 'operational',
label: 'General observation',
inputType: 'text',
questionCode: "op_general",
groupCode: "operational",
label: "General observation",
inputType: "text",
required: true,
questionOrder: 1,
validation: {},
},
{
questionCode: 'op_radio_choice',
groupCode: 'operational',
label: 'A separate laundry basket available to collect the dirty napkins for the laundry, tagged as unclean?',
inputType: 'radio',
options: ['Yes', 'No', 'NA'],
questionCode: "op_radio_choice",
groupCode: "operational",
label:
"A separate laundry basket available to collect the dirty napkins for the laundry, tagged as unclean?",
inputType: "radio",
options: ["Yes", "No", "NA"],
required: true,
questionOrder: 2,
validation: {
......@@ -39,11 +40,12 @@ const sampleSchema: FormDefinitionSchema = {
},
},
{
questionCode: 'op_comment_enabled',
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?',
inputType: 'select',
options: ['No', 'Yes'],
questionCode: "op_comment_enabled",
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?",
inputType: "select",
options: ["No", "Yes"],
required: false,
questionOrder: 3,
validation: {
......@@ -51,19 +53,19 @@ const sampleSchema: FormDefinitionSchema = {
},
},
{
questionCode: 'op_due_date',
groupCode: 'operational',
label: 'Next inspection due date',
inputType: 'date',
questionCode: "op_due_date",
groupCode: "operational",
label: "Next inspection due date",
inputType: "date",
required: true,
questionOrder: 4,
validation: {},
},
{
questionCode: 'op_linked_file_enabled',
groupCode: 'operational',
label: 'Upload evidence (optional)',
inputType: 'checkbox',
questionCode: "op_linked_file_enabled",
groupCode: "operational",
label: "Upload evidence (optional)",
inputType: "checkbox",
required: false,
questionOrder: 5,
validation: {
......@@ -71,20 +73,20 @@ const sampleSchema: FormDefinitionSchema = {
},
},
{
questionCode: 'tech_confirm',
groupCode: 'technical',
label: 'I confirm technical checklist completion',
inputType: 'checkbox',
questionCode: "tech_confirm",
groupCode: "technical",
label: "I confirm technical checklist completion",
inputType: "checkbox",
required: true,
questionOrder: 1,
validation: {},
},
{
questionCode: 'tech_issues',
groupCode: 'technical',
label: 'Select observed issue types',
inputType: 'multiselect',
options: ['Mechanical', 'Electrical', 'Safety', 'Housekeeping'],
questionCode: "tech_issues",
groupCode: "technical",
label: "Select observed issue types",
inputType: "multiselect",
options: ["Mechanical", "Electrical", "Safety", "Housekeeping"],
required: true,
questionOrder: 2,
validation: {
......@@ -92,10 +94,10 @@ const sampleSchema: FormDefinitionSchema = {
},
},
{
questionCode: 'tech_notes',
groupCode: 'technical',
label: 'Technical notes',
inputType: 'textarea',
questionCode: "tech_notes",
groupCode: "technical",
label: "Technical notes",
inputType: "textarea",
required: false,
questionOrder: 3,
validation: {},
......@@ -104,30 +106,29 @@ const sampleSchema: FormDefinitionSchema = {
hiddenFields: [],
validation: {
requiredQuestions: [
'op_general',
'op_radio_choice',
'op_due_date',
'tech_confirm',
'tech_issues',
"op_general",
"op_radio_choice",
"op_due_date",
"tech_confirm",
"tech_issues",
],
},
}
};
const meta = {
title: 'Forms/FormRenderer',
title: "Forms/FormRenderer",
component: FormRenderer,
parameters: {
layout: 'fullscreen',
layout: "fullscreen",
},
args: {
onSubmit: fn(),
onTitleAction: fn(),
schema: sampleSchema,
},
} satisfies Meta<typeof FormRenderer>
} satisfies Meta<typeof FormRenderer>;
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
import React from 'react'
import React from "react";
import { FieldsetTabs } from "../fields/FieldsetTabs";
import { FormActions } from "../fields/FormActions";
import { FormGroupSection } from "../fields/FormGroupSection";
import { FormTitleCard } from "../fields/FormTitleCard";
import { InlineFieldError } from "../fields/InlineFieldError";
import { QuestionRenderer } from "../fields/QuestionRenderer";
import type { FormDefinitionSchema, FormQuestion } from "../types";
import type { FormDefinitionSchema, FormQuestion } from '../types'
import { FieldsetTabs } from '../fields/FieldsetTabs'
import { FormGroupSection } from '../fields/FormGroupSection'
import { FormActions } from '../fields/FormActions'
import { FormTitleCard } from '../fields/FormTitleCard'
import { InlineFieldError } from '../fields/InlineFieldError'
import { QuestionRenderer } from '../fields/QuestionRenderer'
type FormValueMap = Record<string, unknown>
type FormValueMap = Record<string, unknown>;
export type FormRendererProps = {
schema: FormDefinitionSchema
onSubmit?: (payload: { values: FormValueMap }) => void
onTitleAction?: () => void
}
schema: FormDefinitionSchema;
onSubmit?: (payload: { values: FormValueMap }) => void;
onTitleAction?: () => void;
};
const REQUIRED_ERROR = 'This field is required.'
const REQUIRED_ERROR = "This field is required.";
const sortByOrder = (a: FormQuestion, b: FormQuestion) =>
(a.questionOrder ?? 0) - (b.questionOrder ?? 0)
(a.questionOrder ?? 0) - (b.questionOrder ?? 0);
const getRequiredError = (q: FormQuestion, value: unknown) => {
const inputType = String(q.inputType)
if (inputType === 'checkbox') {
return value === true ? undefined : REQUIRED_ERROR
}
const inputType = String(q.inputType);
if (inputType === 'file') {
if (Array.isArray(value) && value.length > 0) return undefined
return 'Please upload a file.'
if (inputType === "checkbox") {
return value === true ? undefined : REQUIRED_ERROR;
}
if (inputType === 'multiselect' || q.validation?.allowsMultiple === true) {
if (Array.isArray(value) && value.length > 0) return undefined
return REQUIRED_ERROR
if (inputType === "file") {
if (Array.isArray(value) && value.length > 0) return undefined;
return "Please upload a file.";
}
if (typeof value === 'string') {
return value.trim().length > 0 ? undefined : REQUIRED_ERROR
if (inputType === "multiselect" || q.validation?.allowsMultiple === true) {
if (Array.isArray(value) && value.length > 0) return undefined;
return REQUIRED_ERROR;
}
return value ? undefined : REQUIRED_ERROR
}
export function FormRenderer({ schema, onSubmit, onTitleAction }: FormRendererProps) {
// Defensive guard for Storybook / mis-wired stories. Prevents hard crashes.
if (!schema || !Array.isArray(schema.questions) || !Array.isArray(schema.layout)) {
return (
<div
className="empty-state"
style={{
padding: '2rem',
maxWidth: 640,
margin: '0 auto',
}}
>
<div className="empty-state-heading">FormRenderer schema missing</div>
<p>Please provide a valid `schema` prop (from `definition_schema`).</p>
</div>
)
if (typeof value === "string") {
return value.trim().length > 0 ? undefined : REQUIRED_ERROR;
}
const [values, setValues] = React.useState<FormValueMap>({})
const [errors, setErrors] = React.useState<Record<string, string>>({})
const [submitAttempted, setSubmitAttempted] = React.useState(false)
const [activeGroupCode, setActiveGroupCode] = React.useState<string>('')
const [query, setQuery] = React.useState('')
return value ? undefined : REQUIRED_ERROR;
};
export function FormRenderer({
schema,
onSubmit,
onTitleAction,
}: FormRendererProps) {
const questions = Array.isArray(schema?.questions) ? schema.questions : [];
const layout = Array.isArray(schema?.layout) ? schema.layout : [];
const isValidSchema =
schema != null &&
Array.isArray(schema.questions) &&
Array.isArray(schema.layout);
const [values, setValues] = React.useState<FormValueMap>({});
const [errors, setErrors] = React.useState<Record<string, string>>({});
const [submitAttempted, setSubmitAttempted] = React.useState(false);
const [activeGroupCode, setActiveGroupCode] = React.useState<string>("");
const [query, setQuery] = React.useState("");
const setValue = React.useCallback((key: string, next: unknown) => {
setValues((prev) => ({ ...prev, [key]: next }))
}, [])
setValues((prev) => ({ ...prev, [key]: next }));
}, []);
const questionsByGroup = React.useMemo(() => {
const map = new Map<string, FormQuestion[]>()
for (const q of schema.questions) {
const groupCode = q.groupCode ?? '__ungrouped__'
map.set(groupCode, [...(map.get(groupCode) ?? []), q])
const map = new Map<string, FormQuestion[]>();
for (const q of questions) {
const groupCode = q.groupCode ?? "__ungrouped__";
map.set(groupCode, [...(map.get(groupCode) ?? []), q]);
}
for (const [, list] of map) {
list.sort(sortByOrder)
list.sort(sortByOrder);
}
return map
}, [schema.questions])
return map;
}, [questions]);
const layoutSorted = React.useMemo(() => {
const copy = [...(schema.layout ?? [])]
copy.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
return copy
}, [schema.layout])
const copy = [...layout];
copy.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
return copy;
}, [layout]);
const groupCodesFromLayout = new Set(layoutSorted.map((g) => g.groupCode))
const groupCodesFromLayout = new Set(layoutSorted.map((g) => g.groupCode));
React.useEffect(() => {
if (!layoutSorted.length) return
if (!activeGroupCode || !layoutSorted.some((g) => g.groupCode === activeGroupCode)) {
setActiveGroupCode(layoutSorted[0].groupCode)
if (!layoutSorted.length) return;
if (
!activeGroupCode ||
!layoutSorted.some((g) => g.groupCode === activeGroupCode)
) {
setActiveGroupCode(layoutSorted[0].groupCode);
}
}, [layoutSorted, activeGroupCode])
}, [layoutSorted, activeGroupCode]);
const onReset = () => {
setValues({})
setErrors({})
setSubmitAttempted(false)
}
setValues({});
setErrors({});
setSubmitAttempted(false);
};
const onFormSubmit = (e: React.FormEvent) => {
e.preventDefault()
const nextErrors: Record<string, string> = {}
e.preventDefault();
const nextErrors: Record<string, string> = {};
for (const q of schema.questions) {
if (!q.required) continue
const err = getRequiredError(q, values[q.questionCode])
if (err) nextErrors[q.questionCode] = err
for (const q of questions) {
if (!q.required) continue;
const err = getRequiredError(q, values[q.questionCode]);
if (err) nextErrors[q.questionCode] = err;
}
setErrors(nextErrors)
setSubmitAttempted(true)
setErrors(nextErrors);
setSubmitAttempted(true);
if (Object.keys(nextErrors).length === 0) {
onSubmit?.({ values })
onSubmit?.({ values });
}
}
};
const errorSummaryCount = submitAttempted ? Object.keys(errors).length : 0
const errorSummaryCount = submitAttempted ? Object.keys(errors).length : 0;
const isAnswered = React.useCallback((q: FormQuestion, value: unknown) => {
const inputType = String(q.inputType)
if (inputType === 'checkbox') return value === true
if (inputType === 'file') return Array.isArray(value) && value.length > 0
if (inputType === 'multiselect' || q.validation?.allowsMultiple === true) {
return Array.isArray(value) && value.length > 0
const inputType = String(q.inputType);
if (inputType === "checkbox") return value === true;
if (inputType === "file") return Array.isArray(value) && value.length > 0;
if (inputType === "multiselect" || q.validation?.allowsMultiple === true) {
return Array.isArray(value) && value.length > 0;
}
if (typeof value === 'string') return value.trim().length > 0
return Boolean(value)
}, [])
if (typeof value === "string") return value.trim().length > 0;
return Boolean(value);
}, []);
const tabs = React.useMemo(
() =>
layoutSorted.map((g) => ({
key: g.groupCode,
label: g.label,
})),
[layoutSorted]
)
layoutSorted.map((g) => {
const questions = questionsByGroup.get(g.groupCode) ?? [];
const total = questions.length;
const answered = questions.filter((q) =>
isAnswered(q, values[q.questionCode]),
).length;
const requiredRemaining = questions.filter(
(q) => q.required && !isAnswered(q, values[q.questionCode]),
).length;
return {
key: g.groupCode,
label: g.label,
total,
answered,
requiredRemaining,
};
}),
[layoutSorted, questionsByGroup, isAnswered, values],
);
const activeGroupQuestions = React.useMemo(() => {
const fallback = layoutSorted[0]?.groupCode ?? ''
const code = activeGroupCode || fallback
return questionsByGroup.get(code) ?? []
}, [layoutSorted, activeGroupCode, questionsByGroup])
const fallback = layoutSorted[0]?.groupCode ?? "";
const code = activeGroupCode || fallback;
return questionsByGroup.get(code) ?? [];
}, [layoutSorted, activeGroupCode, questionsByGroup]);
const activeGroupQuestionsFiltered = React.useMemo(() => {
const term = query.trim().toLowerCase()
if (!term) return activeGroupQuestions
return activeGroupQuestions.filter((q) => q.label.toLowerCase().includes(term))
}, [query, activeGroupQuestions])
const term = query.trim().toLowerCase();
if (!term) return activeGroupQuestions;
return activeGroupQuestions.filter((q) =>
q.label.toLowerCase().includes(term),
);
}, [query, activeGroupQuestions]);
const activeIndex = React.useMemo(() => {
const code = activeGroupCode || layoutSorted[0]?.groupCode
return layoutSorted.findIndex((g) => g.groupCode === code)
}, [layoutSorted, activeGroupCode])
const code = activeGroupCode || layoutSorted[0]?.groupCode;
return layoutSorted.findIndex((g) => g.groupCode === code);
}, [layoutSorted, activeGroupCode]);
const activeTabStats = React.useMemo(() => {
if (activeIndex < 0) return null;
return tabs[activeIndex] ?? null;
}, [tabs, activeIndex]);
const overallStats = React.useMemo(() => {
const total = questions.length;
const answered = questions.filter((q) =>
isAnswered(q, values[q.questionCode]),
).length;
const requiredRemaining = questions.filter(
(q) => q.required && !isAnswered(q, values[q.questionCode]),
).length;
return { total, answered, requiredRemaining };
}, [questions, isAnswered, values]);
const goPrev = () => {
if (activeIndex <= 0) return
setQuery('')
setActiveGroupCode(layoutSorted[activeIndex - 1].groupCode)
}
if (activeIndex <= 0) return;
setQuery("");
setActiveGroupCode(layoutSorted[activeIndex - 1].groupCode);
};
const goNext = () => {
if (activeIndex < 0 || activeIndex >= layoutSorted.length - 1) return
setQuery('')
setActiveGroupCode(layoutSorted[activeIndex + 1].groupCode)
if (activeIndex < 0 || activeIndex >= layoutSorted.length - 1) return;
setQuery("");
setActiveGroupCode(layoutSorted[activeIndex + 1].groupCode);
};
if (!isValidSchema) {
return (
<div
className="empty-state"
style={{
padding: "2rem",
maxWidth: 640,
margin: "0 auto",
}}
>
<div className="empty-state-heading">FormRenderer schema missing</div>
<p>Please provide a valid `schema` prop (from `definition_schema`).</p>
</div>
);
}
return (
<form onSubmit={onFormSubmit} className="maf-form-shell">
<FormTitleCard
title={String(schema.meta.title || 'Checklist')}
title={String(schema.meta.title || "Checklist")}
actionLabel="Action Plan"
onAction={onTitleAction}
/>
......@@ -188,12 +233,33 @@ export function FormRenderer({ schema, onSubmit, onTitleAction }: FormRendererPr
tabs={tabs}
activeKey={activeGroupCode || layoutSorted[0].groupCode}
onChange={(next) => {
setQuery('')
setActiveGroupCode(next)
setQuery("");
setActiveGroupCode(next);
}}
/>
) : null}
<div className="maf-form-progress-bar">
<div className="maf-form-progress-row">
<span className="maf-form-progress-label">Form progress</span>
<span className="maf-form-progress-value">
{overallStats.answered}/{overallStats.total} answered
</span>
</div>
<div className="maf-fieldset-tab-progress" aria-hidden="true">
<span
style={{
width: `${overallStats.total > 0 ? Math.round((overallStats.answered / overallStats.total) * 100) : 0}%`,
}}
/>
</div>
<div className="maf-form-progress-meta">
{overallStats.requiredRemaining > 0
? `${overallStats.requiredRemaining} required field(s) still pending`
: "All required fields completed"}
</div>
</div>
<div className="maf-form-tools">
<input
type="text"
......@@ -204,18 +270,36 @@ export function FormRenderer({ schema, onSubmit, onTitleAction }: FormRendererPr
aria-label="Filter questions in current section"
/>
<div className="maf-form-tools-meta">
Showing {activeGroupQuestionsFiltered.length}/{activeGroupQuestions.length}
Showing {activeGroupQuestionsFiltered.length}/
{activeGroupQuestions.length}
</div>
</div>
{activeTabStats ? (
<div className="maf-active-section-meta">
<span>{activeTabStats.label}</span>
<span>
{activeTabStats.answered}/{activeTabStats.total} done
</span>
<span>
{activeTabStats.requiredRemaining > 0
? `${activeTabStats.requiredRemaining} required left`
: "Required complete"}
</span>
</div>
) : null}
{errorSummaryCount > 0 ? (
<div style={{ marginBottom: '1rem' }}>
<InlineFieldError error={`Please fix ${errorSummaryCount} required field(s).`} />
<div style={{ marginBottom: "1rem" }}>
<InlineFieldError
error={`Please fix ${errorSummaryCount} required field(s).`}
/>
</div>
) : null}
{layoutSorted.map((g, idx) => {
if ((activeGroupCode || layoutSorted[0].groupCode) !== g.groupCode) return null
if ((activeGroupCode || layoutSorted[0].groupCode) !== g.groupCode)
return null;
return (
<FormGroupSection
......@@ -225,7 +309,9 @@ export function FormRenderer({ schema, onSubmit, onTitleAction }: FormRendererPr
totalSections={layoutSorted.length}
>
{activeGroupQuestionsFiltered.length === 0 ? (
<div className="maf-empty-filter">No questions match your filter.</div>
<div className="maf-empty-filter">
No questions match your filter.
</div>
) : null}
{activeGroupQuestionsFiltered.map((q) => (
<QuestionRenderer
......@@ -237,31 +323,38 @@ export function FormRenderer({ schema, onSubmit, onTitleAction }: FormRendererPr
/>
))}
</FormGroupSection>
)
);
})}
{!Array.from(questionsByGroup.keys()).every((groupCode) => groupCodesFromLayout.has(groupCode)) ? (
Array.from(questionsByGroup.entries())
.filter(([groupCode]) => !groupCodesFromLayout.has(groupCode))
.map(([groupCode, groupQuestions]) => (
<FormGroupSection key={groupCode} title="Other">
{groupQuestions.map((q) => (
<QuestionRenderer
key={q.questionCode}
question={q}
values={values}
error={submitAttempted ? errors[q.questionCode] : undefined}
setValue={setValue}
/>
))}
</FormGroupSection>
))
) : null}
{!Array.from(questionsByGroup.keys()).every((groupCode) =>
groupCodesFromLayout.has(groupCode),
)
? Array.from(questionsByGroup.entries())
.filter(([groupCode]) => !groupCodesFromLayout.has(groupCode))
.map(([groupCode, groupQuestions]) => (
<FormGroupSection key={groupCode} title="Other">
{groupQuestions.map((q) => (
<QuestionRenderer
key={q.questionCode}
question={q}
values={values}
error={submitAttempted ? errors[q.questionCode] : undefined}
setValue={setValue}
/>
))}
</FormGroupSection>
))
: null}
{layoutSorted.length > 1 ? (
<div className="maf-section-nav">
<button type="button" className="btn-ghost" onClick={goPrev} disabled={activeIndex <= 0}>
Previous Section
<button
type="button"
className="btn-ghost"
onClick={goPrev}
disabled={activeIndex <= 0}
>
Previous
</button>
<button
type="button"
......@@ -269,13 +362,12 @@ export function FormRenderer({ schema, onSubmit, onTitleAction }: FormRendererPr
onClick={goNext}
disabled={activeIndex < 0 || activeIndex >= layoutSorted.length - 1}
>
Next Section
Next
</button>
</div>
) : null}
<FormActions onReset={onReset} disabled={false} />
</form>
)
);
}
import type { ReactNode } from 'react'
import type { ReactNode } from "react";
import './form-controls.css'
import "./form-controls.css";
export type CheckboxFieldProps = {
id: string
checked?: boolean
label: ReactNode
disabled?: boolean
onChange: (next: boolean) => void
}
id: string;
checked?: boolean;
label: ReactNode;
disabled?: boolean;
onChange: (next: boolean) => void;
};
export function CheckboxField({ id, checked, label, disabled, onChange }: CheckboxFieldProps) {
export function CheckboxField({
id,
checked,
label,
disabled,
onChange,
}: CheckboxFieldProps) {
return (
<label className="maf-choice" data-checked={Boolean(checked)} htmlFor={id}>
<input
......@@ -24,6 +30,5 @@ export function CheckboxField({ id, checked, label, disabled, onChange }: Checkb
<span className="maf-checkbox-ui" aria-hidden="true" />
<span className="maf-choice-text">{label}</span>
</label>
)
);
}
import React from 'react'
import React from "react";
export type DatePickerFieldProps = {
id: string
value?: string
disabled?: boolean
min?: string
max?: string
withTime?: boolean
onChange: (next: string) => void
}
id: string;
value?: string;
disabled?: boolean;
min?: string;
max?: string;
withTime?: boolean;
onChange: (next: string) => void;
};
const MONTHS = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
]
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
const WEEKDAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
const WEEKDAYS = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
const pad = (n: number) => String(n).padStart(2, '0')
const pad = (n: number) => String(n).padStart(2, "0");
const toDatePart = (d: Date) =>
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
const parseDateInput = (value?: string) => {
if (!value) return null
const datePart = value.includes('T') ? value.split('T')[0] : value
const [yy, mm, dd] = datePart.split('-').map(Number)
if (!yy || !mm || !dd) return null
return new Date(yy, mm - 1, dd)
}
if (!value) return null;
const datePart = value.includes("T") ? value.split("T")[0] : value;
const [yy, mm, dd] = datePart.split("-").map(Number);
if (!yy || !mm || !dd) return null;
return new Date(yy, mm - 1, dd);
};
const parseTimeInput = (value?: string) => {
if (!value || !value.includes('T')) return '00:00'
const time = value.split('T')[1]?.slice(0, 5)
return time && /^\d{2}:\d{2}$/.test(time) ? time : '00:00'
}
if (!value || !value.includes("T")) return "00:00";
const time = value.split("T")[1]?.slice(0, 5);
return time && /^\d{2}:\d{2}$/.test(time) ? time : "00:00";
};
const formatDisplay = (value?: string, withTime?: boolean) => {
const parsed = parseDateInput(value)
if (!parsed) return ''
const base = `${MONTHS[parsed.getMonth()]} ${parsed.getDate()}, ${parsed.getFullYear()}`
if (!withTime) return base
return `${base} ${parseTimeInput(value)}`
}
const parsed = parseDateInput(value);
if (!parsed) return "";
const base = `${MONTHS[parsed.getMonth()]} ${parsed.getDate()}, ${parsed.getFullYear()}`;
if (!withTime) return base;
return `${base} ${parseTimeInput(value)}`;
};
export function DatePickerField({
id,
......@@ -63,69 +63,104 @@ export function DatePickerField({
withTime = false,
onChange,
}: DatePickerFieldProps) {
const rootRef = React.useRef<HTMLDivElement | null>(null)
const [open, setOpen] = React.useState(false)
const [hasTouchedTime, setHasTouchedTime] = React.useState(false)
const rootRef = React.useRef<HTMLDivElement | null>(null);
const [open, setOpen] = React.useState(false);
const [openAbove, setOpenAbove] = React.useState(false);
const [hasTouchedTime, setHasTouchedTime] = React.useState(false);
const selected = React.useMemo(() => parseDateInput(value), [value])
const selected = React.useMemo(() => parseDateInput(value), [value]);
const [viewMonth, setViewMonth] = React.useState<Date>(
selected ? new Date(selected.getFullYear(), selected.getMonth(), 1) : new Date()
)
const [draftDate, setDraftDate] = React.useState<string>(value?.split('T')[0] ?? '')
const [draftTime, setDraftTime] = React.useState<string>(parseTimeInput(value))
selected
? new Date(selected.getFullYear(), selected.getMonth(), 1)
: new Date(),
);
const [draftDate, setDraftDate] = React.useState<string>(
value?.split("T")[0] ?? "",
);
const [draftTime, setDraftTime] = React.useState<string>(
parseTimeInput(value),
);
React.useEffect(() => {
const onDocClick = (e: MouseEvent) => {
if (!rootRef.current) return
if (!rootRef.current) return;
if (!rootRef.current.contains(e.target as Node)) {
setOpen(false)
setOpen(false);
}
}
document.addEventListener('mousedown', onDocClick)
return () => document.removeEventListener('mousedown', onDocClick)
}, [])
};
document.addEventListener("mousedown", onDocClick);
return () => document.removeEventListener("mousedown", onDocClick);
}, []);
React.useEffect(() => {
if (!open) return;
const updatePlacement = () => {
const root = rootRef.current;
if (!root) return;
const rect = root.getBoundingClientRect();
const viewportHeight = window.innerHeight;
// Prefer opening below, but flip above when remaining bottom space is tight.
const spaceBelow = viewportHeight - rect.bottom;
const spaceAbove = rect.top;
const expectedPanelHeight = withTime ? 470 : 420;
setOpenAbove(spaceBelow < expectedPanelHeight && spaceAbove > spaceBelow);
};
updatePlacement();
window.addEventListener("resize", updatePlacement);
window.addEventListener("scroll", updatePlacement, true);
return () => {
window.removeEventListener("resize", updatePlacement);
window.removeEventListener("scroll", updatePlacement, true);
};
}, [open, withTime]);
const openPanel = () => {
if (disabled) return
const parsed = parseDateInput(value)
const base = parsed ?? new Date()
setViewMonth(new Date(base.getFullYear(), base.getMonth(), 1))
setDraftDate(parsed ? toDatePart(parsed) : toDatePart(base))
setDraftTime(parseTimeInput(value))
setHasTouchedTime(false)
setOpen(true)
}
if (disabled) return;
const parsed = parseDateInput(value);
const base = parsed ?? new Date();
setViewMonth(new Date(base.getFullYear(), base.getMonth(), 1));
setDraftDate(parsed ? toDatePart(parsed) : toDatePart(base));
setDraftTime(parseTimeInput(value));
setHasTouchedTime(false);
setOpen(true);
};
const moveMonth = (delta: number) => {
setViewMonth((prev) => new Date(prev.getFullYear(), prev.getMonth() + delta, 1))
}
setViewMonth(
(prev) => new Date(prev.getFullYear(), prev.getMonth() + delta, 1),
);
};
const buildGrid = () => {
const first = new Date(viewMonth.getFullYear(), viewMonth.getMonth(), 1)
const mondayIndex = (first.getDay() + 6) % 7
const start = new Date(first)
start.setDate(first.getDate() - mondayIndex)
const first = new Date(viewMonth.getFullYear(), viewMonth.getMonth(), 1);
const mondayIndex = (first.getDay() + 6) % 7;
const start = new Date(first);
start.setDate(first.getDate() - mondayIndex);
return Array.from({ length: 42 }).map((_, i) => {
const d = new Date(start)
d.setDate(start.getDate() + i)
return d
})
}
const d = new Date(start);
d.setDate(start.getDate() + i);
return d;
});
};
const todayPart = toDatePart(new Date())
const selectedPart = draftDate || (selected ? toDatePart(selected) : '')
const minPart = min ?? ''
const maxPart = max ?? ''
const todayPart = toDatePart(new Date());
const selectedPart = draftDate || (selected ? toDatePart(selected) : "");
const minPart = min ?? "";
const maxPart = max ?? "";
const display = formatDisplay(value, withTime)
const draftDateObj = parseDateInput(draftDate || undefined)
const display = formatDisplay(value, withTime);
const draftDateObj = parseDateInput(draftDate || undefined);
const selectedLabel = draftDateObj
? `${MONTHS[draftDateObj.getMonth()]} ${draftDateObj.getDate()}, ${draftDateObj.getFullYear()}`
: 'No date selected'
: "No date selected";
const closePanel = () => {
setOpen(false)
}
setOpen(false);
};
return (
<div className="maf-datepicker" ref={rootRef}>
......@@ -136,20 +171,32 @@ export function DatePickerField({
onClick={openPanel}
disabled={disabled}
>
<span>{display || (withTime ? 'Select date and time' : 'Select date')}</span>
<span>
{display || (withTime ? "Select date and time" : "Select date")}
</span>
<span className="maf-datepicker-input-icon" aria-hidden>
📅
</span>
</button>
{open ? (
<div className="maf-datepicker-panel">
<div
className={`maf-datepicker-panel ${openAbove ? "maf-datepicker-panel--above" : ""}`}
>
<div className="maf-datepicker-head">
<div className="maf-datepicker-nav">
<button type="button" className="maf-datepicker-nav-btn" onClick={() => moveMonth(-12)}>
<button
type="button"
className="maf-datepicker-nav-btn"
onClick={() => moveMonth(-12)}
>
«
</button>
<button type="button" className="maf-datepicker-nav-btn" onClick={() => moveMonth(-1)}>
<button
type="button"
className="maf-datepicker-nav-btn"
onClick={() => moveMonth(-1)}
>
</button>
</div>
......@@ -157,10 +204,18 @@ export function DatePickerField({
{MONTHS[viewMonth.getMonth()]} {viewMonth.getFullYear()}
</div>
<div className="maf-datepicker-nav">
<button type="button" className="maf-datepicker-nav-btn" onClick={() => moveMonth(1)}>
<button
type="button"
className="maf-datepicker-nav-btn"
onClick={() => moveMonth(1)}
>
</button>
<button type="button" className="maf-datepicker-nav-btn" onClick={() => moveMonth(12)}>
<button
type="button"
className="maf-datepicker-nav-btn"
onClick={() => moveMonth(12)}
>
»
</button>
</div>
......@@ -168,17 +223,23 @@ export function DatePickerField({
<div className="maf-datepicker-summary">
<span className="maf-datepicker-summary-label">Selected</span>
<span className="maf-datepicker-summary-value">{selectedLabel}</span>
<span className="maf-datepicker-summary-value">
{selectedLabel}
</span>
<button
type="button"
className="maf-datepicker-today-btn"
onClick={() => {
const today = new Date()
const dayPart = toDatePart(today)
setViewMonth(new Date(today.getFullYear(), today.getMonth(), 1))
setDraftDate(dayPart)
onChange(withTime ? `${dayPart}T${draftTime || '00:00'}` : dayPart)
if (!withTime) closePanel()
const today = new Date();
const dayPart = toDatePart(today);
setViewMonth(
new Date(today.getFullYear(), today.getMonth(), 1),
);
setDraftDate(dayPart);
onChange(
withTime ? `${dayPart}T${draftTime || "00:00"}` : dayPart,
);
if (!withTime) closePanel();
}}
>
Today
......@@ -195,11 +256,14 @@ export function DatePickerField({
<div className="maf-datepicker-grid">
{buildGrid().map((day) => {
const dayPart = toDatePart(day)
const inMonth = day.getMonth() === viewMonth.getMonth()
const isSelected = selectedPart === dayPart
const isToday = todayPart === dayPart
const blocked = Boolean((minPart && dayPart < minPart) || (maxPart && dayPart > maxPart))
const dayPart = toDatePart(day);
const inMonth = day.getMonth() === viewMonth.getMonth();
const isSelected = selectedPart === dayPart;
const isToday = todayPart === dayPart;
const blocked = Boolean(
(minPart && dayPart < minPart) ||
(maxPart && dayPart > maxPart),
);
return (
<button
......@@ -211,20 +275,25 @@ export function DatePickerField({
data-today={isToday}
disabled={blocked}
onClick={() => {
setDraftDate(dayPart)
onChange(withTime ? `${dayPart}T${draftTime || '00:00'}` : dayPart)
closePanel()
setDraftDate(dayPart);
onChange(
withTime ? `${dayPart}T${draftTime || "00:00"}` : dayPart,
);
closePanel();
}}
>
{day.getDate()}
</button>
)
);
})}
</div>
{withTime ? (
<div className="maf-datepicker-time-row">
<label htmlFor={`${id}__time`} className="maf-datepicker-time-label">
<label
htmlFor={`${id}__time`}
className="maf-datepicker-time-label"
>
Time
</label>
<input
......@@ -233,15 +302,15 @@ export function DatePickerField({
className="form-input maf-datepicker-time-input"
value={draftTime}
onChange={(e) => {
const next = e.target.value
setDraftTime(next)
setHasTouchedTime(true)
const datePart = draftDate || toDatePart(new Date())
onChange(`${datePart}T${next || '00:00'}`)
closePanel()
const next = e.target.value;
setDraftTime(next);
setHasTouchedTime(true);
const datePart = draftDate || toDatePart(new Date());
onChange(`${datePart}T${next || "00:00"}`);
closePanel();
}}
onBlur={() => {
if (open) closePanel()
if (open) closePanel();
}}
/>
</div>
......@@ -253,6 +322,5 @@ export function DatePickerField({
</div>
) : null}
</div>
)
);
}
export type FieldsetTabItem = {
key: string
label: string
}
key: string;
label: string;
total?: number;
answered?: number;
requiredRemaining?: number;
};
export type FieldsetTabsProps = {
tabs: FieldsetTabItem[]
activeKey: string
onChange: (key: string) => void
}
tabs: FieldsetTabItem[];
activeKey: string;
onChange: (key: string) => void;
};
export function FieldsetTabs({ tabs, activeKey, onChange }: FieldsetTabsProps) {
return (
......@@ -28,25 +31,51 @@ export function FieldsetTabs({ tabs, activeKey, onChange }: FieldsetTabsProps) {
</select>
</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) => {
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 (
<button
key={tab.key}
type="button"
className="maf-fieldset-tab"
data-active={active}
data-error={requiredRemaining > 0}
role="tab"
aria-selected={active}
onClick={() => onChange(tab.key)}
>
<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>
)
);
})}
</div>
</div>
)
);
}
export type FormActionsProps = {
onReset: () => void
submitLabel?: string
resetLabel?: string
disabled?: boolean
}
onReset: () => void;
submitLabel?: string;
resetLabel?: string;
disabled?: boolean;
};
export function FormActions({
onReset,
submitLabel = 'Submit',
resetLabel = 'Reset',
submitLabel = "Submit",
resetLabel = "Reset",
disabled,
}: FormActionsProps) {
return (
......@@ -16,10 +16,14 @@ export function FormActions({
<button className="btn-primary" type="submit" disabled={disabled}>
{submitLabel}
</button>
<button className="btn-ghost" type="button" onClick={onReset} disabled={disabled}>
<button
className="btn-ghost"
type="button"
onClick={onReset}
disabled={disabled}
>
{resetLabel}
</button>
</div>
)
);
}
import React, { type ReactNode } from 'react'
import { ToggleField } from './ToggleField'
import React, { type ReactNode } from "react";
import { ToggleField } from "./ToggleField";
export type FormGroupSectionProps = {
title: string
sectionIndex?: number
totalSections?: number
children: ReactNode
}
title: string;
sectionIndex?: number;
totalSections?: number;
children: ReactNode;
};
export function FormGroupSection({ title, sectionIndex = 1, totalSections = 1, children }: FormGroupSectionProps) {
const [expanded, setExpanded] = React.useState(true)
export function FormGroupSection({
title,
sectionIndex = 1,
totalSections = 1,
children,
}: FormGroupSectionProps) {
const [expanded, setExpanded] = React.useState(true);
return (
<section className="form-section">
......@@ -32,7 +37,7 @@ export function FormGroupSection({ title, sectionIndex = 1, totalSections = 1, c
<span className="maf-rating-dot maf-rating-dot-na" />
</div>
<ToggleField
id={`group-toggle-${title.replace(/\s+/g, '-').toLowerCase()}`}
id={`group-toggle-${title.replace(/\s+/g, "-").toLowerCase()}`}
checked={expanded}
onChange={(next) => setExpanded(next)}
onLabel="Hide"
......@@ -42,6 +47,5 @@ export function FormGroupSection({ title, sectionIndex = 1, totalSections = 1, c
</div>
{expanded ? children : null}
</section>
)
);
}
export type FormTitleCardProps = {
title: string
actionLabel?: string
onAction?: () => void
}
title: string;
actionLabel?: string;
onAction?: () => void;
};
export function FormTitleCard({
title,
actionLabel = 'Action Plan',
actionLabel = "Action Plan",
onAction,
}: FormTitleCardProps) {
return (
......@@ -21,6 +21,5 @@ export function FormTitleCard({
</button>
</div>
</div>
)
);
}
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 = {
id: string
files: File[]
onChange: (next: File[]) => void
disabled?: boolean
multiple?: boolean
accept?: string
hint?: string
}
id: string;
files: File[];
onChange: (next: File[]) => void;
disabled?: boolean;
multiple?: boolean;
accept?: string;
hint?: string;
};
export function LinkedFileField({
id,
......@@ -19,62 +19,65 @@ export function LinkedFileField({
disabled,
multiple = true,
accept,
hint = 'Click to upload supporting files',
hint = "Click to upload supporting files",
}: LinkedFileFieldProps) {
const inputRef = React.useRef<HTMLInputElement | null>(null)
const [isDragging, setIsDragging] = React.useState(false)
const inputRef = React.useRef<HTMLInputElement | null>(null);
const [isDragging, setIsDragging] = React.useState(false);
const openFileDialog = () => {
if (disabled) return
inputRef.current?.click()
}
if (disabled) return;
inputRef.current?.click();
};
const applyFiles = (nextList: FileList | null | undefined) => {
if (!nextList) return
const next = Array.from(nextList)
onChange(multiple ? next : next.slice(0, 1))
}
if (!nextList) return;
const next = Array.from(nextList);
onChange(multiple ? next : next.slice(0, 1));
};
const onDrop = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
applyFiles(e.dataTransfer?.files)
}
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
applyFiles(e.dataTransfer?.files);
};
const onDragOver = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
}
e.preventDefault();
e.stopPropagation();
};
const onRemoveAt = (idx: number) => {
const next = files.filter((_, i) => i !== idx)
onChange(next)
}
const next = files.filter((_, i) => i !== idx);
onChange(next);
};
return (
<div>
<div
<button
type="button"
className="upload-placeholder"
role="button"
tabIndex={0}
aria-disabled={disabled}
disabled={disabled}
onClick={openFileDialog}
onKeyDown={(e) => {
if (disabled) return
if (e.key === 'Enter' || e.key === ' ') openFileDialog()
}}
onDrop={onDrop}
onDragOver={onDragOver}
onDragEnter={() => setIsDragging(true)}
onDragLeave={() => setIsDragging(false)}
data-dragging={isDragging}
>
<div style={{ fontWeight: 700, color: 'var(--text-1)', fontSize: 'var(--text-base)' }}>
{multiple ? 'Upload files' : 'Upload file'}
<div
style={{
fontWeight: 700,
color: "var(--text-1)",
fontSize: "var(--text-base)",
}}
>
{multiple ? "Upload files" : "Upload file"}
</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
ref={inputRef}
......@@ -88,9 +91,12 @@ export function LinkedFileField({
/>
{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) => (
<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}>
{f.name}
</div>
......@@ -107,9 +113,8 @@ export function LinkedFileField({
</button>
</div>
))}
</div>
</section>
) : null}
</div>
)
);
}
{
"folders": [
{
"path": "../../../../../.."
},
{
"path": "../../../../../../../../../Downloads/maf-mp-frontend-develop"
}
],
"settings": {}
}
export type OptionalCommentFieldProps = {
id: string
value?: string
onChange: (next: string) => void
placeholder?: string
}
id: string;
value?: string;
onChange: (next: string) => void;
placeholder?: string;
};
export function OptionalCommentField({
id,
value,
onChange,
placeholder = 'Add an optional comment',
placeholder = "Add an optional comment",
}: OptionalCommentFieldProps) {
return (
<div>
......@@ -19,12 +19,11 @@ export function OptionalCommentField({
<textarea
id={id}
className="form-input"
value={value ?? ''}
value={value ?? ""}
placeholder={placeholder}
rows={4}
onChange={(e) => onChange(e.target.value)}
/>
</div>
)
);
}
import React from 'react'
import type { FormQuestion } from '../types'
import { InlineFieldError } from './InlineFieldError'
import { CheckboxField } from './CheckboxField'
import { DatePickerField } from './DatePickerField'
import { LinkedFileField } from './LinkedFileField'
import { OptionalCommentField } from './OptionalCommentField'
import { RadioGroupField } from './RadioGroupField'
import { SelectField } from './SelectField'
import { TextInputField } from './TextInputField'
import { TextareaField } from './TextareaField'
import React from "react";
import type { FormQuestion } from "../types";
import { CheckboxField } from "./CheckboxField";
import { DatePickerField } from "./DatePickerField";
import { InlineFieldError } from "./InlineFieldError";
import { LinkedFileField } from "./LinkedFileField";
import { OptionalCommentField } from "./OptionalCommentField";
import { RadioGroupField } from "./RadioGroupField";
import { SelectField } from "./SelectField";
import { TextareaField } from "./TextareaField";
import { TextInputField } from "./TextInputField";
type FormValueMap = Record<string, unknown>
type FormValueMap = Record<string, unknown>;
export type QuestionRendererProps = {
question: FormQuestion
values: FormValueMap
error?: string
setValue: (key: string, next: unknown) => void
}
question: FormQuestion;
values: FormValueMap;
error?: string;
setValue: (key: string, next: unknown) => void;
};
export function QuestionRenderer({ question, values, error, setValue }: QuestionRendererProps) {
const controlId = `q-${question.questionCode}`
const primaryKey = question.questionCode
const commentKey = `${question.questionCode}__comment`
const fileKey = `${question.questionCode}__file`
export function QuestionRenderer({
question,
values,
error,
setValue,
}: QuestionRendererProps) {
const controlId = `q-${question.questionCode}`;
const primaryKey = question.questionCode;
const commentKey = `${question.questionCode}__comment`;
const fileKey = `${question.questionCode}__file`;
const primaryValue = values[primaryKey]
const commentValue = (values[commentKey] as string | undefined) ?? ''
const fileValue = (values[fileKey] as File[] | undefined) ?? []
const primaryValue = values[primaryKey];
const commentValue = (values[commentKey] as string | undefined) ?? "";
const fileValue = (values[fileKey] as File[] | undefined) ?? [];
const inputType = String(question.inputType)
const optionSet = (question.options ?? []).map((opt) => opt.trim().toLowerCase())
const inputType = String(question.inputType);
const optionSet = (question.options ?? []).map((opt) =>
opt.trim().toLowerCase(),
);
const isRatingScale =
inputType === 'radio' &&
optionSet.includes('yes') &&
optionSet.includes('no') &&
(optionSet.includes('na') || optionSet.includes('n/a'))
inputType === "radio" &&
optionSet.includes("yes") &&
optionSet.includes("no") &&
(optionSet.includes("na") || optionSet.includes("n/a"));
const isMultiSelect =
inputType === 'multiselect' || question.validation?.allowsMultiple === true
const [lastCleared, setLastCleared] = React.useState<{ value?: string; comment: string } | null>(null)
inputType === "multiselect" || question.validation?.allowsMultiple === true;
const [lastCleared, setLastCleared] = React.useState<{
value?: string;
comment: string;
} | null>(null);
if (isRatingScale) {
const selectedKey = typeof primaryValue === 'string' ? primaryValue.trim().toLowerCase() : ''
const supportsComment = question.validation?.supportsComment === true
const hasLinkedFile = question.validation?.hasLinkedFile === true
const selectedKey =
typeof primaryValue === "string" ? primaryValue.trim().toLowerCase() : "";
const supportsComment = question.validation?.supportsComment === true;
const hasLinkedFile = question.validation?.hasLinkedFile === true;
const selectionClass =
selectedKey === 'yes'
? 'is-yes'
: selectedKey === 'no'
? 'is-no'
: selectedKey === 'na' || selectedKey === 'n/a'
? 'is-na'
: 'is-unselected'
const score = selectedKey === 'yes' ? '10/10' : selectedKey === 'no' ? '0/10' : selectedKey === 'na' || selectedKey === 'n/a' ? 'NA' : '0/10'
selectedKey === "yes"
? "is-yes"
: selectedKey === "no"
? "is-no"
: selectedKey === "na" || selectedKey === "n/a"
? "is-na"
: "is-unselected";
const score =
selectedKey === "yes"
? "10/10"
: selectedKey === "no"
? "0/10"
: selectedKey === "na" || selectedKey === "n/a"
? "NA"
: "0/10";
const canUndo =
selectedKey === '' &&
selectedKey === "" &&
commentValue.trim().length === 0 &&
Boolean(lastCleared && ((lastCleared.value ?? '').trim().length > 0 || lastCleared.comment.trim().length > 0))
Boolean(
lastCleared &&
((lastCleared.value ?? "").trim().length > 0 ||
lastCleared.comment.trim().length > 0),
);
return (
<div className="form-group maf-checklist-question">
<div className="maf-checklist-question-title">
{question.questionOrder ? <span className="maf-checklist-question-number">{question.questionOrder}</span> : null}
{question.questionOrder ? (
<span className="maf-checklist-question-number">
{question.questionOrder}
</span>
) : null}
<span>
{question.label}
{question.required ? <span className="maf-required-star">*</span> : null}
{question.required ? (
<span className="maf-required-star">*</span>
) : null}
</span>
</div>
......@@ -79,7 +107,10 @@ export function QuestionRenderer({ question, values, error, setValue }: Question
onChange={(e) => setValue(commentKey, e.target.value)}
/>
) : (
<div className="maf-checklist-comment maf-checklist-comment-placeholder" aria-hidden="true" />
<div
className="maf-checklist-comment maf-checklist-comment-placeholder"
aria-hidden="true"
/>
)}
<RadioGroupField
id={controlId}
......@@ -93,31 +124,31 @@ export function QuestionRenderer({ question, values, error, setValue }: Question
<span className="maf-checklist-score">{score}</span>
<button
type="button"
className={`maf-checklist-clear ${canUndo ? 'maf-checklist-undo' : ''}`}
aria-label={canUndo ? 'Undo last clear' : 'Clear selection'}
className={`maf-checklist-clear ${canUndo ? "maf-checklist-undo" : ""}`}
aria-label={canUndo ? "Undo last clear" : "Clear selection"}
onClick={() => {
if (canUndo && lastCleared) {
setValue(primaryKey, lastCleared.value)
setValue(commentKey, lastCleared.comment)
setLastCleared(null)
return
setValue(primaryKey, lastCleared.value);
setValue(commentKey, lastCleared.comment);
setLastCleared(null);
return;
}
setLastCleared({
value: (primaryValue as string | undefined) ?? undefined,
comment: commentValue,
})
setValue(primaryKey, undefined)
setValue(commentKey, '')
});
setValue(primaryKey, undefined);
setValue(commentKey, "");
}}
>
{canUndo ? '↶ Undo' : '×'}
{canUndo ? "↶ Undo" : "×"}
</button>
</div>
<InlineFieldError error={error} />
{hasLinkedFile ? (
<div style={{ marginTop: '0.75rem' }}>
<div style={{ marginTop: "0.75rem" }}>
<LinkedFileField
id={`${controlId}__file`}
files={fileValue}
......@@ -126,10 +157,10 @@ export function QuestionRenderer({ question, values, error, setValue }: Question
</div>
) : null}
</div>
)
);
}
if (inputType === 'checkbox') {
if (inputType === "checkbox") {
return (
<div className="form-group">
<CheckboxField
......@@ -139,7 +170,9 @@ export function QuestionRenderer({ question, values, error, setValue }: Question
label={
<>
{question.label}
{question.required ? <span className="maf-required-star">*</span> : null}
{question.required ? (
<span className="maf-required-star">*</span>
) : null}
</>
}
onChange={(next) => setValue(primaryKey, next)}
......@@ -147,7 +180,7 @@ export function QuestionRenderer({ question, values, error, setValue }: Question
<InlineFieldError error={error} />
{question.validation?.supportsComment ? (
<div style={{ marginTop: '0.75rem' }}>
<div style={{ marginTop: "0.75rem" }}>
<OptionalCommentField
id={`${controlId}__comment`}
value={commentValue}
......@@ -157,8 +190,8 @@ export function QuestionRenderer({ question, values, error, setValue }: Question
) : null}
{question.validation?.hasLinkedFile ? (
<div style={{ marginTop: '0.75rem' }}>
<div className="form-label" style={{ marginBottom: '0.35rem' }}>
<div style={{ marginTop: "0.75rem" }}>
<div className="form-label" style={{ marginBottom: "0.35rem" }}>
Linked file (optional)
</div>
<LinkedFileField
......@@ -169,35 +202,37 @@ export function QuestionRenderer({ question, values, error, setValue }: Question
</div>
) : null}
</div>
)
);
}
return (
<div className="form-group">
<label className="form-label" htmlFor={controlId}>
{question.label}
{question.required ? <span className="maf-required-star">*</span> : null}
{question.required ? (
<span className="maf-required-star">*</span>
) : null}
</label>
{inputType === 'text' ? (
{inputType === "text" ? (
<TextInputField
id={controlId}
value={(primaryValue as string | undefined) ?? ''}
value={(primaryValue as string | undefined) ?? ""}
disabled={false}
onChange={(next) => setValue(primaryKey, next)}
/>
) : null}
{inputType === 'textarea' ? (
{inputType === "textarea" ? (
<TextareaField
id={controlId}
value={(primaryValue as string | undefined) ?? ''}
value={(primaryValue as string | undefined) ?? ""}
disabled={false}
onChange={(next) => setValue(primaryKey, next)}
/>
) : null}
{inputType === 'radio' ? (
{inputType === "radio" ? (
<RadioGroupField
id={controlId}
name={primaryKey}
......@@ -208,7 +243,7 @@ export function QuestionRenderer({ question, values, error, setValue }: Question
/>
) : null}
{inputType === 'select' || inputType === 'multiselect' ? (
{inputType === "select" || inputType === "multiselect" ? (
<SelectField
id={controlId}
value={
......@@ -218,33 +253,33 @@ export function QuestionRenderer({ question, values, error, setValue }: Question
}
options={question.options ?? []}
disabled={false}
placeholder={isMultiSelect ? undefined : 'Select…'}
placeholder={isMultiSelect ? undefined : "Select…"}
multiple={isMultiSelect}
onChange={(next) => setValue(primaryKey, next)}
/>
) : null}
{inputType === 'date' ? (
{inputType === "date" ? (
<DatePickerField
id={controlId}
value={(primaryValue as string | undefined) ?? ''}
value={(primaryValue as string | undefined) ?? ""}
disabled={false}
withTime={false}
onChange={(next) => setValue(primaryKey, next)}
/>
) : null}
{inputType === 'datetime' || inputType === 'datetime-local' ? (
{inputType === "datetime" || inputType === "datetime-local" ? (
<DatePickerField
id={controlId}
value={(primaryValue as string | undefined) ?? ''}
value={(primaryValue as string | undefined) ?? ""}
disabled={false}
withTime={true}
onChange={(next) => setValue(primaryKey, next)}
/>
) : null}
{inputType === 'file' ? (
{inputType === "file" ? (
<LinkedFileField
id={controlId}
files={(primaryValue as File[] | undefined) ?? []}
......@@ -252,18 +287,18 @@ export function QuestionRenderer({ question, values, error, setValue }: Question
/>
) : null}
{inputType !== 'text' &&
inputType !== 'textarea' &&
inputType !== 'radio' &&
inputType !== 'select' &&
inputType !== 'multiselect' &&
inputType !== 'date' &&
inputType !== 'datetime' &&
inputType !== 'datetime-local' &&
inputType !== 'file' ? (
{inputType !== "text" &&
inputType !== "textarea" &&
inputType !== "radio" &&
inputType !== "select" &&
inputType !== "multiselect" &&
inputType !== "date" &&
inputType !== "datetime" &&
inputType !== "datetime-local" &&
inputType !== "file" ? (
<TextInputField
id={controlId}
value={(primaryValue as string | undefined) ?? ''}
value={(primaryValue as string | undefined) ?? ""}
disabled={false}
onChange={(next) => setValue(primaryKey, next)}
/>
......@@ -272,7 +307,7 @@ export function QuestionRenderer({ question, values, error, setValue }: Question
<InlineFieldError error={error} />
{question.validation?.supportsComment ? (
<div style={{ marginTop: '0.75rem' }}>
<div style={{ marginTop: "0.75rem" }}>
<OptionalCommentField
id={`${controlId}__comment`}
value={commentValue}
......@@ -282,8 +317,8 @@ export function QuestionRenderer({ question, values, error, setValue }: Question
) : null}
{question.validation?.hasLinkedFile ? (
<div style={{ marginTop: '0.75rem' }}>
<div className="form-label" style={{ marginBottom: '0.35rem' }}>
<div style={{ marginTop: "0.75rem" }}>
<div className="form-label" style={{ marginBottom: "0.35rem" }}>
Linked file (optional)
</div>
<LinkedFileField
......@@ -294,6 +329,5 @@ export function QuestionRenderer({ question, values, error, setValue }: Question
</div>
) : null}
</div>
)
);
}
import './form-controls.css'
import "./form-controls.css";
export type RadioGroupFieldProps = {
id: string
name: string
value?: string
options: string[]
disabled?: boolean
variant?: 'default' | 'rating'
onChange: (next: string) => void
}
id: string;
name: string;
value?: string;
options: string[];
disabled?: boolean;
variant?: "default" | "rating";
onChange: (next: string) => void;
};
const getRatingClass = (opt: string) => {
const key = opt.trim().toLowerCase()
if (key === 'yes') return 'maf-rating-dot-yes'
if (key === 'no') return 'maf-rating-dot-no'
if (key === 'na' || key === 'n/a') return 'maf-rating-dot-na'
return 'maf-rating-dot-default'
}
const key = opt.trim().toLowerCase();
if (key === "yes") return "maf-rating-dot-yes";
if (key === "no") return "maf-rating-dot-no";
if (key === "na" || key === "n/a") return "maf-rating-dot-na";
return "maf-rating-dot-default";
};
export function RadioGroupField({
id,
......@@ -24,14 +24,14 @@ export function RadioGroupField({
value,
options,
disabled,
variant = 'default',
variant = "default",
onChange,
}: RadioGroupFieldProps) {
if (variant === 'rating') {
if (variant === "rating") {
return (
<div className="maf-rating-group" role="radiogroup" aria-label={id}>
{options.map((opt) => {
const checked = value === opt
const checked = value === opt;
return (
<button
key={opt}
......@@ -42,16 +42,16 @@ export function RadioGroupField({
disabled={disabled}
onClick={() => onChange(opt)}
/>
)
);
})}
</div>
)
);
}
return (
<div className="maf-choice-group" role="radiogroup" aria-label={id}>
{options.map((opt) => {
const checked = value === opt
const checked = value === opt;
return (
<label key={opt} className="maf-choice" data-checked={checked}>
<input
......@@ -66,9 +66,8 @@ export function RadioGroupField({
<span className="maf-radio-ui" aria-hidden="true" />
<span className="maf-choice-text">{opt}</span>
</label>
)
);
})}
</div>
)
);
}
import React from 'react'
import React from "react";
export type SelectFieldProps = {
id: string
value?: string | string[]
options: string[]
disabled?: boolean
placeholder?: string
multiple?: boolean
onChange: (next: string | string[]) => void
}
id: string;
value?: string | string[];
options: string[];
disabled?: boolean;
placeholder?: string;
multiple?: boolean;
onChange: (next: string | string[]) => void;
};
export function SelectField({
id,
......@@ -19,48 +19,54 @@ export function SelectField({
multiple = false,
onChange,
}: SelectFieldProps) {
const [open, setOpen] = React.useState(false)
const [query, setQuery] = React.useState('')
const rootRef = React.useRef<HTMLDivElement | null>(null)
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState("");
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(() => {
if (!multiple) return
if (!multiple) return;
const onDocClick = (e: MouseEvent) => {
if (!rootRef.current) return
if (!rootRef.current) return;
if (!rootRef.current.contains(e.target as Node)) {
setOpen(false)
setQuery('')
setOpen(false);
setQuery("");
}
}
};
document.addEventListener('mousedown', onDocClick)
return () => document.removeEventListener('mousedown', onDocClick)
}, [multiple])
document.addEventListener("mousedown", onDocClick);
return () => document.removeEventListener("mousedown", onDocClick);
}, [multiple]);
const normalizedValue = multiple
? Array.isArray(value)
? value
: []
: typeof value === 'string'
: typeof value === "string"
? value
: ''
: "";
if (multiple) {
const selected = normalizedValue as string[]
const selected = normalizedValue as string[];
const onSelectOption = (opt: string) => {
if (selected.includes(opt)) {
setOpen(false)
setQuery('')
return
setOpen(false);
setQuery("");
return;
}
onChange([...selected, opt])
setOpen(false)
setQuery('')
}
onChange([...selected, opt]);
setOpen(false);
setQuery("");
};
const filteredOptions = options.filter((opt) =>
opt.toLowerCase().includes(query.trim().toLowerCase())
)
opt.toLowerCase().includes(query.trim().toLowerCase()),
);
return (
<div className="maf-multiselect" ref={rootRef}>
......@@ -74,7 +80,9 @@ export function SelectField({
>
<div className="maf-multiselect-chips">
{selected.length === 0 ? (
<span className="maf-multiselect-placeholder">{placeholder ?? 'Select options'}</span>
<span className="maf-multiselect-placeholder">
{placeholder ?? "Select options"}
</span>
) : (
selected.map((item) => (
<span className="maf-multiselect-chip" key={item}>
......@@ -84,14 +92,14 @@ export function SelectField({
className="maf-multiselect-chip-remove"
aria-label={`Remove ${item}`}
onClick={(e) => {
e.stopPropagation()
onChange(selected.filter((v) => v !== item))
e.stopPropagation();
onChange(selected.filter((v) => v !== item));
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
e.stopPropagation()
onChange(selected.filter((v) => v !== item))
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
onChange(selected.filter((v) => v !== item));
}
}}
>
......@@ -107,21 +115,25 @@ export function SelectField({
</button>
{open ? (
<div className="maf-multiselect-menu" role="listbox" aria-multiselectable="true">
<div
className="maf-multiselect-menu"
role="listbox"
aria-multiselectable="true"
>
<input
ref={searchInputRef}
className="maf-multiselect-search"
type="text"
value={query}
placeholder="Search..."
onChange={(e) => setQuery(e.target.value)}
autoFocus
/>
<div className="maf-multiselect-options">
{filteredOptions.length === 0 ? (
<div className="maf-multiselect-empty">No options found</div>
) : (
filteredOptions.map((opt) => {
const checked = selected.includes(opt)
const checked = selected.includes(opt);
return (
<button
key={opt}
......@@ -132,14 +144,14 @@ export function SelectField({
>
<span>{opt}</span>
</button>
)
);
})
)}
</div>
</div>
) : null}
</div>
)
);
}
return (
......@@ -150,7 +162,7 @@ export function SelectField({
value={normalizedValue}
disabled={disabled}
onChange={(e) => {
onChange(e.target.value)
onChange(e.target.value);
}}
>
{placeholder ? <option value="">{placeholder}</option> : null}
......@@ -164,6 +176,5 @@ export function SelectField({
</span>
</div>
)
);
}
export type TextInputFieldProps = {
id: string
value?: string
disabled?: boolean
placeholder?: string
onChange: (next: string) => void
}
id: string;
value?: string;
disabled?: boolean;
placeholder?: string;
onChange: (next: string) => void;
};
export function TextInputField({
id,
......@@ -18,11 +18,10 @@ export function TextInputField({
id={id}
className="form-input"
type="text"
value={value ?? ''}
value={value ?? ""}
disabled={disabled}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
/>
)
);
}
export type TextareaFieldProps = {
id: string
value?: string
disabled?: boolean
placeholder?: string
rows?: number
onChange: (next: string) => void
}
id: string;
value?: string;
disabled?: boolean;
placeholder?: string;
rows?: number;
onChange: (next: string) => void;
};
export function TextareaField({
id,
......@@ -19,12 +19,11 @@ export function TextareaField({
<textarea
id={id}
className="form-input"
value={value ?? ''}
value={value ?? ""}
disabled={disabled}
placeholder={placeholder}
rows={rows}
onChange={(e) => onChange(e.target.value)}
/>
)
);
}
export type ToggleFieldProps = {
id: string
checked: boolean
onChange: (next: boolean) => void
onLabel?: string
offLabel?: string
disabled?: boolean
}
id: string;
checked: boolean;
onChange: (next: boolean) => void;
onLabel?: string;
offLabel?: string;
disabled?: boolean;
};
export function ToggleField({
id,
checked,
onChange,
onLabel = 'Show',
offLabel = 'Hide',
onLabel = "Show",
offLabel = "Hide",
disabled,
}: ToggleFieldProps) {
return (
......@@ -29,6 +29,5 @@ export function ToggleField({
<span className="maf-toggle-text">{checked ? onLabel : offLabel}</span>
</span>
</label>
)
);
}
import React from 'react'
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import type { Meta, StoryObj } from "@storybook/nextjs-vite";
import React from "react";
import { CheckboxField } from './CheckboxField'
import { DatePickerField } from './DatePickerField'
import { LinkedFileField } from './LinkedFileField'
import { OptionalCommentField } from './OptionalCommentField'
import { RadioGroupField } from './RadioGroupField'
import { SelectField } from './SelectField'
import { TextInputField } from './TextInputField'
import { TextareaField } from './TextareaField'
import { ToggleField } from './ToggleField'
import { CheckboxField } from "./CheckboxField";
import { DatePickerField } from "./DatePickerField";
import { LinkedFileField } from "./LinkedFileField";
import { OptionalCommentField } from "./OptionalCommentField";
import { RadioGroupField } from "./RadioGroupField";
import { SelectField } from "./SelectField";
import { TextareaField } from "./TextareaField";
import { TextInputField } from "./TextInputField";
import { ToggleField } from "./ToggleField";
const meta = {
title: 'Forms/Fields',
} satisfies Meta
title: "Forms/Fields",
} satisfies Meta;
export default meta
type Story = StoryObj
type RadioStoryArgs = { variant?: 'default' | 'rating' }
type DatePickerStoryArgs = { withTime?: boolean }
export default meta;
type Story = StoryObj;
type RadioStoryArgs = { variant?: "default" | "rating" };
type DatePickerStoryArgs = { withTime?: boolean };
export const TextInput: Story = {
render: () => {
const [value, setValue] = React.useState('')
const [value, setValue] = React.useState("");
return (
<div style={{ maxWidth: 520 }}>
<div className="form-group">
<label className="form-label" htmlFor="field-text">
Text input *
</label>
<TextInputField id="field-text" value={value} onChange={setValue} placeholder="Type here…" />
<TextInputField
id="field-text"
value={value}
onChange={setValue}
placeholder="Type here…"
/>
</div>
</div>
)
);
},
}
};
export const Textarea: Story = {
render: () => {
const [value, setValue] = React.useState('Initial note')
const [value, setValue] = React.useState("Initial note");
return (
<div style={{ maxWidth: 520 }}>
<div className="form-group">
<label className="form-label" htmlFor="field-textarea">
Textarea
</label>
<TextareaField id="field-textarea" value={value} onChange={setValue} rows={5} />
<TextareaField
id="field-textarea"
value={value}
onChange={setValue}
rows={5}
/>
</div>
</div>
)
);
},
}
};
export const RadioGroup: Story = {
render: (args: RadioStoryArgs) => {
const isRating = args.variant === 'rating'
const [value, setValue] = React.useState<string | undefined>(isRating ? 'Yes' : undefined)
const options = isRating ? ['Yes', 'No', 'NA'] : ['Pass', 'Fail']
const isRating = args.variant === "rating";
const [value, setValue] = React.useState<string | undefined>(
isRating ? "Yes" : undefined,
);
const options = isRating ? ["Yes", "No", "NA"] : ["Pass", "Fail"];
return (
<div style={{ maxWidth: 520 }}>
<div className="form-group">
<label className="form-label">{isRating ? 'Rating (Yes / No / NA)' : 'Radio group'}</label>
<div className="form-label">
{isRating ? "Rating (Yes / No / NA)" : "Radio group"}
</div>
{isRating ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 6 }}>
<span style={{ fontSize: '0.82rem', color: 'var(--text-2)', fontWeight: 600 }}>Yes</span>
<span style={{ fontSize: '0.82rem', color: 'var(--text-2)', fontWeight: 600 }}>No</span>
<span style={{ fontSize: '0.82rem', color: 'var(--text-2)', fontWeight: 600 }}>NA</span>
<div
style={{
display: "flex",
alignItems: "center",
gap: 12,
marginBottom: 6,
}}
>
<span
style={{
fontSize: "0.82rem",
color: "var(--text-2)",
fontWeight: 600,
}}
>
Yes
</span>
<span
style={{
fontSize: "0.82rem",
color: "var(--text-2)",
fontWeight: 600,
}}
>
No
</span>
<span
style={{
fontSize: "0.82rem",
color: "var(--text-2)",
fontWeight: 600,
}}
>
NA
</span>
</div>
) : null}
<RadioGroupField
key={String(args.variant)}
id={isRating ? 'field-rating' : 'field-radio'}
name={isRating ? 'field-rating' : 'field-radio'}
id={isRating ? "field-rating" : "field-radio"}
name={isRating ? "field-rating" : "field-radio"}
value={value}
options={options}
variant={args.variant}
......@@ -80,22 +125,22 @@ export const RadioGroup: Story = {
/>
</div>
</div>
)
);
},
args: {
variant: 'default',
variant: "default",
},
argTypes: {
variant: {
control: 'select',
options: ['default', 'rating'],
control: "select",
options: ["default", "rating"],
},
},
}
};
export const Select: Story = {
render: () => {
const [value, setValue] = React.useState<string | undefined>('No')
const [value, setValue] = React.useState<string | undefined>("No");
return (
<div style={{ maxWidth: 520 }}>
<div className="form-group">
......@@ -105,19 +150,19 @@ export const Select: Story = {
<SelectField
id="field-select"
value={value}
options={['No', 'Yes']}
options={["No", "Yes"]}
placeholder="Select…"
onChange={(next) => setValue(Array.isArray(next) ? next[0] : next)}
/>
</div>
</div>
)
);
},
}
};
export const MultiSelect: Story = {
render: () => {
const [value, setValue] = React.useState<string[]>(['No'])
const [value, setValue] = React.useState<string[]>(["No"]);
return (
<div style={{ maxWidth: 520 }}>
<div className="form-group">
......@@ -127,24 +172,26 @@ export const MultiSelect: Story = {
<SelectField
id="field-multi-select"
value={value}
options={['No', 'Yes', 'N/A']}
options={["No", "Yes", "N/A"]}
multiple
onChange={(next) => setValue(Array.isArray(next) ? next : [])}
/>
</div>
</div>
)
);
},
}
};
export const DatePicker: Story = {
render: (args: DatePickerStoryArgs) => {
const [value, setValue] = React.useState(args.withTime ? '2026-03-23T14:30' : '2026-03-23')
const [value, setValue] = React.useState(
args.withTime ? "2026-03-23T14:30" : "2026-03-23",
);
return (
<div style={{ maxWidth: 520 }}>
<div className="form-group">
<label className="form-label" htmlFor="field-datepicker">
{args.withTime ? 'Date Time Picker' : 'Date Picker'}
{args.withTime ? "Date Time Picker" : "Date Picker"}
</label>
<DatePickerField
id="field-datepicker"
......@@ -156,71 +203,91 @@ export const DatePicker: Story = {
/>
</div>
</div>
)
);
},
args: {
withTime: false,
},
argTypes: {
withTime: {
control: 'boolean',
control: "boolean",
},
},
}
};
export const Checkbox: Story = {
render: () => {
const [checked, setChecked] = React.useState(false)
const [checked, setChecked] = React.useState(false);
return (
<div style={{ maxWidth: 520 }}>
<div className="form-group">
<CheckboxField id="field-checkbox" checked={checked} label="Confirm checkbox" onChange={setChecked} />
<CheckboxField
id="field-checkbox"
checked={checked}
label="Confirm checkbox"
onChange={setChecked}
/>
</div>
</div>
)
);
},
}
};
export const ToggleButton: Story = {
render: () => {
const [checked, setChecked] = React.useState(false)
const [checked, setChecked] = React.useState(false);
return (
<div style={{ maxWidth: 520 }}>
<div className="form-group">
<label className="form-label" htmlFor="field-toggle">
Toggle Button
</label>
<ToggleField id="field-toggle" checked={checked} onChange={setChecked} onLabel="Show" offLabel="Hide" />
<ToggleField
id="field-toggle"
checked={checked}
onChange={setChecked}
onLabel="Show"
offLabel="Hide"
/>
</div>
</div>
)
);
},
}
};
export const OptionalComment: Story = {
render: () => {
const [value, setValue] = React.useState('')
const [value, setValue] = React.useState("");
return (
<div style={{ maxWidth: 520 }}>
<OptionalCommentField id="field-comment" value={value} onChange={setValue} />
<OptionalCommentField
id="field-comment"
value={value}
onChange={setValue}
/>
</div>
)
);
},
}
};
export const LinkedFile: Story = {
render: () => {
const [files, setFiles] = React.useState<File[]>([])
const [files, setFiles] = React.useState<File[]>([]);
return (
<div style={{ maxWidth: 520 }}>
<div className="form-group">
<div className="form-label" style={{ marginBottom: '0.35rem' }}>
<div className="form-label" style={{ marginBottom: "0.35rem" }}>
Linked file
</div>
<LinkedFileField id="field-file" files={files} onChange={setFiles} multiple accept="*/*" />
<LinkedFileField
id="field-file"
files={files}
onChange={setFiles}
multiple
accept="*/*"
/>
</div>
</div>
)
);
},
}
};
......@@ -64,7 +64,10 @@
font-weight: 700;
white-space: nowrap;
cursor: pointer;
transition: border-color var(--t1), background var(--t1), color var(--t1);
transition:
border-color var(--t1),
background var(--t1),
color var(--t1);
}
.maf-title-card-btn:hover {
......@@ -111,48 +114,51 @@
}
.maf-fieldset-tab {
flex: 0 0 210px;
flex: 0 0 240px;
border: 1px solid var(--maf-line);
border-radius: 10px;
background: #fff;
color: #6a5b49;
min-height: 44px;
min-height: 72px;
padding: 0.45rem 0.7rem;
display: inline-flex;
align-items: center;
justify-content: space-between;
display: inline-grid;
grid-template-columns: 1fr;
align-content: start;
gap: 0.5rem;
text-align: left;
cursor: pointer;
transition: border-color var(--t1), background var(--t1), box-shadow var(--t1);
transition:
border-color var(--t1),
background var(--t1),
box-shadow var(--t1);
scroll-snap-align: start;
}
.maf-fieldset-tab[data-error='true'] {
.maf-fieldset-tab[data-error="true"] {
border-color: #d495ab;
}
.maf-fieldset-tab-label {
font-size: 0.84rem;
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
line-height: 1.3;
}
.maf-fieldset-tab-meta {
display: inline-flex;
align-items: center;
gap: 0.35rem;
display: grid;
gap: 0.14rem;
}
.maf-fieldset-tab-count {
font-size: 0.76rem;
font-size: 0.72rem;
font-weight: 700;
color: #8e8172;
}
.maf-fieldset-tab-required {
font-size: 0.72rem;
font-size: 0.68rem;
color: #9a8c7a;
}
......@@ -171,7 +177,7 @@
background: #2f8d5f;
}
.maf-fieldset-tab[data-active='true'] {
.maf-fieldset-tab[data-active="true"] {
border-color: var(--maf-brown-soft);
background: var(--maf-surface);
box-shadow: 0 0 0 2px rgba(167, 138, 104, 0.15);
......@@ -195,6 +201,58 @@
white-space: nowrap;
}
.maf-form-progress-bar {
border: 1px solid var(--maf-line);
border-radius: 12px;
background: var(--maf-surface-2);
padding: 0.62rem 0.7rem;
display: grid;
gap: 0.4rem;
}
.maf-form-progress-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.maf-form-progress-label {
font-size: 0.74rem;
color: #7d7366;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.maf-form-progress-value {
font-size: 0.78rem;
color: #635648;
font-weight: 700;
}
.maf-form-progress-meta {
font-size: 0.74rem;
color: #8b7f73;
font-weight: 600;
}
.maf-active-section-meta {
display: flex;
flex-wrap: wrap;
gap: 0.35rem 0.45rem;
}
.maf-active-section-meta span {
border: 1px solid #ddd2c2;
border-radius: 999px;
background: #f8f4ee;
color: #786b5e;
padding: 0.26rem 0.52rem;
font-size: 0.7rem;
font-weight: 700;
}
.maf-section-nav {
display: flex;
justify-content: space-between;
......@@ -218,6 +276,56 @@
gap: 0.85rem;
}
.maf-title-card-kicker {
font-size: 0.62rem;
}
.maf-title-card-title {
font-size: 0.94rem;
}
.maf-title-card-btn {
font-size: 0.76rem;
min-height: 34px;
}
.maf-fieldset-select-label {
font-size: 0.68rem;
}
.maf-fieldset-select {
font-size: 0.82rem;
min-height: 36px;
}
.maf-form-shell .form-label {
font-size: 0.8rem;
}
.maf-form-shell .form-input,
.maf-form-shell .form-select {
font-size: 0.84rem;
min-height: 40px;
}
.maf-choice-text {
font-size: 0.86rem;
}
.maf-checklist-question-title {
font-size: 0.88rem;
}
.maf-checklist-comment {
font-size: 0.78rem;
}
.maf-form-shell .btn-primary,
.maf-form-shell .btn-ghost {
font-size: 0.8rem;
min-height: 40px;
}
.maf-checklist-section-head {
align-items: flex-start;
flex-wrap: wrap;
......@@ -263,7 +371,10 @@
border-color: var(--maf-line);
background: #fff;
color: #514438;
transition: border-color var(--t1), box-shadow var(--t1), background var(--t1);
transition:
border-color var(--t1),
box-shadow var(--t1),
background var(--t1);
}
.maf-form-shell .form-input:hover,
......@@ -395,7 +506,10 @@
border-radius: var(--r-sm);
background: var(--maf-surface-2);
border: 1px solid #e7e0d6;
transition: background var(--t1), border-color var(--t1), box-shadow var(--t1);
transition:
background var(--t1),
border-color var(--t1),
box-shadow var(--t1);
}
.maf-checklist-answer-row.is-unselected {
......@@ -446,17 +560,17 @@
height: 24px;
}
.maf-checklist-answer-row.is-yes .maf-rating-dot[data-checked='true'],
.maf-checklist-answer-row.is-no .maf-rating-dot[data-checked='true'],
.maf-checklist-answer-row.is-na .maf-rating-dot[data-checked='true'] {
.maf-checklist-answer-row.is-yes .maf-rating-dot[data-checked="true"],
.maf-checklist-answer-row.is-no .maf-rating-dot[data-checked="true"],
.maf-checklist-answer-row.is-na .maf-rating-dot[data-checked="true"] {
background: transparent;
border-color: #ffffff;
box-shadow: none;
}
.maf-checklist-answer-row.is-yes .maf-rating-dot[data-checked='false'],
.maf-checklist-answer-row.is-no .maf-rating-dot[data-checked='false'],
.maf-checklist-answer-row.is-na .maf-rating-dot[data-checked='false'] {
.maf-checklist-answer-row.is-yes .maf-rating-dot[data-checked="false"],
.maf-checklist-answer-row.is-no .maf-rating-dot[data-checked="false"],
.maf-checklist-answer-row.is-na .maf-rating-dot[data-checked="false"] {
border-color: rgba(255, 255, 255, 0.5);
}
......@@ -485,7 +599,9 @@
font-size: 1.15rem;
line-height: 1;
cursor: pointer;
transition: filter var(--t1), transform var(--t1);
transition:
filter var(--t1),
transform var(--t1);
padding: 0 9px;
white-space: nowrap;
}
......@@ -513,7 +629,10 @@
border-radius: var(--r-sm);
cursor: pointer;
user-select: none;
transition: border-color var(--t1), background var(--t1), transform var(--t1);
transition:
border-color var(--t1),
background var(--t1),
transform var(--t1);
}
.maf-choice:hover {
......@@ -522,7 +641,7 @@
transform: translateY(-1px);
}
.maf-choice[data-checked='true'] {
.maf-choice[data-checked="true"] {
background: #f3ebe1;
border-color: var(--maf-brown-soft);
}
......@@ -546,12 +665,12 @@
flex-shrink: 0;
}
.maf-choice[data-checked='true'] .maf-radio-ui {
.maf-choice[data-checked="true"] .maf-radio-ui {
border-color: var(--maf-brown-soft);
}
.maf-radio-ui::after {
content: '';
content: "";
width: 8px;
height: 8px;
border-radius: 999px;
......@@ -560,7 +679,7 @@
transition: transform var(--t1);
}
.maf-choice[data-checked='true'] .maf-radio-ui::after {
.maf-choice[data-checked="true"] .maf-radio-ui::after {
transform: scale(1);
}
......@@ -576,14 +695,17 @@
border-radius: 999px;
border: 2px solid #d2d2d2;
cursor: pointer;
transition: transform var(--t1), box-shadow var(--t1), opacity var(--t1);
transition:
transform var(--t1),
box-shadow var(--t1),
opacity var(--t1);
}
.maf-rating-dot:hover:not(:disabled) {
transform: translateY(-1px);
}
.maf-rating-dot[data-checked='true'] {
.maf-rating-dot[data-checked="true"] {
box-shadow: 0 0 0 3px rgba(17, 20, 24, 0.16);
}
......@@ -633,8 +755,10 @@
letter-spacing: 0.05em;
text-transform: uppercase;
cursor: pointer;
color: #ffffff;
transition: background var(--t1), border-color var(--t1), color var(--t1);
transition:
background var(--t1),
border-color var(--t1),
color var(--t1);
}
.maf-toggle-text {
......@@ -645,7 +769,7 @@
}
.maf-toggle-pill::after {
content: '';
content: "";
position: absolute;
top: 3px;
left: 3px;
......@@ -654,16 +778,18 @@
border-radius: 999px;
background: #ffffff;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.28);
transition: left var(--t1), background var(--t1);
transition:
left var(--t1),
background var(--t1);
}
.maf-toggle-pill[data-checked='true'] {
.maf-toggle-pill[data-checked="true"] {
border-color: #23b6b0;
background: #23b6b0;
color: #ffffff;
}
.maf-toggle-pill[data-checked='true']::after {
.maf-toggle-pill[data-checked="true"]::after {
left: calc(100% - 29px);
}
......@@ -689,7 +815,7 @@
}
.maf-checkbox-ui::after {
content: '';
content: "";
width: 7px;
height: 3px;
border-left: 2px solid transparent;
......@@ -698,12 +824,12 @@
transition: border-color var(--t1);
}
.maf-choice[data-checked='true'] .maf-checkbox-ui {
.maf-choice[data-checked="true"] .maf-checkbox-ui {
background: var(--gold);
border-color: var(--gold);
}
.maf-choice[data-checked='true'] .maf-checkbox-ui::after {
.maf-choice[data-checked="true"] .maf-checkbox-ui::after {
border-left-color: var(--ink);
border-bottom-color: var(--ink);
}
......@@ -739,7 +865,10 @@
gap: 0.5rem;
padding: 0.4rem 0.65rem;
cursor: pointer;
transition: border-color var(--t1), background var(--t1), box-shadow var(--t1);
transition:
border-color var(--t1),
background var(--t1),
box-shadow var(--t1);
}
.maf-multiselect-control:focus-visible {
......@@ -855,7 +984,7 @@
background: var(--maf-surface);
}
.maf-multiselect-option[data-checked='true'] {
.maf-multiselect-option[data-checked="true"] {
background: #f3ebe1;
}
......@@ -925,7 +1054,10 @@
justify-content: space-between;
padding: 0.55rem 0.75rem;
cursor: pointer;
transition: border-color var(--t1), background var(--t1), box-shadow var(--t1);
transition:
border-color var(--t1),
background var(--t1),
box-shadow var(--t1);
}
.maf-datepicker-input:hover {
......@@ -947,7 +1079,10 @@
position: absolute;
top: calc(100% + 6px);
left: 0;
width: min(410px, 94vw);
right: auto;
width: min(410px, calc(100vw - 20px));
max-height: min(70vh, 640px);
overflow: auto;
z-index: 30;
background: #fff;
color: #514438;
......@@ -957,6 +1092,11 @@
padding: 14px;
}
.maf-datepicker-panel--above {
top: auto;
bottom: calc(100% + 6px);
}
.maf-datepicker-summary {
display: grid;
grid-template-columns: auto 1fr auto;
......@@ -993,7 +1133,10 @@
min-height: 32px;
padding: 0 10px;
cursor: pointer;
transition: border-color var(--t1), background var(--t1), color var(--t1);
transition:
border-color var(--t1),
background var(--t1),
color var(--t1);
}
.maf-datepicker-today-btn:hover {
......@@ -1022,7 +1165,11 @@
border-radius: 10px;
color: #52463a;
cursor: pointer;
transition: border-color var(--t1), background var(--t1), color var(--t1), transform var(--t1);
transition:
border-color var(--t1),
background var(--t1),
color var(--t1),
transform var(--t1);
}
.maf-datepicker-nav-btn:hover {
......@@ -1065,18 +1212,22 @@
cursor: pointer;
font-size: 0.95rem;
color: #514438;
transition: border-color var(--t1), background var(--t1), color var(--t1), transform var(--t1);
transition:
border-color var(--t1),
background var(--t1),
color var(--t1),
transform var(--t1);
}
.maf-datepicker-day[data-in-month='false'] {
.maf-datepicker-day[data-in-month="false"] {
color: #bcae9e;
}
.maf-datepicker-day[data-today='true'] {
.maf-datepicker-day[data-today="true"] {
border-color: var(--gold-border);
}
.maf-datepicker-day[data-selected='true'] {
.maf-datepicker-day[data-selected="true"] {
background: #f4e7d5;
color: var(--maf-brown);
border-color: var(--maf-brown-soft);
......@@ -1088,7 +1239,16 @@
transform: translateY(-1px);
}
.upload-placeholder[data-dragging='true'] {
button.upload-placeholder {
font: inherit;
color: inherit;
width: 100%;
margin: 0;
appearance: none;
box-sizing: border-box;
}
.upload-placeholder[data-dragging="true"] {
border-color: var(--gold);
background: var(--ink-2);
}
......@@ -1177,7 +1337,17 @@
}
.maf-form-tools-meta {
font-size: 0.74rem;
font-size: 0.68rem;
}
.maf-form-progress-bar {
position: sticky;
top: 0.5rem;
z-index: 15;
}
.maf-active-section-meta {
margin-top: -0.1rem;
}
.maf-section-nav {
......@@ -1194,6 +1364,17 @@
border-radius: 12px;
}
.maf-form-shell .form-label {
font-size: 0.76rem;
margin-bottom: 0.3rem;
}
.maf-form-shell .form-input,
.maf-form-shell .form-select {
font-size: 0.8rem;
min-height: 38px;
}
.maf-form-shell .form-actions {
position: sticky;
bottom: 8px;
......@@ -1222,7 +1403,7 @@
}
.maf-checklist-section-title {
font-size: 0.98rem;
font-size: 0.9rem;
letter-spacing: 0.03em;
}
......@@ -1254,10 +1435,19 @@
}
.maf-choice {
min-height: 46px;
min-height: 40px;
padding: 0.65rem 0.75rem;
}
.maf-checklist-question-title {
font-size: 0.82rem;
line-height: 1.35;
}
.maf-choice-text {
font-size: 0.8rem;
}
.maf-multiselect-chip {
max-width: 100%;
}
......@@ -1278,20 +1468,24 @@
}
.maf-datepicker-panel {
position: fixed;
left: 50%;
transform: translateX(-50%);
position: absolute;
left: 0;
right: auto;
top: auto;
bottom: 10px;
width: min(520px, calc(100vw - 20px));
max-width: none;
max-height: min(78vh, 700px);
top: calc(100% + 6px);
bottom: auto;
transform: none;
width: min(360px, calc(100vw - 20px));
max-height: min(66vh, 560px);
overflow: auto;
z-index: 100;
z-index: 60;
padding: 12px;
}
.maf-datepicker-panel--above {
top: auto;
bottom: calc(100% + 6px);
}
.maf-datepicker-nav-btn {
width: 44px;
height: 44px;
......@@ -1299,17 +1493,18 @@
.maf-datepicker-day {
min-height: 42px;
font-size: 0.98rem;
font-size: 0.86rem;
}
.maf-datepicker-time-input {
min-height: 44px;
font-size: 0.8rem;
}
}
@media (max-width: 560px) {
.maf-fieldset-tab {
min-height: 40px;
min-height: 68px;
}
.maf-section-nav {
......@@ -1317,7 +1512,13 @@
}
.maf-title-card-title {
font-size: 0.94rem;
font-size: 0.86rem;
}
.maf-form-progress-label,
.maf-form-progress-value,
.maf-form-progress-meta {
font-size: 0.7rem;
}
.maf-form-shell {
......@@ -1334,16 +1535,16 @@
}
.maf-checklist-section-title {
font-size: 0.92rem;
font-size: 0.84rem;
}
.maf-checklist-legend {
font-size: 0.68rem;
font-size: 0.62rem;
gap: 0.45rem;
}
.maf-checklist-question-title {
font-size: 0.9rem;
font-size: 0.76rem;
gap: 0.45rem;
}
......@@ -1352,7 +1553,7 @@
}
.maf-checklist-comment {
font-size: 0.78rem;
font-size: 0.72rem;
min-height: 36px;
}
......@@ -1361,7 +1562,7 @@
min-height: 30px;
height: 30px;
padding: 0 30px;
font-size: 0.62rem;
font-size: 0.58rem;
}
.maf-toggle-pill::after {
......@@ -1371,22 +1572,26 @@
height: 22px;
}
.maf-toggle-pill[data-checked='true']::after {
.maf-toggle-pill[data-checked="true"]::after {
left: calc(100% - 25px);
}
.maf-datepicker-panel {
position: fixed;
left: 12px;
right: 12px;
top: auto;
bottom: 12px;
width: auto;
max-width: none;
max-height: min(78vh, 620px);
position: absolute;
left: 0;
right: auto;
top: calc(100% + 6px);
bottom: auto;
width: min(320px, calc(100vw - 20px));
max-height: min(62vh, 520px);
overflow: auto;
z-index: 100;
padding: 12px;
z-index: 60;
padding: 10px;
}
.maf-datepicker-panel--above {
top: auto;
bottom: calc(100% + 6px);
}
.maf-datepicker-head {
......@@ -1394,7 +1599,7 @@
}
.maf-datepicker-title {
font-size: 1rem;
font-size: 0.88rem;
}
.maf-datepicker-grid {
......@@ -1403,7 +1608,7 @@
.maf-datepicker-day {
min-height: 34px;
font-size: 0.95rem;
font-size: 0.78rem;
}
.maf-datepicker-time-row {
......@@ -1414,6 +1619,13 @@
.maf-datepicker-time-label {
min-width: 0;
font-size: 0.72rem;
}
.maf-form-shell .btn-primary,
.maf-form-shell .btn-ghost {
font-size: 0.76rem;
min-height: 38px;
}
}
......@@ -1453,7 +1665,10 @@
background: transparent;
color: #8f8477;
cursor: pointer;
transition: color var(--t1), background var(--t1), border-color var(--t1);
transition:
color var(--t1),
background var(--t1),
border-color var(--t1);
display: flex;
align-items: center;
justify-content: center;
......@@ -1475,4 +1690,3 @@
stroke-linecap: round;
stroke-linejoin: round;
}
export type FormTypeEnum = 'audit' | 'inspection' | 'checklist'
export type FormTypeEnum = "audit" | "inspection" | "checklist";
export type FormInputType =
| 'text'
| 'textarea'
| 'radio'
| 'select'
| 'multiselect'
| 'date'
| 'datetime'
| 'datetime-local'
| 'checkbox'
| 'file'
| 'hidden'
| string
| "text"
| "textarea"
| "radio"
| "select"
| "multiselect"
| "date"
| "datetime"
| "datetime-local"
| "checkbox"
| "file"
| "hidden"
| string;
export type FormQuestionValidation = {
supportsComment?: boolean
hasLinkedFile?: boolean
allowsMultiple?: boolean
supportsComment?: boolean;
hasLinkedFile?: boolean;
allowsMultiple?: boolean;
// Keep room for future flags without breaking strict typing.
[key: string]: unknown
}
[key: string]: unknown;
};
export type FormQuestion = {
questionCode: string
groupCode?: string
label: string
inputType: FormInputType
required?: boolean
questionOrder?: number
options?: string[]
validation?: FormQuestionValidation
}
questionCode: string;
groupCode?: string;
label: string;
inputType: FormInputType;
required?: boolean;
questionOrder?: number;
options?: string[];
validation?: FormQuestionValidation;
};
export type FormGroupLayout = {
groupCode: string
label: string
order?: number
}
groupCode: string;
label: string;
order?: number;
};
export type FormDefinitionSchema = {
meta: {
formCode: string
sourceId?: string
title: string
categoryIds?: string[]
version?: number
[key: string]: unknown
}
layout: FormGroupLayout[]
questions: FormQuestion[]
hiddenFields?: unknown[]
formCode: string;
sourceId?: string;
title: string;
categoryIds?: string[];
version?: number;
[key: string]: unknown;
};
layout: FormGroupLayout[];
questions: FormQuestion[];
hiddenFields?: unknown[];
validation?: {
requiredQuestions?: string[]
[key: string]: unknown
}
}
requiredQuestions?: string[];
[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;
}
"use client";
import type React from "react";
import { useEffect, useId, useRef } from "react";
import "./modal-shell.css";
export type ModalShellProps = {
open: boolean;
onClose: () => void;
title: React.ReactNode;
brandLabel?: string;
/** Small badge letter shown next to brandLabel */
badge?: string;
children: React.ReactNode;
className?: string;
/** Clicking backdrop closes modal (default true) */
closeOnBackdrop?: boolean;
};
export function ModalShell({
open,
onClose,
title,
brandLabel,
badge,
children,
className = "",
closeOnBackdrop = true,
}: ModalShellProps) {
const titleId = useId();
const closeBtnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
if (!open) return;
const prevOverflow = document.documentElement.style.overflow;
document.documentElement.style.overflow = "hidden";
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKeyDown);
// Focus the close button to keep the experience keyboard friendly.
const t = window.setTimeout(() => closeBtnRef.current?.focus(), 0);
return () => {
window.clearTimeout(t);
window.removeEventListener("keydown", onKeyDown);
document.documentElement.style.overflow = prevOverflow;
};
}, [open, onClose]);
if (!open) return null;
return (
<div className="maf-widget-modal" role="presentation">
<button
type="button"
className="maf-widget-modal__backdrop"
aria-label="Close dialog"
onClick={closeOnBackdrop ? onClose : undefined}
/>
<section
className={`maf-widget-modal__panel ${className}`.trim()}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
>
<header className="maf-widget-modal__header">
{brandLabel ? (
<div className="maf-widget-modal__brand" aria-label={brandLabel}>
{badge ? <span className="maf-widget-modal__badge">{badge}</span> : null}
<span className="maf-widget-modal__brandText">{brandLabel}</span>
</div>
) : (
<span />
)}
<button
ref={closeBtnRef}
type="button"
className="maf-widget-modal__close"
aria-label="Close dialog"
onClick={onClose}
>
×
</button>
</header>
<div className="maf-widget-modal__body">
<h2 id={titleId} className="maf-widget-modal__title font-serif">
{title}
</h2>
{children}
</div>
</section>
</div>
);
}
.maf-widget-modal {
position: fixed;
inset: 0;
z-index: 60;
display: grid;
place-items: center;
padding: 1.25rem;
}
.maf-widget-modal__backdrop {
position: absolute;
inset: 0;
border: 0;
background:
radial-gradient(1200px 600px at 50% 30%, var(--glow, rgba(196, 165, 116, 0.15)), transparent 55%),
rgba(12, 11, 9, 0.72);
}
.maf-widget-modal__panel {
position: relative;
width: min(520px, 92vw);
border-radius: 24px;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--accent, #c4a574) 28%, transparent);
background:
radial-gradient(900px 460px at 12% 12%, var(--glow, rgba(196, 165, 116, 0.15)), transparent 55%),
radial-gradient(900px 420px at 95% 80%, rgba(196, 165, 116, 0.08), transparent 55%),
linear-gradient(
180deg,
color-mix(in srgb, var(--background, #0c0b09) 86%, #141210) 0%,
color-mix(in srgb, var(--background, #0c0b09) 96%, #000) 100%
);
box-shadow:
0 30px 90px rgba(0, 0, 0, 0.65),
0 0 0 1px rgba(255, 255, 255, 0.04) inset;
}
.maf-widget-modal__panel::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0.22;
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-widget-modal__header {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.15rem 1.25rem 0.75rem;
}
.maf-widget-modal__brand {
display: inline-flex;
align-items: center;
gap: 0.7rem;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: color-mix(in srgb, var(--foreground, #f4f0e8) 72%, transparent);
}
.maf-widget-modal__badge {
width: 34px;
height: 34px;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
background: linear-gradient(
160deg,
color-mix(in srgb, var(--accent, #c4a574) 92%, #fff) 0%,
color-mix(in srgb, var(--accent, #c4a574) 55%, #6e5a44) 100%
);
color: rgba(12, 11, 9, 0.92);
box-shadow:
0 10px 26px rgba(0, 0, 0, 0.35),
inset 0 1px 0 rgba(255, 255, 255, 0.22);
}
.maf-widget-modal__close {
width: 40px;
height: 40px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.04);
color: color-mix(in srgb, var(--foreground, #f4f0e8) 72%, transparent);
font-size: 20px;
line-height: 1;
}
.maf-widget-modal__close:hover {
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.92);
}
.maf-widget-modal__close:focus-visible {
outline: 2px solid var(--accent, #c4a574);
outline-offset: 3px;
}
.maf-widget-modal__body {
position: relative;
z-index: 1;
padding: 0 1.25rem 1.25rem;
}
.maf-widget-modal__title {
margin: 0;
font-size: 2.1rem;
line-height: 1.1;
font-weight: 500;
letter-spacing: 0.01em;
color: color-mix(in srgb, var(--foreground, #f4f0e8) 92%, transparent);
}
@media (prefers-reduced-motion: reduce) {
.maf-widget-modal__close {
transition: none;
}
}
import type { Meta, StoryObj } from "@storybook/nextjs-vite";
import { SupportModal } from "./SupportModal";
const meta = {
title: "Widgets/Support Modal",
component: SupportModal,
parameters: {
layout: "fullscreen",
},
} satisfies Meta<typeof SupportModal>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Open: Story = {
args: {
open: true,
onClose: () => {},
email: "support@mafgateway.com",
responseSlaLabel: "Responds within 24h",
},
};
"use client";
import type React from "react";
import { useMemo } from "react";
import { ModalShell } from "@/components/widgets/ModalShell/ModalShell";
import "./support-modal.css";
export type SupportFaqItem = {
id: string;
question: string;
answer: React.ReactNode;
};
export type SupportModalProps = {
open: boolean;
onClose: () => void;
email?: string;
responseSlaLabel?: string;
faqs?: SupportFaqItem[];
className?: string;
};
function MailGlyph({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" aria-hidden>
<path
d="M4.5 7.5h15v10h-15z"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
<path
d="M5.2 8.1l6.8 5 6.8-5"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
</svg>
);
}
function ChevronGlyph({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" aria-hidden>
<path
d="M8 10l4 4 4-4"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function SupportModal({
open,
onClose,
email = "support@mafgateway.com",
responseSlaLabel = "Responds within 24h",
faqs,
className = "",
}: SupportModalProps) {
const defaultFaqs: SupportFaqItem[] = useMemo(
() => [
{
id: "reset-password",
question: "I forgot my password — how do I reset it?",
answer:
"If you’re a MAF employee, password changes are managed through your Microsoft account. Contractors/suppliers should contact their organization admin or reach support.",
},
{
id: "login-type",
question: "Which login type should I choose?",
answer:
"Choose MAF Employee if you sign in via Microsoft Azure Active Directory. Choose Contractor / Supplier if you were issued separate credentials.",
},
{
id: "app-error",
question: "The app won’t load or is showing an error.",
answer:
"Try refreshing, clearing your browser cache, or switching networks. If it persists, contact support and include a screenshot and the time the error occurred.",
},
{
id: "download",
question: "How do I download the mobile app?",
answer:
"Use the App Store or Google Play links on the landing page. If your device is managed, you may need to install via your organization’s app catalog.",
},
],
[],
);
const items = faqs?.length ? faqs : defaultFaqs;
return (
<ModalShell
open={open}
onClose={onClose}
brandLabel="Support Center"
badge="M"
title="How can we help?"
className={`maf-support-modal ${className}`.trim()}
>
<p className="maf-support-modal__subtitle">Find quick answers or reach our team directly.</p>
<div className="maf-support-modal__faq" aria-label="Frequently asked questions">
{items.map((item) => (
<details key={item.id} className="maf-support-modal__qa">
<summary className="maf-support-modal__q">
<span>{item.question}</span>
<ChevronGlyph className="maf-support-modal__chev" />
</summary>
<div className="maf-support-modal__a">{item.answer}</div>
</details>
))}
</div>
<section className="maf-support-modal__email" aria-label="Email support">
<span className="maf-support-modal__emailIcon" aria-hidden>
<MailGlyph className="maf-support-modal__emailSvg" />
</span>
<div className="maf-support-modal__emailBody">
<div className="maf-support-modal__emailKicker">
Email support · <span className="maf-support-modal__emailSla">{responseSlaLabel}</span>
</div>
<a className="maf-support-modal__emailLink" href={`mailto:${email}`}>
{email}
</a>
</div>
</section>
</ModalShell>
);
}
.maf-support-modal .maf-widget-modal__title {
margin-top: 0.35rem;
}
.maf-support-modal__subtitle {
margin: 0.55rem 0 1rem;
color: color-mix(in srgb, var(--foreground, #f4f0e8) 62%, transparent);
font-size: 0.95rem;
line-height: 1.5;
}
.maf-support-modal__faq {
margin-top: 0.25rem;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.maf-support-modal__qa {
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.maf-support-modal__q {
list-style: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.95rem 0.25rem;
font-weight: 700;
color: rgba(255, 255, 255, 0.92);
}
.maf-support-modal__q::-webkit-details-marker {
display: none;
}
.maf-support-modal__chev {
width: 18px;
height: 18px;
color: color-mix(in srgb, var(--foreground, #f4f0e8) 52%, transparent);
transition: transform 180ms ease;
}
.maf-support-modal__qa[open] .maf-support-modal__chev {
transform: rotate(180deg);
}
.maf-support-modal__a {
padding: 0 0.25rem 0.95rem;
color: color-mix(in srgb, var(--foreground, #f4f0e8) 62%, transparent);
font-size: 0.92rem;
line-height: 1.6;
}
.maf-support-modal__q:focus-visible {
outline: 2px solid var(--accent, #c4a574);
outline-offset: 4px;
border-radius: 12px;
}
.maf-support-modal__email {
margin-top: 1.15rem;
display: grid;
grid-template-columns: auto 1fr;
gap: 0.95rem;
align-items: center;
padding: 1rem 1rem;
border-radius: 18px;
border: 1px solid color-mix(in srgb, var(--accent, #c4a574) 20%, transparent);
background: rgba(255, 255, 255, 0.03);
}
.maf-support-modal__emailIcon {
width: 54px;
height: 54px;
border-radius: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
background: linear-gradient(
160deg,
color-mix(in srgb, var(--accent, #c4a574) 92%, #fff) 0%,
color-mix(in srgb, var(--accent, #c4a574) 55%, #6e5a44) 100%
);
color: rgba(12, 11, 9, 0.92);
box-shadow:
0 14px 40px rgba(0, 0, 0, 0.35),
inset 0 1px 0 rgba(255, 255, 255, 0.18);
}
.maf-support-modal__emailSvg {
width: 22px;
height: 22px;
}
.maf-support-modal__emailKicker {
font-size: 0.75rem;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
color: color-mix(in srgb, var(--foreground, #f4f0e8) 55%, transparent);
}
.maf-support-modal__emailSla {
color: color-mix(in srgb, var(--accent, #c4a574) 88%, transparent);
}
.maf-support-modal__emailLink {
display: inline-flex;
margin-top: 0.25rem;
color: color-mix(in srgb, var(--accent, #c4a574) 92%, transparent);
text-decoration: none;
font-weight: 800;
letter-spacing: 0.01em;
}
.maf-support-modal__emailLink:hover {
text-decoration: underline;
}
.maf-support-modal__emailLink:focus-visible {
outline: 2px solid var(--accent, #c4a574);
outline-offset: 3px;
border-radius: 10px;
}
@media (prefers-reduced-motion: reduce) {
.maf-support-modal__chev {
transition: none;
}
}
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
images: {
remotePatterns: [
{
protocol: "https",
hostname: "app-mafe-mafgateway-preprod-un-01.azurewebsites.net",
pathname: "/**",
},
],
},
};
export default nextConfig;
......@@ -7,33 +7,38 @@
"build": "next build",
"start": "next start",
"lint": "eslint",
"lint:biome": "biome check .",
"lint:biome:fix": "biome check --write .",
"format": "biome format --write .",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
"dependencies": {
"next": "16.2.0",
"react": "19.2.4",
"react-dom": "19.2.4"
"react-dom": "19.2.4",
"uplot": "^1.6.32"
},
"devDependencies": {
"@biomejs/biome": "^2.4.8",
"@chromatic-com/storybook": "^5.0.2",
"@storybook/addon-a11y": "^10.3.1",
"@storybook/addon-docs": "^10.3.1",
"@storybook/addon-vitest": "^10.3.1",
"@storybook/nextjs-vite": "^10.3.1",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitest/browser-playwright": "^4.1.0",
"@vitest/coverage-v8": "^4.1.0",
"eslint": "^9",
"eslint-config-next": "16.2.0",
"playwright": "^1.58.2",
"storybook": "^10.3.1",
"tailwindcss": "^4",
"typescript": "^5",
"storybook": "^10.3.1",
"@storybook/nextjs-vite": "^10.3.1",
"@chromatic-com/storybook": "^5.0.2",
"@storybook/addon-vitest": "^10.3.1",
"@storybook/addon-a11y": "^10.3.1",
"@storybook/addon-docs": "^10.3.1",
"vite": "^8.0.1",
"vitest": "^4.1.0",
"playwright": "^1.58.2",
"@vitest/browser-playwright": "^4.1.0",
"@vitest/coverage-v8": "^4.1.0"
"vitest": "^4.1.0"
}
}
......@@ -17,7 +17,13 @@ importers:
react-dom:
specifier: 19.2.4
version: 19.2.4(react@19.2.4)
uplot:
specifier: ^1.6.32
version: 1.6.32
devDependencies:
'@biomejs/biome':
specifier: ^2.4.8
version: 2.4.8
'@chromatic-com/storybook':
specifier: ^5.0.2
version: 5.0.2(storybook@10.3.1(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))
......@@ -160,6 +166,59 @@ packages:
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
'@biomejs/biome@2.4.8':
resolution: {integrity: sha512-ponn0oKOky1oRXBV+rlSaUlixUxf1aZvWC19Z41zBfUOUesthrQqL3OtiAlSB1EjFjyWpn98Q64DHelhA6jNlA==}
engines: {node: '>=14.21.3'}
hasBin: true
'@biomejs/cli-darwin-arm64@2.4.8':
resolution: {integrity: sha512-ARx0tECE8I7S2C2yjnWYLNbBdDoPdq3oyNLhMglmuctThwUsuzFWRKrHmIGwIRWKz0Mat9DuzLEDp52hGnrxGQ==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [darwin]
'@biomejs/cli-darwin-x64@2.4.8':
resolution: {integrity: sha512-Jg9/PsB9vDCJlANE8uhG7qDhb5w0Ix69D7XIIc8IfZPUoiPrbLm33k2Ig3NOJ/7nb3UbesFz3D1aDKm9DvzjhQ==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [darwin]
'@biomejs/cli-linux-arm64-musl@2.4.8':
resolution: {integrity: sha512-Zo9OhBQDJ3IBGPlqHiTISloo5H0+FBIpemqIJdW/0edJ+gEcLR+MZeZozcUyz3o1nXkVA7++DdRKQT0599j9jA==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
'@biomejs/cli-linux-arm64@2.4.8':
resolution: {integrity: sha512-5CdrsJct76XG2hpKFwXnEtlT1p+4g4yV+XvvwBpzKsTNLO9c6iLlAxwcae2BJ7ekPGWjNGw9j09T5KGPKKxQig==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
'@biomejs/cli-linux-x64-musl@2.4.8':
resolution: {integrity: sha512-Gi8quv8MEuDdKaPFtS2XjEnMqODPsRg6POT6KhoP+VrkNb+T2ywunVB+TvOU0LX1jAZzfBr+3V1mIbBhzAMKvw==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
'@biomejs/cli-linux-x64@2.4.8':
resolution: {integrity: sha512-PdKXspVEaMCQLjtZCn6vfSck/li4KX9KGwSDbZdgIqlrizJ2MnMcE3TvHa2tVfXNmbjMikzcfJpuPWH695yJrw==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
'@biomejs/cli-win32-arm64@2.4.8':
resolution: {integrity: sha512-LoFatS0tnHv6KkCVpIy3qZCih+MxUMvdYiPWLHRri7mhi2vyOOs8OrbZBcLTUEWCS+ktO72nZMy4F96oMhkOHQ==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [win32]
'@biomejs/cli-win32-x64@2.4.8':
resolution: {integrity: sha512-vAn7iXDoUbqFXqVocuq1sMYAd33p8+mmurqJkWl6CtIhobd/O6moe4rY5AJvzbunn/qZCdiDVcveqtkFh1e7Hg==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [win32]
'@blazediff/core@1.9.1':
resolution: {integrity: sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==}
......@@ -2790,6 +2849,9 @@ packages:
peerDependencies:
browserslist: '>= 4.21.0'
uplot@1.6.32:
resolution: {integrity: sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==}
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
......@@ -3066,6 +3128,41 @@ snapshots:
'@bcoe/v8-coverage@1.0.2': {}
'@biomejs/biome@2.4.8':
optionalDependencies:
'@biomejs/cli-darwin-arm64': 2.4.8
'@biomejs/cli-darwin-x64': 2.4.8
'@biomejs/cli-linux-arm64': 2.4.8
'@biomejs/cli-linux-arm64-musl': 2.4.8
'@biomejs/cli-linux-x64': 2.4.8
'@biomejs/cli-linux-x64-musl': 2.4.8
'@biomejs/cli-win32-arm64': 2.4.8
'@biomejs/cli-win32-x64': 2.4.8
'@biomejs/cli-darwin-arm64@2.4.8':
optional: true
'@biomejs/cli-darwin-x64@2.4.8':
optional: true
'@biomejs/cli-linux-arm64-musl@2.4.8':
optional: true
'@biomejs/cli-linux-arm64@2.4.8':
optional: true
'@biomejs/cli-linux-x64-musl@2.4.8':
optional: true
'@biomejs/cli-linux-x64@2.4.8':
optional: true
'@biomejs/cli-win32-arm64@2.4.8':
optional: true
'@biomejs/cli-win32-x64@2.4.8':
optional: true
'@blazediff/core@1.9.1': {}
'@chromatic-com/storybook@5.0.2(storybook@10.3.1(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))':
......@@ -5847,6 +5944,8 @@ snapshots:
escalade: 3.2.0
picocolors: 1.1.1
uplot@1.6.32: {}
uri-js@4.4.1:
dependencies:
punycode: 2.3.1
......
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vitest/config';
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
import { playwright } from '@vitest/browser-playwright';
import path from "node:path";
import { fileURLToPath } from "node:url";
import { storybookTest } from "@storybook/addon-vitest/vitest-plugin";
import { playwright } from "@vitest/browser-playwright";
import { defineConfig } from "vitest/config";
const dirname =
typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url));
typeof __dirname !== "undefined"
? __dirname
: path.dirname(fileURLToPath(import.meta.url));
// More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon
export default defineConfig({
......@@ -19,15 +18,15 @@ export default defineConfig({
plugins: [
// The plugin will run tests for the stories defined in your Storybook config
// See options at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon#storybooktest
storybookTest({ configDir: path.join(dirname, '.storybook') }),
storybookTest({ configDir: path.join(dirname, ".storybook") }),
],
test: {
name: 'storybook',
name: "storybook",
browser: {
enabled: true,
headless: true,
provider: playwright({}),
instances: [{ browser: 'chromium' }],
instances: [{ browser: "chromium" }],
},
},
},
......
/// <reference types="@vitest/browser-playwright" />
\ No newline at end of file
/// <reference types="@vitest/browser-playwright" />
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