Add an interactive 3D weather-journey landing page for Abrban at the site root.
Replace the root redirect with a scroll-driven three.js scene that flies from overcast clouds through rain and a thunderstorm into a clear blue sunny sky, fronted by Persian/RTL marketing sections. The dashboard at /dashboard is untouched. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -154,3 +154,69 @@
|
||||
.animate-modal-enter {
|
||||
animation: modalEnter 0.2s ease-out;
|
||||
}
|
||||
|
||||
/* ─── Lenis smooth scroll (only active on the landing page) ─── */
|
||||
html.lenis,
|
||||
html.lenis body {
|
||||
height: auto;
|
||||
}
|
||||
.lenis.lenis-smooth {
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
.lenis.lenis-smooth [data-lenis-prevent] {
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.lenis.lenis-stopped {
|
||||
overflow: hidden;
|
||||
}
|
||||
.lenis.lenis-smooth iframe {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ─── Landing-only helpers ───────────────────────────── */
|
||||
@keyframes abrbanFloat {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-10px); }
|
||||
}
|
||||
@keyframes abrbanScrollHint {
|
||||
0% { opacity: 0; transform: translateY(-6px); }
|
||||
50% { opacity: 1; }
|
||||
100% { opacity: 0; transform: translateY(8px); }
|
||||
}
|
||||
@keyframes abrbanShimmer {
|
||||
to { background-position: 200% center; }
|
||||
}
|
||||
.abrban-float { animation: abrbanFloat 6s ease-in-out infinite; }
|
||||
.abrban-scroll-hint { animation: abrbanScrollHint 1.8s ease-in-out infinite; }
|
||||
.abrban-shimmer {
|
||||
background: linear-gradient(100deg, #93c5fd 0%, #ffffff 25%, #2563eb 50%, #ffffff 75%, #93c5fd 100%);
|
||||
background-size: 200% auto;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: abrbanShimmer 6s linear infinite;
|
||||
filter: drop-shadow(0 2px 12px rgba(2, 6, 23, 0.55));
|
||||
}
|
||||
|
||||
/* Strong legibility over the ever-changing sky (bright clouds <-> dark storm). */
|
||||
.abrban-ink {
|
||||
text-shadow: 0 1px 2px rgba(2, 6, 23, 0.5), 0 6px 30px rgba(2, 6, 23, 0.45);
|
||||
}
|
||||
/* Frosted dark-glass panel so text stays crisp on any weather stage. */
|
||||
.abrban-panel {
|
||||
background: rgba(15, 23, 42, 0.46);
|
||||
backdrop-filter: blur(18px) saturate(125%);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(125%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
box-shadow:
|
||||
0 30px 80px -30px rgba(2, 6, 23, 0.78),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.abrban-float,
|
||||
.abrban-scroll-hint,
|
||||
.abrban-shimmer {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import type { Metadata } from 'next';
|
||||
import { Vazirmatn } from 'next/font/google';
|
||||
import { LandingPage } from '@/components/landing/LandingPage';
|
||||
|
||||
const vazir = Vazirmatn({
|
||||
subsets: ['arabic'],
|
||||
variable: '--font-vazir',
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'ابربان | زیرساخت ابری، در کنترل تو',
|
||||
description:
|
||||
'ابربان؛ پلتفرم ابریِ خودسرویس روی کوبرنتیز. اپ خود را در چند ثانیه منتشر کن — دیتابیس مدیریتشده، دامنهٔ اختصاصی، SSL خودکار و لاگ زنده.',
|
||||
};
|
||||
|
||||
export default function Home() {
|
||||
redirect('/dashboard');
|
||||
return (
|
||||
<div dir="rtl" className={`${vazir.variable} font-vazir`}>
|
||||
<LandingPage />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import Lenis from 'lenis';
|
||||
|
||||
import { setScroll, setPointer, flashState } from './scroll-store';
|
||||
import { SiteHeader } from './sections/SiteHeader';
|
||||
import { Hero } from './sections/Hero';
|
||||
import { Value } from './sections/Value';
|
||||
import { Features } from './sections/Features';
|
||||
import { HowItWorks } from './sections/HowItWorks';
|
||||
import { Trust } from './sections/Trust';
|
||||
import { FinalCta } from './sections/FinalCta';
|
||||
import { Footer } from './sections/Footer';
|
||||
|
||||
const SceneCanvas = dynamic(() => import('./SceneCanvas'), { ssr: false });
|
||||
|
||||
export function LandingPage() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [reduced, setReduced] = useState(false);
|
||||
const flashRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Drive the full-screen lightning flash overlay from the scene's storm signal.
|
||||
useEffect(() => {
|
||||
let raf = 0;
|
||||
const loop = () => {
|
||||
if (flashRef.current) flashRef.current.style.opacity = String(flashState.value * 0.6);
|
||||
raf = requestAnimationFrame(loop);
|
||||
};
|
||||
raf = requestAnimationFrame(loop);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, []);
|
||||
|
||||
// Client mount + reduced-motion preference.
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
setReduced(mq.matches);
|
||||
const onChange = () => setReduced(mq.matches);
|
||||
mq.addEventListener('change', onChange);
|
||||
return () => mq.removeEventListener('change', onChange);
|
||||
}, []);
|
||||
|
||||
// Pointer signal for camera parallax (whole-window, overlay-proof).
|
||||
useEffect(() => {
|
||||
const onMove = (e: PointerEvent) => {
|
||||
setPointer((e.clientX / window.innerWidth) * 2 - 1, -((e.clientY / window.innerHeight) * 2 - 1));
|
||||
};
|
||||
window.addEventListener('pointermove', onMove, { passive: true });
|
||||
return () => window.removeEventListener('pointermove', onMove);
|
||||
}, []);
|
||||
|
||||
// Smooth scroll (Lenis) — skipped for reduced-motion, which falls back to native.
|
||||
useEffect(() => {
|
||||
if (reduced) {
|
||||
const onScroll = () => {
|
||||
const limit = document.documentElement.scrollHeight - window.innerHeight;
|
||||
setScroll(limit > 0 ? window.scrollY / limit : 0);
|
||||
};
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
onScroll();
|
||||
return () => window.removeEventListener('scroll', onScroll);
|
||||
}
|
||||
|
||||
const lenis = new Lenis({ duration: 1.15, smoothWheel: true });
|
||||
let raf = 0;
|
||||
const loop = (time: number) => {
|
||||
lenis.raf(time);
|
||||
raf = requestAnimationFrame(loop);
|
||||
};
|
||||
raf = requestAnimationFrame(loop);
|
||||
lenis.on('scroll', () => {
|
||||
setScroll(Number.isFinite(lenis.progress) ? lenis.progress : 0);
|
||||
});
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
lenis.destroy();
|
||||
};
|
||||
}, [reduced]);
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen w-full overflow-x-hidden bg-gradient-to-b from-[#9fb4d4] via-[#c3d0e0] to-[#e9f0f8] text-white">
|
||||
{/* Fixed cinematic sky backdrop */}
|
||||
<div className="pointer-events-none fixed inset-0 z-0">
|
||||
{mounted && <SceneCanvas reduced={reduced} />}
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,_transparent_62%,_rgba(15,23,42,0.34)_100%)]" />
|
||||
</div>
|
||||
|
||||
{/* Lightning flash overlay (storm stage) */}
|
||||
<div ref={flashRef} className="pointer-events-none fixed inset-0 z-[5] bg-white opacity-0" />
|
||||
|
||||
{/* Scrolling content */}
|
||||
<div className="relative z-10">
|
||||
<SiteHeader />
|
||||
<Hero />
|
||||
<Value />
|
||||
<Features />
|
||||
<HowItWorks />
|
||||
<Trust />
|
||||
<FinalCta />
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Cloud } from 'lucide-react';
|
||||
|
||||
export function Logo({ className = '' }: { className?: string }) {
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-2 text-xl font-black ${className}`}>
|
||||
<span className="relative inline-flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-primary-500 to-primary-700 shadow-lg shadow-primary-600/30">
|
||||
<Cloud className="h-5 w-5 text-white" />
|
||||
</span>
|
||||
<span className="text-white">ابربان</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export function Reveal({
|
||||
children,
|
||||
delay = 0,
|
||||
className = '',
|
||||
}: {
|
||||
children: ReactNode;
|
||||
delay?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<motion.div
|
||||
className={className}
|
||||
initial={{ opacity: 0, y: 28 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: '-80px' }}
|
||||
transition={{ duration: 0.7, delay, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useRef, useState, useEffect } from 'react';
|
||||
import { Canvas, useFrame } from '@react-three/fiber';
|
||||
import { Clouds, Cloud } from '@react-three/drei';
|
||||
import { EffectComposer, Bloom, Vignette, GodRays } from '@react-three/postprocessing';
|
||||
import { BlendFunction, KernelSize } from 'postprocessing';
|
||||
import * as THREE from 'three';
|
||||
|
||||
import { scrollState, pointerState, flashState, clamp01 } from './scroll-store';
|
||||
import { makeCloudField, makeRain, makeBolt, makeWeatherSample, sampleWeather } from './weather';
|
||||
import { rainVertexShader, rainFragmentShader, skyVertexShader, skyFragmentShader } from './rain-shader';
|
||||
|
||||
// One shared, allocation-free weather sample, refreshed once per frame by
|
||||
// <WeatherClock/> (mounted first) and read by every other piece of the scene.
|
||||
const weather = makeWeatherSample();
|
||||
|
||||
const SUN_POS = new THREE.Vector3(0, 34, -205);
|
||||
const SKY_RADIUS = 400;
|
||||
|
||||
function WeatherClock() {
|
||||
useFrame(() => {
|
||||
sampleWeather(clamp01(scrollState.progress), weather);
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
function GradientSky() {
|
||||
const matRef = useRef<THREE.ShaderMaterial>(null);
|
||||
const meshRef = useRef<THREE.Mesh>(null);
|
||||
const uniforms = useMemo(
|
||||
() => ({
|
||||
uTop: { value: new THREE.Color('#9fb4d4') },
|
||||
uBottom: { value: new THREE.Color('#e9f0f8') },
|
||||
uRadius: { value: SKY_RADIUS },
|
||||
uCameraPos: { value: new THREE.Vector3() },
|
||||
uSunDir: { value: SUN_POS.clone().normalize() },
|
||||
uSunColor: { value: new THREE.Color('#fff2d2') },
|
||||
uSunReveal: { value: 0 },
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
useFrame((state) => {
|
||||
const m = matRef.current;
|
||||
if (!m) return;
|
||||
m.uniforms.uTop.value.copy(weather.skyTop);
|
||||
m.uniforms.uBottom.value.copy(weather.skyBottom);
|
||||
m.uniforms.uSunColor.value.copy(weather.sunColor);
|
||||
m.uniforms.uSunReveal.value = weather.sunReveal;
|
||||
m.uniforms.uCameraPos.value.copy(state.camera.position);
|
||||
if (meshRef.current) {
|
||||
meshRef.current.position.x = state.camera.position.x;
|
||||
meshRef.current.position.z = state.camera.position.z;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<mesh ref={meshRef} frustumCulled={false}>
|
||||
<sphereGeometry args={[SKY_RADIUS, 32, 16]} />
|
||||
<shaderMaterial
|
||||
ref={matRef}
|
||||
side={THREE.BackSide}
|
||||
depthWrite={false}
|
||||
fog={false}
|
||||
uniforms={uniforms}
|
||||
vertexShader={skyVertexShader}
|
||||
fragmentShader={skyFragmentShader}
|
||||
/>
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
function SceneLights() {
|
||||
const hemi = useRef<THREE.HemisphereLight>(null);
|
||||
const sun = useRef<THREE.DirectionalLight>(null);
|
||||
const flash = useRef<THREE.PointLight>(null);
|
||||
|
||||
useFrame((state) => {
|
||||
if (hemi.current) {
|
||||
hemi.current.color.copy(weather.hemiSky);
|
||||
hemi.current.groundColor.copy(weather.hemiGround);
|
||||
hemi.current.intensity = weather.hemiIntensity + flashState.value * 1.2 * weather.storm;
|
||||
}
|
||||
if (sun.current) {
|
||||
sun.current.color.copy(weather.sunColor);
|
||||
sun.current.intensity = weather.sunIntensity;
|
||||
}
|
||||
if (flash.current) {
|
||||
flash.current.intensity = flashState.value * 9 * weather.storm;
|
||||
flash.current.position.set(
|
||||
state.camera.position.x + 6,
|
||||
state.camera.position.y + 22,
|
||||
state.camera.position.z - 18,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<hemisphereLight ref={hemi} intensity={1.1} />
|
||||
<directionalLight ref={sun} position={SUN_POS} intensity={0.5} />
|
||||
<pointLight ref={flash} color="#dbe6ff" intensity={0} distance={140} decay={1.4} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CloudLayer({ clouds, segments }: { clouds: number; segments: number }) {
|
||||
const group = useRef<THREE.Group>(null);
|
||||
const specs = useMemo(() => makeCloudField(clouds), [clouds]);
|
||||
|
||||
useFrame(() => {
|
||||
const g = group.current;
|
||||
if (!g) return;
|
||||
const o = clamp01(weather.cloudOpacity);
|
||||
g.traverse((child) => {
|
||||
const mat = (child as THREE.Mesh).material as THREE.Material | undefined;
|
||||
if (mat && 'opacity' in mat) (mat as THREE.Material & { opacity: number }).opacity = o;
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={group}>
|
||||
<Clouds material={THREE.MeshLambertMaterial} limit={clouds * segments + 64} frustumCulled={false}>
|
||||
{specs.map((c, i) => (
|
||||
<Cloud
|
||||
key={i}
|
||||
seed={c.seed}
|
||||
segments={segments}
|
||||
position={c.position}
|
||||
bounds={[8, 3, 8]}
|
||||
volume={c.volume}
|
||||
scale={c.scale}
|
||||
opacity={c.opacity}
|
||||
speed={c.speed}
|
||||
growth={4}
|
||||
color="#ffffff"
|
||||
fade={34}
|
||||
/>
|
||||
))}
|
||||
</Clouds>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function Rain({ drops }: { drops: number }) {
|
||||
const ref = useRef<THREE.LineSegments>(null);
|
||||
|
||||
const geometry = useMemo(() => {
|
||||
const { positions, seeds } = makeRain(drops);
|
||||
const g = new THREE.BufferGeometry();
|
||||
g.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
g.setAttribute('aSeed', new THREE.BufferAttribute(seeds, 1));
|
||||
return g;
|
||||
}, [drops]);
|
||||
|
||||
const material = useMemo(
|
||||
() =>
|
||||
new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
uTime: { value: 0 },
|
||||
uFall: { value: 34 },
|
||||
uBoxY: { value: 60 },
|
||||
uWind: { value: 2.2 },
|
||||
uColor: { value: new THREE.Color('#cfe2ff') },
|
||||
uOpacity: { value: 0 },
|
||||
},
|
||||
vertexShader: rainVertexShader,
|
||||
fragmentShader: rainFragmentShader,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => () => {
|
||||
geometry.dispose();
|
||||
material.dispose();
|
||||
}, [geometry, material]);
|
||||
|
||||
useFrame((state, delta) => {
|
||||
const d = Math.min(delta, 0.05);
|
||||
material.uniforms.uTime.value += d;
|
||||
material.uniforms.uOpacity.value = weather.rain * 0.85;
|
||||
if (ref.current) {
|
||||
ref.current.position.copy(state.camera.position);
|
||||
ref.current.visible = weather.rain > 0.02;
|
||||
}
|
||||
});
|
||||
|
||||
return <lineSegments ref={ref} geometry={geometry} material={material} frustumCulled={false} rotation={[0, 0, 0.07]} />;
|
||||
}
|
||||
|
||||
const BOLT_MAX = 600;
|
||||
|
||||
function Storm() {
|
||||
const boltRef = useRef<THREE.LineSegments>(null);
|
||||
const matRef = useRef<THREE.LineBasicMaterial>(null);
|
||||
const nextStrike = useRef(1.5);
|
||||
const flash = useRef(0);
|
||||
const boltLife = useRef(0);
|
||||
const strikeSeed = useRef(1);
|
||||
|
||||
const geometry = useMemo(() => {
|
||||
const g = new THREE.BufferGeometry();
|
||||
g.setAttribute('position', new THREE.BufferAttribute(new Float32Array(BOLT_MAX * 3), 3));
|
||||
g.setDrawRange(0, 0);
|
||||
return g;
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => geometry.dispose(), [geometry]);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
const d = Math.min(delta, 0.05);
|
||||
const storm = weather.storm;
|
||||
|
||||
flash.current *= Math.exp(-d * 6.5);
|
||||
nextStrike.current -= d;
|
||||
|
||||
if (storm > 0.22 && nextStrike.current <= 0) {
|
||||
flash.current = 1;
|
||||
boltLife.current = 0.22 + Math.random() * 0.14;
|
||||
strikeSeed.current = (Math.random() * 1e6) | 0;
|
||||
const bolt = makeBolt(strikeSeed.current);
|
||||
const attr = geometry.getAttribute('position') as THREE.BufferAttribute;
|
||||
const n = Math.min(bolt.length, BOLT_MAX * 3);
|
||||
(attr.array as Float32Array).set(bolt.subarray(0, n));
|
||||
attr.needsUpdate = true;
|
||||
geometry.setDrawRange(0, n / 3);
|
||||
nextStrike.current = (1.3 + Math.random() * 3.2) / Math.max(0.3, storm);
|
||||
}
|
||||
|
||||
flashState.value = Math.min(1, flash.current);
|
||||
|
||||
boltLife.current -= d;
|
||||
if (matRef.current) {
|
||||
const v = Math.max(0, boltLife.current);
|
||||
matRef.current.opacity = Math.min(1, v * 6) * (0.65 + 0.35 * Math.random());
|
||||
}
|
||||
if (boltRef.current) boltRef.current.visible = boltLife.current > 0 && storm > 0.2;
|
||||
});
|
||||
|
||||
return (
|
||||
<lineSegments ref={boltRef} geometry={geometry} frustumCulled={false} visible={false}>
|
||||
<lineBasicMaterial
|
||||
ref={matRef}
|
||||
color="#e4eeff"
|
||||
transparent
|
||||
opacity={0}
|
||||
depthWrite={false}
|
||||
blending={THREE.AdditiveBlending}
|
||||
/>
|
||||
</lineSegments>
|
||||
);
|
||||
}
|
||||
|
||||
function Sun({ onReady }: { onReady: (m: THREE.Mesh | null) => void }) {
|
||||
const ref = useRef<THREE.Mesh | null>(null);
|
||||
const matRef = useRef<THREE.MeshBasicMaterial>(null);
|
||||
const coronaRef = useRef<THREE.Mesh>(null);
|
||||
const coronaMat = useRef<THREE.MeshBasicMaterial>(null);
|
||||
|
||||
useEffect(() => {
|
||||
onReady(ref.current);
|
||||
return () => onReady(null);
|
||||
}, [onReady]);
|
||||
|
||||
useFrame(() => {
|
||||
const reveal = weather.sunReveal;
|
||||
if (ref.current) {
|
||||
ref.current.visible = reveal > 0.02;
|
||||
const s = 0.55 + reveal * 0.45;
|
||||
ref.current.scale.setScalar(s);
|
||||
}
|
||||
if (matRef.current) {
|
||||
matRef.current.color.copy(weather.sunColor);
|
||||
matRef.current.opacity = clamp01(reveal * 1.3);
|
||||
}
|
||||
if (coronaRef.current) {
|
||||
coronaRef.current.visible = reveal > 0.02;
|
||||
coronaRef.current.scale.setScalar(0.5 + reveal * 0.5);
|
||||
}
|
||||
if (coronaMat.current) {
|
||||
coronaMat.current.color.copy(weather.sunColor);
|
||||
coronaMat.current.opacity = reveal * 0.18;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<group position={SUN_POS}>
|
||||
<mesh ref={coronaRef} visible={false}>
|
||||
<sphereGeometry args={[14, 24, 24]} />
|
||||
<meshBasicMaterial
|
||||
ref={coronaMat}
|
||||
color="#fff3d2"
|
||||
transparent
|
||||
opacity={0}
|
||||
depthWrite={false}
|
||||
blending={THREE.AdditiveBlending}
|
||||
toneMapped={false}
|
||||
/>
|
||||
</mesh>
|
||||
<mesh ref={ref} visible={false}>
|
||||
<sphereGeometry args={[10, 32, 32]} />
|
||||
<meshBasicMaterial ref={matRef} color="#fff2d2" transparent toneMapped={false} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function CameraRig({ reduced }: { reduced: boolean }) {
|
||||
const look = useMemo(() => new THREE.Vector3(), []);
|
||||
useFrame((state, delta) => {
|
||||
const p = clamp01(scrollState.progress);
|
||||
const cam = state.camera;
|
||||
const k = Math.min(1, delta * 2.2);
|
||||
|
||||
if (reduced) {
|
||||
cam.position.set(0, 1.5, 12);
|
||||
look.set(0, 1.5, -30);
|
||||
cam.lookAt(look);
|
||||
return;
|
||||
}
|
||||
|
||||
const z = 24 + (-150 - 24) * p;
|
||||
const y = 2 - Math.sin(p * Math.PI) * 3.2 + p * 2.0;
|
||||
cam.position.x += (pointerState.x * 1.3 - cam.position.x) * k;
|
||||
cam.position.y += (y + pointerState.y * 0.7 - cam.position.y) * k;
|
||||
cam.position.z += (z - cam.position.z) * k;
|
||||
|
||||
look.set(
|
||||
pointerState.x * 2.5,
|
||||
cam.position.y + 1 + weather.sunReveal * 9,
|
||||
cam.position.z - 30,
|
||||
);
|
||||
cam.lookAt(look);
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
function FogController() {
|
||||
const ref = useRef<THREE.FogExp2>(null);
|
||||
useFrame(() => {
|
||||
if (!ref.current) return;
|
||||
ref.current.color.copy(weather.fog);
|
||||
ref.current.density = weather.fogDensity;
|
||||
});
|
||||
return <fogExp2 ref={ref} attach="fog" args={['#d8e0ec', 0.014]} />;
|
||||
}
|
||||
|
||||
export default function SceneCanvas({ reduced }: { reduced: boolean }) {
|
||||
const [sun, setSun] = useState<THREE.Mesh | null>(null);
|
||||
|
||||
const cfg = useMemo(() => {
|
||||
if (reduced) return { clouds: 8, segments: 12, rain: 0 };
|
||||
if (typeof window === 'undefined') return { clouds: 16, segments: 22, rain: 6000 };
|
||||
const w = window.innerWidth;
|
||||
if (w < 640) return { clouds: 9, segments: 13, rain: 1800 };
|
||||
if (w < 1024) return { clouds: 13, segments: 18, rain: 4000 };
|
||||
return { clouds: 16, segments: 24, rain: 6500 };
|
||||
}, [reduced]);
|
||||
|
||||
return (
|
||||
<Canvas
|
||||
dpr={[1, 2]}
|
||||
gl={{ antialias: true, powerPreference: 'high-performance', alpha: false }}
|
||||
camera={{ position: [0, 2, 24], fov: 60, near: 0.1, far: 700 }}
|
||||
>
|
||||
<WeatherClock />
|
||||
<FogController />
|
||||
<GradientSky />
|
||||
<SceneLights />
|
||||
<CloudLayer clouds={cfg.clouds} segments={cfg.segments} />
|
||||
{!reduced && cfg.rain > 0 && <Rain drops={cfg.rain} />}
|
||||
{!reduced && <Storm />}
|
||||
<Sun onReady={setSun} />
|
||||
<CameraRig reduced={reduced} />
|
||||
|
||||
{!reduced && sun && (
|
||||
<EffectComposer>
|
||||
<GodRays
|
||||
sun={sun}
|
||||
blendFunction={BlendFunction.SCREEN}
|
||||
samples={60}
|
||||
density={0.86}
|
||||
decay={0.94}
|
||||
weight={0.26}
|
||||
exposure={0.3}
|
||||
clampMax={0.85}
|
||||
kernelSize={KernelSize.SMALL}
|
||||
blur
|
||||
/>
|
||||
<Bloom intensity={0.55} luminanceThreshold={0.72} luminanceSmoothing={0.4} mipmapBlur radius={0.65} />
|
||||
<Vignette offset={0.2} darkness={0.55} />
|
||||
</EffectComposer>
|
||||
)}
|
||||
</Canvas>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
Rocket,
|
||||
Database,
|
||||
ShieldCheck,
|
||||
Link2,
|
||||
ScrollText,
|
||||
History,
|
||||
CreditCard,
|
||||
LifeBuoy,
|
||||
Zap,
|
||||
Server,
|
||||
Globe,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
export const BRAND = 'ابربان';
|
||||
|
||||
export const hero = {
|
||||
title: 'ابربان',
|
||||
tagline: 'زیرساخت ابری، در کنترل تو',
|
||||
subtitle:
|
||||
'اپلیکیشنت را در چند ثانیه روی کوبرنتیز منتشر کن — بدون دردسر سرور، بدون پیچیدگیِ DevOps.',
|
||||
ctaPrimary: 'رایگان شروع کن',
|
||||
ctaSecondary: 'ورود',
|
||||
};
|
||||
|
||||
interface Feature {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
desc: string;
|
||||
}
|
||||
|
||||
export const features: Feature[] = [
|
||||
{ icon: Rocket, title: 'دیپلوی برقآسا', desc: 'Node.js، لاراول و وردپرس را با یک کلیک منتشر کن؛ بیلد و انتشار خودکار.' },
|
||||
{ icon: Database, title: 'دیتابیس مدیریتشده', desc: 'PostgreSQL، MySQL، Redis، RabbitMQ و Elasticsearch، آماده و پایدار.' },
|
||||
{ icon: ShieldCheck, title: 'SSL خودکار', desc: 'دامنهٔ اختصاصی وصل کن؛ گواهی SSL خودکار صادر و تمدید میشود.' },
|
||||
{ icon: Link2, title: 'لینک پیشنمایش', desc: 'برای هر دیپلوی یک Preview URL پایدار روی TLS بگیر و سریع تست کن.' },
|
||||
{ icon: ScrollText, title: 'لاگ زنده', desc: 'لاگ بیلد و اجرای اپ را همان لحظه و بهصورت زنده دنبال کن.' },
|
||||
{ icon: History, title: 'اسنپشات و بکاپ', desc: 'از وضعیت اپ اسنپشات بگیر و هر زمان خواستی بازگردان.' },
|
||||
{ icon: CreditCard, title: 'بیلینگ شفاف', desc: 'کیفپول پیشپرداخت، فاکتور دقیق و مصرف لحظهای — بدون سورپرایز.' },
|
||||
{ icon: LifeBuoy, title: 'پشتیبانی فارسی', desc: 'تیم پشتیبانی و سیستم تیکت، فارسی و همیشه کنارت.' },
|
||||
];
|
||||
|
||||
interface Step {
|
||||
n: string;
|
||||
title: string;
|
||||
desc: string;
|
||||
}
|
||||
|
||||
export const steps: Step[] = [
|
||||
{ n: '۱', title: 'کدت را بده', desc: 'ریپازیتوری یا اپت را وصل کن.' },
|
||||
{ n: '۲', title: 'ما میسازیم', desc: 'ابربان بهصورت خودکار بیلد و روی کوبرنتیز دیپلوی میکند.' },
|
||||
{ n: '۳', title: 'آنلاین شو', desc: 'دامنه و SSL آماده است؛ اپت زنده میشود.' },
|
||||
];
|
||||
|
||||
interface Kpi {
|
||||
icon: LucideIcon;
|
||||
kpi: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const trust: Kpi[] = [
|
||||
{ icon: Zap, kpi: '< ۶۰ ثانیه', label: 'میانگین زمان دیپلوی' },
|
||||
{ icon: Server, kpi: '۹۹٫۹٪', label: 'پایداریِ زیرساخت' },
|
||||
{ icon: ShieldCheck, kpi: 'TLS خودکار', label: 'امنیتِ پیشفرض' },
|
||||
{ icon: Globe, kpi: 'بومیِ ایران', label: 'بهینه برای کاربر ایرانی' },
|
||||
];
|
||||
@@ -0,0 +1,70 @@
|
||||
// Rain as GL line segments. Each drop falls and wraps within a tall box that is
|
||||
// recentred on the camera every frame, so it always surrounds the viewer. The
|
||||
// two verts of a drop share aSeed, so the segment moves as one piece.
|
||||
|
||||
export const rainVertexShader = /* glsl */ `
|
||||
uniform float uTime;
|
||||
uniform float uFall;
|
||||
uniform float uBoxY;
|
||||
uniform float uWind;
|
||||
attribute float aSeed;
|
||||
varying float vAlpha;
|
||||
|
||||
void main() {
|
||||
float speed = uFall * (0.55 + aSeed * 0.9);
|
||||
float off = mod(uTime * speed + aSeed * uBoxY, uBoxY);
|
||||
float y = position.y - off;
|
||||
if (y < -uBoxY * 0.5) y += uBoxY;
|
||||
float x = position.x + uWind * (off * 0.12);
|
||||
vec4 mv = modelViewMatrix * vec4(x, y, position.z, 1.0);
|
||||
gl_Position = projectionMatrix * mv;
|
||||
// fade drops out near the top/bottom edges of the box
|
||||
float h = (y + uBoxY * 0.5) / uBoxY;
|
||||
vAlpha = smoothstep(0.0, 0.12, h) * smoothstep(1.0, 0.78, h);
|
||||
}
|
||||
`;
|
||||
|
||||
export const rainFragmentShader = /* glsl */ `
|
||||
uniform vec3 uColor;
|
||||
uniform float uOpacity;
|
||||
varying float vAlpha;
|
||||
|
||||
void main() {
|
||||
float a = vAlpha * uOpacity;
|
||||
if (a <= 0.001) discard;
|
||||
gl_FragColor = vec4(uColor, a);
|
||||
}
|
||||
`;
|
||||
|
||||
export const skyVertexShader = /* glsl */ `
|
||||
varying vec3 vLocal;
|
||||
varying vec3 vWorld;
|
||||
void main() {
|
||||
vLocal = position;
|
||||
vWorld = (modelMatrix * vec4(position, 1.0)).xyz;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
export const skyFragmentShader = /* glsl */ `
|
||||
uniform vec3 uTop;
|
||||
uniform vec3 uBottom;
|
||||
uniform float uRadius;
|
||||
uniform vec3 uCameraPos;
|
||||
uniform vec3 uSunDir;
|
||||
uniform vec3 uSunColor;
|
||||
uniform float uSunReveal;
|
||||
varying vec3 vLocal;
|
||||
varying vec3 vWorld;
|
||||
|
||||
void main() {
|
||||
float h = clamp(vLocal.y / uRadius * 0.5 + 0.5, 0.0, 1.0);
|
||||
vec3 col = mix(uBottom, uTop, pow(h, 0.9));
|
||||
// tight sun disc + a small soft halo, so the rest of the sky stays blue
|
||||
vec3 dir = normalize(vWorld - uCameraPos);
|
||||
float d = max(dot(dir, normalize(uSunDir)), 0.0);
|
||||
float halo = pow(d, 34.0) * 0.4 + pow(d, 220.0) * 1.3;
|
||||
col = mix(col, uSunColor, clamp(halo * uSunReveal, 0.0, 1.0));
|
||||
gl_FragColor = vec4(col, 1.0);
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,29 @@
|
||||
// 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 };
|
||||
|
||||
export function setScroll(progress: number) {
|
||||
scrollState.progress = progress < 0 ? 0 : progress > 1 ? 1 : progress;
|
||||
}
|
||||
|
||||
// Normalized pointer (-1..1), updated from a window listener so camera parallax
|
||||
// works across the whole page even where DOM sections overlay the canvas.
|
||||
export const pointerState = { x: 0, y: 0 };
|
||||
|
||||
export function setPointer(x: number, y: number) {
|
||||
pointerState.x = x;
|
||||
pointerState.y = y;
|
||||
}
|
||||
|
||||
// Lightning flash (0..1). Written by the storm controller in the render loop and
|
||||
// read by the DOM flash overlay so a strike lights the whole screen, not just 3D.
|
||||
export const flashState = { value: 0 };
|
||||
|
||||
export function clamp01(v: number) {
|
||||
return v < 0 ? 0 : v > 1 ? 1 : v;
|
||||
}
|
||||
|
||||
export function smoothstep(edge0: number, edge1: number, x: number) {
|
||||
const t = clamp01((x - edge0) / (edge1 - edge0));
|
||||
return t * t * (3 - 2 * t);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import { Reveal } from '../Reveal';
|
||||
import { features } from '../content';
|
||||
|
||||
export function Features() {
|
||||
return (
|
||||
<section className="relative px-6 py-28">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<Reveal className="mb-14 flex justify-center">
|
||||
<div className="abrban-panel rounded-3xl px-8 py-7 text-center">
|
||||
<h2 className="abrban-ink text-4xl font-bold text-white">هرچه برای ساختن لازم داری</h2>
|
||||
<p className="abrban-ink mt-4 text-lg text-white/85">یک جعبهابزارِ کامل برای انتشار و نگهداریِ اپ.</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{features.map((f, i) => (
|
||||
<Reveal key={f.title} delay={(i % 4) * 0.07}>
|
||||
<div className="abrban-panel group h-full rounded-2xl p-6 transition hover:-translate-y-1 hover:ring-1 hover:ring-primary-400/50">
|
||||
<div className="inline-flex h-12 w-12 items-center justify-center rounded-xl bg-primary-500/25 text-primary-100 ring-1 ring-primary-400/40">
|
||||
<f.icon className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="abrban-ink mt-5 text-lg font-bold text-white">{f.title}</h3>
|
||||
<p className="mt-2 text-sm leading-7 text-white/80">{f.desc}</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Reveal } from '../Reveal';
|
||||
|
||||
export function FinalCta() {
|
||||
return (
|
||||
<section className="relative px-6 py-36">
|
||||
<Reveal className="mx-auto max-w-3xl">
|
||||
<div className="abrban-panel rounded-[2rem] px-8 py-14 text-center sm:px-14">
|
||||
<h2 className="abrban-ink text-4xl font-black leading-tight text-white sm:text-5xl">
|
||||
آسمان صاف است؛ وقتِ <span className="abrban-shimmer">زنده</span> کردنِ اپِ توست.
|
||||
</h2>
|
||||
<p className="abrban-ink mt-6 text-lg text-white/85">
|
||||
همین حالا حسابت را بساز و اولین دیپلوی را تجربه کن.
|
||||
</p>
|
||||
<div className="mt-9 flex flex-col items-center justify-center gap-4 sm:flex-row">
|
||||
<Link
|
||||
href="/register"
|
||||
className="rounded-xl bg-primary-600 px-8 py-4 font-bold text-white shadow-lg shadow-primary-600/30 transition hover:bg-primary-500"
|
||||
>
|
||||
همین حالا شروع کن
|
||||
</Link>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="rounded-xl border border-white/25 bg-white/10 px-8 py-4 font-bold text-white transition hover:bg-white/20"
|
||||
>
|
||||
داشبورد
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import Link from 'next/link';
|
||||
import { Logo } from '../Logo';
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="relative border-t border-white/15 bg-slate-950/35 px-6 py-12 backdrop-blur-md">
|
||||
<div className="mx-auto flex max-w-6xl flex-col items-center justify-between gap-6 sm:flex-row">
|
||||
<Logo className="abrban-ink" />
|
||||
<nav className="abrban-ink flex items-center gap-6 text-sm text-white/80">
|
||||
<Link href="/login" className="transition hover:text-white">
|
||||
ورود
|
||||
</Link>
|
||||
<Link href="/register" className="transition hover:text-white">
|
||||
ثبتنام
|
||||
</Link>
|
||||
<Link href="/dashboard" className="transition hover:text-white">
|
||||
داشبورد
|
||||
</Link>
|
||||
</nav>
|
||||
<p className="abrban-ink text-sm text-white/70">© ابربان — همهٔ حقوق محفوظ است</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ArrowLeft, ChevronDown } from 'lucide-react';
|
||||
import { hero } from '../content';
|
||||
|
||||
export function Hero() {
|
||||
return (
|
||||
<section className="relative flex min-h-screen flex-col items-center justify-center px-6 text-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 1, ease: [0.22, 1, 0.36, 1] }}
|
||||
className="abrban-panel mx-4 max-w-2xl rounded-[2rem] px-6 py-12 sm:px-12 sm:py-14"
|
||||
>
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-white/20 bg-white/10 px-4 py-1.5 text-sm font-medium text-white/90 backdrop-blur-md">
|
||||
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-primary-400" />
|
||||
پلتفرم ابریِ خودسرویس
|
||||
</span>
|
||||
|
||||
<h1 className="mt-7 text-6xl font-black tracking-tight sm:text-7xl md:text-8xl">
|
||||
<span className="abrban-shimmer">{hero.title}</span>
|
||||
</h1>
|
||||
|
||||
<p className="abrban-ink mt-5 text-2xl font-bold text-white sm:text-3xl">{hero.tagline}</p>
|
||||
<p className="abrban-ink mx-auto mt-5 max-w-2xl text-base leading-8 text-white/85 sm:text-lg">
|
||||
{hero.subtitle}
|
||||
</p>
|
||||
|
||||
<div className="mt-9 flex flex-col items-center justify-center gap-4 sm:flex-row">
|
||||
<Link
|
||||
href="/register"
|
||||
className="group inline-flex items-center gap-2 rounded-xl bg-primary-600 px-7 py-3.5 font-bold text-white shadow-lg shadow-primary-600/30 transition hover:bg-primary-500 hover:shadow-primary-500/40"
|
||||
>
|
||||
{hero.ctaPrimary}
|
||||
<ArrowLeft className="h-4 w-4 transition group-hover:-translate-x-1" />
|
||||
</Link>
|
||||
<Link
|
||||
href="/login"
|
||||
className="rounded-xl border border-white/20 bg-white/5 px-7 py-3.5 font-bold text-white backdrop-blur-md transition hover:bg-white/10"
|
||||
>
|
||||
{hero.ctaSecondary}
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div className="abrban-ink absolute bottom-10 flex flex-col items-center text-white/80">
|
||||
<span className="text-xs font-medium">به دلِ ابرها بزن</span>
|
||||
<ChevronDown className="abrban-scroll-hint mt-1 h-5 w-5" />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import { Reveal } from '../Reveal';
|
||||
import { steps } from '../content';
|
||||
|
||||
export function HowItWorks() {
|
||||
return (
|
||||
<section 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">
|
||||
به سادگیِ سه قدم
|
||||
</h2>
|
||||
</Reveal>
|
||||
|
||||
<div className="grid gap-8 md:grid-cols-3">
|
||||
{steps.map((s, i) => (
|
||||
<Reveal key={s.n} delay={i * 0.1}>
|
||||
<div className="abrban-panel relative rounded-2xl p-8 text-center">
|
||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-primary-600/35 text-2xl font-black text-primary-100 ring-1 ring-primary-400/50">
|
||||
{s.n}
|
||||
</div>
|
||||
<h3 className="abrban-ink mt-5 text-xl font-bold text-white">{s.title}</h3>
|
||||
<p className="mt-2 leading-8 text-white/80">{s.desc}</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Logo } from '../Logo';
|
||||
|
||||
export function SiteHeader() {
|
||||
return (
|
||||
<motion.header
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
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">
|
||||
<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">
|
||||
<Link
|
||||
href="/login"
|
||||
className="rounded-lg px-4 py-2 text-sm font-semibold text-white/90 transition hover:text-white"
|
||||
>
|
||||
ورود
|
||||
</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"
|
||||
>
|
||||
ثبتنام
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</motion.header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import { Reveal } from '../Reveal';
|
||||
import { trust } from '../content';
|
||||
|
||||
export function Trust() {
|
||||
return (
|
||||
<section className="relative px-6 py-28">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<Reveal className="mb-14 flex justify-center">
|
||||
<h2 className="abrban-panel abrban-ink rounded-3xl px-8 py-5 text-center text-3xl font-bold text-white sm:text-4xl">
|
||||
چرا تیمها به ابربان اعتماد میکنند
|
||||
</h2>
|
||||
</Reveal>
|
||||
|
||||
<div className="grid grid-cols-2 gap-5 lg:grid-cols-4">
|
||||
{trust.map((t, i) => (
|
||||
<Reveal key={t.label} delay={(i % 4) * 0.07}>
|
||||
<div className="abrban-panel rounded-2xl p-7 text-center">
|
||||
<t.icon className="mx-auto h-7 w-7 text-primary-200" />
|
||||
<div className="abrban-ink mt-4 text-3xl font-black text-white">{t.kpi}</div>
|
||||
<div className="mt-1 text-sm text-white/75">{t.label}</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import { Reveal } from '../Reveal';
|
||||
|
||||
export function Value() {
|
||||
return (
|
||||
<section className="relative px-6 py-32">
|
||||
<Reveal className="mx-auto max-w-3xl">
|
||||
<div className="abrban-panel rounded-3xl px-8 py-12 text-center sm:px-12">
|
||||
<h2 className="abrban-ink text-3xl font-bold leading-snug text-white sm:text-4xl">
|
||||
یک پلتفرمِ <span className="text-primary-300">PaaS خودسرویس</span> روی کوبرنتیز —
|
||||
تمامِ قدرتِ زیرساختِ ابری، بدون پیچیدگیِ طوفانیاش.
|
||||
</h2>
|
||||
<p className="abrban-ink mt-6 text-lg leading-9 text-white/85">
|
||||
ابربان لایههای سختِ DevOps را برایت مدیریت میکند تا فقط روی محصولت تمرکز کنی.
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import * as THREE from 'three';
|
||||
|
||||
// A scroll-driven weather journey: float among bright clouds -> dive into them ->
|
||||
// rain -> thunderstorm -> the sky clears to brilliant sun. Each stop below is a
|
||||
// keyframe; the scene linearly interpolates between adjacent stops by scroll
|
||||
// progress (0..1) every frame, so colors/fog/light/rain all move together.
|
||||
|
||||
interface WeatherStop {
|
||||
p: number;
|
||||
skyTop: string;
|
||||
skyBottom: string;
|
||||
fog: string;
|
||||
fogDensity: number;
|
||||
hemiSky: string;
|
||||
hemiGround: string;
|
||||
hemiIntensity: number;
|
||||
sunColor: string;
|
||||
sunIntensity: number;
|
||||
cloudOpacity: number;
|
||||
rain: number; // 0..1 rain intensity
|
||||
storm: number; // 0..1 lightning likelihood
|
||||
sunReveal: number; // 0..1 sun disc brightness + glow
|
||||
}
|
||||
|
||||
const STOPS: WeatherStop[] = [
|
||||
// 0 — calm, bright overcast: floating among soft white clouds (Hero)
|
||||
{ p: 0.0, skyTop: '#9fb4d4', skyBottom: '#e9f0f8', fog: '#d8e0ec', fogDensity: 0.014, hemiSky: '#dbe8fb', hemiGround: '#c6cfdb', hemiIntensity: 1.2, sunColor: '#eef4ff', sunIntensity: 0.45, cloudOpacity: 0.85, rain: 0, storm: 0, sunReveal: 0 },
|
||||
// 1 — diving into the cloud deck: fog thickens, light grays out
|
||||
{ p: 0.2, skyTop: '#8090a6', skyBottom: '#bac6d4', fog: '#aab6c5', fogDensity: 0.04, hemiSky: '#c0cddd', hemiGround: '#a6b0bd', hemiIntensity: 0.85, sunColor: '#dde6f2', sunIntensity: 0.25, cloudOpacity: 1.0, rain: 0.12, storm: 0, sunReveal: 0 },
|
||||
// 2 — rain: dark gray cloud, streaks falling
|
||||
{ p: 0.42, skyTop: '#525e70', skyBottom: '#7c8a9a', fog: '#6d7b8c', fogDensity: 0.052, hemiSky: '#838fa0', hemiGround: '#67707e', hemiIntensity: 0.6, sunColor: '#b7c2d0', sunIntensity: 0.16, cloudOpacity: 1.0, rain: 0.75, storm: 0.18, sunReveal: 0 },
|
||||
// 3 — thunderstorm peak: darkest, heavy rain + lightning
|
||||
{ p: 0.64, skyTop: '#1f2735', skyBottom: '#374150', fog: '#2b3543', fogDensity: 0.06, hemiSky: '#3a4453', hemiGround: '#242b37', hemiIntensity: 0.38, sunColor: '#7c8898', sunIntensity: 0.08, cloudOpacity: 1.0, rain: 1.0, storm: 1.0, sunReveal: 0 },
|
||||
// 4 — clouds part, sun breaks through, sky brightens to blue
|
||||
{ p: 0.85, skyTop: '#2f7fd2', skyBottom: '#d2eaff', fog: '#c4e3ff', fogDensity: 0.015, hemiSky: '#bfe3ff', hemiGround: '#ffe8be', hemiIntensity: 1.3, sunColor: '#fff2d2', sunIntensity: 1.5, cloudOpacity: 0.34, rain: 0.08, storm: 0, sunReveal: 0.72 },
|
||||
// 5 — clear, calm blue sky, no clouds, bright open sun
|
||||
{ p: 1.0, skyTop: '#1763c8', skyBottom: '#8ccbff', fog: '#bfe4ff', fogDensity: 0.003, hemiSky: '#bfe4ff', hemiGround: '#fff1d2', hemiIntensity: 1.45, sunColor: '#fff2cc', sunIntensity: 2.0, cloudOpacity: 0.0, rain: 0, storm: 0, sunReveal: 1.0 },
|
||||
];
|
||||
|
||||
interface StopColors {
|
||||
p: number;
|
||||
skyTop: THREE.Color;
|
||||
skyBottom: THREE.Color;
|
||||
fog: THREE.Color;
|
||||
fogDensity: number;
|
||||
hemiSky: THREE.Color;
|
||||
hemiGround: THREE.Color;
|
||||
hemiIntensity: number;
|
||||
sunColor: THREE.Color;
|
||||
sunIntensity: number;
|
||||
cloudOpacity: number;
|
||||
rain: number;
|
||||
storm: number;
|
||||
sunReveal: number;
|
||||
}
|
||||
|
||||
const STOP_COLORS: StopColors[] = STOPS.map((s) => ({
|
||||
p: s.p,
|
||||
skyTop: new THREE.Color(s.skyTop),
|
||||
skyBottom: new THREE.Color(s.skyBottom),
|
||||
fog: new THREE.Color(s.fog),
|
||||
fogDensity: s.fogDensity,
|
||||
hemiSky: new THREE.Color(s.hemiSky),
|
||||
hemiGround: new THREE.Color(s.hemiGround),
|
||||
hemiIntensity: s.hemiIntensity,
|
||||
sunColor: new THREE.Color(s.sunColor),
|
||||
sunIntensity: s.sunIntensity,
|
||||
cloudOpacity: s.cloudOpacity,
|
||||
rain: s.rain,
|
||||
storm: s.storm,
|
||||
sunReveal: s.sunReveal,
|
||||
}));
|
||||
|
||||
export interface WeatherSample {
|
||||
skyTop: THREE.Color;
|
||||
skyBottom: THREE.Color;
|
||||
fog: THREE.Color;
|
||||
fogDensity: number;
|
||||
hemiSky: THREE.Color;
|
||||
hemiGround: THREE.Color;
|
||||
hemiIntensity: number;
|
||||
sunColor: THREE.Color;
|
||||
sunIntensity: number;
|
||||
cloudOpacity: number;
|
||||
rain: number;
|
||||
storm: number;
|
||||
sunReveal: number;
|
||||
}
|
||||
|
||||
export function makeWeatherSample(): WeatherSample {
|
||||
return {
|
||||
skyTop: new THREE.Color(),
|
||||
skyBottom: new THREE.Color(),
|
||||
fog: new THREE.Color(),
|
||||
fogDensity: 0,
|
||||
hemiSky: new THREE.Color(),
|
||||
hemiGround: new THREE.Color(),
|
||||
hemiIntensity: 0,
|
||||
sunColor: new THREE.Color(),
|
||||
sunIntensity: 0,
|
||||
cloudOpacity: 0,
|
||||
rain: 0,
|
||||
storm: 0,
|
||||
sunReveal: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
|
||||
|
||||
// Interpolate the palette at progress `p` into `out` (mutated, no allocation).
|
||||
export function sampleWeather(p: number, out: WeatherSample): WeatherSample {
|
||||
const prog = p < 0 ? 0 : p > 1 ? 1 : p;
|
||||
let i = 0;
|
||||
while (i < STOP_COLORS.length - 2 && prog > STOP_COLORS[i + 1].p) i++;
|
||||
const a = STOP_COLORS[i];
|
||||
const b = STOP_COLORS[i + 1];
|
||||
const span = b.p - a.p || 1;
|
||||
let t = (prog - a.p) / span;
|
||||
t = t < 0 ? 0 : t > 1 ? 1 : t;
|
||||
t = t * t * (3 - 2 * t); // smoothstep for buttery transitions
|
||||
|
||||
out.skyTop.copy(a.skyTop).lerp(b.skyTop, t);
|
||||
out.skyBottom.copy(a.skyBottom).lerp(b.skyBottom, t);
|
||||
out.fog.copy(a.fog).lerp(b.fog, t);
|
||||
out.hemiSky.copy(a.hemiSky).lerp(b.hemiSky, t);
|
||||
out.hemiGround.copy(a.hemiGround).lerp(b.hemiGround, t);
|
||||
out.sunColor.copy(a.sunColor).lerp(b.sunColor, t);
|
||||
out.fogDensity = lerp(a.fogDensity, b.fogDensity, t);
|
||||
out.hemiIntensity = lerp(a.hemiIntensity, b.hemiIntensity, t);
|
||||
out.sunIntensity = lerp(a.sunIntensity, b.sunIntensity, t);
|
||||
out.cloudOpacity = lerp(a.cloudOpacity, b.cloudOpacity, t);
|
||||
out.rain = lerp(a.rain, b.rain, t);
|
||||
out.storm = lerp(a.storm, b.storm, t);
|
||||
out.sunReveal = lerp(a.sunReveal, b.sunReveal, t);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Cloud field layout ────────────────────────────────────────────────
|
||||
// Billboardy volumetric clouds spread along -Z so the camera flies through them.
|
||||
// Denser in the middle (the storm), thinning out near the end so the sky opens
|
||||
// up to the sun. Deterministic (seeded) for stable frames.
|
||||
|
||||
export interface CloudSpec {
|
||||
position: [number, number, number];
|
||||
scale: number;
|
||||
seed: number;
|
||||
volume: number;
|
||||
opacity: number;
|
||||
speed: number;
|
||||
}
|
||||
|
||||
function mulberry32(seed: number) {
|
||||
let a = seed >>> 0;
|
||||
return () => {
|
||||
a |= 0;
|
||||
a = (a + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
export function makeCloudField(count: number): CloudSpec[] {
|
||||
const rnd = mulberry32(0x5eed1234);
|
||||
const clouds: CloudSpec[] = [];
|
||||
// Z range the camera travels through (start ~ +18, ends near the sun ~ -150).
|
||||
const zStart = 14;
|
||||
const zEnd = -150;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const u = i / Math.max(1, count - 1);
|
||||
// Bias clouds toward the middle of the journey (the storm region).
|
||||
const z = zStart + (zEnd - zStart) * Math.min(1, u * 0.92 + (rnd() - 0.5) * 0.06);
|
||||
const stormy = 1 - Math.abs(u - 0.62) / 0.62; // 0..1, peaks mid-journey
|
||||
const spread = 16 + 14 * (1 - stormy);
|
||||
const x = (rnd() - 0.5) * spread * 2;
|
||||
const y = (rnd() - 0.5) * 14 - 1.5;
|
||||
const scale = 1.4 + rnd() * 2.6 + stormy * 1.2;
|
||||
clouds.push({
|
||||
position: [x, y, z],
|
||||
scale,
|
||||
seed: Math.floor(rnd() * 1e6),
|
||||
volume: 7 + rnd() * 9 + stormy * 4,
|
||||
opacity: 0.55 + rnd() * 0.4,
|
||||
speed: 0.12 + rnd() * 0.25,
|
||||
});
|
||||
}
|
||||
return clouds;
|
||||
}
|
||||
|
||||
// ── Rain geometry ─────────────────────────────────────────────────────
|
||||
// Each drop is a short vertical line segment (2 verts). aSeed drives a per-drop
|
||||
// fall phase/speed in the shader; the field is a box recentred on the camera.
|
||||
|
||||
export interface RainGeometryData {
|
||||
positions: Float32Array; // xyz per vertex
|
||||
seeds: Float32Array; // one value per vertex (shared per drop)
|
||||
}
|
||||
|
||||
export function makeRain(count: number, box = { x: 64, y: 60, z: 60 }): RainGeometryData {
|
||||
const rnd = mulberry32(0xa11ce);
|
||||
const positions = new Float32Array(count * 2 * 3);
|
||||
const seeds = new Float32Array(count * 2);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const x = (rnd() - 0.5) * box.x;
|
||||
const y = (rnd() - 0.5) * box.y;
|
||||
const z = (rnd() - 0.5) * box.z;
|
||||
const len = 1.1 + rnd() * 1.6;
|
||||
const seed = rnd();
|
||||
const o = i * 6;
|
||||
// top vertex
|
||||
positions[o] = x;
|
||||
positions[o + 1] = y + len;
|
||||
positions[o + 2] = z;
|
||||
// bottom vertex
|
||||
positions[o + 3] = x;
|
||||
positions[o + 4] = y;
|
||||
positions[o + 5] = z;
|
||||
seeds[i * 2] = seed;
|
||||
seeds[i * 2 + 1] = seed;
|
||||
}
|
||||
return { positions, seeds };
|
||||
}
|
||||
|
||||
// A jagged top-to-bottom lightning bolt path (x,y,z triples) for LineSegments.
|
||||
export function makeBolt(seedBase: number): Float32Array {
|
||||
const rnd = mulberry32(seedBase >>> 0);
|
||||
const segments = 11 + Math.floor(rnd() * 6);
|
||||
const top = 26 + rnd() * 8;
|
||||
const bottom = -16 - rnd() * 6;
|
||||
const x0 = (rnd() - 0.5) * 36;
|
||||
const z = -40 - rnd() * 50;
|
||||
const pts: number[] = [];
|
||||
let x = x0;
|
||||
let prevY = top;
|
||||
let prevX = x;
|
||||
for (let s = 1; s <= segments; s++) {
|
||||
const y = top + (bottom - top) * (s / segments);
|
||||
x += (rnd() - 0.5) * 6;
|
||||
// line segment from previous point to this point
|
||||
pts.push(prevX, prevY, z, x, y, z);
|
||||
// occasional fork
|
||||
if (rnd() > 0.78 && s < segments - 1) {
|
||||
pts.push(x, y, z, x + (rnd() - 0.5) * 9, y - 3 - rnd() * 4, z);
|
||||
}
|
||||
prevX = x;
|
||||
prevY = y;
|
||||
}
|
||||
return new Float32Array(pts);
|
||||
}
|
||||
Reference in New Issue
Block a user