Add a compile-time i18n system with 270 translation keys across 5 locales (EN, DE, FR, ES, PT). Translations are embedded via include_str! and parsed lazily into flat HashMaps with English fallback for missing keys. - Add src/i18n module with Locale enum, t()/tw() lookup functions, and tests - Add JSON translation files for all 5 locales under assets/i18n/ - Provide locale Signal via Dioxus context in App, persisted to localStorage - Replace all hardcoded UI strings across 33 component/page files - Add compact locale picker (globe icon + ISO alpha-2 code) in sidebar header - Add click-outside backdrop dismissal for locale dropdown Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Sharang Parnerkar <parnerkarsharang@gmail.com> Reviewed-on: #12
519 lines
19 KiB
Rust
519 lines
19 KiB
Rust
use dioxus::prelude::*;
|
|
use dioxus_free_icons::icons::bs_icons::{
|
|
BsArrowRight, BsGlobe2, BsKey, BsRobot, BsServer, BsShieldCheck,
|
|
};
|
|
use dioxus_free_icons::icons::fa_solid_icons::FaCubes;
|
|
use dioxus_free_icons::Icon;
|
|
|
|
use crate::i18n::{t, Locale};
|
|
use crate::Route;
|
|
|
|
/// Public landing page for the CERTifAI platform.
|
|
///
|
|
/// Displays a marketing-oriented page with hero section, feature grid,
|
|
/// how-it-works steps, and call-to-action banners. This page is accessible
|
|
/// without authentication.
|
|
#[component]
|
|
pub fn LandingPage() -> Element {
|
|
rsx! {
|
|
div { class: "landing",
|
|
LandingNav {}
|
|
HeroSection {}
|
|
SocialProof {}
|
|
FeaturesGrid {}
|
|
HowItWorks {}
|
|
CtaBanner {}
|
|
LandingFooter {}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Sticky top navigation bar with logo, nav links, and CTA buttons.
|
|
#[component]
|
|
fn LandingNav() -> Element {
|
|
let locale = use_context::<Signal<Locale>>();
|
|
let l = *locale.read();
|
|
|
|
rsx! {
|
|
nav { class: "landing-nav",
|
|
div { class: "landing-nav-inner",
|
|
Link { to: Route::LandingPage {}, class: "landing-logo",
|
|
span { class: "landing-logo-icon",
|
|
Icon { icon: BsShieldCheck, width: 24, height: 24 }
|
|
}
|
|
span { "CERTifAI" }
|
|
}
|
|
div { class: "landing-nav-links",
|
|
a { href: "#features", {t(l, "common.features")} }
|
|
a { href: "#how-it-works", {t(l, "common.how_it_works")} }
|
|
a { href: "#pricing", {t(l, "nav.pricing")} }
|
|
}
|
|
div { class: "landing-nav-actions",
|
|
Link {
|
|
to: Route::Login {
|
|
redirect_url: "/dashboard".into(),
|
|
},
|
|
class: "btn btn-ghost btn-sm",
|
|
{t(l, "common.log_in")}
|
|
}
|
|
Link {
|
|
to: Route::Login {
|
|
redirect_url: "/dashboard".into(),
|
|
},
|
|
class: "btn btn-primary btn-sm",
|
|
{t(l, "common.get_started")}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Hero section with headline, subtitle, and CTA buttons.
|
|
#[component]
|
|
fn HeroSection() -> Element {
|
|
let locale = use_context::<Signal<Locale>>();
|
|
let l = *locale.read();
|
|
|
|
rsx! {
|
|
section { class: "hero-section",
|
|
div { class: "hero-content",
|
|
div { class: "hero-badge badge badge-outline", {t(l, "landing.badge")} }
|
|
h1 { class: "hero-title",
|
|
{t(l, "landing.hero_title_1")}
|
|
br {}
|
|
span { class: "hero-title-accent", {t(l, "landing.hero_title_2")} }
|
|
}
|
|
p { class: "hero-subtitle",
|
|
{t(l, "landing.hero_subtitle")}
|
|
}
|
|
div { class: "hero-actions",
|
|
Link {
|
|
to: Route::Login {
|
|
redirect_url: "/dashboard".into(),
|
|
},
|
|
class: "btn btn-primary btn-lg",
|
|
{t(l, "common.get_started")}
|
|
Icon { icon: BsArrowRight, width: 18, height: 18 }
|
|
}
|
|
a { href: "#features", class: "btn btn-outline btn-lg",
|
|
{t(l, "landing.learn_more")}
|
|
}
|
|
}
|
|
}
|
|
div { class: "hero-graphic",
|
|
// Abstract shield/network SVG motif
|
|
svg {
|
|
view_box: "0 0 400 400",
|
|
fill: "none",
|
|
width: "100%",
|
|
height: "100%",
|
|
// Gradient definitions
|
|
defs {
|
|
linearGradient {
|
|
id: "grad1",
|
|
x1: "0%",
|
|
y1: "0%",
|
|
x2: "100%",
|
|
y2: "100%",
|
|
stop { offset: "0%", stop_color: "#91a4d2" }
|
|
stop { offset: "100%", stop_color: "#6d85c6" }
|
|
}
|
|
linearGradient {
|
|
id: "grad2",
|
|
x1: "0%",
|
|
y1: "100%",
|
|
x2: "100%",
|
|
y2: "0%",
|
|
stop { offset: "0%", stop_color: "#f97066" }
|
|
stop { offset: "100%", stop_color: "#f9a066" }
|
|
}
|
|
radialGradient {
|
|
id: "glow",
|
|
cx: "50%",
|
|
cy: "50%",
|
|
r: "50%",
|
|
stop {
|
|
offset: "0%",
|
|
stop_color: "rgba(145,164,210,0.3)",
|
|
}
|
|
stop {
|
|
offset: "100%",
|
|
stop_color: "rgba(145,164,210,0)",
|
|
}
|
|
}
|
|
}
|
|
// Background glow
|
|
circle {
|
|
cx: "200",
|
|
cy: "200",
|
|
r: "180",
|
|
fill: "url(#glow)",
|
|
}
|
|
// Shield outline
|
|
path {
|
|
d: "M200 40 L340 110 L340 230 C340 300 270 360 200 380 \
|
|
C130 360 60 300 60 230 L60 110 Z",
|
|
stroke: "url(#grad1)",
|
|
stroke_width: "2",
|
|
fill: "none",
|
|
opacity: "0.6",
|
|
}
|
|
// Inner shield
|
|
path {
|
|
d: "M200 80 L310 135 L310 225 C310 280 255 330 200 345 \
|
|
C145 330 90 280 90 225 L90 135 Z",
|
|
stroke: "url(#grad1)",
|
|
stroke_width: "1.5",
|
|
fill: "rgba(145,164,210,0.05)",
|
|
opacity: "0.8",
|
|
}
|
|
// Network nodes
|
|
circle {
|
|
cx: "200",
|
|
cy: "180",
|
|
r: "8",
|
|
fill: "url(#grad1)",
|
|
}
|
|
circle {
|
|
cx: "150",
|
|
cy: "230",
|
|
r: "6",
|
|
fill: "url(#grad2)",
|
|
}
|
|
circle {
|
|
cx: "250",
|
|
cy: "230",
|
|
r: "6",
|
|
fill: "url(#grad2)",
|
|
}
|
|
circle {
|
|
cx: "200",
|
|
cy: "280",
|
|
r: "6",
|
|
fill: "url(#grad1)",
|
|
}
|
|
circle {
|
|
cx: "130",
|
|
cy: "170",
|
|
r: "4",
|
|
fill: "#91a4d2",
|
|
opacity: "0.6",
|
|
}
|
|
circle {
|
|
cx: "270",
|
|
cy: "170",
|
|
r: "4",
|
|
fill: "#91a4d2",
|
|
opacity: "0.6",
|
|
}
|
|
// Network connections
|
|
line {
|
|
x1: "200",
|
|
y1: "180",
|
|
x2: "150",
|
|
y2: "230",
|
|
stroke: "#91a4d2",
|
|
stroke_width: "1",
|
|
opacity: "0.4",
|
|
}
|
|
line {
|
|
x1: "200",
|
|
y1: "180",
|
|
x2: "250",
|
|
y2: "230",
|
|
stroke: "#91a4d2",
|
|
stroke_width: "1",
|
|
opacity: "0.4",
|
|
}
|
|
line {
|
|
x1: "150",
|
|
y1: "230",
|
|
x2: "200",
|
|
y2: "280",
|
|
stroke: "#91a4d2",
|
|
stroke_width: "1",
|
|
opacity: "0.4",
|
|
}
|
|
line {
|
|
x1: "250",
|
|
y1: "230",
|
|
x2: "200",
|
|
y2: "280",
|
|
stroke: "#91a4d2",
|
|
stroke_width: "1",
|
|
opacity: "0.4",
|
|
}
|
|
line {
|
|
x1: "200",
|
|
y1: "180",
|
|
x2: "130",
|
|
y2: "170",
|
|
stroke: "#91a4d2",
|
|
stroke_width: "1",
|
|
opacity: "0.3",
|
|
}
|
|
line {
|
|
x1: "200",
|
|
y1: "180",
|
|
x2: "270",
|
|
y2: "170",
|
|
stroke: "#91a4d2",
|
|
stroke_width: "1",
|
|
opacity: "0.3",
|
|
}
|
|
// Checkmark inside shield center
|
|
path {
|
|
d: "M180 200 L195 215 L225 185",
|
|
stroke: "url(#grad1)",
|
|
stroke_width: "3",
|
|
stroke_linecap: "round",
|
|
stroke_linejoin: "round",
|
|
fill: "none",
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Social proof / trust indicator strip.
|
|
#[component]
|
|
fn SocialProof() -> Element {
|
|
let locale = use_context::<Signal<Locale>>();
|
|
let l = *locale.read();
|
|
|
|
rsx! {
|
|
section { class: "social-proof",
|
|
p { class: "social-proof-text",
|
|
{t(l, "landing.social_proof")}
|
|
span { class: "social-proof-highlight", {t(l, "landing.data_sovereignty")} }
|
|
}
|
|
div { class: "social-proof-stats",
|
|
div { class: "proof-stat",
|
|
span { class: "proof-stat-value", "100%" }
|
|
span { class: "proof-stat-label", {t(l, "landing.on_premise")} }
|
|
}
|
|
div { class: "proof-divider" }
|
|
div { class: "proof-stat",
|
|
span { class: "proof-stat-value", "GDPR" }
|
|
span { class: "proof-stat-label", {t(l, "landing.compliant")} }
|
|
}
|
|
div { class: "proof-divider" }
|
|
div { class: "proof-stat",
|
|
span { class: "proof-stat-value", "EU" }
|
|
span { class: "proof-stat-label", {t(l, "landing.data_residency")} }
|
|
}
|
|
div { class: "proof-divider" }
|
|
div { class: "proof-stat",
|
|
span { class: "proof-stat-value", "Zero" }
|
|
span { class: "proof-stat-label", {t(l, "landing.third_party")} }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Feature cards grid section.
|
|
#[component]
|
|
fn FeaturesGrid() -> Element {
|
|
let locale = use_context::<Signal<Locale>>();
|
|
let l = *locale.read();
|
|
|
|
rsx! {
|
|
section { id: "features", class: "features-section",
|
|
h2 { class: "section-title", {t(l, "landing.features_title")} }
|
|
p { class: "section-subtitle",
|
|
{t(l, "landing.features_subtitle")}
|
|
}
|
|
div { class: "features-grid",
|
|
FeatureCard {
|
|
icon: rsx! {
|
|
Icon { icon: BsServer, width: 28, height: 28 }
|
|
},
|
|
title: t(l, "landing.feat_infra_title"),
|
|
description: t(l, "landing.feat_infra_desc"),
|
|
}
|
|
FeatureCard {
|
|
icon: rsx! {
|
|
Icon { icon: BsShieldCheck, width: 28, height: 28 }
|
|
},
|
|
title: t(l, "landing.feat_gdpr_title"),
|
|
description: t(l, "landing.feat_gdpr_desc"),
|
|
}
|
|
FeatureCard {
|
|
icon: rsx! {
|
|
Icon { icon: FaCubes, width: 28, height: 28 }
|
|
},
|
|
title: t(l, "landing.feat_llm_title"),
|
|
description: t(l, "landing.feat_llm_desc"),
|
|
}
|
|
FeatureCard {
|
|
icon: rsx! {
|
|
Icon { icon: BsRobot, width: 28, height: 28 }
|
|
},
|
|
title: t(l, "landing.feat_agent_title"),
|
|
description: t(l, "landing.feat_agent_desc"),
|
|
}
|
|
FeatureCard {
|
|
icon: rsx! {
|
|
Icon { icon: BsGlobe2, width: 28, height: 28 }
|
|
},
|
|
title: t(l, "landing.feat_mcp_title"),
|
|
description: t(l, "landing.feat_mcp_desc"),
|
|
}
|
|
FeatureCard {
|
|
icon: rsx! {
|
|
Icon { icon: BsKey, width: 28, height: 28 }
|
|
},
|
|
title: t(l, "landing.feat_api_title"),
|
|
description: t(l, "landing.feat_api_desc"),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Individual feature card.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `icon` - The icon element to display
|
|
/// * `title` - Feature title (owned String from translation lookup)
|
|
/// * `description` - Feature description text (owned String from translation lookup)
|
|
#[component]
|
|
fn FeatureCard(icon: Element, title: String, description: String) -> Element {
|
|
rsx! {
|
|
div { class: "card feature-card",
|
|
div { class: "feature-card-icon", {icon} }
|
|
h3 { class: "feature-card-title", "{title}" }
|
|
p { class: "feature-card-desc", "{description}" }
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Three-step "How It Works" section.
|
|
#[component]
|
|
fn HowItWorks() -> Element {
|
|
let locale = use_context::<Signal<Locale>>();
|
|
let l = *locale.read();
|
|
|
|
rsx! {
|
|
section { id: "how-it-works", class: "how-it-works-section",
|
|
h2 { class: "section-title", {t(l, "landing.how_title")} }
|
|
p { class: "section-subtitle", {t(l, "landing.how_subtitle")} }
|
|
div { class: "steps-grid",
|
|
StepCard {
|
|
number: "01",
|
|
title: t(l, "landing.step_deploy"),
|
|
description: t(l, "landing.step_deploy_desc"),
|
|
}
|
|
StepCard {
|
|
number: "02",
|
|
title: t(l, "landing.step_configure"),
|
|
description: t(l, "landing.step_configure_desc"),
|
|
}
|
|
StepCard {
|
|
number: "03",
|
|
title: t(l, "landing.step_scale"),
|
|
description: t(l, "landing.step_scale_desc"),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Individual step card.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `number` - Step number string (e.g. "01")
|
|
/// * `title` - Step title (owned String from translation lookup)
|
|
/// * `description` - Step description text (owned String from translation lookup)
|
|
#[component]
|
|
fn StepCard(number: &'static str, title: String, description: String) -> Element {
|
|
rsx! {
|
|
div { class: "step-card",
|
|
span { class: "step-number", "{number}" }
|
|
h3 { class: "step-title", "{title}" }
|
|
p { class: "step-desc", "{description}" }
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Call-to-action banner before the footer.
|
|
#[component]
|
|
fn CtaBanner() -> Element {
|
|
let locale = use_context::<Signal<Locale>>();
|
|
let l = *locale.read();
|
|
|
|
rsx! {
|
|
section { class: "cta-banner",
|
|
h2 { class: "cta-title", {t(l, "landing.cta_title")} }
|
|
p { class: "cta-subtitle",
|
|
{t(l, "landing.cta_subtitle")}
|
|
}
|
|
div { class: "cta-actions",
|
|
Link {
|
|
to: Route::Login {
|
|
redirect_url: "/dashboard".into(),
|
|
},
|
|
class: "btn btn-primary btn-lg",
|
|
{t(l, "landing.get_started_free")}
|
|
Icon { icon: BsArrowRight, width: 18, height: 18 }
|
|
}
|
|
Link {
|
|
to: Route::Login {
|
|
redirect_url: "/dashboard".into(),
|
|
},
|
|
class: "btn btn-outline btn-lg",
|
|
{t(l, "common.log_in")}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Landing page footer with links and copyright.
|
|
#[component]
|
|
fn LandingFooter() -> Element {
|
|
let locale = use_context::<Signal<Locale>>();
|
|
let l = *locale.read();
|
|
|
|
rsx! {
|
|
footer { class: "landing-footer",
|
|
div { class: "landing-footer-inner",
|
|
div { class: "footer-brand",
|
|
div { class: "landing-logo",
|
|
span { class: "landing-logo-icon",
|
|
Icon { icon: BsShieldCheck, width: 20, height: 20 }
|
|
}
|
|
span { "CERTifAI" }
|
|
}
|
|
p { class: "footer-tagline", {t(l, "landing.footer_tagline")} }
|
|
}
|
|
div { class: "footer-links-group",
|
|
h4 { class: "footer-links-heading", {t(l, "landing.product")} }
|
|
a { href: "#features", {t(l, "common.features")} }
|
|
a { href: "#how-it-works", {t(l, "common.how_it_works")} }
|
|
a { href: "#pricing", {t(l, "nav.pricing")} }
|
|
}
|
|
div { class: "footer-links-group",
|
|
h4 { class: "footer-links-heading", {t(l, "landing.legal")} }
|
|
Link { to: Route::ImpressumPage {}, {t(l, "common.impressum")} }
|
|
Link { to: Route::PrivacyPage {}, {t(l, "common.privacy_policy")} }
|
|
}
|
|
div { class: "footer-links-group",
|
|
h4 { class: "footer-links-heading", {t(l, "landing.resources")} }
|
|
a { href: "#", {t(l, "landing.documentation")} }
|
|
a { href: "#", {t(l, "landing.api_reference")} }
|
|
a { href: "#", {t(l, "landing.support")} }
|
|
}
|
|
}
|
|
div { class: "footer-bottom",
|
|
p { {t(l, "landing.copyright")} }
|
|
}
|
|
}
|
|
}
|
|
}
|