feat(landing): glass header nav with side drawer + blog section
- Header: full glassmorphism navigation (services, pricing, estimator, learn, blog). Desktop shows an inline glass pill; mobile opens a side drawer that slides in from the inline-start edge (right in RTL, left in LTR) over a blurred backdrop. - Nav links smooth-scroll via the shared Lenis instance (exposed through scroll-store: setLenis/scrollToId), with an 80px offset under the header. - "Learn" links to the existing how-it-works steps (id="learn"); add a lightweight Blog teaser section (id="blog", "coming soon" cards). - i18n: fa/en strings for nav and blog. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,7 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import Lenis from 'lenis';
|
||||
|
||||
import { setScroll, setPointer, flashState } from './scroll-store';
|
||||
import { setScroll, setPointer, flashState, setLenis } from './scroll-store';
|
||||
import { SiteHeader } from './sections/SiteHeader';
|
||||
import { Hero } from './sections/Hero';
|
||||
import { ServicesBar } from './sections/ServicesBar';
|
||||
@@ -14,6 +14,7 @@ import { HowItWorks } from './sections/HowItWorks';
|
||||
import { Pricing } from './sections/Pricing';
|
||||
import { Estimator } from './sections/Estimator';
|
||||
import { Trust } from './sections/Trust';
|
||||
import { Blog } from './sections/Blog';
|
||||
import { FinalCta } from './sections/FinalCta';
|
||||
import { Footer } from './sections/Footer';
|
||||
|
||||
@@ -67,6 +68,7 @@ export function LandingPage() {
|
||||
}
|
||||
|
||||
const lenis = new Lenis({ duration: 1.15, smoothWheel: true });
|
||||
setLenis(lenis);
|
||||
let raf = 0;
|
||||
const loop = (time: number) => {
|
||||
lenis.raf(time);
|
||||
@@ -79,6 +81,7 @@ export function LandingPage() {
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
lenis.destroy();
|
||||
setLenis(null);
|
||||
};
|
||||
}, [reduced]);
|
||||
|
||||
@@ -104,6 +107,7 @@ export function LandingPage() {
|
||||
<Pricing />
|
||||
<Estimator />
|
||||
<Trust />
|
||||
<Blog />
|
||||
<FinalCta />
|
||||
<Footer />
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,29 @@
|
||||
import type Lenis from 'lenis';
|
||||
|
||||
// Lightweight scroll-progress signal read inside the r3f render loop.
|
||||
// Kept as a module-level mutable so useFrame can read it without React re-renders.
|
||||
export const scrollState = { progress: 0 };
|
||||
|
||||
// Active Lenis instance (set by LandingPage) so header nav links can drive the
|
||||
// same smooth-scroll engine. Lenis disables native CSS smooth scroll, so anchor
|
||||
// jumps must go through it to stay smooth.
|
||||
export const lenisRef: { current: Lenis | null } = { current: null };
|
||||
|
||||
export function setLenis(instance: Lenis | null) {
|
||||
lenisRef.current = instance;
|
||||
}
|
||||
|
||||
/** Smooth-scroll to a section by id, via Lenis when available (native fallback). */
|
||||
export function scrollToId(id: string) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
if (lenisRef.current) {
|
||||
lenisRef.current.scrollTo(el, { offset: -80, duration: 1.1 });
|
||||
} else {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
}
|
||||
|
||||
export function setScroll(progress: number) {
|
||||
scrollState.progress = progress < 0 ? 0 : progress > 1 ? 1 : progress;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import { ArrowLeft, BookOpen } from 'lucide-react';
|
||||
import { useT } from '@/i18n/I18nProvider';
|
||||
import { Reveal } from '../Reveal';
|
||||
|
||||
export function Blog() {
|
||||
const b = useT().landing.blog;
|
||||
return (
|
||||
<section id="blog" className="relative px-6 py-28">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<Reveal className="mb-12 flex justify-center">
|
||||
<div className="abrban-panel rounded-3xl px-8 py-7 text-center">
|
||||
<h2 className="abrban-ink flex items-center justify-center gap-2 text-4xl font-bold text-white">
|
||||
<BookOpen className="h-7 w-7 text-primary-200" />
|
||||
{b.title}
|
||||
</h2>
|
||||
<p className="abrban-ink mt-4 text-lg text-white/85">{b.subtitle}</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{b.posts.map((post, i) => (
|
||||
<Reveal key={post.title} delay={(i % 3) * 0.07}>
|
||||
<article className="abrban-panel group flex h-full flex-col rounded-2xl p-6 transition hover:-translate-y-1 hover:ring-1 hover:ring-primary-400/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex rounded-full bg-primary-500/25 px-3 py-1 text-xs font-bold text-primary-100 ring-1 ring-primary-400/40">
|
||||
{post.tag}
|
||||
</span>
|
||||
<span className="inline-flex rounded-full bg-amber-400/20 px-2.5 py-1 text-xs font-bold text-amber-100 ring-1 ring-amber-300/40">
|
||||
{b.comingSoon}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="abrban-ink mt-5 text-lg font-bold leading-8 text-white">{post.title}</h3>
|
||||
<p className="mt-2 flex-1 text-sm leading-7 text-white/80">{post.desc}</p>
|
||||
<span className="mt-5 inline-flex items-center gap-1 text-sm font-semibold text-primary-200/80">
|
||||
{b.readMore}
|
||||
<ArrowLeft className="h-4 w-4 ltr:rotate-180" />
|
||||
</span>
|
||||
</article>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Reveal className="mt-10 flex justify-center">
|
||||
<span className="inline-flex items-center gap-2 rounded-xl border border-white/20 bg-white/5 px-7 py-3.5 font-bold text-white/90 backdrop-blur-md">
|
||||
{b.cta}
|
||||
<span className="rounded-full bg-amber-400/20 px-2.5 py-0.5 text-xs font-bold text-amber-100 ring-1 ring-amber-300/40">
|
||||
{b.comingSoon}
|
||||
</span>
|
||||
</span>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { useT } from '@/i18n/I18nProvider';
|
||||
export function HowItWorks() {
|
||||
const how = useT().landing.how;
|
||||
return (
|
||||
<section className="relative px-6 py-28">
|
||||
<section id="learn" className="relative px-6 py-28">
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<Reveal className="mb-16 flex justify-center">
|
||||
<h2 className="abrban-panel abrban-ink rounded-3xl px-8 py-5 text-4xl font-bold text-white">
|
||||
|
||||
@@ -1,13 +1,40 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { Menu, X } from 'lucide-react';
|
||||
import { Link } from '@/i18n/Link';
|
||||
import { Logo } from '../Logo';
|
||||
import { useT } from '@/i18n/I18nProvider';
|
||||
import { useT, useLocale } from '@/i18n/I18nProvider';
|
||||
import { dirFor } from '@/i18n/config';
|
||||
import { LanguageSwitcher } from '@/i18n/LanguageSwitcher';
|
||||
import { scrollToId } from '../scroll-store';
|
||||
|
||||
// Section anchors the header navigates to (id ↔ nav dictionary key).
|
||||
const NAV_ITEMS = [
|
||||
{ id: 'services', key: 'services' },
|
||||
{ id: 'pricing', key: 'pricing' },
|
||||
{ id: 'estimator', key: 'estimator' },
|
||||
{ id: 'learn', key: 'learn' },
|
||||
{ id: 'blog', key: 'blog' },
|
||||
] as const;
|
||||
|
||||
export function SiteHeader() {
|
||||
const t = useT();
|
||||
const nav = t.landing.nav;
|
||||
const locale = useLocale();
|
||||
const isRtl = dirFor(locale) === 'rtl';
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleNav = (id: string) => (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setOpen(false);
|
||||
scrollToId(id);
|
||||
};
|
||||
|
||||
// Drawer slides in from the inline-start edge (right in RTL, left in LTR).
|
||||
const offscreen = isRtl ? '100%' : '-100%';
|
||||
|
||||
return (
|
||||
<motion.header
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
@@ -15,24 +42,123 @@ export function SiteHeader() {
|
||||
transition={{ duration: 0.8, ease: [0.22, 1, 0.36, 1] }}
|
||||
className="fixed inset-x-0 top-0 z-30"
|
||||
>
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between px-6 py-5">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between gap-3 px-6 py-5">
|
||||
<Logo className="abrban-ink" />
|
||||
<div className="flex items-center gap-2 rounded-xl bg-slate-950/30 p-1.5 backdrop-blur-md ring-1 ring-white/10">
|
||||
<LanguageSwitcher className="text-white/90 hover:text-white" />
|
||||
<Link
|
||||
href="/login"
|
||||
className="rounded-lg px-4 py-2 text-sm font-semibold text-white/90 transition hover:text-white"
|
||||
|
||||
{/* Desktop nav — glass pill */}
|
||||
<nav className="hidden items-center gap-1 rounded-2xl bg-slate-950/30 p-1.5 backdrop-blur-md ring-1 ring-white/10 lg:flex">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<a
|
||||
key={item.id}
|
||||
href={`#${item.id}`}
|
||||
onClick={handleNav(item.id)}
|
||||
className="rounded-xl px-4 py-2 text-sm font-semibold text-white/85 transition hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
{nav[item.key]}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Right controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="hidden items-center gap-2 rounded-xl bg-slate-950/30 p-1.5 backdrop-blur-md ring-1 ring-white/10 sm:flex">
|
||||
<LanguageSwitcher className="text-white/90 hover:text-white" />
|
||||
<Link
|
||||
href="/login"
|
||||
className="rounded-lg px-4 py-2 text-sm font-semibold text-white/90 transition hover:text-white"
|
||||
>
|
||||
{t.common.login}
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white shadow-lg shadow-primary-600/30 transition hover:bg-primary-500"
|
||||
>
|
||||
{t.common.register}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label={nav.menu}
|
||||
aria-expanded={open}
|
||||
className="inline-flex h-11 w-11 items-center justify-center rounded-xl bg-slate-950/30 text-white ring-1 ring-white/10 backdrop-blur-md transition hover:bg-white/10 lg:hidden"
|
||||
>
|
||||
{t.common.login}
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white shadow-lg shadow-primary-600/30 transition hover:bg-primary-500"
|
||||
>
|
||||
{t.common.register}
|
||||
</Link>
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile side drawer */}
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<>
|
||||
<motion.div
|
||||
key="backdrop"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
onClick={() => setOpen(false)}
|
||||
className="fixed inset-0 z-40 bg-slate-950/50 backdrop-blur-sm lg:hidden"
|
||||
/>
|
||||
<motion.aside
|
||||
key="drawer"
|
||||
initial={{ x: offscreen }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: offscreen }}
|
||||
transition={{ type: 'tween', duration: 0.3, ease: [0.22, 1, 0.36, 1] }}
|
||||
className={`abrban-panel fixed top-0 z-50 flex h-full w-[82%] max-w-xs flex-col p-5 lg:hidden ${
|
||||
isRtl ? 'right-0 rounded-l-3xl' : 'left-0 rounded-r-3xl'
|
||||
}`}
|
||||
>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<Logo className="abrban-ink" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-label={t.common.close}
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-xl bg-white/10 text-white ring-1 ring-white/15 transition hover:bg-white/20"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="flex flex-col gap-1">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<a
|
||||
key={item.id}
|
||||
href={`#${item.id}`}
|
||||
onClick={handleNav(item.id)}
|
||||
className="rounded-xl px-4 py-3 text-base font-semibold text-white/90 transition hover:bg-white/10"
|
||||
>
|
||||
{nav[item.key]}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="my-5 h-px bg-white/10" />
|
||||
|
||||
<div className="mt-auto flex flex-col gap-3">
|
||||
<LanguageSwitcher className="text-white/90 hover:text-white" />
|
||||
<Link
|
||||
href="/login"
|
||||
className="rounded-xl border border-white/20 bg-white/5 px-4 py-3 text-center font-bold text-white backdrop-blur-md transition hover:bg-white/10"
|
||||
>
|
||||
{t.common.login}
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
className="rounded-xl bg-primary-600 px-4 py-3 text-center font-bold text-white shadow-lg shadow-primary-600/30 transition hover:bg-primary-500"
|
||||
>
|
||||
{t.common.register}
|
||||
</Link>
|
||||
</div>
|
||||
</motion.aside>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,6 +57,14 @@ const en: Dictionary = {
|
||||
|
||||
landing: {
|
||||
badge: 'Self-service cloud platform',
|
||||
nav: {
|
||||
services: 'Services',
|
||||
pricing: 'Pricing',
|
||||
estimator: 'Estimate',
|
||||
learn: 'Learn',
|
||||
blog: 'Blog',
|
||||
menu: 'Menu',
|
||||
},
|
||||
hero: {
|
||||
title: 'Abrban',
|
||||
tagline: 'Cloud infrastructure, in your control',
|
||||
@@ -176,6 +184,18 @@ const en: Dictionary = {
|
||||
currency: 'Toman',
|
||||
error: 'Could not calculate cost.',
|
||||
},
|
||||
blog: {
|
||||
title: 'Blog & guides',
|
||||
subtitle: 'Guides and the latest writing to help you build faster on Abrban.',
|
||||
comingSoon: 'Coming soon',
|
||||
cta: 'View all articles',
|
||||
readMore: 'Read more',
|
||||
posts: [
|
||||
{ tag: 'Getting started', title: 'Ship your first app in minutes', desc: 'From connecting a repo to a domain and automatic SSL — step by step.' },
|
||||
{ tag: 'Cost optimization', title: 'Pick the right resources and pay less', desc: 'Tune CPU, memory, replicas and add-on services wisely.' },
|
||||
{ tag: 'Security', title: 'Custom domains with automatic SSL', desc: 'Connect your domain and let Abrban issue and renew the certificate.' },
|
||||
],
|
||||
},
|
||||
finalCta: {
|
||||
titleLead: 'The sky is clear; it’s time to ',
|
||||
titleHighlight: 'go live',
|
||||
|
||||
@@ -56,6 +56,14 @@ const fa = {
|
||||
|
||||
landing: {
|
||||
badge: 'پلتفرم ابریِ خودسرویس',
|
||||
nav: {
|
||||
services: 'سرویسها',
|
||||
pricing: 'قیمتگذاری',
|
||||
estimator: 'تخمین هزینه',
|
||||
learn: 'آموزش',
|
||||
blog: 'بلاگ',
|
||||
menu: 'منو',
|
||||
},
|
||||
hero: {
|
||||
title: 'ابربان',
|
||||
tagline: 'زیرساخت ابری، در کنترل تو',
|
||||
@@ -175,6 +183,18 @@ const fa = {
|
||||
currency: 'تومان',
|
||||
error: 'محاسبهٔ هزینه ممکن نشد.',
|
||||
},
|
||||
blog: {
|
||||
title: 'بلاگ و آموزشها',
|
||||
subtitle: 'راهنماها و تازهترین نوشتهها برای ساختن سریعتر روی ابربان.',
|
||||
comingSoon: 'بهزودی',
|
||||
cta: 'مشاهدهٔ همهٔ مقالات',
|
||||
readMore: 'ادامهٔ مطلب',
|
||||
posts: [
|
||||
{ tag: 'شروع کار', title: 'اولین اپلیکیشن خود را در چند دقیقه منتشر کن', desc: 'از اتصال ریپازیتوری تا دامنه و SSL خودکار — قدمبهقدم.' },
|
||||
{ tag: 'بهینهسازی هزینه', title: 'منابع را درست انتخاب کن و کمتر هزینه بده', desc: 'CPU، حافظه، رپلیکا و سرویسهای جانبی را هوشمندانه تنظیم کن.' },
|
||||
{ tag: 'امنیت', title: 'دامنهٔ اختصاصی و گواهی SSL خودکار', desc: 'دامنهات را وصل کن و بگذار ابربان گواهی را صادر و تمدید کند.' },
|
||||
],
|
||||
},
|
||||
finalCta: {
|
||||
titleLead: 'آسمان صاف است؛ وقتِ ',
|
||||
titleHighlight: 'زنده',
|
||||
|
||||
Reference in New Issue
Block a user