fix(portal): corporate landing SEO, favicon, and public home UX
Some checks failed
API CI / API Lint (push) Successful in 53s
API CI / API Type Check (push) Failing after 51s
API CI / API Test (push) Successful in 1m32s
API CI / API Build (push) Failing after 1m0s
API CI / Build Docker Image (push) Has been skipped
CD Pipeline / Deploy to Staging (push) Failing after 32s
CI Pipeline / Lint and Type Check (push) Failing after 33s
CI Pipeline / Build (push) Has been skipped
CI Pipeline / Test Backend (push) Failing after 2m1s
CI Pipeline / Test Frontend (push) Failing after 35s
CI Pipeline / Security Scan (push) Failing after 1m12s
Deploy to Staging / Deploy to Staging (push) Failing after 28s
Portal CI / Portal Lint (push) Failing after 19s
Portal CI / Portal Type Check (push) Failing after 18s
Portal CI / Portal Test (push) Failing after 19s
Portal CI / Portal Build (push) Failing after 18s
Test Suite / frontend-tests (push) Failing after 30s
Test Suite / api-tests (push) Failing after 44s
Test Suite / blockchain-tests (push) Failing after 27s
Type Check / type-check (map[directory:. name:root]) (push) Failing after 18s
Type Check / type-check (map[directory:api name:api]) (push) Failing after 19s
Type Check / type-check (map[directory:portal name:portal]) (push) Failing after 18s
CD Pipeline / Deploy to Production (push) Has been skipped
Some checks failed
API CI / API Lint (push) Successful in 53s
API CI / API Type Check (push) Failing after 51s
API CI / API Test (push) Successful in 1m32s
API CI / API Build (push) Failing after 1m0s
API CI / Build Docker Image (push) Has been skipped
CD Pipeline / Deploy to Staging (push) Failing after 32s
CI Pipeline / Lint and Type Check (push) Failing after 33s
CI Pipeline / Build (push) Has been skipped
CI Pipeline / Test Backend (push) Failing after 2m1s
CI Pipeline / Test Frontend (push) Failing after 35s
CI Pipeline / Security Scan (push) Failing after 1m12s
Deploy to Staging / Deploy to Staging (push) Failing after 28s
Portal CI / Portal Lint (push) Failing after 19s
Portal CI / Portal Type Check (push) Failing after 18s
Portal CI / Portal Test (push) Failing after 19s
Portal CI / Portal Build (push) Failing after 18s
Test Suite / frontend-tests (push) Failing after 30s
Test Suite / api-tests (push) Failing after 44s
Test Suite / blockchain-tests (push) Failing after 27s
Type Check / type-check (map[directory:. name:root]) (push) Failing after 18s
Type Check / type-check (map[directory:api name:api]) (push) Failing after 19s
Type Check / type-check (map[directory:portal name:portal]) (push) Failing after 18s
CD Pipeline / Deploy to Production (push) Has been skipped
Add sankofa.nexus marketing site with institutional grade scorecards, server-side metadata/title, dynamic icons, favicon rewrite, and instant landing render without session-loading flash; split authenticated AppShell from unauthenticated corporate chrome. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
61
portal/src/components/auth/PortalSignInCard.tsx
Normal file
61
portal/src/components/auth/PortalSignInCard.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
'use client';
|
||||
|
||||
import { signIn } from 'next-auth/react';
|
||||
import { useCallback, useState, type ReactNode } from 'react';
|
||||
|
||||
import { CloudflareTurnstile } from '@/components/security/CloudflareTurnstile';
|
||||
|
||||
const TURNSTILE_SITE_KEY = process.env.NEXT_PUBLIC_CLOUDFLARE_TURNSTILE_SITE_KEY;
|
||||
|
||||
export interface PortalSignInCardProps {
|
||||
badge?: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
callbackUrl?: string;
|
||||
/** Extra hint under the sign-in button (e.g. dev IdP note) */
|
||||
footer?: ReactNode;
|
||||
}
|
||||
|
||||
export function PortalSignInCard({
|
||||
badge = 'Sankofa Phoenix',
|
||||
title,
|
||||
subtitle,
|
||||
callbackUrl = '/',
|
||||
footer,
|
||||
}: PortalSignInCardProps) {
|
||||
const [turnstileToken, setTurnstileToken] = useState<string | null>(() =>
|
||||
TURNSTILE_SITE_KEY ? null : ''
|
||||
);
|
||||
|
||||
const onTurnstileToken = useCallback((token: string | null) => {
|
||||
setTurnstileToken(token);
|
||||
}, []);
|
||||
|
||||
const canSignIn = !TURNSTILE_SITE_KEY || Boolean(turnstileToken);
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-md rounded-2xl border border-gray-800 bg-gray-900/80 p-8 shadow-xl shadow-black/40 backdrop-blur-sm">
|
||||
<p className="mb-1 text-center text-sm font-medium uppercase tracking-wide text-orange-400">{badge}</p>
|
||||
<h1 className="mb-2 text-center text-2xl font-bold text-white">{title}</h1>
|
||||
<p className="mb-8 text-center text-gray-400">{subtitle}</p>
|
||||
|
||||
{TURNSTILE_SITE_KEY ? (
|
||||
<div className="mb-6">
|
||||
<p className="mb-2 text-center text-sm text-gray-500">Verification</p>
|
||||
<CloudflareTurnstile siteKey={TURNSTILE_SITE_KEY} onToken={onTurnstileToken} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canSignIn}
|
||||
onClick={() => signIn(undefined, { callbackUrl })}
|
||||
className="w-full rounded-lg bg-gradient-to-r from-orange-500 to-amber-500 px-6 py-3 font-semibold text-gray-950 shadow-lg transition hover:from-orange-400 hover:to-amber-400 focus:outline-none focus:ring-2 focus:ring-orange-400 focus:ring-offset-2 focus:ring-offset-gray-900 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
94
portal/src/components/corporate/CorporateFooter.tsx
Normal file
94
portal/src/components/corporate/CorporateFooter.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ECOSYSTEM_SIGN_IN_PATH } from '@/lib/corporate-site-data';
|
||||
|
||||
function isExternalHref(href: string) {
|
||||
return href.startsWith('http://') || href.startsWith('https://');
|
||||
}
|
||||
|
||||
const footerColumns = [
|
||||
{
|
||||
title: 'Products',
|
||||
links: [
|
||||
{ label: 'Phoenix Cloud', href: 'https://phoenix.sankofa.nexus' },
|
||||
{ label: 'Complete Credential', href: 'https://cc.sankofa.nexus' },
|
||||
{ label: 'Sankofa Studio', href: 'https://studio.sankofa.nexus/studio/' },
|
||||
{ label: 'Client Portal', href: 'https://portal.sankofa.nexus' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Platform',
|
||||
links: [
|
||||
{ label: 'Identity (Keycloak)', href: 'https://keycloak.sankofa.nexus' },
|
||||
{ label: 'Blockchain Explorer', href: 'https://explorer.d-bis.org' },
|
||||
{ label: 'Documentation', href: 'https://docs.d-bis.org' },
|
||||
{ label: 'Marketplace', href: 'https://portal.sankofa.nexus' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Company',
|
||||
links: [
|
||||
{ label: 'About Sankofa', href: '#resources' },
|
||||
{ label: 'Institutional registry', href: 'https://d-bis.org/cb/dbis' },
|
||||
{ label: 'Partner program', href: '/partner' },
|
||||
{ label: 'Contact', href: 'https://portal.sankofa.nexus' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Access',
|
||||
links: [
|
||||
{ label: 'Sign in to ecosystem', href: ECOSYSTEM_SIGN_IN_PATH },
|
||||
{ label: 'Phoenix console', href: 'https://phoenix.sankofa.nexus' },
|
||||
{ label: 'Operator dashboard', href: 'https://dash.sankofa.nexus' },
|
||||
{ label: 'Admin console', href: 'https://admin.sankofa.nexus' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function CorporateFooter() {
|
||||
return (
|
||||
<footer className="border-t border-gray-800 bg-gray-950">
|
||||
<div className="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
|
||||
<div className="grid grid-cols-2 gap-8 md:grid-cols-4 lg:gap-12">
|
||||
{footerColumns.map((column) => (
|
||||
<div key={column.title}>
|
||||
<h3 className="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-400">
|
||||
{column.title}
|
||||
</h3>
|
||||
<ul className="space-y-2.5">
|
||||
{column.links.map((link) => (
|
||||
<li key={link.label}>
|
||||
<Link
|
||||
href={link.href}
|
||||
className="text-sm text-gray-400 no-underline transition-colors hover:text-orange-300"
|
||||
{...(isExternalHref(link.href)
|
||||
? { target: '_blank', rel: 'noopener noreferrer' }
|
||||
: {})}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-12 flex flex-col items-start justify-between gap-4 border-t border-gray-800 pt-8 sm:flex-row sm:items-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-md bg-gradient-to-br from-orange-500 to-amber-400 text-xs font-bold text-gray-950">
|
||||
S
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">Sankofa — Sovereign Technologies</p>
|
||||
<p className="text-xs text-gray-500">Remember · Retrieve · Restore · Rise</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
© {new Date().getFullYear()} Sankofa Ltd. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
122
portal/src/components/corporate/CorporateHeader.tsx
Normal file
122
portal/src/components/corporate/CorporateHeader.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
'use client';
|
||||
|
||||
import { ChevronDown, LogIn, Menu, X } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { corporateNav, ECOSYSTEM_SIGN_IN_PATH } from '@/lib/corporate-site-data';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function CorporateHeader() {
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b border-gray-800/80 bg-gray-950/95 backdrop-blur-md">
|
||||
<div className="mx-auto flex h-16 max-w-7xl items-center gap-4 px-4 sm:px-6 lg:px-8">
|
||||
{/* Top-left: brand + ecosystem sign-in (AWS / Microsoft pattern) */}
|
||||
<div className="flex shrink-0 items-center gap-3 sm:gap-4">
|
||||
<Link href="/" className="group flex items-center gap-2 no-underline">
|
||||
<span
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-orange-500 to-amber-400 text-sm font-bold text-gray-950 shadow-lg shadow-orange-500/20"
|
||||
aria-hidden
|
||||
>
|
||||
S
|
||||
</span>
|
||||
<span className="hidden bg-gradient-to-r from-orange-300 to-amber-200 bg-clip-text text-lg font-semibold tracking-tight text-transparent sm:inline">
|
||||
Sankofa
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<span className="hidden h-5 w-px bg-gray-700 sm:block" aria-hidden />
|
||||
|
||||
<Link
|
||||
href={ECOSYSTEM_SIGN_IN_PATH}
|
||||
className="inline-flex items-center gap-1.5 rounded-md px-2 py-1.5 text-sm font-medium text-orange-300 no-underline transition-colors hover:bg-orange-500/10 hover:text-orange-200"
|
||||
>
|
||||
<LogIn className="h-4 w-4" aria-hidden />
|
||||
<span>Sign in</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Desktop nav */}
|
||||
<nav className="hidden flex-1 items-center justify-center gap-1 lg:flex" aria-label="Primary">
|
||||
{corporateNav.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="rounded-md px-3 py-2 text-sm font-medium text-gray-300 no-underline transition-colors hover:bg-gray-800/60 hover:text-white"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Desktop CTAs */}
|
||||
<div className="ml-auto hidden items-center gap-2 lg:flex">
|
||||
<Link
|
||||
href="https://portal.sankofa.nexus"
|
||||
className="rounded-md px-3 py-2 text-sm font-medium text-gray-300 no-underline transition-colors hover:text-white"
|
||||
>
|
||||
Contact sales
|
||||
</Link>
|
||||
<Link
|
||||
href="https://phoenix.sankofa.nexus"
|
||||
className="rounded-lg bg-gradient-to-r from-orange-500 to-amber-500 px-4 py-2 text-sm font-semibold text-gray-950 no-underline shadow-md shadow-orange-500/25 transition hover:from-orange-400 hover:to-amber-400"
|
||||
>
|
||||
Get started
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu toggle */}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-auto inline-flex items-center justify-center rounded-md p-2 text-gray-400 hover:bg-gray-800 hover:text-white lg:hidden"
|
||||
aria-expanded={mobileOpen}
|
||||
aria-label={mobileOpen ? 'Close menu' : 'Open menu'}
|
||||
onClick={() => setMobileOpen((open) => !open)}
|
||||
>
|
||||
{mobileOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile drawer */}
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden border-t border-gray-800/80 bg-gray-950 lg:hidden',
|
||||
mobileOpen ? 'max-h-[28rem] opacity-100' : 'max-h-0 opacity-0'
|
||||
)}
|
||||
>
|
||||
<nav className="flex flex-col gap-1 px-4 py-3" aria-label="Mobile primary">
|
||||
{corporateNav.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="flex items-center justify-between rounded-md px-3 py-2.5 text-sm font-medium text-gray-200 no-underline hover:bg-gray-800"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
>
|
||||
{item.label}
|
||||
<ChevronDown className="-rotate-90 h-4 w-4 text-gray-500" aria-hidden />
|
||||
</Link>
|
||||
))}
|
||||
<div className="mt-2 flex flex-col gap-2 border-t border-gray-800 pt-3">
|
||||
<Link
|
||||
href={ECOSYSTEM_SIGN_IN_PATH}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg border border-gray-700 px-4 py-2.5 text-sm font-medium text-white no-underline hover:bg-gray-800"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
>
|
||||
<LogIn className="h-4 w-4" />
|
||||
Sign in to ecosystem
|
||||
</Link>
|
||||
<Link
|
||||
href="https://phoenix.sankofa.nexus"
|
||||
className="inline-flex items-center justify-center rounded-lg bg-gradient-to-r from-orange-500 to-amber-500 px-4 py-2.5 text-sm font-semibold text-gray-950 no-underline"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
>
|
||||
Get started with Phoenix
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
270
portal/src/components/corporate/CorporateLandingPage.tsx
Normal file
270
portal/src/components/corporate/CorporateLandingPage.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
'use client';
|
||||
|
||||
import { ArrowRight, ExternalLink } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { CorporateFooter } from '@/components/corporate/CorporateFooter';
|
||||
import { CorporateHeader } from '@/components/corporate/CorporateHeader';
|
||||
import { InstitutionalGradeSection } from '@/components/corporate/InstitutionalGradeSection';
|
||||
import {
|
||||
ECOSYSTEM_SIGN_IN_PATH,
|
||||
philosophySteps,
|
||||
platformServices,
|
||||
productDivisions,
|
||||
solutionVerticals,
|
||||
} from '@/lib/corporate-site-data';
|
||||
|
||||
export function CorporateLandingPage() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-gray-950 text-gray-100">
|
||||
<CorporateHeader />
|
||||
|
||||
<main className="flex-1">
|
||||
{/* Hero */}
|
||||
<section className="relative overflow-hidden border-b border-gray-800/60">
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_80%_60%_at_50%_-20%,rgba(251,146,60,0.18),transparent)]"
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="relative mx-auto max-w-7xl px-4 py-20 sm:px-6 sm:py-28 lg:px-8">
|
||||
<p className="mb-4 text-sm font-semibold uppercase tracking-widest text-orange-400">
|
||||
Sovereign Technologies
|
||||
</p>
|
||||
<h1 className="max-w-4xl text-4xl font-bold tracking-tight text-white sm:text-5xl lg:text-6xl">
|
||||
Build on infrastructure you{' '}
|
||||
<span className="bg-gradient-to-r from-orange-300 to-amber-200 bg-clip-text text-transparent">
|
||||
own and control
|
||||
</span>
|
||||
</h1>
|
||||
<p className="mt-6 max-w-2xl text-lg leading-relaxed text-gray-400">
|
||||
Sankofa delivers sovereign cloud, identity, financial rails, and credential infrastructure —
|
||||
the complete ecosystem for institutions that cannot depend on hyperscaler public cloud.
|
||||
</p>
|
||||
<div className="mt-10 flex flex-wrap items-center gap-4">
|
||||
<Link
|
||||
href="https://phoenix.sankofa.nexus"
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-gradient-to-r from-orange-500 to-amber-500 px-6 py-3 text-sm font-semibold text-gray-950 no-underline shadow-lg shadow-orange-500/25 transition hover:from-orange-400 hover:to-amber-400"
|
||||
>
|
||||
Explore Phoenix Cloud
|
||||
<ArrowRight className="h-4 w-4" aria-hidden />
|
||||
</Link>
|
||||
<Link
|
||||
href={ECOSYSTEM_SIGN_IN_PATH}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-gray-700 bg-gray-900/50 px-6 py-3 text-sm font-semibold text-white no-underline transition hover:border-gray-600 hover:bg-gray-800"
|
||||
>
|
||||
Sign in to ecosystem
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Philosophy */}
|
||||
<section className="border-b border-gray-800/60 bg-gray-900/30 py-14">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{philosophySteps.map((step, index) => (
|
||||
<div key={step.verb} className="relative rounded-xl border border-gray-800/80 bg-gray-900/40 p-5">
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-orange-500/80">
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</span>
|
||||
<h2 className="mt-2 text-xl font-semibold text-white">{step.verb}</h2>
|
||||
<p className="mt-2 text-sm leading-relaxed text-gray-400">{step.detail}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Product divisions */}
|
||||
<section id="products" className="scroll-mt-20 py-20">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-2xl">
|
||||
<h2 className="text-3xl font-bold tracking-tight text-white sm:text-4xl">Products</h2>
|
||||
<p className="mt-4 text-lg text-gray-400">
|
||||
From sovereign cloud to verifiable credentials — integrated products under one ecosystem,
|
||||
modeled for institutional scale.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 grid gap-6 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{productDivisions.map((product) => {
|
||||
const Icon = product.icon;
|
||||
return (
|
||||
<article
|
||||
key={product.id}
|
||||
className="group flex flex-col rounded-2xl border border-gray-800 bg-gray-900/40 p-6 transition hover:border-orange-500/40 hover:bg-gray-900/70"
|
||||
>
|
||||
<div className="mb-4 flex items-start justify-between gap-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-orange-500/10 text-orange-400 ring-1 ring-orange-500/20">
|
||||
<Icon className="h-5 w-5" aria-hidden />
|
||||
</div>
|
||||
{product.external ? (
|
||||
<ExternalLink className="h-4 w-4 shrink-0 text-gray-600 group-hover:text-orange-400" aria-hidden />
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-orange-400/90">
|
||||
{product.tagline}
|
||||
</p>
|
||||
<h3 className="mt-1 text-xl font-semibold text-white">{product.name}</h3>
|
||||
<p className="mt-3 flex-1 text-sm leading-relaxed text-gray-400">{product.description}</p>
|
||||
<ul className="mt-4 space-y-1.5">
|
||||
{product.highlights.map((item) => (
|
||||
<li key={item} className="flex items-center gap-2 text-sm text-gray-300">
|
||||
<span className="h-1 w-1 rounded-full bg-orange-500" aria-hidden />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Link
|
||||
href={product.href}
|
||||
className="mt-6 inline-flex items-center gap-1.5 text-sm font-medium text-orange-300 no-underline hover:text-orange-200"
|
||||
{...(product.external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
|
||||
>
|
||||
Learn more
|
||||
<ArrowRight className="h-4 w-4 transition group-hover:translate-x-0.5" aria-hidden />
|
||||
</Link>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Platform services */}
|
||||
<section id="services" className="scroll-mt-20 border-y border-gray-800/60 bg-gray-900/20 py-20">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-2xl">
|
||||
<h2 className="text-3xl font-bold tracking-tight text-white sm:text-4xl">Platform services</h2>
|
||||
<p className="mt-4 text-lg text-gray-400">
|
||||
Owned core primitives — ledger, identity, wallet, orchestration — available through the
|
||||
Phoenix marketplace without third-party platform lock-in.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{platformServices.map((service) => {
|
||||
const Icon = service.icon;
|
||||
return (
|
||||
<div
|
||||
key={service.name}
|
||||
className="rounded-xl border border-gray-800 bg-gray-950/60 p-5 transition hover:border-gray-700"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-gray-800 text-orange-400">
|
||||
<Icon className="h-4 w-4" aria-hidden />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-gray-500">{service.category}</p>
|
||||
<h3 className="text-base font-semibold text-white">{service.name}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-relaxed text-gray-400">{service.description}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-10">
|
||||
<Link
|
||||
href="https://portal.sankofa.nexus"
|
||||
className="inline-flex items-center gap-2 text-sm font-medium text-orange-300 no-underline hover:text-orange-200"
|
||||
>
|
||||
Browse full marketplace catalog
|
||||
<ArrowRight className="h-4 w-4" aria-hidden />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Solutions */}
|
||||
<section id="solutions" className="scroll-mt-20 py-20">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-2xl">
|
||||
<h2 className="text-3xl font-bold tracking-tight text-white sm:text-4xl">Industry solutions</h2>
|
||||
<p className="mt-4 text-lg text-gray-400">
|
||||
Purpose-built stacks for regulated industries — public sector, finance, healthcare, and telecom.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 grid gap-6 sm:grid-cols-2">
|
||||
{solutionVerticals.map((vertical) => {
|
||||
const Icon = vertical.icon;
|
||||
return (
|
||||
<div
|
||||
key={vertical.name}
|
||||
className="flex gap-4 rounded-2xl border border-gray-800 bg-gray-900/30 p-6"
|
||||
>
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-orange-500/20 to-amber-500/10 text-orange-400">
|
||||
<Icon className="h-6 w-6" aria-hidden />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">{vertical.name}</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-gray-400">{vertical.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<InstitutionalGradeSection />
|
||||
|
||||
{/* Resources / trust */}
|
||||
<section id="resources" className="scroll-mt-20 border-t border-gray-800/60 bg-gray-900/30 py-20">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid gap-10 lg:grid-cols-2 lg:items-center">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight text-white">Built for institutional trust</h2>
|
||||
<p className="mt-4 text-lg leading-relaxed text-gray-400">
|
||||
Sankofa Phoenix operates under sovereign governance with SOC 2, GDPR, and sector-specific
|
||||
compliance pathways. Identity, ledger, and credential services are designed for operators
|
||||
who need auditability without outsourcing control.
|
||||
</p>
|
||||
<ul className="mt-6 space-y-3 text-sm text-gray-300">
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="text-orange-500">✓</span> Sovereign Keycloak identity — no Azure AD dependency
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="text-orange-500">✓</span> Multi-tenant Proxmox orchestration with GitOps
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="text-orange-500">✓</span> Chain 138 DeFi Oracle Meta Mainnet & cross-chain mesh
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="text-orange-500">✓</span> Complete Credential eIDAS-aligned issuance
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-gray-800 bg-gradient-to-br from-gray-900 to-gray-950 p-8 shadow-xl">
|
||||
<p className="text-sm font-semibold uppercase tracking-wider text-orange-400">Get started</p>
|
||||
<h3 className="mt-2 text-2xl font-bold text-white">Join the Sankofa ecosystem</h3>
|
||||
<p className="mt-3 text-sm leading-relaxed text-gray-400">
|
||||
Sign in to access Phoenix Cloud, marketplace subscriptions, partner tools, and client
|
||||
workspaces — one identity across the platform.
|
||||
</p>
|
||||
<div className="mt-6 flex flex-col gap-3 sm:flex-row">
|
||||
<Link
|
||||
href={ECOSYSTEM_SIGN_IN_PATH}
|
||||
className="inline-flex flex-1 items-center justify-center rounded-lg bg-gradient-to-r from-orange-500 to-amber-500 px-5 py-3 text-sm font-semibold text-gray-950 no-underline hover:from-orange-400 hover:to-amber-400"
|
||||
>
|
||||
Sign in to ecosystem
|
||||
</Link>
|
||||
<Link
|
||||
href="https://phoenix.sankofa.nexus"
|
||||
className="inline-flex flex-1 items-center justify-center rounded-lg border border-gray-700 px-5 py-3 text-sm font-semibold text-white no-underline hover:bg-gray-800"
|
||||
>
|
||||
Phoenix Cloud
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<CorporateFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
186
portal/src/components/corporate/InstitutionalGradeSection.tsx
Normal file
186
portal/src/components/corporate/InstitutionalGradeSection.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { ArrowRight, ShieldCheck } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
import {
|
||||
gradeBadgeClass,
|
||||
institutionalGradeRubric,
|
||||
scoreBarClass,
|
||||
sovereignInstitutionGrades,
|
||||
} from '@/lib/corporate-site-data';
|
||||
|
||||
const audienceLabels = {
|
||||
sovereign: 'Sovereign & public sector',
|
||||
regulated: 'Regulated counterparties',
|
||||
platform: 'Platform & L1',
|
||||
} as const;
|
||||
|
||||
export function InstitutionalGradeSection() {
|
||||
const sovereignRows = sovereignInstitutionGrades.filter((e) => e.audience === 'sovereign');
|
||||
const otherRows = sovereignInstitutionGrades.filter((e) => e.audience !== 'sovereign');
|
||||
|
||||
return (
|
||||
<section id="institutional-grade" className="scroll-mt-20 border-y border-gray-800/60 bg-gray-950 py-20">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="max-w-2xl">
|
||||
<div className="mb-3 inline-flex items-center gap-2 rounded-full border border-orange-500/30 bg-orange-500/10 px-3 py-1 text-xs font-semibold uppercase tracking-wider text-orange-300">
|
||||
<ShieldCheck className="h-3.5 w-3.5" aria-hidden />
|
||||
Institutional readiness
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold tracking-tight text-white sm:text-4xl">
|
||||
Grade & score for sovereign institutions
|
||||
</h2>
|
||||
<p className="mt-4 text-lg text-gray-400">
|
||||
Transparent engineering scorecards aligned with the DBIS institutional rubric — the same
|
||||
framework used for ecosystem readiness, jurisdiction matrices, and settlement evidence.
|
||||
</p>
|
||||
</div>
|
||||
<p className="max-w-md text-xs leading-relaxed text-gray-500">
|
||||
Informational only — not legal advice, a regulatory determination, or an audit certificate.
|
||||
Counsel owns statutory interpretation. Scores reflect repo artifacts and live probes as of each
|
||||
assessment date.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Rubric legend */}
|
||||
<div className="mt-10 overflow-x-auto rounded-xl border border-gray-800 bg-gray-900/40">
|
||||
<table className="w-full min-w-[640px] text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-800 text-xs uppercase tracking-wider text-gray-500">
|
||||
<th className="px-4 py-3 font-semibold">Grade</th>
|
||||
<th className="px-4 py-3 font-semibold">Score</th>
|
||||
<th className="px-4 py-3 font-semibold">Institutional meaning</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{institutionalGradeRubric.map((row) => (
|
||||
<tr key={row.grade} className="border-b border-gray-800/60 last:border-0">
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-flex min-w-[2rem] items-center justify-center rounded-md px-2 py-0.5 text-sm font-bold ring-1 ${gradeBadgeClass(row.grade)}`}
|
||||
>
|
||||
{row.grade}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-gray-300">{row.range}</td>
|
||||
<td className="px-4 py-3 text-gray-400">{row.meaning}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Sovereign institution scorecards */}
|
||||
<div className="mt-12">
|
||||
<h3 className="text-lg font-semibold text-white">{audienceLabels.sovereign}</h3>
|
||||
<div className="mt-6 grid gap-4 lg:grid-cols-2">
|
||||
{sovereignRows.map((entry) => (
|
||||
<GradeCard key={entry.id} entry={entry} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-12">
|
||||
<h3 className="text-lg font-semibold text-white">Related institutional assessments</h3>
|
||||
<div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{otherRows.map((entry) => (
|
||||
<GradeCard key={entry.id} entry={entry} compact />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 flex flex-wrap items-center gap-4 text-sm">
|
||||
<Link
|
||||
href="https://d-bis.org/cb/dbis"
|
||||
className="inline-flex items-center gap-1.5 font-medium text-orange-300 no-underline hover:text-orange-200"
|
||||
>
|
||||
DBIS institutional registry
|
||||
<ArrowRight className="h-4 w-4" aria-hidden />
|
||||
</Link>
|
||||
<span className="text-gray-600" aria-hidden>
|
||||
·
|
||||
</span>
|
||||
<Link
|
||||
href="https://docs.d-bis.org"
|
||||
className="inline-flex items-center gap-1.5 font-medium text-orange-300 no-underline hover:text-orange-200"
|
||||
>
|
||||
Compliance matrices & onboarding charter
|
||||
<ArrowRight className="h-4 w-4" aria-hidden />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function GradeCard({
|
||||
entry,
|
||||
compact = false,
|
||||
}: {
|
||||
entry: (typeof sovereignInstitutionGrades)[number];
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const pct = Math.round((entry.score / entry.maxScore) * 100);
|
||||
const inner = (
|
||||
<>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white">{entry.name}</p>
|
||||
{!compact ? (
|
||||
<p className="mt-1 text-xs text-gray-500">Assessed {entry.assessmentDate}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<span
|
||||
className={`shrink-0 rounded-lg px-2.5 py-1 text-lg font-bold ring-1 ${gradeBadgeClass(entry.letterGrade)}`}
|
||||
>
|
||||
{entry.letterGrade}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold tabular-nums text-white">{entry.score}</span>
|
||||
<span className="text-sm text-gray-500">/ {entry.maxScore}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 h-1.5 overflow-hidden rounded-full bg-gray-800">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${scoreBarClass(entry.letterGrade)}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
role="progressbar"
|
||||
aria-valuenow={entry.score}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={entry.maxScore}
|
||||
aria-label={`${entry.name} score ${entry.score} of ${entry.maxScore}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className={`text-gray-400 ${compact ? 'mt-3 text-xs leading-relaxed' : 'mt-4 text-sm leading-relaxed'}`}>
|
||||
{entry.tier}
|
||||
</p>
|
||||
|
||||
{compact ? (
|
||||
<p className="mt-2 text-xs text-gray-600">{entry.assessmentDate}</p>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
const className = compact
|
||||
? 'rounded-xl border border-gray-800 bg-gray-900/30 p-4 transition hover:border-gray-700'
|
||||
: 'rounded-2xl border border-gray-800 bg-gray-900/40 p-6 transition hover:border-orange-500/30';
|
||||
|
||||
if (entry.href) {
|
||||
return (
|
||||
<Link href={entry.href} className={`group block no-underline ${className}`}>
|
||||
{inner}
|
||||
{!compact ? (
|
||||
<span className="mt-4 inline-flex items-center gap-1 text-xs font-medium text-orange-400 group-hover:text-orange-300">
|
||||
View assessment
|
||||
<ArrowRight className="h-3 w-3" aria-hidden />
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return <article className={className}>{inner}</article>;
|
||||
}
|
||||
35
portal/src/components/layout/AppShell.tsx
Normal file
35
portal/src/components/layout/AppShell.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
'use client';
|
||||
|
||||
import { useSession } from 'next-auth/react';
|
||||
|
||||
import { KeyboardShortcutsProvider } from '@/components/KeyboardShortcutsProvider';
|
||||
import { MobileNavigation } from '@/components/layout/MobileNavigation';
|
||||
import { PortalBreadcrumbs } from '@/components/layout/PortalBreadcrumbs';
|
||||
import { PortalHeader } from '@/components/layout/PortalHeader';
|
||||
import { PortalSidebar } from '@/components/layout/PortalSidebar';
|
||||
|
||||
/**
|
||||
* Full console chrome only after sign-in. Unauthenticated routes render children alone
|
||||
* so the landing / sign-in view is not squeezed beside sidebar + mega-header.
|
||||
*/
|
||||
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
const { status } = useSession();
|
||||
|
||||
if (status !== 'authenticated') {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardShortcutsProvider>
|
||||
<div className="flex min-h-screen flex-col bg-gray-950 text-gray-100 antialiased">
|
||||
<PortalHeader />
|
||||
<PortalBreadcrumbs />
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<PortalSidebar />
|
||||
<main className="min-w-0 flex-1 md:ml-64">{children}</main>
|
||||
</div>
|
||||
<MobileNavigation />
|
||||
</div>
|
||||
</KeyboardShortcutsProvider>
|
||||
);
|
||||
}
|
||||
@@ -10,26 +10,32 @@ export function PortalHeader() {
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 w-full border-b border-gray-800 bg-gray-900/95 backdrop-blur supports-[backdrop-filter]:bg-gray-900/60">
|
||||
<div className="container flex h-16 items-center">
|
||||
<Link href="/" className="mr-6 flex items-center space-x-2">
|
||||
<span className="text-xl font-bold bg-gradient-to-r from-orange-500 to-yellow-500 bg-clip-text text-transparent">
|
||||
<div className="mx-auto flex h-auto min-h-16 w-full max-w-[1920px] flex-wrap items-center gap-3 px-4 py-2 sm:px-6 lg:h-16 lg:flex-nowrap lg:py-0">
|
||||
<Link
|
||||
href="/"
|
||||
className="order-1 flex shrink-0 items-center no-underline hover:opacity-90"
|
||||
>
|
||||
<span className="bg-gradient-to-r from-orange-400 to-amber-400 bg-clip-text text-xl font-bold text-transparent">
|
||||
Nexus Console
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Search Bar - Enterprise-class search-first UX */}
|
||||
<div className="flex flex-1 items-center justify-center max-w-2xl mx-8">
|
||||
|
||||
<div className="order-3 flex min-w-0 flex-1 basis-full items-center lg:order-2 lg:mx-6 lg:max-w-2xl lg:basis-auto">
|
||||
<div className="relative w-full">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<Search
|
||||
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400"
|
||||
aria-hidden
|
||||
/>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search resources, settings, docs..."
|
||||
className="w-full rounded-md border border-gray-700 bg-gray-800 py-2 pl-10 pr-4 text-sm text-white placeholder-gray-400 focus:border-orange-500 focus:outline-none focus:ring-1 focus:ring-orange-500"
|
||||
placeholder="Search resources, settings, docs…"
|
||||
aria-label="Search resources and settings"
|
||||
className="w-full rounded-md border border-gray-700 bg-gray-800 py-2 pl-10 pr-4 text-sm text-white placeholder-gray-400 focus:border-orange-500 focus:outline-none focus:ring-2 focus:ring-orange-500/40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex items-center space-x-4">
|
||||
<nav className="order-2 flex shrink-0 items-center gap-1 sm:gap-2 lg:order-3 lg:ml-auto">
|
||||
<button className="relative p-2 text-gray-400 hover:text-white transition-colors">
|
||||
<Bell className="h-5 w-5" />
|
||||
<span className="absolute top-1 right-1 h-2 w-2 rounded-full bg-orange-500" />
|
||||
@@ -37,7 +43,7 @@ export function PortalHeader() {
|
||||
|
||||
<Link
|
||||
href="/settings"
|
||||
className="p-2 text-gray-400 hover:text-white transition-colors"
|
||||
className="p-2 text-gray-400 no-underline transition-colors hover:text-white"
|
||||
>
|
||||
<Settings className="h-5 w-5" />
|
||||
</Link>
|
||||
|
||||
@@ -1,54 +1,25 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Server,
|
||||
Network,
|
||||
Settings,
|
||||
FileText,
|
||||
Activity,
|
||||
Users,
|
||||
CreditCard,
|
||||
Shield,
|
||||
HelpCircle
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
|
||||
import { primaryNavigation, supportNavigation } from '@/lib/portal-navigation'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', href: '/', icon: LayoutDashboard },
|
||||
{ name: 'Infrastructure', href: '/infrastructure', icon: Server },
|
||||
{ name: 'Resources', href: '/resources', icon: Server },
|
||||
{ name: 'Virtual Machines', href: '/vms', icon: Server },
|
||||
{ name: 'Networking', href: '/network', icon: Network },
|
||||
{ name: 'Monitoring', href: '/dashboards', icon: Activity },
|
||||
{ name: 'Users & Access', href: '/users', icon: Users },
|
||||
{ name: 'Billing', href: '/billing', icon: CreditCard },
|
||||
{ name: 'Security', href: '/security', icon: Shield },
|
||||
{ name: 'Settings', href: '/settings', icon: Settings },
|
||||
]
|
||||
|
||||
const helpLinks = [
|
||||
{ name: 'Documentation', href: '/help/docs', icon: FileText },
|
||||
{ name: 'Support', href: '/help/support', icon: HelpCircle },
|
||||
]
|
||||
|
||||
export function PortalSidebar() {
|
||||
const pathname = usePathname()
|
||||
|
||||
return (
|
||||
<aside className="fixed left-0 top-16 h-[calc(100vh-4rem)] w-64 border-r border-gray-800 bg-gray-900 overflow-y-auto">
|
||||
<nav className="p-4 space-y-1">
|
||||
{navigation.map((item) => {
|
||||
{primaryNavigation.map((item) => {
|
||||
const isActive = pathname === item.href || pathname?.startsWith(item.href + '/')
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'flex items-center space-x-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
||||
'flex items-center space-x-3 rounded-lg px-3 py-2 text-sm font-medium no-underline transition-colors',
|
||||
isActive
|
||||
? 'bg-orange-500/10 text-orange-500 border border-orange-500/20'
|
||||
: 'text-gray-400 hover:bg-gray-800 hover:text-white'
|
||||
@@ -66,11 +37,11 @@ export function PortalSidebar() {
|
||||
Help & Support
|
||||
</div>
|
||||
<nav className="space-y-1">
|
||||
{helpLinks.map((item) => (
|
||||
{supportNavigation.map((item) => (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className="flex items-center space-x-3 rounded-lg px-3 py-2 text-sm font-medium text-gray-400 hover:bg-gray-800 hover:text-white transition-colors"
|
||||
className="flex items-center space-x-3 rounded-lg px-3 py-2 text-sm font-medium text-gray-400 no-underline transition-colors hover:bg-gray-800 hover:text-white"
|
||||
>
|
||||
<item.icon className="h-5 w-5" />
|
||||
<span>{item.name}</span>
|
||||
@@ -81,4 +52,3 @@ export function PortalSidebar() {
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -15,20 +15,31 @@ interface OnboardingStep {
|
||||
|
||||
interface OnboardingWizardProps {
|
||||
steps: OnboardingStep[];
|
||||
onComplete: () => void;
|
||||
onComplete: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function OnboardingWizard({ steps, onComplete }: OnboardingWizardProps) {
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [completedSteps, setCompletedSteps] = useState<Set<string>>(new Set());
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const handleNext = () => {
|
||||
const handleNext = async () => {
|
||||
setError(null);
|
||||
|
||||
if (currentStep < steps.length - 1) {
|
||||
setCurrentStep(currentStep + 1);
|
||||
} else {
|
||||
onComplete();
|
||||
router.push('/dashboard');
|
||||
try {
|
||||
setSubmitting(true);
|
||||
await onComplete();
|
||||
router.push('/dashboard');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to complete onboarding.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -110,6 +121,11 @@ export function OnboardingWizard({ steps, onComplete }: OnboardingWizardProps) {
|
||||
<p className="text-gray-400 mb-6">
|
||||
{steps[currentStep].description}
|
||||
</p>
|
||||
{error ? (
|
||||
<div className="mb-4 rounded-lg border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
<CurrentStepComponent
|
||||
onComplete={() => handleStepComplete(steps[currentStep].id)}
|
||||
/>
|
||||
@@ -126,9 +142,10 @@ export function OnboardingWizard({ steps, onComplete }: OnboardingWizardProps) {
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNext}
|
||||
disabled={submitting}
|
||||
className="px-6 py-2 bg-orange-500 text-white rounded-lg hover:bg-orange-600 transition-colors flex items-center gap-2"
|
||||
>
|
||||
{currentStep === steps.length - 1 ? 'Complete' : 'Next'}
|
||||
{submitting ? 'Saving...' : currentStep === steps.length - 1 ? 'Complete' : 'Next'}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -137,4 +154,3 @@ export function OnboardingWizard({ steps, onComplete }: OnboardingWizardProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
81
portal/src/components/preview/FeaturePreviewPage.tsx
Normal file
81
portal/src/components/preview/FeaturePreviewPage.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/Card';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface PreviewAction {
|
||||
href: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface FeaturePreviewPageProps {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
status?: 'preview' | 'request-only' | 'active';
|
||||
bullets: readonly string[];
|
||||
primaryAction?: PreviewAction;
|
||||
secondaryAction?: PreviewAction;
|
||||
}
|
||||
|
||||
export function FeaturePreviewPage({
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
status = 'preview',
|
||||
bullets,
|
||||
primaryAction,
|
||||
secondaryAction,
|
||||
}: FeaturePreviewPageProps) {
|
||||
const primaryActionClassName =
|
||||
'inline-flex h-10 items-center justify-center rounded-md bg-gray-700 px-4 text-base font-medium text-white transition-colors hover:bg-gray-600';
|
||||
const secondaryActionClassName =
|
||||
'inline-flex h-10 items-center justify-center rounded-md border border-gray-600 bg-transparent px-4 text-base font-medium text-white transition-colors hover:bg-gray-800';
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-4 py-10">
|
||||
<div className="mb-8">
|
||||
<p className="mb-2 text-sm font-medium uppercase tracking-wide text-orange-400">{eyebrow}</p>
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold text-white">{title}</h1>
|
||||
<Badge variant={status === 'active' ? 'default' : 'secondary'}>
|
||||
{status === 'request-only' ? 'Request Only' : status === 'active' ? 'Active' : 'Preview'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="max-w-3xl text-gray-400">{description}</p>
|
||||
</div>
|
||||
|
||||
<Card className="border-gray-800 bg-gray-900/70">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">Current Scope</CardTitle>
|
||||
<CardDescription>
|
||||
This route is now real and intentionally describes the supported boundary for this workspace area.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<ul className="space-y-2 text-sm text-gray-300">
|
||||
{bullets.map((bullet) => (
|
||||
<li key={bullet}>• {bullet}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="flex flex-col gap-3 pt-2 sm:flex-row">
|
||||
{primaryAction ? (
|
||||
<Link href={primaryAction.href} className={cn(primaryActionClassName)}>
|
||||
{primaryAction.label}
|
||||
</Link>
|
||||
) : null}
|
||||
{secondaryAction ? (
|
||||
<Link href={secondaryAction.href} className={cn(secondaryActionClassName)}>
|
||||
{secondaryAction.label}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
116
portal/src/components/security/CloudflareTurnstile.tsx
Normal file
116
portal/src/components/security/CloudflareTurnstile.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Cloudflare Turnstile widget. Site key is public (NEXT_PUBLIC_*).
|
||||
* Pair with Turnstile secret on any backend route you protect — not the same as Cloudflare DNS API keys.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
const SCRIPT_SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
turnstile?: {
|
||||
render: (
|
||||
container: HTMLElement,
|
||||
options: {
|
||||
sitekey: string;
|
||||
callback: (token: string) => void;
|
||||
'error-callback'?: () => void;
|
||||
'expired-callback'?: () => void;
|
||||
}
|
||||
) => string;
|
||||
remove?: (widgetId: string) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function loadTurnstileScript(): Promise<void> {
|
||||
if (typeof window === 'undefined') {
|
||||
return Promise.reject(new Error('no window'));
|
||||
}
|
||||
if (window.turnstile) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
const existing = document.querySelector<HTMLScriptElement>(`script[src="${SCRIPT_SRC}"]`);
|
||||
if (existing) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const t0 = Date.now();
|
||||
const tick = () => {
|
||||
if (window.turnstile) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
if (Date.now() - t0 > 15000) {
|
||||
reject(new Error('Turnstile script timeout'));
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
tick();
|
||||
});
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = SCRIPT_SRC;
|
||||
script.async = true;
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject(new Error('Failed to load Turnstile'));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
export interface CloudflareTurnstileProps {
|
||||
siteKey: string;
|
||||
onToken: (token: string | null) => void;
|
||||
}
|
||||
|
||||
export function CloudflareTurnstile({ siteKey, onToken }: CloudflareTurnstileProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const widgetIdRef = useRef<string | null>(null);
|
||||
const onTokenRef = useRef(onToken);
|
||||
onTokenRef.current = onToken;
|
||||
|
||||
useEffect(() => {
|
||||
if (!siteKey || !containerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
await loadTurnstileScript();
|
||||
if (cancelled || !containerRef.current || !window.turnstile) {
|
||||
return;
|
||||
}
|
||||
containerRef.current.innerHTML = '';
|
||||
const id = window.turnstile.render(containerRef.current, {
|
||||
sitekey: siteKey,
|
||||
callback: (token: string) => onTokenRef.current(token),
|
||||
'error-callback': () => onTokenRef.current(null),
|
||||
'expired-callback': () => onTokenRef.current(null),
|
||||
});
|
||||
widgetIdRef.current = id;
|
||||
} catch {
|
||||
onTokenRef.current(null);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
const id = widgetIdRef.current;
|
||||
widgetIdRef.current = null;
|
||||
if (id && window.turnstile?.remove) {
|
||||
try {
|
||||
window.turnstile.remove(id);
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [siteKey]);
|
||||
|
||||
return <div ref={containerRef} className="min-h-[65px]" aria-live="polite" />;
|
||||
}
|
||||
Reference in New Issue
Block a user