Skip to content

Commit 4e3c08b

Browse files
authored
Merge pull request #358 from arandomogg/feat/industry-split-hero
feat(industry): build industry solutions header and split enterprise block
2 parents 51c040a + ed780f3 commit 4e3c08b

13 files changed

Lines changed: 875 additions & 0 deletions

File tree

app/api/industry/hero/route.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// Local content endpoint for the Industry Solutions page.
2+
// It serves the exact `IndustryHeroResponse` contract the platform backend will
3+
// expose, so the client layers (service -> hook -> component) talk to a real
4+
// HTTP endpoint. Point NEXT_PUBLIC_API_URL at the backend to override it.
5+
import { NextResponse } from 'next/server';
6+
import type { IndustryHeroResponse } from '@/types/industry';
7+
8+
const payload: IndustryHeroResponse = {
9+
hero: {
10+
eyebrow: 'Industry Solutions',
11+
title: 'Modernizing the Global Supply Chain',
12+
description:
13+
'SwiftChain replaces paperwork, blind hand-offs and delayed settlement with escrow-backed contracts and live telemetry — so every shipment, partner and payout is verifiable end to end.',
14+
primaryCta: { label: 'Talk to our team', href: '/contact' },
15+
secondaryCta: { label: 'Explore the platform', href: '/dashboard' },
16+
stats: [
17+
{ id: 'stat-corridors', label: 'Active trade corridors', value: '120+' },
18+
{ id: 'stat-settlement', label: 'Average escrow settlement', value: '4.2s' },
19+
{ id: 'stat-visibility', label: 'Shipment visibility', value: '99.98%' },
20+
],
21+
},
22+
features: [
23+
{
24+
id: 'enterprise-logistics',
25+
eyebrow: 'Enterprise Logistics',
26+
title: 'One command center for every shipment you move',
27+
description:
28+
'Dispatch, exceptions, proof of delivery and escrow release in a single operational view. Your teams stop reconciling spreadsheets and start acting on what is happening on the road right now.',
29+
imagePosition: 'right',
30+
image: {
31+
src: '/images/industry/enterprise-command-center.png',
32+
alt: 'SwiftChain enterprise command center showing live delivery routes, fleet performance metrics and an escrow settlement ledger.',
33+
width: 1280,
34+
height: 960,
35+
},
36+
highlights: [
37+
{
38+
id: 'highlight-visibility',
39+
title: 'Live network visibility',
40+
description:
41+
'Track every driver, lane and hand-off on one map with telemetry streamed straight from the field.',
42+
},
43+
{
44+
id: 'highlight-escrow',
45+
title: 'Escrow-backed settlement',
46+
description:
47+
'Funds lock at pickup and release the moment proof of delivery is confirmed on chain — no invoice chasing.',
48+
},
49+
{
50+
id: 'highlight-audit',
51+
title: 'Audit-ready by default',
52+
description:
53+
'Every status change, signature and payout is immutably recorded and exportable for compliance reviews.',
54+
},
55+
],
56+
cta: { label: 'See how enterprise teams deploy SwiftChain', href: '/contact' },
57+
},
58+
],
59+
};
60+
61+
export async function GET() {
62+
return NextResponse.json(payload);
63+
}

app/industry/page.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
export const dynamic = 'force-dynamic';
2+
3+
import { IndustryHero } from '@/components/industry/IndustryHero';
4+
5+
export const metadata = {
6+
title: 'Industry Solutions | SwiftChain',
7+
description:
8+
'Enterprise logistics solutions built on SwiftChain: escrow-backed settlement, live fleet telemetry, and an auditable supply chain.',
9+
};
10+
11+
export default function IndustryPage() {
12+
return (
13+
<main className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(34,211,238,0.18),_transparent_40%),linear-gradient(135deg,_#020617_0%,_#0f172a_70%,_#111827_100%)] px-4 py-16 sm:px-6 lg:px-8 lg:py-24">
14+
<div className="mx-auto max-w-6xl">
15+
<IndustryHero />
16+
</div>
17+
</main>
18+
);
19+
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { render, screen } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
3+
import { IndustryHero } from '@/components/industry/IndustryHero';
4+
import { useIndustryHero } from '@/hooks/useIndustryHero';
5+
import type { UseIndustryHeroResult } from '@/hooks/useIndustryHero';
6+
import type { IndustrySplitFeature } from '@/types/industry';
7+
8+
jest.mock('@/hooks/useIndustryHero', () => ({
9+
useIndustryHero: jest.fn(),
10+
}));
11+
12+
const mockUseIndustryHero = useIndustryHero as jest.MockedFunction<
13+
typeof useIndustryHero
14+
>;
15+
16+
const feature: IndustrySplitFeature = {
17+
id: 'enterprise-logistics',
18+
eyebrow: 'Enterprise Logistics',
19+
title: 'One command center for every shipment you move',
20+
description: 'Dispatch, exceptions and escrow release in a single view.',
21+
imagePosition: 'right',
22+
image: {
23+
src: '/images/industry/enterprise-command-center.png',
24+
alt: 'SwiftChain enterprise command center',
25+
width: 1280,
26+
height: 960,
27+
},
28+
highlights: [
29+
{
30+
id: 'highlight-1',
31+
title: 'Live network visibility',
32+
description: 'Track every driver and lane on one map.',
33+
},
34+
],
35+
cta: { label: 'See how it works', href: '/contact' },
36+
};
37+
38+
const loadedState: UseIndustryHeroResult = {
39+
hero: {
40+
eyebrow: 'Industry Solutions',
41+
title: 'Modernizing the Global Supply Chain',
42+
description: 'Escrow-backed logistics for enterprise networks.',
43+
primaryCta: { label: 'Talk to our team', href: '/contact' },
44+
secondaryCta: null,
45+
stats: [{ id: 'stat-1', label: 'Active trade corridors', value: '120+' }],
46+
},
47+
features: [feature],
48+
isLoading: false,
49+
error: null,
50+
refetch: jest.fn(),
51+
};
52+
53+
function mockState(overrides: Partial<UseIndustryHeroResult> = {}) {
54+
mockUseIndustryHero.mockReturnValue({ ...loadedState, ...overrides });
55+
}
56+
57+
describe('IndustryHero', () => {
58+
beforeEach(() => jest.clearAllMocks());
59+
60+
it('renders the supply chain header and stats from the API', () => {
61+
mockState();
62+
render(<IndustryHero />);
63+
64+
expect(
65+
screen.getByRole('heading', {
66+
level: 1,
67+
name: 'Modernizing the Global Supply Chain',
68+
}),
69+
).toBeInTheDocument();
70+
expect(screen.getByText('Active trade corridors')).toBeInTheDocument();
71+
expect(screen.getByText('120+')).toBeInTheDocument();
72+
expect(
73+
screen.getByRole('link', { name: 'Talk to our team' }),
74+
).toHaveAttribute('href', '/contact');
75+
});
76+
77+
it('renders the Enterprise Logistics split block with a next/image asset', () => {
78+
mockState();
79+
render(<IndustryHero />);
80+
81+
expect(screen.getByText('Enterprise Logistics')).toBeInTheDocument();
82+
expect(
83+
screen.getByRole('heading', {
84+
level: 2,
85+
name: 'One command center for every shipment you move',
86+
}),
87+
).toBeInTheDocument();
88+
expect(screen.getByText('Live network visibility')).toBeInTheDocument();
89+
90+
const image = screen.getByAltText('SwiftChain enterprise command center');
91+
// next/image emits an <img> with an optimizer srcset and intrinsic
92+
// dimensions, which is what prevents the layout shift.
93+
expect(image).toHaveAttribute('width', '1280');
94+
expect(image).toHaveAttribute('height', '960');
95+
expect(image.getAttribute('srcset')).toContain(
96+
encodeURIComponent('/images/industry/enterprise-command-center.png'),
97+
);
98+
});
99+
100+
it('shows the loading skeleton while content is fetched', () => {
101+
mockState({ isLoading: true, hero: null, features: [] });
102+
render(<IndustryHero />);
103+
104+
expect(screen.getByRole('status')).toBeInTheDocument();
105+
expect(screen.queryByRole('heading', { level: 1 })).not.toBeInTheDocument();
106+
});
107+
108+
it('shows an error state and retries through the hook', async () => {
109+
const refetch = jest.fn();
110+
mockState({
111+
error: 'Service unavailable',
112+
hero: null,
113+
features: [],
114+
refetch,
115+
});
116+
render(<IndustryHero />);
117+
118+
expect(screen.getByRole('alert')).toHaveTextContent('Service unavailable');
119+
120+
await userEvent.click(screen.getByRole('button', { name: 'Try again' }));
121+
expect(refetch).toHaveBeenCalledTimes(1);
122+
});
123+
124+
it('shows an empty state when nothing is published', () => {
125+
mockState({ hero: null, features: [] });
126+
render(<IndustryHero />);
127+
128+
expect(
129+
screen.getByText('No industry solutions content is published yet.'),
130+
).toBeInTheDocument();
131+
});
132+
});
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
'use client';
2+
3+
import { useIndustryHero } from '@/hooks/useIndustryHero';
4+
import { IndustryHeroHeader } from './IndustryHeroHeader';
5+
import { IndustryHeroSkeleton } from './IndustryHeroSkeleton';
6+
import { IndustrySplitSection } from './IndustrySplitSection';
7+
8+
/**
9+
* IndustryHero — top-level Industry Solutions component.
10+
*
11+
* Consumes the useIndustryHero hook (Component -> Hook -> Service) and owns the
12+
* loading, error and empty states; all rendering of the header and the split
13+
* feature blocks is delegated to presentational children.
14+
*/
15+
export function IndustryHero() {
16+
const { hero, features, isLoading, error, refetch } = useIndustryHero();
17+
18+
if (isLoading) {
19+
return <IndustryHeroSkeleton />;
20+
}
21+
22+
if (error) {
23+
return (
24+
<div
25+
role="alert"
26+
className="mx-auto max-w-xl rounded-2xl border border-rose-500/20 bg-rose-500/10 p-8 text-center"
27+
>
28+
<p className="text-base font-semibold text-rose-200">
29+
We couldn&apos;t load the industry solutions content.
30+
</p>
31+
<p className="mt-2 text-sm text-rose-300/80">{error}</p>
32+
<button
33+
type="button"
34+
onClick={refetch}
35+
className="mt-6 inline-flex items-center justify-center rounded-full bg-rose-500/20 px-5 py-2.5 text-sm font-semibold text-rose-100 transition hover:bg-rose-500/30 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-rose-300"
36+
>
37+
Try again
38+
</button>
39+
</div>
40+
);
41+
}
42+
43+
if (!hero && features.length === 0) {
44+
return (
45+
<div className="mx-auto max-w-xl rounded-2xl border border-slate-800 bg-slate-900/70 p-8 text-center text-slate-300">
46+
No industry solutions content is published yet.
47+
</div>
48+
);
49+
}
50+
51+
return (
52+
<div className="space-y-20 lg:space-y-28">
53+
{hero && <IndustryHeroHeader hero={hero} />}
54+
55+
{features.map((feature, index) => (
56+
<IndustrySplitSection
57+
key={feature.id}
58+
feature={feature}
59+
priority={index === 0}
60+
/>
61+
))}
62+
</div>
63+
);
64+
}
65+
66+
export default IndustryHero;
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import Link from 'next/link';
2+
import type { IndustryHeroContent } from '@/types/industry';
3+
4+
interface IndustryHeroHeaderProps {
5+
hero: IndustryHeroContent;
6+
}
7+
8+
/**
9+
* IndustryHeroHeader — presentational page header for Industry Solutions
10+
* ("Modernizing the Global Supply Chain"). Receives content only; it never
11+
* fetches. Stats render as a semantic definition list for screen readers.
12+
*/
13+
export function IndustryHeroHeader({ hero }: IndustryHeroHeaderProps) {
14+
return (
15+
<header className="mx-auto max-w-3xl text-center">
16+
<p className="text-xs font-semibold uppercase tracking-[0.35em] text-cyan-300 sm:text-sm">
17+
{hero.eyebrow}
18+
</p>
19+
20+
<h1 className="mt-4 text-4xl font-semibold tracking-tight text-white sm:text-5xl lg:text-6xl">
21+
{hero.title}
22+
</h1>
23+
24+
<p className="mt-5 text-base leading-8 text-slate-300 sm:text-lg">
25+
{hero.description}
26+
</p>
27+
28+
{(hero.primaryCta || hero.secondaryCta) && (
29+
<div className="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row">
30+
{hero.primaryCta && (
31+
<Link
32+
href={hero.primaryCta.href}
33+
className="inline-flex w-full items-center justify-center rounded-full bg-cyan-400 px-6 py-3 text-sm font-semibold text-slate-950 transition hover:bg-cyan-300 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cyan-300 sm:w-auto"
34+
>
35+
{hero.primaryCta.label}
36+
</Link>
37+
)}
38+
{hero.secondaryCta && (
39+
<Link
40+
href={hero.secondaryCta.href}
41+
className="inline-flex w-full items-center justify-center rounded-full border border-slate-700 px-6 py-3 text-sm font-semibold text-slate-200 transition hover:border-cyan-400/60 hover:text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cyan-300 sm:w-auto"
42+
>
43+
{hero.secondaryCta.label}
44+
</Link>
45+
)}
46+
</div>
47+
)}
48+
49+
{hero.stats.length > 0 && (
50+
<dl className="mt-12 grid grid-cols-1 gap-4 text-left sm:grid-cols-3">
51+
{hero.stats.map((stat) => (
52+
<div
53+
key={stat.id}
54+
className="rounded-2xl border border-slate-800 bg-slate-900/60 px-5 py-4"
55+
>
56+
<dt className="text-xs font-medium uppercase tracking-wide text-slate-400">
57+
{stat.label}
58+
</dt>
59+
<dd className="mt-1 text-2xl font-semibold text-white">
60+
{stat.value}
61+
</dd>
62+
</div>
63+
))}
64+
</dl>
65+
)}
66+
</header>
67+
);
68+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/**
2+
* IndustryHeroSkeleton — loading placeholder that mirrors the final layout
3+
* (header, stats row, split block) so the page does not jump when data lands.
4+
*/
5+
export function IndustryHeroSkeleton() {
6+
return (
7+
<div
8+
role="status"
9+
aria-live="polite"
10+
aria-busy="true"
11+
className="animate-pulse space-y-16"
12+
>
13+
<span className="sr-only">Loading industry solutions…</span>
14+
15+
<div className="mx-auto flex max-w-3xl flex-col items-center gap-4">
16+
<div className="h-3 w-40 rounded-full bg-slate-800" />
17+
<div className="h-10 w-full rounded-2xl bg-slate-800" />
18+
<div className="h-10 w-3/4 rounded-2xl bg-slate-800" />
19+
<div className="h-4 w-5/6 rounded-full bg-slate-800/70" />
20+
</div>
21+
22+
<div className="mx-auto grid max-w-3xl grid-cols-1 gap-4 sm:grid-cols-3">
23+
{[0, 1, 2].map((i) => (
24+
<div key={i} className="h-24 rounded-2xl bg-slate-800/70" />
25+
))}
26+
</div>
27+
28+
<div className="grid grid-cols-1 items-center gap-10 lg:grid-cols-2 lg:gap-16">
29+
<div className="space-y-4">
30+
<div className="h-3 w-32 rounded-full bg-slate-800" />
31+
<div className="h-8 w-2/3 rounded-2xl bg-slate-800" />
32+
<div className="h-4 w-full rounded-full bg-slate-800/70" />
33+
<div className="h-4 w-5/6 rounded-full bg-slate-800/70" />
34+
<div className="h-4 w-4/6 rounded-full bg-slate-800/70" />
35+
</div>
36+
<div className="aspect-[4/3] w-full rounded-3xl bg-slate-800/70" />
37+
</div>
38+
</div>
39+
);
40+
}

0 commit comments

Comments
 (0)