This commit is contained in:
jrosh 2026-03-17 18:47:48 +01:00
commit 8c0e589376
Signed by: jrosh
GPG key ID: CC50156D9BDF5EFB
51 changed files with 4882 additions and 908 deletions

19
components.json Normal file
View file

@ -0,0 +1,19 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "slate",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
},
"registries": {
"@fancy": "https://www.fancycomponents.dev/r/{name}.json"
}
}

View file

@ -10,18 +10,28 @@
"preview": "vite preview"
},
"dependencies": {
"@types/matter-js": "^0.20.2",
"clsx": "^2.1.1",
"i18next": "^25.8.4",
"i18next-browser-languagedetector": "^8.2.0",
"lodash": "^4.17.23",
"lucide-react": "^0.563.0",
"matter-js": "^0.20.0",
"motion": "^12.34.2",
"poly-decomp": "^0.3.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-i18next": "^16.5.4",
"react-router-dom": "^7.13.0"
"react-router-dom": "^7.13.0",
"shadcn": "^3.8.5",
"svg-path-commander": "^2.1.11",
"tailwind-merge": "^3.5.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tailwindcss/vite": "^4.1.18",
"@types/react": "^19.2.5",
"@types/node": "^25.3.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.23",
@ -30,7 +40,8 @@
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"tailwindcss": "^4.2.0",
"typescript": "^5.9.3",
"vite": "^7.2.4"
}
}

View file

@ -1,68 +1,52 @@
<?php
require_once __DIR__ . '/vendor/autoload.php';
use Dotenv\Dotenv;
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;
// Load environment variables
$dotenv = Dotenv::createImmutable(__DIR__);
$dotenv->load();
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: POST');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$data = json_decode(file_get_contents('php://input'), true);
// Check honeypot
if (!empty($data['website']) || !empty($data['phone_check'])) {
http_response_code(200);
echo json_encode(['success' => true]);
exit;
}
// Validate and sanitize
$name = htmlspecialchars($data['name']);
$email = filter_var($data['email'], FILTER_SANITIZE_EMAIL);
$company = htmlspecialchars($data['company']);
$type = htmlspecialchars($data['type']);
$message = htmlspecialchars($data['message']);
try {
$mail = new PHPMailer(true);
// SMTP Configuration
$mail->isSMTP();
$mail->Host = $_ENV['SMTP_HOST'];
$mail->SMTPAuth = true;
$mail->Username = $_ENV['SMTP_USERNAME'];
$mail->Password = $_ENV['SMTP_PASSWORD'];
$mail->SMTPSecure = $_ENV['SMTP_ENCRYPTION'];
$mail->Port = $_ENV['SMTP_PORT'];
// Recipients
$mail->setFrom($_ENV['SMTP_FROM_EMAIL'], $_ENV['SMTP_FROM_NAME']);
$mail->addAddress($_ENV['CONTACT_EMAIL']);
$mail->addReplyTo($email, $name);
// Content
$mail->isHTML(false);
$mail->Subject = 'New Contact Form: ' . $type;
$mail->Body = "Name: $name\nEmail: $email\nCompany: $company\nType: $type\n\nMessage:\n$message";
$mail->send();
http_response_code(200);
echo json_encode(['success' => true]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => $mail->ErrorInfo]);
}
} else {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);
exit;
}
?>
$data = json_decode(file_get_contents('php://input'), true);
// Check honeypot
if (!empty($data['website']) || !empty($data['phone_check'])) {
http_response_code(200);
echo json_encode(['success' => true]);
exit;
}
// Validate and sanitize
$name = htmlspecialchars($data['name'] ?? '');
$email = filter_var($data['email'] ?? '', FILTER_SANITIZE_EMAIL);
$company = htmlspecialchars($data['company'] ?? '');
$type = htmlspecialchars($data['type'] ?? '');
$message = htmlspecialchars($data['message'] ?? '');
if (!$name || !$email || !$message || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
http_response_code(422);
echo json_encode(['success' => false, 'error' => 'Invalid input']);
exit;
}
$to = 'contact@ahojsvet.eu'; // TODO: replace with your actual address
$subject = 'New Contact Form: ' . $type;
$body = "Name: $name\nEmail: $email\nCompany: $company\nType: $type\n\nMessage:\n$message";
$headers = implode("\r\n", [
'From: noreply@ahojsvet.eu',
'Reply-To: ' . $name . ' <' . $email . '>',
'X-Mailer: PHP/' . phpversion(),
'Content-Type: text/plain; charset=UTF-8',
]);
if (mail($to, $subject, $body, $headers)) {
http_response_code(200);
echo json_encode(['success' => true]);
} else {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'mail() failed']);
}
?>

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

BIN
public/screenshots/cms.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 692 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

View file

@ -25,16 +25,18 @@ export default function App() {
return (
<div className="bg-gray-900 text-white min-h-screen font-sans antialiased">
<Navigation scrollY={scrollY} />
<Hero />
<TheProblem />
<Features />
<SpeedComparison />
<HiddenCostsIcebergSection />
<Screenshots />
<InTheWild />
<ContactForm />
<CTA />
<main className="relative z-10 bg-gray-900">
<Navigation scrollY={scrollY} />
<Hero />
<TheProblem />
<Features />
<SpeedComparison />
<HiddenCostsIcebergSection />
<Screenshots />
{/* <InTheWild /> */}
<ContactForm />
<CTA />
</main>
<Footer />
</div>
);

View file

@ -0,0 +1,90 @@
import { useEffect, useState } from 'react';
interface GlowState {
x: number;
y: number;
w: number;
h: number;
}
interface AmbientGlowProps {
/** Base width in px */
baseWidth?: number;
/** Base height in px */
baseHeight?: number;
/** Max horizontal drift from center in px */
driftX?: number;
/** Max vertical drift from center in px */
driftY?: number;
/** How much the size can vary (01 fraction of base size) */
sizeFuzz?: number;
/** Milliseconds between morphs */
interval?: number;
/** Tailwind classes for bg color + opacity, e.g. "bg-red-700/10" */
className?: string;
/** Anchor point — defaults to center of parent */
anchorX?: string;
anchorY?: string;
}
function randomState(
baseWidth: number,
baseHeight: number,
driftX: number,
driftY: number,
sizeFuzz: number,
): GlowState {
return {
x: (Math.random() - 0.5) * driftX * 2,
y: (Math.random() - 0.5) * driftY * 2,
w: baseWidth * (1 + (Math.random() - 0.5) * sizeFuzz),
h: baseHeight * (1 + (Math.random() - 0.5) * sizeFuzz),
};
}
export default function AmbientGlow({
baseWidth = 600,
baseHeight = 600,
driftX = 120,
driftY = 80,
sizeFuzz = 0.35,
interval = 5000,
className = 'bg-red-700/10',
anchorX = '50%',
anchorY = '50%',
}: AmbientGlowProps) {
const [glow, setGlow] = useState<GlowState>({
x: 0,
y: 0,
w: baseWidth,
h: baseHeight,
});
useEffect(() => {
const morph = () =>
setGlow(randomState(baseWidth, baseHeight, driftX, driftY, sizeFuzz));
// Start at a random position immediately
morph();
const id = setInterval(morph, interval);
return () => clearInterval(id);
}, [baseWidth, baseHeight, driftX, driftY, sizeFuzz, interval]);
const duration = Math.round(interval * 0.85);
return (
<div className="absolute inset-0 pointer-events-none overflow-hidden">
<div
className={`absolute rounded-full blur-3xl ${className}`}
style={{
width: glow.w,
height: glow.h,
top: anchorY,
left: anchorX,
transform: `translate(calc(-50% + ${glow.x}px), calc(-50% + ${glow.y}px))`,
transition: `transform ${duration}ms cubic-bezier(0.45, 0, 0.55, 1), width ${duration}ms ease-in-out, height ${duration}ms ease-in-out`,
}}
/>
</div>
);
}

View file

@ -1,29 +0,0 @@
import { useTranslation } from 'react-i18next';
import { Play } from 'lucide-react';
export default function CTA() {
const { t } = useTranslation();
const cta = t('cta', { returnObjects: true });
return (
<section className="py-32 bg-gray-900 relative overflow-hidden">
<div className="absolute inset-0">
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] bg-red-700/10 rounded-full blur-3xl" />
</div>
<div className="max-w-4xl mx-auto px-6 text-center relative z-10">
<h2 className="text-4xl md:text-6xl font-black mb-6 text-white">
{cta.title}
</h2>
<p className="text-xl text-gray-400 mb-12">
{cta.subtitle}
</p>
<a
href="https://demo.ahojsvet.eu"
className="inline-flex items-center gap-2 bg-red-700 hover:bg-red-600 text-white px-10 py-5 rounded-xl font-bold text-xl transition-all glow hover:scale-105"
>
<Play size={24} /> {cta.button}
</a>
</div>
</section>
);
}

83
src/components/CTA.tsx Normal file
View file

@ -0,0 +1,83 @@
import { useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { BookOpen } from 'lucide-react';
import ScrambleIn, { ScrambleInHandle } from './fancy/text/scramble-in';
import { useInView } from 'motion/react';
import ScrambleHover from './fancy/text/scramble-hover';
import VerticalCutReveal from './fancy/text/vertical-cut-reveal';
import AmbientGlow from './AmbientGlow';
// ── types ────────────────────────────────────────────────────────────────────
interface CTATranslation {
title: string;
subtitle: string;
button: string;
}
// ── component ─────────────────────────────────────────────────────────────────
export default function CTA() {
const { t } = useTranslation();
const cta = t('cta', { returnObjects: true }) as CTATranslation;
const scrambleRef = useRef<ScrambleInHandle>(null);
const h2Ref = useRef<HTMLHeadingElement>(null);
const isH2InView = useInView(h2Ref, { once: true });
useEffect(() => {
if (isH2InView) scrambleRef.current?.start();
}, [isH2InView]);
return (
<section className="relative overflow-hidden bg-gray-900 py-64">
<AmbientGlow className="bg-red-700/10" />
<div className="relative z-10 flex flex-col items-center gap-8 text-center px-6">
{/* Title */}
<h2 ref={h2Ref} className="text-4xl md:text-6xl font-black text-white">
<ScrambleIn
ref={scrambleRef}
text={cta.title}
characters="ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%^&*"
scrambleSpeed={35}
autoStart={false}
className="inline"
/>
</h2>
{/* Subtitle */}
<p className="text-xl text-gray-400 max-w-2xl mx-auto">
<VerticalCutReveal
splitBy="words"
staggerFrom="first"
staggerDuration={0.04}
transition={{ type: 'spring', damping: 26, stiffness: 140, delay: 0.5 }}
autoStart
containerClassName="justify-center flex-wrap"
elementLevelClassName="pb-1"
>
{cta.subtitle}
</VerticalCutReveal>
</p>
{/* CTA button */}
<a
href="https://demo.ahojsvet.eu"
className="group inline-flex items-center gap-3 bg-red-700 hover:bg-red-600 text-white px-10 py-5 rounded-xl font-bold text-xl transition-colors glow select-none ring-2 ring-red-700/40 hover:ring-red-500/60 ring-offset-2 ring-offset-gray-900"
>
<BookOpen size={24} className="shrink-0 transition-transform group-hover:-rotate-6" />
<ScrambleHover
text={cta.button}
scrambleSpeed={40}
maxIterations={8}
characters="ABCDEFGHIJKLMNOPQRSTUVWXYZ"
className="text-white font-bold"
scrambledClassName="text-red-300"
/>
</a>
</div>
</section>
);
}

View file

@ -1,10 +1,42 @@
import { useState } from 'react';
import { useState, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useInView } from 'motion/react';
import ScrambleIn, { ScrambleInHandle } from '@/components/fancy/text/scramble-in';
interface ContactTranslation {
title: string;
subtitle: string;
name: string;
email: string;
company: string;
type: string;
types: string[];
message: string;
send: string;
success: string;
errors: {
name: string;
email: string;
emailInvalid: string;
message: string;
send: string;
};
}
export default function ContactForm() {
const { t } = useTranslation();
const contact = t('contact', { returnObjects: true });
const contact = t('contact', { returnObjects: true }) as ContactTranslation;
const h2Ref = useRef<HTMLHeadingElement>(null);
const scrambleRef = useRef<ScrambleInHandle>(null);
const isH2InView = useInView(h2Ref, { once: true });
useEffect(() => {
if (isH2InView) scrambleRef.current?.start();
}, [isH2InView]);
const [formSent, setFormSent] = useState(false);
const [formError, setFormError] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const [formData, setFormData] = useState({
name: '',
email: '',
@ -15,33 +47,55 @@ export default function ContactForm() {
phone_check: ''
});
const handleSubmit = () => {
// Check honeypot fields
if (formData.website || formData.phone_check) {
return; // Ignore if honeypot fields are filled
const validate = () => {
const next: Record<string, string> = {};
if (!formData.name.trim()) next.name = contact.errors?.name ?? 'Required';
if (!formData.email.trim()) {
next.email = contact.errors?.email ?? 'Required';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
next.email = contact.errors?.emailInvalid ?? 'Invalid email';
}
if (!formData.message.trim()) next.message = contact.errors?.message ?? 'Required';
return next;
};
// TODO: Replace with actual form submission endpoint
console.log('Form submitted:', formData);
setFormSent(true);
setTimeout(() => setFormSent(false), 3000);
setFormData({
name: '',
email: '',
company: '',
type: contact.types[0],
message: '',
website: '',
phone_check: ''
});
const handleSubmit = async () => {
if (formData.website || formData.phone_check) return;
const validationErrors = validate();
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
setErrors({});
setFormError('');
setIsSubmitting(true);
try {
const res = await fetch('/api/contact.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
});
const json = await res.json();
if (!res.ok || !json.success) throw new Error(json.error ?? 'Server error');
setFormSent(true);
setTimeout(() => setFormSent(false), 3000);
setFormData({ name: '', email: '', company: '', type: contact.types[0], message: '', website: '', phone_check: '' });
} catch {
setFormError(contact.errors?.send ?? 'Failed to send. Please try again.');
} finally {
setIsSubmitting(false);
}
};
return (
<section id="contact" className="py-32 bg-gray-900">
<section id="contact" className="pt-32 bg-gray-900">
<div className="max-w-3xl mx-auto px-6">
<div className="text-center mb-16">
<h2 className="text-4xl md:text-6xl font-black mb-4 text-white">
{contact.title}
<h2 ref={h2Ref} className="text-4xl md:text-6xl font-black mb-4 text-white">
<ScrambleIn ref={scrambleRef} text={contact.title} autoStart={false} />
</h2>
<p className="text-xl text-gray-400">
{contact.subtitle}
@ -59,8 +113,9 @@ export default function ContactForm() {
required
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-3 text-white focus:border-red-700 focus:outline-none transition-colors"
className={`w-full bg-gray-900 border rounded-lg px-4 py-3 text-white focus:outline-none transition-colors ${errors.name ? 'border-red-500 focus:border-red-500' : 'border-gray-700 focus:border-red-700'}`}
/>
{errors.name && <p className="mt-1 text-xs text-red-400">{errors.name}</p>}
</div>
<div>
<label className="block text-sm font-bold mb-2 text-white">
@ -71,8 +126,9 @@ export default function ContactForm() {
required
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-3 text-white focus:border-red-700 focus:outline-none transition-colors"
className={`w-full bg-gray-900 border rounded-lg px-4 py-3 text-white focus:outline-none transition-colors ${errors.email ? 'border-red-500 focus:border-red-500' : 'border-gray-700 focus:border-red-700'}`}
/>
{errors.email && <p className="mt-1 text-xs text-red-400">{errors.email}</p>}
</div>
</div>
@ -114,8 +170,9 @@ export default function ContactForm() {
rows={6}
value={formData.message}
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-3 text-white focus:border-red-700 focus:outline-none transition-colors resize-none"
className={`w-full bg-gray-900 border rounded-lg px-4 py-3 text-white focus:outline-none transition-colors resize-none ${errors.message ? 'border-red-500 focus:border-red-500' : 'border-gray-700 focus:border-red-700'}`}
/>
{errors.message && <p className="mt-1 text-xs text-red-400">{errors.message}</p>}
</div>
{/* Honeypot fields - hidden from users */}
@ -140,9 +197,10 @@ export default function ContactForm() {
<button
onClick={handleSubmit}
className="w-full bg-red-700 hover:bg-red-600 text-white px-8 py-4 rounded-xl font-bold text-lg transition-all glow hover:scale-[1.02]"
disabled={isSubmitting}
className="w-full bg-red-700 hover:bg-red-600 disabled:opacity-60 disabled:cursor-not-allowed text-white px-8 py-4 rounded-xl font-bold text-lg transition-all glow hover:scale-[1.02]"
>
{contact.send}
{isSubmitting ? '...' : contact.send}
</button>
{formSent && (
@ -150,6 +208,12 @@ export default function ContactForm() {
<div className="text-green-400 font-bold">{contact.success}</div>
</div>
)}
{formError && (
<div className="bg-red-900 border border-red-700 rounded-lg px-6 py-4 text-center">
<div className="text-red-400 font-bold">{formError}</div>
</div>
)}
</div>
</div>
</section>

View file

@ -1,38 +0,0 @@
import { useTranslation } from 'react-i18next';
import { Shield, Globe2, BarChart3, Blocks, Mail, Tag, Users, Layers, Zap } from 'lucide-react';
const Icons = { Shield, Globe2, BarChart3, Blocks, Mail, Tag, Users, Layers, Zap };
export default function Features() {
const { t } = useTranslation();
return (
<section className="pb-32 pt-12 bg-white text-gray-900">
<div className="max-w-6xl mx-auto px-6">
<div className="text-center mb-20">
<h2 className="text-4xl md:text-6xl font-black mb-4">
{t('features.title')}
</h2>
<p className="text-2xl md:text-3xl font-bold text-red-700">
{t('features.subtitle')}
</p>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{t('features.items', { returnObjects: true }).map((feature, i) => {
const Icon = Icons[feature.icon];
return (
<div key={i} className="card-hover bg-gray-100 rounded-2xl p-8 border border-gray-200">
<div className="w-14 h-14 bg-red-700 rounded-xl flex items-center justify-center mb-6">
<Icon size={28} className="text-white" />
</div>
<h3 className="text-xl font-bold mb-3">{feature.title}</h3>
<p className="text-gray-600 leading-relaxed">{feature.desc}</p>
</div>
);
})}
</div>
</div>
</section>
);
}

133
src/components/Features.tsx Normal file
View file

@ -0,0 +1,133 @@
import { useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Shield, Globe2, BarChart3, Blocks, Mail, Tag, Users, Layers, Zap } from 'lucide-react';
import { motion, useInView } from 'motion/react';
import { TextHighlighter } from './fancy/text/text-highlighter';
import VerticalCutReveal from './fancy/text/vertical-cut-reveal';
import ScrambleIn, { ScrambleInHandle } from '@/components/fancy/text/scramble-in';
// ── types ────────────────────────────────────────────────────────────────────
interface FeatureItem {
icon: string;
title: string;
desc: string; // matches your JSON key
}
interface FeaturesTranslation {
title: string;
subtitle: string;
items: FeatureItem[];
}
// ── icon map ─────────────────────────────────────────────────────────────────
const Icons: Record<string, React.ElementType> = {
Shield, Globe2, BarChart3, Blocks, Mail, Tag, Users, Layers, Zap,
};
// ── animated feature card ────────────────────────────────────────────────────
function FeatureCard({ feature, index }: { feature: FeatureItem; index: number }) {
const ref = useRef<HTMLDivElement>(null);
const isInView = useInView(ref, { once: true, amount: 0.4 });
const Icon = Icons[feature.icon] ?? Blocks;
return (
<motion.div
ref={ref}
initial={{ opacity: 0, y: 40 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{
type: 'spring',
damping: 22,
stiffness: 120,
delay: index * 0.08,
}}
className="group bg-gray-100/60 backdrop-blur-sm rounded-2xl p-8 border border-gray-700/50
hover:border-red-700/40 transition-colors duration-300"
>
{/* Icon + counter */}
<div className="flex items-start justify-between mb-5">
<div className="w-12 h-12 bg-red-700/80 rounded-xl flex items-center justify-center
group-hover:bg-red-600 transition-colors duration-300">
<Icon size={24} className="text-white" />
</div>
<span className="text-xs font-mono text-gray-600 select-none">
{String(index + 1).padStart(2, '0')}
</span>
</div>
{/* Title with highlight sweep */}
<h3 className="text-lg font-bold mb-2 text-gray-800">
<TextHighlighter
highlightColor="rgba(185,28,28,0.3)"
direction="ltr"
triggerType="inView"
useInViewOptions={{ once: true, amount: 0.8 }}
transition={{ type: 'spring', duration: 0.8, bounce: 0, delay: 0.1 }}
>
{feature.title}
</TextHighlighter>
</h3>
<p className="text-gray-900 leading-relaxed text-sm">
{feature.desc}
</p>
</motion.div>
);
}
// ── main component ───────────────────────────────────────────────────────────
export default function Features() {
const { t } = useTranslation();
const { title, subtitle, items } = t('features', { returnObjects: true }) as FeaturesTranslation;
const h2Ref = useRef<HTMLHeadingElement>(null);
const scrambleRef = useRef<ScrambleInHandle>(null);
const isH2InView = useInView(h2Ref, { once: true });
useEffect(() => {
if (isH2InView) scrambleRef.current?.start();
}, [isH2InView]);
return (
<section className="pb-24 pt-16 bg-white text-gray-900">
<div className="max-w-6xl mx-auto px-6">
{/* ── Section header ──────────────────────────────────────────── */}
<div className="text-center mb-20">
<h2 ref={h2Ref} className="text-4xl md:text-6xl font-black mb-4">
<ScrambleIn ref={scrambleRef} text={title} autoStart={false} />
</h2>
<p className="text-2xl md:text-3xl font-bold text-red-500">
<VerticalCutReveal
splitBy="words"
staggerFrom="first"
staggerDuration={0.04}
transition={{ type: 'spring', damping: 26, stiffness: 140, delay: 0.3 }}
autoStart
containerClassName="justify-center flex-wrap"
elementLevelClassName="pb-1"
>
{subtitle}
</VerticalCutReveal>
</p>
</div>
{/* ── Feature grid ────────────────────────────────────────────── */}
{/*
3-column grid instead of stacking cards:
- 9 items scan in ~2 seconds vs ~15s of scrolling
- Staggered fade-in still gives motion and life
- Much less scroll fatigue on a landing page
*/}
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-5">
{items.map((feature, i) => (
<FeatureCard key={i} feature={feature} index={i} />
))}
</div>
</div>
</section>
);
}

View file

@ -1,35 +0,0 @@
import { useTranslation } from 'react-i18next';
export default function Footer() {
const { t } = useTranslation();
const footer = t('footer', { returnObjects: true });
return (
<footer className="py-12 border-t border-gray-800 bg-gray-900">
<div className="max-w-6xl mx-auto px-6">
<div className="flex flex-col md:flex-row items-center justify-between gap-6">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-red-700 rounded-lg flex items-center justify-center font-black text-white">
A
</div>
<span className="font-bold text-white">AhojSvet.eu</span>
</div>
<div className="flex items-center gap-8 text-gray-400">
<a href="https://demo.ahojsvet.eu" className="hover:text-white transition-colors">
Demo
</a>
<a href="https://navod.ahojsvet.eu" className="hover:text-white transition-colors">
Docs
</a>
<a href="#contact" className="hover:text-white transition-colors">
Kontakt
</a>
</div>
<p className="text-gray-500 text-sm">
{footer.tagline}
</p>
</div>
</div>
</footer>
);
}

141
src/components/Footer.tsx Normal file
View file

@ -0,0 +1,141 @@
import { useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import ScrambleHover from './fancy/text/scramble-hover';
import ScrambleIn, { ScrambleInHandle } from './fancy/text/scramble-in';
import { useInView } from 'motion/react';
// ── types ────────────────────────────────────────────────────────────────────
interface FooterTranslation {
tagline: string;
}
// ── nav structure ─────────────────────────────────────────────────────────────
interface NavLink {
label: string;
href: string;
}
const NAV_PRIMARY: NavLink[] = [
{ label: 'Demo', href: 'https://demo.ahojsvet.eu' },
{ label: 'Docs', href: 'https://navod.ahojsvet.eu' },
{ label: 'Kontakt', href: '#contact' },
];
const NAV_SECONDARY: NavLink[] = [
{ label: 'GitHub', href: 'https://github.com/JRoshthen1' },
{ label: 'Telegram', href: 'https://t.me/peter_dirichlet' },
//{ label: 'X (Twitter)', href: '#' },
];
// ── scramble hover link ──────────────────────────────────────────────────────
function NavItem({ label, href }: NavLink) {
return (
<li>
<a
href={href}
target={href.startsWith('http') ? '_blank' : undefined}
rel={href.startsWith('http') ? 'noopener noreferrer' : undefined}
className="hover:text-red-500 transition-colors cursor-pointer block"
>
<ScrambleHover
text={label}
characters="ABCDEFGHIJKLMNOPQRSTUVWXYZ"
sequential
revealDirection="start"
scrambleSpeed={60}
className="font-medium"
/>
</a>
</li>
);
}
// ── component ─────────────────────────────────────────────────────────────────
export default function Footer() {
const { t } = useTranslation();
const footer = t('footer', { returnObjects: true }) as FooterTranslation;
const footerRef = useRef<HTMLDivElement>(null);
const scrambleRef = useRef<ScrambleInHandle>(null);
const isInView = useInView(footerRef, { once: true, margin: '0px 0px -60px 0px' });
useEffect(() => {
if (isInView) scrambleRef.current?.start();
}, [isInView]);
return (
/*
Pattern from fancycomponents.dev/docs/components/blocks/sticky-footer:
- footer: sticky bottom-0 z-0 left-0 + fixed height (h-80)
- main content above must have: relative z-10
The main content slides over this footer as you scroll.
*/
<footer
ref={footerRef}
className="sticky z-0 bottom-0 left-0 w-full h-80 bg-gray-900 border-t border-gray-800"
>
{/* Single relative + overflow-hidden container fills the footer */}
<div className="relative overflow-hidden w-full h-full flex justify-end items-start px-8 md:px-12 py-10">
{/* ── Logo + nav + tagline row ──────────────────────────────── */}
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-8 w-full">
{/* Logo mark */}
<div className="flex items-center gap-2 flex-shrink-0">
<div className="w-8 h-8 bg-red-700 rounded-lg flex items-center justify-center font-black text-white select-none">
A
</div>
<span className="font-bold text-white">AhojSvet.eu</span>
</div>
{/* Nav columns */}
<div className="flex gap-12 md:gap-20 text-gray-400 text-sm md:text-base">
<ul className="space-y-2">
{NAV_PRIMARY.map((link) => (
<NavItem key={link.label} {...link} />
))}
</ul>
<ul className="space-y-2">
{NAV_SECONDARY.map((link) => (
<NavItem key={link.label} {...link} />
))}
</ul>
</div>
{/* Tagline */}
<p className="text-gray-500 text-sm max-w-xs text-left md:text-right">
{footer.tagline}
</p>
</div>
{/*
Oversized brand name
Absolute-positioned at bottom-left of the footer container,
translate-y-1/3 clips the bottom third via overflow-hidden.
Matches the fancy.dev reference pattern exactly.
*/}
<h2
className="absolute bottom-0 left-0 translate-y-1/3 font-black text-gray-800 leading-none whitespace-nowrap pointer-events-none select-none"
style={{ fontSize: 'clamp(80px, 14vw, 192px)' }}
aria-hidden="true"
>
<ScrambleIn
ref={scrambleRef}
text="AhojSvet"
characters="ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
scrambleSpeed={30}
revealSpeed={50}
revealDirection="start"
autoStart={false}
className="inline"
/>
</h2>
</div>
</footer>
);
}

View file

@ -1,47 +0,0 @@
import { useTranslation } from 'react-i18next';
import { BookOpen, Play, ChevronRight } from 'lucide-react';
export default function Hero() {
const { t } = useTranslation();
return (
<section className="min-h-screen flex items-center justify-center relative overflow-hidden pt-20 bg-gray-900">
<div className="absolute inset-0 overflow-hidden">
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-red-700/20 rounded-full blur-3xl pulse-slow" />
<div className="absolute bottom-1/4 right-1/4 w-64 h-64 bg-red-900/20 rounded-full blur-3xl pulse-slow" style={{ animationDelay: '2s' }} />
</div>
<div className="max-w-6xl mx-auto px-6 text-center relative z-10">
<div className="float">
<h1 className="text-5xl sm:text-7xl md:text-8xl font-black mb-2 tracking-tight text-white">
{t('hero.tagline')}
</h1>
<h1 className="text-5xl sm:text-7xl md:text-8xl font-black pb-8 tracking-tight gradient-text">
{t('hero.subtitle')}
</h1>
</div>
<p className="text-xl md:text-2xl text-gray-400 max-w-2xl mx-auto mb-12 font-medium">
{t('hero.description')}
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<a
href="https://demo.ahojsvet.eu"
className="inline-flex items-center justify-center gap-2 bg-red-700 hover:bg-red-600 text-white px-8 py-4 rounded-xl font-bold text-lg transition-all glow hover:scale-105"
>
<Play size={20} /> {t('hero.cta')}
</a>
<a
href="https://navod.ahojsvet.eu"
className="inline-flex items-center justify-center gap-2 bg-gray-800 hover:bg-gray-700 text-white px-8 py-4 rounded-xl font-bold text-lg transition-colors"
>
<BookOpen size={20} /> {t('hero.docs')}
</a>
</div>
</div>
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 animate-bounce">
<ChevronRight size={32} className="rotate-90 text-gray-600" />
</div>
</section>
);
}

129
src/components/Hero.tsx Normal file
View file

@ -0,0 +1,129 @@
import { useTranslation } from 'react-i18next';
import { BookOpen, Play, ChevronRight } from 'lucide-react';
import ScrambleIn, { ScrambleInHandle } from '@/components/fancy/text/scramble-in';
import TextRotate from '@/components/fancy/text/text-rotate';
import VerticalCutReveal from '@/components/fancy/text/vertical-cut-reveal';
import { useRef, useEffect } from 'react';
import AmbientGlow from './AmbientGlow';
import ScrambleHover from './fancy/text/scramble-hover';
// ── types ────────────────────────────────────────────────────────────────────
interface HeroTranslation {
tagline: string;
/** Array of pain-point strings cycled by TextRotate */
subtitleRotations: string[];
description: string;
cta: string;
docs: string;
}
export default function Hero() {
const { t, ready } = useTranslation();
const hero = t('hero', { returnObjects: true }) as HeroTranslation;
const scrambleRef = useRef<ScrambleInHandle>(null);
// Re-fire the scramble whenever the tagline changes (e.g. language switch)
useEffect(() => {
scrambleRef.current?.start();
}, [hero.tagline]);
// Don't render animated components until i18n is hydrated
if (!ready || !Array.isArray(hero.subtitleRotations)) return null;
return (
<section className="min-h-screen flex items-center justify-center relative overflow-hidden pt-20 bg-gray-900">
{/* ── Ambient blobs ──────────────────────────────────────────────── */}
<AmbientGlow className="bg-red-700/20" baseWidth={384} baseHeight={384} anchorX="25%" anchorY="25%" driftX={80} driftY={60} interval={6000} />
<AmbientGlow className="bg-red-900/20" baseWidth={256} baseHeight={256} anchorX="75%" anchorY="75%" driftX={60} driftY={50} interval={7500} />
<div className="max-w-6xl mx-auto px-6 text-center relative z-10">
{/* ── Main headline — ScrambleIn ──────────────────────────────── */}
<div className="float">
<h1 className="text-5xl sm:text-7xl md:text-8xl font-black mb-2 tracking-tight text-white">
<ScrambleIn
ref={scrambleRef}
text={hero.tagline}
characters="ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%^&*"
scrambleSpeed={40}
scrambledLetterCount={5}
autoStart={false}
className="inline"
/>
</h1>
{/* ── Rotating subtitle — TextRotate ─────────────────────── */}
<h2 className="text-5xl sm:text-7xl md:text-8xl font-black pb-8 tracking-tight gradient-text overflow-hidden flex justify-center h-24 sm:h-36 md:h-48">
<TextRotate
texts={hero.subtitleRotations}
auto
rotationInterval={2800}
splitBy="words"
initial={{ y: '100%', opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: '-120%', opacity: 0 }}
transition={{ type: 'spring', damping: 22, stiffness: 200 }}
staggerDuration={0.06}
mainClassName="inline-flex text-center flex-wrap justify-center gap-x-4"
splitLevelClassName="overflow-hidden"
elementLevelClassName="inline-block"
/>
</h2>
</div>
{/* ── Description — VerticalCutReveal ────────────────────────── */}
<div className="text-xl md:text-2xl text-gray-400 max-w-2xl mx-auto mb-12 font-medium">
<VerticalCutReveal
splitBy="words"
staggerFrom="first"
staggerDuration={0.04}
transition={{ type: 'spring', damping: 26, stiffness: 180, delay: 0.6 }}
autoStart
containerClassName="justify-center flex-wrap"
elementLevelClassName="pb-1"
>
{hero.description}
</VerticalCutReveal>
</div>
{/* ── CTAs ───────────────────────────────────────────────────── */}
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<a
href="https://demo.ahojsvet.eu"
className="inline-flex items-center justify-center gap-2 bg-red-700 hover:bg-red-600 text-white px-8 py-4 rounded-xl font-bold text-lg transition-all glow hover:scale-105"
>
<Play size={20} />
<ScrambleHover
text={hero.cta}
scrambleSpeed={40}
maxIterations={8}
characters="ABCDEFGHIJKLMNOPQRSTUVWXYZ"
scrambledClassName="text-red-300"
/>
</a>
<a
href="https://navod.ahojsvet.eu"
className="inline-flex items-center justify-center gap-2 bg-gray-800 hover:bg-gray-700 text-white px-8 py-4 rounded-xl font-bold text-lg transition-colors"
>
<BookOpen size={20} />
<ScrambleHover
text={hero.docs}
scrambleSpeed={40}
maxIterations={8}
characters="ABCDEFGHIJKLMNOPQRSTUVWXYZ"
scrambledClassName="text-red-300"
/>
</a>
</div>
</div>
{/* ── Scroll hint ────────────────────────────────────────────────── */}
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 animate-bounce">
<ChevronRight size={32} className="rotate-90 text-gray-600" />
</div>
</section>
);
}

View file

@ -1,212 +0,0 @@
import { useState, useEffect, useRef } from "react";
import { Eye, EyeOff, ChevronDown, Puzzle, RefreshCw, Shield, CreditCard, Code2, Cookie, BarChart3, Mail, AlertTriangle, Clock, Check } from "lucide-react";
import { useTranslation } from 'react-i18next';
const iconMap = {
setup: CreditCard, theme: Code2, plugin: Puzzle, hosting: BarChart3,
maintenance: RefreshCw, conflict: AlertTriangle, security: Shield,
change: Clock, cookie: Cookie, analytics: BarChart3, email: Mail, dev: Code2,
};
const severityColor = {
1: "border-yellow-500/40 bg-yellow-500/5",
2: "border-orange-500/40 bg-orange-500/5",
3: "border-red-500/40 bg-red-500/5",
};
const severityDot = { 1: "bg-yellow-500", 2: "bg-orange-500", 3: "bg-red-500" };
export default function CostIceberg() {
const [revealed, setRevealed] = useState(false);
const [visibleCount, setVisibleCount] = useState(0);
const sectionRef = useRef(null);
const { t } = useTranslation();
useEffect(() => {
if (!revealed) { setVisibleCount(0); return; }
let i = 0;
const hiddenItems = t('iceberg.hidden', { returnObjects: true });
const interval = setInterval(() => {
i++;
setVisibleCount(i);
if (i >= hiddenItems.length) clearInterval(interval);
}, 120);
return () => clearInterval(interval);
}, [revealed, t]);
const tipItems = t('iceberg.tip', { returnObjects: true });
const hiddenItems = t('iceberg.hidden', { returnObjects: true });
const ahojsvetPoints = t('iceberg.ahojsvet.points', { returnObjects: true });
return (
<section ref={sectionRef} className="py-20 md:py-32 text-white" style={{ background: "#111827" }}>
<div className="max-w-6xl mx-auto px-4 md:px-6">
{/* Header */}
<div className="text-center mb-16 md:mb-20">
<h2 className="text-4xl md:text-6xl font-black mb-4" style={{ fontFamily: "'Work Sans', sans-serif" }}>
{t('iceberg.title')}
</h2>
<p className="text-lg md:text-2xl font-medium" style={{ color: "#f87171" }}>
{t('iceberg.subtitle')}
</p>
</div>
<div className="grid lg:grid-cols-2 gap-8 md:gap-12 items-start">
{/* WordPress Iceberg */}
<div>
{/* Tip */}
<div className="mb-2">
<div className="flex items-center gap-2 mb-4">
<Eye size={18} style={{ color: "#9ca3af" }} />
<span className="text-sm font-bold uppercase tracking-wider" style={{ color: "#9ca3af" }}>
{t('iceberg.tipLabel')}
</span>
</div>
<div className="space-y-3">
{tipItems.map((item, i) => {
const Icon = iconMap[item.icon];
return (
<div key={i} className="rounded-xl p-4 md:p-5 border flex items-start gap-4"
style={{ background: "#1f2937", borderColor: "#374151" }}>
<div className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0"
style={{ background: "#374151" }}>
<Icon size={20} style={{ color: "#9ca3af" }} />
</div>
<div>
<div className="font-bold">{item.label}</div>
<div className="text-sm" style={{ color: "#9ca3af" }}>{item.desc}</div>
</div>
</div>
);
})}
</div>
</div>
{/* Waterline + Reveal Button */}
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t-2 border-dashed" style={{ borderColor: "#3b82f6", opacity: 0.4 }} />
</div>
<div className="relative flex justify-center">
<button
onClick={() => setRevealed(!revealed)}
className="flex items-center gap-2 px-5 py-2.5 rounded-full font-bold text-sm transition-all"
style={{
background: revealed ? "#1f2937" : "#b91c1c",
color: "#fff",
boxShadow: revealed ? "none" : "0 0 30px rgba(185,28,28,0.4)",
}}
>
{revealed ? <EyeOff size={16} /> : <ChevronDown size={16} />}
{revealed ? t('iceberg.hideBtn') : t('iceberg.revealBtn')}
</button>
</div>
</div>
{/* Hidden Costs */}
<div style={{
maxHeight: revealed ? "2000px" : "0px",
opacity: revealed ? 1 : 0,
overflow: "hidden",
transition: "max-height 0.8s ease, opacity 0.5s ease",
}}>
<div className="flex items-center gap-2 mb-4">
<EyeOff size={18} style={{ color: "#ef4444" }} />
<span className="text-sm font-bold uppercase tracking-wider" style={{ color: "#ef4444" }}>
{t('iceberg.hiddenLabel')}
</span>
</div>
<div className="space-y-3">
{hiddenItems.map((item, i) => {
const Icon = iconMap[item.icon];
const visible = i < visibleCount;
return (
<div
key={i}
className={`rounded-xl p-4 md:p-5 border flex items-start gap-4 ${severityColor[item.severity]}`}
style={{
opacity: visible ? 1 : 0,
transform: visible ? "translateY(0)" : "translateY(12px)",
transition: "opacity 0.35s ease, transform 0.35s ease",
}}
>
<div className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0"
style={{ background: "rgba(255,255,255,0.05)" }}>
<Icon size={20} style={{ color: "#f87171" }} />
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-0.5">
<span className="font-bold">{item.label}</span>
<span className={`w-2 h-2 rounded-full ${severityDot[item.severity]}`} />
</div>
<div className="text-sm" style={{ color: "#9ca3af" }}>{item.desc}</div>
</div>
</div>
);
})}
</div>
{/* Summary */}
{visibleCount >= hiddenItems.length && (
<div className="mt-6 rounded-xl p-5 border text-center"
style={{
borderColor: "#ef4444",
background: "rgba(239,68,68,0.08)",
animation: "fadeIn 0.5s ease",
}}>
<div className="text-2xl md:text-3xl font-black" style={{ color: "#ef4444" }}>
10+ skrytých nákladov
</div>
<div className="text-sm mt-1" style={{ color: "#9ca3af" }}>
...a to sme ešte nezačali riešiť škálovanie.
</div>
</div>
)}
</div>
</div>
{/* AhojSvet - Clean Side */}
<div className="lg:sticky lg:top-32">
<div className="rounded-2xl p-6 md:p-8 border-4" style={{ background: "#f0fdf4", borderColor: "#16a34a" }}>
<div className="text-center mb-8">
<div className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full text-sm font-bold mb-4"
style={{ background: "#16a34a", color: "#fff" }}>
Bez prekvapení
</div>
<h3 className="text-3xl font-black" style={{ color: "#111827" }}>{t('iceberg.ahojsvet.title')}</h3>
<p className="text-base mt-1" style={{ color: "#6b7280" }}>{t('iceberg.ahojsvet.subtitle')}</p>
</div>
<div className="space-y-5">
{ahojsvetPoints.map((p, i) => (
<div key={i} className="flex items-start gap-3">
<div className="w-6 h-6 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5"
style={{ background: "#16a34a" }}>
<Check size={14} color="#fff" strokeWidth={3} />
</div>
<div>
<div className="font-bold" style={{ color: "#111827" }}>{p.label}</div>
<div className="text-sm" style={{ color: "#6b7280" }}>{p.desc}</div>
</div>
</div>
))}
</div>
<div className="mt-8 text-center">
<a href="#contact"
className="inline-block px-8 py-4 rounded-xl font-bold text-lg transition-all"
style={{
background: "#b91c1c", color: "#fff",
boxShadow: "0 0 40px rgba(185,28,28,0.3)",
}}>
{t('iceberg.ahojsvet.cta')}
</a>
</div>
</div>
</div>
</div>
</div>
<style>{`
@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
`}</style>
</section>
);
}

View file

@ -0,0 +1,287 @@
import { useState, useRef, useEffect } from "react";
import {
Eye, EyeOff, ChevronDown, Puzzle, RefreshCw, Shield,
CreditCard, Code2, Cookie, BarChart3, Mail, AlertTriangle, Clock, Check, HelpCircle,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import Gravity, { MatterBody } from "./fancy/physics/gravity";
import { useInView } from 'motion/react';
import ScrambleIn, { ScrambleInHandle } from '@/components/fancy/text/scramble-in';
// ─── maps ────────────────────────────────────────────────────────────────────
const iconMap: Record<string, React.ElementType> = {
setup: CreditCard, theme: Code2, plugin: Puzzle, hosting: BarChart3,
maintenance: RefreshCw, conflict: AlertTriangle, security: Shield,
change: Clock, cookie: Cookie, analytics: BarChart3, email: Mail, dev: Code2,
};
const severityBorder: Record<number, string> = {
1: "border-yellow-500/40 bg-yellow-500/5",
2: "border-orange-500/40 bg-orange-500/5",
3: "border-red-500/40 bg-red-500/5",
};
const severityDotColor: Record<number, string> = {
1: "#eab308",
2: "#f97316",
3: "#ef4444",
};
const staggered = (index: number, total: number) => {
const cols = 3;
const col = index % cols;
const row = Math.floor(index / cols);
return {
x: `${15 + col * 30}%`, // 15%, 45%, 75%
y: `${4 + row * 9}%`, // start inside the box, spaced vertically
angle: (index % 2 === 0 ? -1 : 1) * (3 + (index % 5)),
};
};
/* ── Inside the revealed && (...) block ── */
// ─── component ───────────────────────────────────────────────────────────────
export default function CostIceberg() {
const [revealed, setRevealed] = useState(false);
const [gravityKey, setGravityKey] = useState(0);
const { t } = useTranslation();
const tipItems = t("iceberg.tip", { returnObjects: true }) as any[];
const hiddenItems = t("iceberg.hidden", { returnObjects: true }) as any[];
const ahojsvetPts = t("iceberg.ahojsvet.points", { returnObjects: true }) as any[];
const h2Ref = useRef<HTMLHeadingElement>(null);
const scrambleRef = useRef<ScrambleInHandle>(null);
const isH2InView = useInView(h2Ref, { once: true });
useEffect(() => {
if (isH2InView) scrambleRef.current?.start();
}, [isH2InView]);
const handleToggle = () => {
if (!revealed) {
setGravityKey((k) => k + 1);
setRevealed(true);
} else {
setRevealed(false);
}
};
return (
<section className="py-20 md:py-32 text-white" style={{ background: "#111827" }}>
<div className="max-w-6xl mx-auto px-4 md:px-6">
{/* Header */}
<div className="text-center mb-16 md:mb-20">
<h2
ref={h2Ref}
className="text-4xl md:text-6xl font-black mb-4"
style={{ fontFamily: "'Work Sans', sans-serif" }}
>
<ScrambleIn ref={scrambleRef} text={t("iceberg.title")} autoStart={false} />
</h2>
<p className="text-lg md:text-2xl font-medium" style={{ color: "#f87171" }}>
{t("iceberg.subtitle")}
</p>
</div>
<div className="grid lg:grid-cols-2 gap-8 md:gap-12 items-start">
{/* ── WordPress Iceberg (physics side) ─────────────────────────── */}
<div>
{/* Visible tip items */}
<div className="mb-2">
<div className="flex items-center gap-2 mb-4">
<Eye size={18} style={{ color: "#9ca3af" }} />
<span
className="text-sm font-bold uppercase tracking-wider"
style={{ color: "#9ca3af" }}
>
{t("iceberg.tipLabel")}
</span>
</div>
<div className="space-y-3">
{tipItems.map((item, i) => {
const Icon = iconMap[item.icon] ?? HelpCircle;
return (
<div
key={i}
className="rounded-xl p-4 md:p-5 border flex items-start gap-4"
style={{ background: "#1f2937", borderColor: "#374151" }}
>
<div
className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0"
style={{ background: "#374151" }}
>
<Icon size={20} style={{ color: "#9ca3af" }} />
</div>
<div>
<div className="font-bold">{item.label}</div>
<div className="text-sm" style={{ color: "#9ca3af" }}>{item.desc}</div>
</div>
</div>
);
})}
</div>
</div>
{/* Waterline + toggle button */}
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div
className="w-full border-t-2 border-dashed"
style={{ borderColor: "#3b82f6", opacity: 0.4 }}
/>
</div>
<div className="relative flex justify-center">
<button
onClick={handleToggle}
className="flex items-center gap-2 px-5 py-2.5 rounded-full font-bold text-sm transition-all"
style={{
background: revealed ? "#1f2937" : "#b91c1c",
color: "#fff",
boxShadow: revealed ? "none" : "0 0 30px rgba(185,28,28,0.4)",
}}
>
{revealed ? <EyeOff size={16} /> : <ChevronDown size={16} />}
{revealed ? t("iceberg.hideBtn") : t("iceberg.revealBtn")}
</button>
</div>
</div>
{/* ── Physics drop zone ────────────────────────────────────── */}
{revealed && (
<div
className="relative rounded-2xl overflow-hidden"
style={{
// scale height to item count so there's room to breathe
height: Math.max(520, hiddenItems.length * 64),
background: "rgba(239,68,68,0.04)",
border: "1px solid rgba(239,68,68,0.15)",
}}
>
{/* Label */}
<div className="absolute top-3 left-4 flex items-center gap-2 z-10 pointer-events-none">
<EyeOff size={16} style={{ color: "#ef4444" }} />
<span className="text-xs font-bold uppercase tracking-wider" style={{ color: "#ef4444" }}>
{t("iceberg.hiddenLabel")}
</span>
</div>
<Gravity
key={gravityKey}
gravity={{ x: 0, y: 1.6 }}
className="w-full h-full"
grabCursor
// ❌ no resetOnResize — this was causing the scroll-triggered rescatter
>
{hiddenItems.map((item: any, i: number) => {
const Icon = iconMap[item.icon] ?? HelpCircle;
const pos = staggered(i, hiddenItems.length);
return (
<MatterBody
key={i}
x={pos.x}
y={pos.y}
angle={pos.angle}
matterBodyOptions={{
friction: 0.9, // was 0.6 — higher = settle faster
restitution: 0.15, // was 0.25 — less bouncy
density: 0.004, // was 0.002 — heavier = harder to push around
}}
>
<div
className={`
rounded-lg border flex items-start gap-2
cursor-grab active:cursor-grabbing select-none
${severityBorder[item.severity] ?? severityBorder[1]}
`}
style={{ padding: "8px 12px", maxWidth: 200, minWidth: 140 }}
>
{/* Severity dot */}
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ background: severityDotColor[item.severity] ?? "#eab308" }}
/>
<Icon size={14} style={{ color: "#f87171", flexShrink: 0 }} />
<div className="min-w-0">
<div
className="font-bold text-white leading-tight"
style={{ fontSize: 14 }}
>
{item.label}
</div>
<div
className="leading-snug"
style={{ fontSize: 10, color: "#9ca3af", marginTop: 1 }}
>
{item.desc}
</div>
</div>
</div>
</MatterBody>
);
})}
</Gravity>
</div>
)}
</div>
{/* ── AhojSvet side ────────────────────────────────────────────── */}
<div className="lg:sticky lg:top-32">
<div
className="rounded-2xl p-6 md:p-8 border-4"
style={{ background: "#5ac37a45", borderColor: "rgba(19, 141, 64, 0.77)" }}
>
<div className="text-center mb-8">
<div
className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full text-sm font-bold mb-4"
style={{ background: "#16a34a", color: "#fff" }}
>
{t("iceberg.ahojsvet.badge", { defaultValue: "Bez prekvapení" })}
</div>
<h3 className="text-3xl font-black" style={{ color: "#fff" }}>
{t("iceberg.ahojsvet.title")}
</h3>
<p className="text-base mt-1" style={{ color: "#6b7280" }}>
{t("iceberg.ahojsvet.subtitle")}
</p>
</div>
<div className="space-y-5">
{ahojsvetPts.map((p: any, i: number) => (
<div key={i} className="flex items-start gap-3">
<div
className="w-6 h-6 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5"
style={{ background: "#16a34a" }}
>
<Check size={14} color="#fff" strokeWidth={3} />
</div>
<div>
<div className="font-bold" style={{ color: "#fff" }}>{p.label}</div>
<div className="text-sm" style={{ color: "#bac1d0ce" }}>{p.desc}</div>
</div>
</div>
))}
</div>
<div className="mt-8 text-center">
<a
href="#contact"
className="inline-block px-8 py-4 rounded-xl font-bold text-lg transition-all"
style={{
background: "#b91c1c",
color: "#fff",
boxShadow: "0 0 40px rgba(185,28,28,0.3)",
}}
>
{t("iceberg.ahojsvet.cta")}
</a>
</div>
</div>
</div>
</div>
</div>
</section>
);
}

View file

@ -1,60 +0,0 @@
import { useTranslation } from 'react-i18next';
import { ExternalLink, ChevronRight } from 'lucide-react';
export default function InTheWild() {
const { t } = useTranslation();
const wild = t('wild', { returnObjects: true });
const projects = [
{ name: 'Projekt 1', desc: 'Pridaj sem svoj projekt', url: '#', placeholder: true },
{ name: 'Projekt 2', desc: 'Pridaj sem svoj projekt', url: '#', placeholder: true }
];
return (
<section id="wild" className="py-32 bg-white text-gray-900">
<div className="max-w-6xl mx-auto px-6">
<div className="text-center mb-16">
<h2 className="text-4xl md:text-6xl font-black mb-4">
{wild.title}
</h2>
<p className="text-xl text-gray-500">
{wild.subtitle}
</p>
</div>
<div className="grid md:grid-cols-2 gap-8 max-w-4xl mx-auto mb-12">
{projects.map((project, i) => (
<a
key={i}
href={project.url}
target="_blank"
rel="noopener noreferrer"
className="card-hover bg-gray-100 rounded-2xl overflow-hidden border border-gray-200 block"
>
<div className="aspect-video bg-gray-200 flex items-center justify-center">
{project.placeholder ? (
<ExternalLink size={32} className="text-gray-400" />
) : (
<img src={project.image} alt={project.name} className="w-full h-full object-cover" />
)}
</div>
<div className="p-6">
<h3 className="font-bold text-lg mb-1">{project.name}</h3>
<p className="text-gray-500 text-sm">{project.desc}</p>
</div>
</a>
))}
</div>
<div className="text-center">
<a
href="mailto:kontakt@ahojsvet.eu"
className="inline-flex items-center gap-2 text-red-700 font-bold hover:underline"
>
{wild.cta} <ChevronRight size={20} />
</a>
</div>
</div>
</section>
);
}

View file

@ -0,0 +1,133 @@
import { useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { ExternalLink, ChevronRight } from 'lucide-react';
import Floating, { FloatingElement } from './fancy/image/parallax-floating';
import VerticalCutReveal from './fancy/text/vertical-cut-reveal';
import { useInView } from 'motion/react';
import ScrambleIn, { ScrambleInHandle } from '@/components/fancy/text/scramble-in';
// ── types ────────────────────────────────────────────────────────────────────
interface WildTranslation {
title: string;
subtitle: string;
cta: string;
}
interface Project {
name: string;
desc: string;
url: string;
image?: string;
placeholder?: boolean;
}
// ── component ─────────────────────────────────────────────────────────────────
const projects: Project[] = [
{ name: 'Projekt 1', desc: 'Pridaj sem svoj projekt', url: '#', placeholder: true },
{ name: 'Projekt 2', desc: 'Pridaj sem svoj projekt', url: '#', placeholder: true },
];
// Alternating depths: card 1 moves toward cursor, card 2 gently away.
// This pulls the two cards apart on hover giving a parallax depth illusion.
const DEPTHS: number[] = [0.8, -0.5];
export default function InTheWild() {
const { t } = useTranslation();
const wild = t('wild', { returnObjects: true }) as WildTranslation;
const h2Ref = useRef<HTMLHeadingElement>(null);
const scrambleRef = useRef<ScrambleInHandle>(null);
const isH2InView = useInView(h2Ref, { once: true });
useEffect(() => {
if (isH2InView) scrambleRef.current?.start();
}, [isH2InView]);
return (
<section id="wild" className="py-32 bg-white text-gray-900">
<div className="max-w-6xl mx-auto px-6">
{/* ── Header ─────────────────────────────────────────────────── */}
<div className="text-center mb-16">
<h2 ref={h2Ref} className="text-4xl md:text-6xl font-black mb-4">
<ScrambleIn ref={scrambleRef} text={wild.title} autoStart={false} />
</h2>
<p className="text-xl text-gray-500">
<VerticalCutReveal
text={wild.subtitle}
splitBy="words"
staggerFrom="first"
staggerDuration={0.04}
transition={{ type: 'spring', damping: 26, stiffness: 140, delay: 0.3 }}
autoStart
containerClassName="justify-center flex-wrap"
elementLevelClassName="pb-1"
/>
</p>
</div>
{/*
Parallax card grid
Floating tracks the cursor across the entire grid container.
Each FloatingElement drifts at its own depth card 1 moves
toward the cursor, card 2 slightly away creating a tactile
sense of depth that rewards slow mouse movement.
sensitivity={0.06}: gentle enough not to feel seasick on fast
mouse sweeps but noticeable on deliberate hover.
easingFactor={0.04}: very smooth lag elements trail the
cursor like they have real weight.
NOTE: FloatingElement does NOT change layout it only applies
a CSS transform. The grid spacing is handled by the inner div.
*/}
<Floating
sensitivity={0.06}
easingFactor={0.04}
className="relative"
>
<div className="grid md:grid-cols-2 gap-8 max-w-4xl mx-auto mb-12">
{projects.map((project, i) => (
<FloatingElement key={i} depth={DEPTHS[i] ?? 0.5}>
<a
href={project.url}
target="_blank"
rel="noopener noreferrer"
className="card-hover bg-gray-100 rounded-2xl overflow-hidden border border-gray-200 block transition-shadow hover:shadow-xl"
>
<div className="aspect-video bg-gray-200 flex items-center justify-center overflow-hidden">
{project.placeholder ? (
<ExternalLink size={32} className="text-gray-400" />
) : (
<img
src={project.image}
alt={project.name}
className="w-full h-full object-cover"
/>
)}
</div>
<div className="p-6">
<h3 className="font-bold text-lg mb-1">{project.name}</h3>
<p className="text-gray-500 text-sm">{project.desc}</p>
</div>
</a>
</FloatingElement>
))}
</div>
</Floating>
{/* ── CTA link ────────────────────────────────────────────────── */}
<div className="text-center">
<a
href="mailto:kontakt@ahojsvet.eu"
className="inline-flex items-center gap-2 text-red-700 font-bold hover:underline"
>
{wild.cta} <ChevronRight size={20} />
</a>
</div>
</div>
</section>
);
}

View file

@ -1,55 +0,0 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Menu, X } from 'lucide-react';
export default function Navigation({ scrollY }) {
const { t } = useTranslation();
const [menuOpen, setMenuOpen] = useState(false);
return (
<nav className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${scrollY > 50 ? 'bg-gray-900/95 backdrop-blur-sm' : ''}`}>
<div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-10 h-10 bg-red-700 rounded-lg flex items-center justify-center font-black text-xl">A</div>
<span className="font-extrabold text-xl text-white">AhojSvet</span>
</div>
<div className="hidden md:flex items-center gap-8">
<a href="https://demo.ahojsvet.eu" className="flex items-center gap-2 bg-gray-800 hover:bg-gray-700 px-4 py-2 rounded-lg transition-colors font-medium text-white">
{t('nav.demo')}
</a>
<a href="https://navod.ahojsvet.eu" className="text-gray-400 hover:text-white transition-colors font-medium">
{t('nav.docs')}
</a>
<a href="#wild" className="text-gray-400 hover:text-white transition-colors font-medium">
{t('nav.wild')}
</a>
<a href="#contact" className="text-gray-400 hover:text-white transition-colors font-medium">
{t('nav.contact')}
</a>
</div>
<button onClick={() => setMenuOpen(!menuOpen)} className="md:hidden p-2 text-white">
{menuOpen ? <X size={24} /> : <Menu size={24} />}
</button>
</div>
{menuOpen && (
<div className="md:hidden bg-gray-900 border-t border-gray-800 px-6 py-4 flex flex-col gap-4">
<a href="https://demo.ahojsvet.eu" className="text-gray-400 hover:text-white py-2">
{t('nav.demo')}
</a>
<a href="https://navod.ahojsvet.eu" className="text-gray-400 hover:text-white py-2">
{t('nav.docs')}
</a>
<a href="#wild" className="text-gray-400 hover:text-white py-2">
{t('nav.wild')}
</a>
<a href="#contact" className="text-gray-400 hover:text-white py-2">
{t('nav.contact')}
</a>
</div>
)}
</nav>
);
}

View file

@ -0,0 +1,132 @@
import { useState, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Menu, X } from 'lucide-react';
// ── Simple SVG flags (4:3, 20×15) ──────────────────────────────────────────
function FlagSK({ className }: { className?: string }) {
return (
<svg className={className} xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><mask id="SVGuywqVbel"><circle cx="256" cy="256" r="256" fill="#fff"/></mask><g mask="url(#SVGuywqVbel)"><path fill="#0052b4" d="m0 160l256-32l256 32v192l-256 32L0 352z"/><path fill="#eee" d="M0 0h512v160H0z"/><path fill="#d80027" d="M0 352h512v160H0z"/><path fill="#eee" d="M64 63v217c0 104 144 137 144 137s144-33 144-137V63z"/><path fill="#d80027" d="M96 95v185a83 78 0 0 0 9 34h206a83 77 0 0 0 9-34V95z"/><path fill="#eee" d="M288 224h-64v-32h32v-32h-32v-32h-32v32h-32v32h32v32h-64v32h64v32h32v-32h64z"/><path fill="#0052b4" d="M152 359a247 231 0 0 0 56 24c12-3 34-11 56-24a123 115 0 0 0 47-45a60 56 0 0 0-34-10l-14 2a60 56 0 0 0-110 0a60 56 0 0 0-14-2c-12 0-24 4-34 10a123 115 0 0 0 47 45"/></g></svg>
);
}
function FlagCZ({ className }: { className?: string }) {
return (
<svg className={className} xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><mask id="SVGuywqVbel"><circle cx="256" cy="256" r="256" fill="#fff"/></mask><g mask="url(#SVGuywqVbel)"><path fill="#eee" d="M0 0h512v256l-265 45.2z"/><path fill="#d80027" d="M210 256h302v256H0z"/><path fill="#0052b4" d="M0 0v512l256-256z"/></g></svg>
);
}
function FlagEN({ className }: { className?: string }) {
return (
<svg className={className} xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><mask id="circleFlagsEn0"><circle cx="256" cy="256" r="256" fill="#fff"/></mask><g mask="url(#circleFlagsEn0)"><path fill="#eee" d="m0 0l8 22l-8 23v23l32 54l-32 54v32l32 48l-32 48v32l32 54l-32 54v68l22-8l23 8h23l54-32l54 32h32l48-32l48 32h32l54-32l54 32h68l-8-22l8-23v-23l-32-54l32-54v-32l-32-48l32-48v-32l-32-54l32-54V0l-22 8l-23-8h-23l-54 32l-54-32h-32l-48 32l-48-32h-32l-54 32L68 0z"/><path fill="#0052b4" d="M336 0v108L444 0Zm176 68L404 176h108zM0 176h108L0 68ZM68 0l108 108V0Zm108 512V404L68 512ZM0 444l108-108H0Zm512-108H404l108 108Zm-68 176L336 404v108z"/><path fill="#d80027" d="M0 0v45l131 131h45zm208 0v208H0v96h208v208h96V304h208v-96H304V0zm259 0L336 131v45L512 0zM176 336L0 512h45l131-131zm160 0l176 176v-45L381 336z"/></g></svg>
);
}
const languages = [
{ code: 'sk', label: 'SK', Flag: FlagSK },
{ code: 'cs', label: 'CZ', Flag: FlagCZ },
{ code: 'en', label: 'EN', Flag: FlagEN },
] as const;
// ── Language switcher ───────────────────────────────────────────────────────
function LanguageSwitcher() {
const { i18n } = useTranslation();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const current = languages.find(l => l.code === i18n.language) ?? languages[0];
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, []);
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen(!open)}
className="flex items-center gap-1.5 px-2 py-1.5 rounded-lg bg-gray-800 hover:bg-gray-700 transition-colors"
>
<current.Flag className="w-5 h-auto" />
<span className="text-xs font-bold text-gray-300">{current.label}</span>
</button>
{open && (
<div className="absolute right-0 mt-1 bg-gray-800 border border-gray-700 rounded-lg overflow-hidden shadow-lg">
{languages.filter(l => l.code !== i18n.language).map(({ code, label, Flag }) => (
<button
key={code}
onClick={() => { i18n.changeLanguage(code); setOpen(false); }}
className="flex items-center gap-2 w-full px-3 py-2 hover:bg-gray-700 transition-colors"
>
<Flag className="w-5 h-auto" />
<span className="text-xs font-bold text-gray-300">{label}</span>
</button>
))}
</div>
)}
</div>
);
}
// ── Navigation ──────────────────────────────────────────────────────────────
export default function Navigation({ scrollY }: { scrollY: number }) {
const { t } = useTranslation();
const [menuOpen, setMenuOpen] = useState(false);
return (
<nav className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${scrollY > 50 ? 'bg-gray-900/95 backdrop-blur-sm' : ''}`}>
<div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-10 h-10 bg-red-700 rounded-lg flex items-center justify-center font-black text-xl">A</div>
<span className="font-extrabold text-xl text-white">AhojSvet</span>
</div>
<div className="hidden md:flex items-center gap-8">
<a href="https://demo.ahojsvet.eu" className="flex items-center gap-2 bg-gray-800 hover:bg-gray-700 px-4 py-2 rounded-lg transition-colors font-medium text-white">
{t('nav.demo')}
</a>
<a href="https://navod.ahojsvet.eu" className="text-gray-400 hover:text-white transition-colors font-medium">
{t('nav.docs')}
</a>
<a href="#wild" className="text-gray-400 hover:text-white transition-colors font-medium">
{t('nav.wild')}
</a>
<a href="#contact" className="text-gray-400 hover:text-white transition-colors font-medium">
{t('nav.contact')}
</a>
<LanguageSwitcher />
</div>
<div className="flex items-center gap-2 md:hidden">
<LanguageSwitcher />
<button onClick={() => setMenuOpen(!menuOpen)} className="p-2 text-white">
{menuOpen ? <X size={24} /> : <Menu size={24} />}
</button>
</div>
</div>
{menuOpen && (
<div className="md:hidden bg-gray-900 border-t border-gray-800 px-6 py-4 flex flex-col gap-4">
<a href="https://demo.ahojsvet.eu" className="text-gray-400 hover:text-white py-2">
{t('nav.demo')}
</a>
<a href="https://navod.ahojsvet.eu" className="text-gray-400 hover:text-white py-2">
{t('nav.docs')}
</a>
<a href="#wild" className="text-gray-400 hover:text-white py-2">
{t('nav.wild')}
</a>
<a href="#contact" className="text-gray-400 hover:text-white py-2">
{t('nav.contact')}
</a>
</div>
)}
</nav>
);
}

View file

@ -1,55 +0,0 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { BarChart3, ShoppingBag, FileText, Blocks } from 'lucide-react';
export default function Screenshots() {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState(0);
const tabs = t('screenshots.tabs', { returnObjects: true });
const iconMap = {
0: BarChart3,
1: ShoppingBag,
2: FileText,
3: Blocks
};
const CurrentIcon = iconMap[activeTab];
return (
<section className="py-32 bg-gray-900">
<div className="max-w-6xl mx-auto px-6">
<h2 className="text-4xl md:text-6xl font-black text-center mb-16 text-white">
{t('screenshots.title')}
</h2>
<div className="grid grid-cols-2 md:grid-cols-4 justify-center gap-2 mb-8">
{tabs.map((tab, i) => (
<button
key={i}
onClick={() => setActiveTab(i)}
className={`px-6 py-3 rounded-lg font-bold transition-all ${
activeTab === i
? 'bg-red-700 text-white'
: 'bg-gray-800 text-gray-400 hover:bg-gray-700'
}`}
>
{tab}
</button>
))}
</div>
<div className="bg-gray-800 rounded-2xl p-4 glow">
<div className="bg-gray-950 rounded-xl aspect-video flex items-center justify-center">
<div className="text-center">
<CurrentIcon size={64} className="mx-auto mb-4 text-red-500" />
<p className="text-gray-500 font-medium text-lg">
{tabs[activeTab]} screenshot
</p>
</div>
</div>
</div>
</div>
</section>
);
}

View file

@ -0,0 +1,60 @@
import { useState, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useInView } from 'motion/react';
import ScrambleIn, { ScrambleInHandle } from '@/components/fancy/text/scramble-in';
export default function Screenshots() {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState(0);
const tabs = t('screenshots.tabs', { returnObjects: true }) as string[];
const h2Ref = useRef<HTMLHeadingElement>(null);
const scrambleRef = useRef<ScrambleInHandle>(null);
const isH2InView = useInView(h2Ref, { once: true });
useEffect(() => {
if (isH2InView) scrambleRef.current?.start();
}, [isH2InView]);
const imageMap: Record<number, string> = {
0: '/screenshots/admin.png',
1: '/screenshots/obchod.png',
2: '/screenshots/objednavky.png',
3: '/screenshots/cms.png',
};
return (
<section className="py-32 bg-gray-900">
<div className="max-w-6xl mx-auto px-6">
<h2 ref={h2Ref} className="text-4xl md:text-6xl font-black text-center mb-16 text-white">
<ScrambleIn ref={scrambleRef} text={t('screenshots.title')} autoStart={false} />
</h2>
<div className="grid grid-cols-2 md:grid-cols-4 justify-center gap-2 mb-8">
{tabs.map((tab, i) => (
<button
key={i}
onClick={() => setActiveTab(i)}
className={`px-6 py-3 rounded-lg font-bold transition-all ${
activeTab === i
? 'bg-red-700 text-white'
: 'bg-gray-800 text-gray-400 hover:bg-gray-700'
}`}
>
{tab}
</button>
))}
</div>
<div className="bg-gray-800 rounded-2xl p-4 glow">
<div className="bg-gray-950 rounded-xl aspect-video overflow-hidden">
<img
src={imageMap[activeTab]}
alt={tabs[activeTab]}
className="w-full h-full object-cover"
/>
</div>
</div>
</div>
</section>
);
}

View file

@ -1,94 +0,0 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { speedData } from '../constants/speedData';
export default function SpeedComparison() {
const { t } = useTranslation();
const [network, setNetwork] = useState('WiFi');
return (
<section className="py-32 bg-gray-900">
<div className="max-w-6xl mx-auto px-6">
<div className="text-center mb-16">
<h2 className="text-4xl md:text-6xl font-black mb-4 text-white">
{t('speed.title')}
</h2>
<p className="text-xl text-gray-400 mb-8">
{t('speed.subtitle')}
</p>
<div className="flex flex-wrap justify-center gap-3 mb-12">
<span className="text-gray-400 font-medium self-center">{t('speed.selector')}</span>
{t('speed.networks', { returnObjects: true }).map(net => (
<button
key={net}
onClick={() => setNetwork(net)}
className={`px-4 py-2 rounded-lg font-bold transition-all ${
network === net
? 'bg-red-700 text-white'
: 'bg-gray-800 text-gray-400 hover:bg-gray-700'
}`}
>
{net}
</button>
))}
</div>
</div>
<div className="grid gap-8">
{Object.entries(t('speed.metrics', { returnObjects: true })).map(([key, label]) => {
const asData = speedData[network].ahojsvet[key];
const wpData = speedData[network].wordpress[key];
const percentFaster = Math.round((1 - asData[0] / wpData[0]) * 100);
return (
<div key={key} className="bg-gray-800 rounded-2xl p-6">
<h3 className="text-2xl font-bold mb-6 text-center text-white">{label}</h3>
<div className="grid md:grid-cols-2 gap-6">
<div className="bg-green-950 border-2 border-green-700 rounded-xl p-6">
<div className="text-sm font-bold text-green-400 mb-3">AhojSvet</div>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-gray-400">Load time:</span>
<span className="font-bold text-white">{asData[0]}{t('speed.units.time')}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Data:</span>
<span className="font-bold text-white">{asData[1]}{t('speed.units.size')}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Requests:</span>
<span className="font-bold text-white">{asData[2]} {t('speed.units.requests')}</span>
</div>
</div>
</div>
<div className="bg-gray-900 border-2 border-gray-700 rounded-xl p-6">
<div className="text-sm font-bold text-gray-500 mb-3">WordPress + WooCommerce</div>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-gray-500">Load time:</span>
<span className="font-bold text-gray-400">{wpData[0]}{t('speed.units.time')}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Data:</span>
<span className="font-bold text-gray-400">{wpData[1]}{t('speed.units.size')}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Requests:</span>
<span className="font-bold text-gray-400">{wpData[2]} {t('speed.units.requests')}</span>
</div>
</div>
</div>
</div>
<div className="mt-4 text-center">
<span className="inline-block bg-red-700 text-white px-4 py-2 rounded-lg font-bold">
{percentFaster}% rýchlejšie
</span>
</div>
</div>
);
})}
</div>
</div>
</section>
);
}

View file

@ -0,0 +1,223 @@
import { useState, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { motion, useInView } from 'motion/react';
import ScrambleIn, { ScrambleInHandle } from '@/components/fancy/text/scramble-in';
import VerticalCutReveal from '@/components/fancy/text/vertical-cut-reveal';
import { ChevronDown, EyeOff } from 'lucide-react';
import { speedData } from '../constants/speedData';
function MetricCard({ label, asData, wpData, percentFaster, t }: {
label: string;
asData: number[];
wpData: number[];
percentFaster: number;
t: (key: string) => string;
}) {
return (
<div className="bg-gray-800 rounded-2xl p-6">
<h3 className="text-2xl font-bold mb-6 text-center text-white">{label}</h3>
<div className="grid md:grid-cols-2 gap-6">
<div className="bg-green-950 border-2 border-green-700 rounded-xl p-6">
<div className="text-sm font-bold text-green-400 mb-3">AhojSvet</div>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-gray-400">Load time:</span>
<span className="font-bold text-white">{asData[0]}{t('speed.units.time')}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Data:</span>
<span className="font-bold text-white">{asData[1]}{t('speed.units.size')}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Requests:</span>
<span className="font-bold text-white">{asData[2]} {t('speed.units.requests')}</span>
</div>
</div>
</div>
<div className="bg-gray-900 border-2 border-gray-700 rounded-xl p-6">
<div className="text-sm font-bold text-gray-500 mb-3">WordPress + WooCommerce</div>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-gray-500">Load time:</span>
<span className="font-bold text-gray-400">{wpData[0]}{t('speed.units.time')}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Data:</span>
<span className="font-bold text-gray-400">{wpData[1]}{t('speed.units.size')}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Requests:</span>
<span className="font-bold text-gray-400">{wpData[2]} {t('speed.units.requests')}</span>
</div>
</div>
</div>
</div>
<div className="mt-4 text-center">
<span className="inline-block bg-red-700 text-white px-4 py-2 rounded-lg font-bold">
{percentFaster}% rýchlejšie
</span>
</div>
</div>
);
}
export default function SpeedComparison() {
const { t } = useTranslation();
const [network, setNetwork] = useState('WiFi');
const [revealed, setRevealed] = useState(false);
const metrics = Object.entries(t('speed.metrics', { returnObjects: true }) as Record<string, string>);
const h2Ref = useRef<HTMLHeadingElement>(null);
const scrambleRef = useRef<ScrambleInHandle>(null);
const isH2InView = useInView(h2Ref, { once: true });
useEffect(() => {
if (isH2InView) scrambleRef.current?.start();
}, [isH2InView]);
return (
<section className="py-32 bg-gray-900">
<div className="max-w-6xl mx-auto px-6">
<div className="text-center mb-16">
<h2 ref={h2Ref} className="text-4xl md:text-6xl font-black mb-4 text-white">
<ScrambleIn ref={scrambleRef} text={t('speed.title')} autoStart={false} />
</h2>
<p className="text-xl text-gray-400 mb-8">
<VerticalCutReveal
splitBy="words"
staggerFrom="first"
staggerDuration={0.04}
transition={{
type: 'spring',
damping: 26,
stiffness: 140,
delay: 0.35,
}}
autoStart
containerClassName="justify-center flex-wrap"
elementLevelClassName="pb-1"
>
{t('speed.subtitle')}
</VerticalCutReveal>
</p>
<div className="flex flex-wrap justify-center gap-3 mb-12">
<span className="text-gray-400 font-medium self-center">{t('speed.selector')}</span>
{(t('speed.networks', { returnObjects: true }) as string[]).map(net => (
<button
key={net}
onClick={() => setNetwork(net)}
className={`px-4 py-2 rounded-lg font-bold transition-all ${
network === net
? 'bg-red-700 text-white'
: 'bg-gray-800 text-gray-400 hover:bg-gray-700'
}`}
>
{net}
</button>
))}
</div>
</div>
<div className="relative">
<div className="grid gap-8">
{metrics.slice(0, 2).map(([key, label]) => {
const asData = speedData[network].ahojsvet[key];
const wpData = speedData[network].wordpress[key];
const percentFaster = Math.round((1 - asData[0] / wpData[0]) * 100);
return (
<MetricCard key={key} label={label} asData={asData} wpData={wpData} percentFaster={percentFaster} t={t} />
);
})}
<motion.div
initial={false}
animate={{ height: revealed ? 'auto' : 0 }}
transition={{ duration: 0.55, ease: [0.04, 0.62, 0.23, 0.98] }}
style={{ overflow: 'hidden' }}
>
<div className="grid gap-8">
{metrics.slice(2).map(([key, label], i) => {
const asData = speedData[network].ahojsvet[key];
const wpData = speedData[network].wordpress[key];
const percentFaster = Math.round((1 - asData[0] / wpData[0]) * 100);
return (
<motion.div
key={key}
initial={{ opacity: 0, y: 24 }}
animate={revealed ? { opacity: 1, y: 0 } : { opacity: 0, y: 24 }}
transition={{ duration: 0.4, delay: revealed ? i * 0.08 : 0, ease: 'easeOut' }}
>
<MetricCard label={label} asData={asData} wpData={wpData} percentFaster={percentFaster} t={t} />
</motion.div>
);
})}
</div>
</motion.div>
</div>
{/* Gradient overlay + reveal button */}
{!revealed && (
<div className="absolute bottom-0 left-0 right-0 flex flex-col items-center"
style={{ height: '40%', pointerEvents: 'none' }}
>
<div
className="w-full flex-1"
style={{
background: 'linear-gradient(to bottom, transparent 0%, rgb(17 24 39) 70%)',
}}
/>
<div className="w-full bg-gray-900 pb-4" style={{ pointerEvents: 'auto' }}>
<div className="relative my-2">
<div className="absolute inset-0 flex items-center">
<div
className="w-full border-t-2 border-dashed"
style={{ borderColor: '#21427a' }}
/>
</div>
<div className="relative flex justify-center">
<button
onClick={() => setRevealed(true)}
className="flex items-center gap-2 px-5 py-2.5 rounded-full font-bold text-sm transition-all"
style={{
background: '#b91c1c',
color: '#fff',
boxShadow: '0 0 30px rgba(185,28,28,0.4)',
}}
>
<ChevronDown size={16} />
{t('speed.showMore')}
</button>
</div>
</div>
</div>
</div>
)}
</div>
{/* Collapse button after all items */}
{revealed && (
<div className="relative mt-8">
<div className="absolute inset-0 flex items-center">
<div
className="w-full border-t-2 border-dashed"
style={{ borderColor: '#21427a' }}
/>
</div>
<div className="relative flex justify-center">
<button
onClick={() => setRevealed(false)}
className="flex items-center gap-2 px-5 py-2.5 rounded-full font-bold text-sm transition-all"
style={{
background: '#1f2937',
color: '#fff',
}}
>
<EyeOff size={16} />
{t('speed.showLess')}
</button>
</div>
</div>
)}
</div>
</section>
);
}

View file

@ -1,40 +0,0 @@
import { useTranslation } from 'react-i18next';
export default function TheProblem() {
const { t } = useTranslation();
return (
<section className="pt-32 bg-gray-800">
<div className="max-w-6xl mx-auto px-6 pb-24">
<div className="text-center mb-20">
<h2 className="text-4xl md:text-6xl font-black mb-4 text-white">
{t('problem.title')}
</h2>
<p className="text-xl md:text-2xl text-red-400 font-medium max-w-3xl mx-auto">
{t('problem.subtitle')}
</p>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
{t('problem.cards', { returnObjects: true }).map((card, i) => (
<div key={i} className="bg-gray-900 rounded-2xl p-8 border border-gray-700 text-center">
<div className="text-3xl font-black text-red-500 mb-3">{card.title}</div>
<p className="text-gray-400 leading-relaxed">{card.desc}</p>
</div>
))}
</div>
</div>
{/* Bleeding Sword Divider */}
<div className="bg-white">
<div className="w-full mx-auto">
<img
src="katana.svg"
className="w-full h-auto"
alt=""
/>
</div>
</div>
</section>
);
}

View file

@ -0,0 +1,378 @@
import { useRef, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import {
useInView,
motion,
useScroll,
useTransform,
useMotionValueEvent,
useReducedMotion,
} from 'motion/react';
import VerticalCutReveal from './fancy/text/vertical-cut-reveal';
import ScrambleIn, { ScrambleInHandle } from '@/components/fancy/text/scramble-in';
import AmbientGlow from './AmbientGlow';
// ── types ────────────────────────────────────────────────────────────────────
interface ProblemCard {
title: string;
desc: string;
}
interface ProblemTranslation {
title: string;
subtitle: string;
cards: ProblemCard[];
}
// ── lightspeed star ─────────────────────────────────────────────────────────
interface Star {
x: number;
y: number;
z: number;
speed: number;
hue: number;
}
function createStars(count: number): Star[] {
return Array.from({ length: count }, () => ({
x: (Math.random() - 0.5) * 2,
y: (Math.random() - 0.5) * 2,
z: Math.random() * 0.98 + 0.02,
speed: Math.random() * 0.8 + 0.4,
hue: 200 + Math.random() * 60,
}));
}
function respawnStar(star: Star) {
star.x = (Math.random() - 0.5) * 2;
star.y = (Math.random() - 0.5) * 2;
star.z = 0.9 + Math.random() * 0.1;
star.speed = Math.random() * 0.8 + 0.4;
}
// ── animated card ────────────────────────────────────────────────────────────
function StatCard({ card, index }: { card: ProblemCard; index: number }) {
const ref = useRef<HTMLDivElement>(null);
const isInView = useInView(ref, { once: true, margin: '0px 0px -80px 0px' });
return (
<motion.div
ref={ref}
initial={{ opacity: 0, y: 32 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{
type: 'spring',
damping: 24,
stiffness: 120,
delay: index * 0.12,
}}
className="bg-gray-900 rounded-2xl p-8 border border-gray-700 text-center flex flex-col items-center gap-2"
>
<div className="text-2xl sm:text-3xl font-black text-red-500 mb-1 leading-tight">
{card.title}
</div>
<p className="text-gray-400 leading-relaxed text-sm">{card.desc}</p>
</motion.div>
);
}
// ── main component ───────────────────────────────────────────────────────────
const STAR_COUNT = 400;
const BASE_SPEED = 0.02;
const WARP_BOOST = 3.5;
export default function TheProblem() {
const { t } = useTranslation();
const { title, subtitle, cards } = t('problem', {
returnObjects: true,
}) as ProblemTranslation;
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const starsRef = useRef<Star[]>(createStars(STAR_COUNT));
const warpRef = useRef(0);
const rafRef = useRef(0);
const lastTimeRef = useRef(0);
const isActiveRef = useRef(false);
const prefersReducedMotion = useReducedMotion();
const h2Ref = useRef<HTMLHeadingElement>(null);
const h2ScrambleRef = useRef<ScrambleInHandle>(null);
const h2InView = useInView(h2Ref, { once: true });
useEffect(() => {
if (h2InView) h2ScrambleRef.current?.start();
}, [h2InView]);
// ── scroll-driven values ───────────────────────────────────────────────────
const { scrollYProgress } = useScroll({
target: containerRef,
offset: ['start start', 'end end'],
});
// Content visible initially, fades out mid-scroll
const contentOpacity = useTransform(
scrollYProgress,
[0, 0.15, 0.28, 0.38],
[1, 1, 1, 0],
);
// Canvas fades in as content fades (baseline 0.2 so faint stars are visible immediately)
const canvasOpacity = useTransform(scrollYProgress, [0.0, 0.15, 0.35], [0.2, 0.4, 1]);
// Warp factor for star speed (tapers gradually, movement until the very end)
const warp = useTransform(
scrollYProgress,
[0.25, 0.40, 0.65, 0.80, 1.0],
[0, 0.3, 1.0, 0.4, 0],
);
// Fade to white for transition into Features (more gradual)
const whiteOverlayOpacity = useTransform(scrollYProgress, [0.78, 1.0], [0, 1]);
// Cinematic exit: blur + brightness + scale — "arriving from hyperspace"
const canvasBlur = useTransform(scrollYProgress, [0.80, 0.95], [0, 12]);
const canvasBrightness = useTransform(scrollYProgress, [0.85, 0.95], [1, 1.8]);
const canvasFilter = useTransform(
[canvasBlur, canvasBrightness] as any,
([b, br]: number[]) => `blur(${b}px) brightness(${br})`,
);
const canvasScale = useTransform(scrollYProgress, [0.75, 1.0], [1, 1.5]);
useMotionValueEvent(warp, 'change', (v) => {
warpRef.current = v;
});
useMotionValueEvent(scrollYProgress, 'change', (v) => {
isActiveRef.current = v > 0.01 && v < 1;
});
// ── canvas draw loop ──────────────────────────────────────────────────────
const draw = useCallback((time: number) => {
const canvas = canvasRef.current;
if (!canvas) return;
rafRef.current = requestAnimationFrame(draw);
if (!isActiveRef.current) {
lastTimeRef.current = 0;
return;
}
const ctx = canvas.getContext('2d');
if (!ctx) return;
const dt = lastTimeRef.current
? Math.min((time - lastTimeRef.current) / 1000, 0.05)
: 0.016;
lastTimeRef.current = time;
const dpr = window.devicePixelRatio || 1;
const w = canvas.width / dpr;
const h = canvas.height / dpr;
const cx = w / 2;
const cy = h / 2;
const warpLevel = warpRef.current;
const fov = Math.max(w, h) * 0.5;
ctx.clearRect(0, 0, w, h);
// Red ambient glow — echoes Hero's blobs, fades out as warp kicks in
if (warpLevel < 0.2) {
const fadeOut = 1 - warpLevel / 0.2;
const glow = ctx.createRadialGradient(cx * 0.7, cy * 0.8, 0, cx * 0.7, cy * 0.8, fov * 0.6);
glow.addColorStop(0, `rgba(185, 28, 28, ${fadeOut * 0.05})`);
glow.addColorStop(0.5, `rgba(185, 28, 28, ${fadeOut * 0.02})`);
glow.addColorStop(1, 'rgba(185, 28, 28, 0)');
ctx.fillStyle = glow;
ctx.fillRect(0, 0, w, h);
}
// Central glow during warp
if (warpLevel > 0.15) {
const intensity = Math.min(1, (warpLevel - 0.15) / 0.85);
const gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, fov * 0.7);
gradient.addColorStop(0, `rgba(120, 140, 255, ${intensity * 0.12})`);
gradient.addColorStop(0.4, `rgba(80, 100, 255, ${intensity * 0.04})`);
gradient.addColorStop(1, 'rgba(80, 100, 255, 0)');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, w, h);
}
const stars = starsRef.current;
for (const star of stars) {
const prevZ = star.z;
star.z -= (BASE_SPEED + warpLevel * WARP_BOOST) * star.speed * dt;
if (star.z <= 0.001) {
respawnStar(star);
continue;
}
// project current & previous position
const sx = cx + (star.x * fov) / star.z;
const sy = cy + (star.y * fov) / star.z;
const px = cx + (star.x * fov) / prevZ;
const py = cy + (star.y * fov) / prevZ;
if (sx < -50 || sx > w + 50 || sy < -50 || sy > h + 50) {
respawnStar(star);
continue;
}
const brightness = Math.min(1, (1 - star.z) * 1.8);
const streakLen = Math.hypot(sx - px, sy - py);
if (warpLevel > 0.05 && streakLen > 2) {
// streak
ctx.strokeStyle = `hsla(${star.hue}, 80%, 90%, ${brightness})`;
ctx.lineWidth = Math.max(0.5, (1 - star.z) * 2.5);
ctx.beginPath();
ctx.moveTo(px, py);
ctx.lineTo(sx, sy);
ctx.stroke();
} else {
// dot
const r = Math.max(0.5, (1 - star.z) * 1.5);
ctx.fillStyle = `hsla(${star.hue}, 50%, 88%, ${brightness * 0.7})`;
ctx.beginPath();
ctx.arc(sx, sy, r, 0, Math.PI * 2);
ctx.fill();
}
}
}, []);
// ── canvas setup ──────────────────────────────────────────────────────────
useEffect(() => {
if (prefersReducedMotion) return;
const canvas = canvasRef.current;
if (!canvas) return;
const resize = () => {
const dpr = window.devicePixelRatio || 1;
const vw = window.innerWidth;
const vh = window.innerHeight;
canvas.width = vw * dpr;
canvas.height = vh * dpr;
canvas.style.width = `${vw}px`;
canvas.style.height = `${vh}px`;
const ctx = canvas.getContext('2d');
if (ctx) ctx.scale(dpr, dpr);
};
resize();
window.addEventListener('resize', resize);
rafRef.current = requestAnimationFrame(draw);
return () => {
window.removeEventListener('resize', resize);
cancelAnimationFrame(rafRef.current);
};
}, [draw, prefersReducedMotion]);
// ── reduced-motion fallback ───────────────────────────────────────────────
if (prefersReducedMotion) {
return (
<section className="pt-32 pb-24 bg-gray-900">
<div className="max-w-6xl mx-auto px-6">
<div className="text-center mb-20">
<AmbientGlow className="bg-red-700/20" baseWidth={384} baseHeight={384} anchorX="25%" anchorY="25%" driftX={80} driftY={60} interval={6000} />
<AmbientGlow className="bg-red-900/20" baseWidth={256} baseHeight={256} anchorX="75%" anchorY="75%" driftX={60} driftY={50} interval={7500} />
<h2 ref={h2Ref} className="text-4xl md:text-6xl font-black mb-4 text-white">
<ScrambleIn ref={h2ScrambleRef} text={title} autoStart={false} />
</h2>
<p className="text-xl md:text-2xl text-red-400 font-medium max-w-3xl mx-auto">
{subtitle}
</p>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
{cards.map((card, i) => (
<div
key={i}
className="bg-gray-900 rounded-2xl p-8 border border-gray-700 text-center flex flex-col items-center gap-2"
>
<div className="text-2xl sm:text-3xl font-black text-red-500 mb-1 leading-tight">
{card.title}
</div>
<p className="text-gray-400 leading-relaxed text-sm">
{card.desc}
</p>
</div>
))}
</div>
</div>
</section>
);
}
// ── full experience ───────────────────────────────────────────────────────
return (
<section ref={containerRef} className="relative" style={{ height: '250vh' }}>
<div className="sticky top-0 h-screen w-full overflow-hidden bg-gray-900">
{/* Lightspeed canvas (behind content) */}
<motion.canvas
ref={canvasRef}
className="absolute inset-0 z-0"
style={{ opacity: canvasOpacity, filter: canvasFilter, scale: canvasScale }}
/>
{/* Content layer */}
<motion.div
className="absolute inset-0 z-10 bg-gray-900 flex items-center"
style={{ opacity: contentOpacity }}
>
<div className="max-w-6xl mx-auto px-6 w-full">
{/* Section header */}
<div className="text-center mb-20">
<h2 ref={h2Ref} className="text-4xl md:text-6xl font-black mb-4 text-white">
<ScrambleIn ref={h2ScrambleRef} text={title} autoStart={false} />
</h2>
<p className="text-xl md:text-2xl text-red-400 font-medium max-w-3xl mx-auto">
<VerticalCutReveal
splitBy="words"
staggerFrom="first"
staggerDuration={0.04}
transition={{
type: 'spring',
damping: 26,
stiffness: 140,
delay: 0.35,
}}
autoStart
containerClassName="justify-center flex-wrap"
elementLevelClassName="pb-1"
>
{subtitle}
</VerticalCutReveal>
</p>
</div>
{/* Stat cards */}
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
{cards.map((card, i) => (
<StatCard key={i} card={card} index={i} />
))}
</div>
</div>
</motion.div>
{/* Fade to white — seamless transition into Features */}
<motion.div
className="absolute inset-0 z-20 pointer-events-none"
style={{ opacity: whiteOverlayOpacity, backgroundColor: '#ffffff' }}
/>
</div>
</section>
);
}

View file

@ -0,0 +1,103 @@
"use client"
import React, { useEffect, useRef } from "react"
import {
motion,
useAnimationFrame,
useMotionValue,
} from "motion/react"
import { cn } from "@/lib/utils"
import { useDimensions } from "@/hooks/use-dimensions"
type ScreensaverProps = {
children: React.ReactNode
containerRef: React.RefObject<HTMLElement | null>
speed?: number
startPosition?: { x: number; y: number } // x,y as percentages (0-100)
startAngle?: number // in degrees
className?: string
}
const Screensaver: React.FC<ScreensaverProps> = ({
children,
speed = 3,
startPosition = { x: 0, y: 0 },
startAngle = 45,
containerRef,
className,
}) => {
const elementRef = useRef<HTMLDivElement>(null)
const x = useMotionValue(0)
const y = useMotionValue(0)
const angle = useRef((startAngle * Math.PI) / 180)
const containerDimensions = useDimensions(containerRef)
const elementDimensions = useDimensions(elementRef)
// Set initial position based on container dimensions and percentage
useEffect(() => {
if (containerDimensions.width && containerDimensions.height) {
const initialX =
(startPosition.x / 100) *
(containerDimensions.width - (elementDimensions.width || 0))
const initialY =
(startPosition.y / 100) *
(containerDimensions.height - (elementDimensions.height || 0))
x.set(initialX)
y.set(initialY)
}
}, [containerDimensions, elementDimensions, startPosition])
useAnimationFrame(() => {
const velocity = speed
const dx = Math.cos(angle.current) * velocity
const dy = Math.sin(angle.current) * velocity
let newX = x.get() + dx
let newY = y.get() + dy
// Check for collisions with container boundaries
if (
newX <= 0 ||
newX + elementDimensions.width >= containerDimensions.width
) {
angle.current = Math.PI - angle.current
newX = Math.max(
0,
Math.min(newX, containerDimensions.width - elementDimensions.width)
)
}
if (
newY <= 0 ||
newY + elementDimensions.height >= containerDimensions.height
) {
angle.current = -angle.current
newY = Math.max(
0,
Math.min(newY, containerDimensions.height - elementDimensions.height)
)
}
x.set(newX)
y.set(newY)
})
return (
<motion.div
ref={elementRef}
style={{
position: "absolute",
top: 0,
left: 0,
x,
y,
}}
className={cn("transform will-change-transform", className)}
>
{children}
</motion.div>
)
}
export default Screensaver

View file

@ -0,0 +1,103 @@
"use client"
import {
createContext,
useContext,
useRef,
type HTMLAttributes,
type PropsWithChildren,
} from "react"
import {
motion,
useScroll,
useTransform,
type MotionValue,
type UseScrollOptions,
} from "motion/react"
import { cn } from "@/lib/utils"
interface StackingCardsProps
extends PropsWithChildren,
HTMLAttributes<HTMLDivElement> {
scrollOptions?: UseScrollOptions
scaleMultiplier?: number
totalCards: number
}
interface StackingCardItemProps
extends HTMLAttributes<HTMLDivElement>,
PropsWithChildren {
index: number
topPosition?: string
}
export default function StackingCards({
children,
className,
scrollOptions,
scaleMultiplier,
totalCards,
...props
}: StackingCardsProps) {
const targetRef = useRef<HTMLDivElement>(null)
const { scrollYProgress } = useScroll({
offset: ["start start", "end end"],
...scrollOptions,
target: targetRef,
})
return (
<StackingCardsContext.Provider
value={{ progress: scrollYProgress, scaleMultiplier, totalCards }}
>
<div className={cn(className)} ref={targetRef} {...props}>
{children}
</div>
</StackingCardsContext.Provider>
)
}
const StackingCardItem = ({
index,
topPosition,
className,
children,
...props
}: StackingCardItemProps) => {
const {
progress,
scaleMultiplier,
totalCards = 0,
} = useStackingCardsContext() // Get from Context
const scaleTo = 1 - (totalCards - index) * (scaleMultiplier ?? 0.03)
const rangeScale = [index * (1 / totalCards), 1]
const scale = useTransform(progress, rangeScale, [1, scaleTo])
const top = topPosition ?? `${5 + index * 3}%`
return (
<div className={cn("h-full sticky top-0", className)} {...props}>
<motion.div
className={"origin-top relative h-full"}
style={{ top, scale }}
>
{children}
</motion.div>
</div>
)
}
const StackingCardsContext = createContext<{
progress: MotionValue<number>
scaleMultiplier?: number
totalCards?: number
} | null>(null)
export const useStackingCardsContext = () => {
const context = useContext(StackingCardsContext)
if (!context)
throw new Error("StackingCardItem must be used within StackingCards")
return context
}
export { StackingCardItem }

View file

@ -0,0 +1,134 @@
"use client"
import {
createContext,
ReactNode,
useCallback,
useContext,
useEffect,
useRef,
} from "react"
import { useAnimationFrame } from "motion/react"
import { cn } from "@/lib/utils"
import { useMousePositionRef } from "@/hooks/use-mouse-position-ref"
interface FloatingContextType {
registerElement: (id: string, element: HTMLDivElement, depth: number) => void
unregisterElement: (id: string) => void
}
const FloatingContext = createContext<FloatingContextType | null>(null)
interface FloatingProps {
children: ReactNode
className?: string
sensitivity?: number
easingFactor?: number
}
const Floating = ({
children,
className,
sensitivity = 1,
easingFactor = 0.05,
...props
}: FloatingProps) => {
const containerRef = useRef<HTMLDivElement>(null)
const elementsMap = useRef(
new Map<
string,
{
element: HTMLDivElement
depth: number
currentPosition: { x: number; y: number }
}
>()
)
const mousePositionRef = useMousePositionRef(containerRef)
const registerElement = useCallback(
(id: string, element: HTMLDivElement, depth: number) => {
elementsMap.current.set(id, {
element,
depth,
currentPosition: { x: 0, y: 0 },
})
},
[]
)
const unregisterElement = useCallback((id: string) => {
elementsMap.current.delete(id)
}, [])
useAnimationFrame(() => {
if (!containerRef.current) return
elementsMap.current.forEach((data) => {
const strength = (data.depth * sensitivity) / 20
// Calculate new target position
const newTargetX = mousePositionRef.current.x * strength
const newTargetY = mousePositionRef.current.y * strength
// Check if we need to update
const dx = newTargetX - data.currentPosition.x
const dy = newTargetY - data.currentPosition.y
// Update position only if we're still moving
data.currentPosition.x += dx * easingFactor
data.currentPosition.y += dy * easingFactor
data.element.style.transform = `translate3d(${data.currentPosition.x}px, ${data.currentPosition.y}px, 0)`
})
})
return (
<FloatingContext.Provider value={{ registerElement, unregisterElement }}>
<div
ref={containerRef}
className={cn("absolute top-0 left-0 w-full h-full", className)}
{...props}
>
{children}
</div>
</FloatingContext.Provider>
)
}
export default Floating
interface FloatingElementProps {
children: ReactNode
className?: string
depth?: number
}
export const FloatingElement = ({
children,
className,
depth = 1,
}: FloatingElementProps) => {
const elementRef = useRef<HTMLDivElement>(null)
const idRef = useRef(Math.random().toString(36).substring(7))
const context = useContext(FloatingContext)
useEffect(() => {
if (!elementRef.current || !context) return
const nonNullDepth = depth ?? 0.01
context.registerElement(idRef.current, elementRef.current, nonNullDepth)
return () => context.unregisterElement(idRef.current)
}, [depth])
return (
<div
ref={elementRef}
className={cn("absolute will-change-transform", className)}
>
{children}
</div>
)
}

View file

@ -0,0 +1,512 @@
"use client"
import {
createContext,
forwardRef,
ReactNode,
useCallback,
useContext,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react"
import { calculatePosition } from "@/utils/calculate-position"
import { parsePathToVertices } from "@/utils/svg-path-to-vertices"
import { debounce } from "lodash"
import decomp from "poly-decomp"
import Matter, {
Bodies,
Common,
Engine,
Events,
Mouse,
MouseConstraint,
Query,
Render,
Runner,
World,
} from "matter-js"
import { cn } from "@/lib/utils"
type GravityProps = {
children: ReactNode
debug?: boolean
gravity?: { x: number; y: number }
resetOnResize?: boolean
grabCursor?: boolean
addTopWall?: boolean
autoStart?: boolean
className?: string
}
type PhysicsBody = {
element: HTMLElement
body: Matter.Body
props: MatterBodyProps
}
type MatterBodyProps = {
children: ReactNode
matterBodyOptions?: Matter.IBodyDefinition
isDraggable?: boolean
bodyType?: "rectangle" | "circle" | "svg"
sampleLength?: number
x?: number | string
y?: number | string
angle?: number
className?: string
}
export type GravityRef = {
start: () => void
stop: () => void
reset: () => void
}
const GravityContext = createContext<{
registerElement: (
id: string,
element: HTMLElement,
props: MatterBodyProps
) => void
unregisterElement: (id: string) => void
} | null>(null)
export const MatterBody = ({
children,
className,
matterBodyOptions = {
friction: 0.1,
restitution: 0.1,
density: 0.001,
isStatic: false,
},
bodyType = "rectangle",
isDraggable = true,
sampleLength = 15,
x = 0,
y = 0,
angle = 0,
...props
}: MatterBodyProps) => {
const elementRef = useRef<HTMLDivElement>(null)
const idRef = useRef(Math.random().toString(36).substring(7))
const context = useContext(GravityContext)
useEffect(() => {
if (!elementRef.current || !context) return
context.registerElement(idRef.current, elementRef.current, {
children,
matterBodyOptions,
bodyType,
sampleLength,
isDraggable,
x,
y,
angle,
...props,
})
return () => context.unregisterElement(idRef.current)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<div
ref={elementRef}
className={cn(
"absolute",
className,
isDraggable && "pointer-events-none"
)}
>
{children}
</div>
)
}
const Gravity = forwardRef<GravityRef, GravityProps>(
(
{
children,
debug = false,
gravity = { x: 0, y: 1 },
grabCursor = true,
resetOnResize = true,
addTopWall = true,
autoStart = true,
className,
...props
},
ref
) => {
const canvas = useRef<HTMLDivElement>(null)
const engine = useRef(Engine.create())
const render = useRef<Render>(undefined)
const runner = useRef<Runner>(undefined)
const bodiesMap = useRef(new Map<string, PhysicsBody>())
const frameId = useRef<number>(undefined)
const mouseConstraint = useRef<Matter.MouseConstraint>(undefined)
const mouseDown = useRef(false)
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 })
const isRunning = useRef(false)
// Register Matter.js body in the physics world
const registerElement = useCallback(
(id: string, element: HTMLElement, props: MatterBodyProps) => {
if (!canvas.current) return
const width = element.offsetWidth
const height = element.offsetHeight
const canvasRect = canvas.current!.getBoundingClientRect()
const angle = (props.angle || 0) * (Math.PI / 180)
const x = calculatePosition(props.x, canvasRect.width, width)
const y = calculatePosition(props.y, canvasRect.height, height)
let body
if (props.bodyType === "circle") {
const radius = Math.max(width, height) / 2
body = Bodies.circle(x, y, radius, {
...props.matterBodyOptions,
angle: angle,
render: {
fillStyle: debug ? "#888888" : "#00000000",
strokeStyle: debug ? "#333333" : "#00000000",
lineWidth: debug ? 3 : 0,
},
})
} else if (props.bodyType === "svg") {
const paths = element.querySelectorAll("path")
const vertexSets: Matter.Vector[][] = []
paths.forEach((path) => {
const d = path.getAttribute("d")
const p = parsePathToVertices(d!, props.sampleLength)
vertexSets.push(p)
})
body = Bodies.fromVertices(x, y, vertexSets, {
...props.matterBodyOptions,
angle: angle,
render: {
fillStyle: debug ? "#888888" : "#00000000",
strokeStyle: debug ? "#333333" : "#00000000",
lineWidth: debug ? 3 : 0,
},
})
} else {
body = Bodies.rectangle(x, y, width, height, {
...props.matterBodyOptions,
angle: angle,
render: {
fillStyle: debug ? "#888888" : "#00000000",
strokeStyle: debug ? "#333333" : "#00000000",
lineWidth: debug ? 3 : 0,
},
})
}
if (body) {
World.add(engine.current.world, [body])
bodiesMap.current.set(id, { element, body, props })
}
},
[debug]
)
// Unregister Matter.js body from the physics world
const unregisterElement = useCallback((id: string) => {
const body = bodiesMap.current.get(id)
if (body) {
World.remove(engine.current.world, body.body)
bodiesMap.current.delete(id)
}
}, [])
// Keep react elements in sync with the physics world
const updateElements = useCallback(() => {
bodiesMap.current.forEach(({ element, body }) => {
const { x, y } = body.position
const rotation = body.angle * (180 / Math.PI)
element.style.transform = `translate(${
x - element.offsetWidth / 2
}px, ${y - element.offsetHeight / 2}px) rotate(${rotation}deg)`
})
frameId.current = requestAnimationFrame(updateElements)
}, [])
const initializeRenderer = useCallback(() => {
if (!canvas.current) return
const height = canvas.current.offsetHeight
const width = canvas.current.offsetWidth
Common.setDecomp(decomp)
engine.current.gravity.x = gravity.x
engine.current.gravity.y = gravity.y
render.current = Render.create({
element: canvas.current,
engine: engine.current,
options: {
width,
height,
wireframes: false,
background: "#00000000",
},
})
const mouse = Mouse.create(render.current.canvas)
mouseConstraint.current = MouseConstraint.create(engine.current, {
mouse: mouse,
constraint: {
stiffness: 0.2,
render: {
visible: debug,
},
},
})
// Add walls
const walls = [
// Floor
Bodies.rectangle(width / 2, height + 10, width, 20, {
isStatic: true,
friction: 1,
render: {
visible: debug,
},
}),
// Right wall
Bodies.rectangle(width + 10, height / 2, 20, height, {
isStatic: true,
friction: 1,
render: {
visible: debug,
},
}),
// Left wall
Bodies.rectangle(-10, height / 2, 20, height, {
isStatic: true,
friction: 1,
render: {
visible: debug,
},
}),
]
const topWall = addTopWall
? Bodies.rectangle(width / 2, -10, width, 20, {
isStatic: true,
friction: 1,
render: {
visible: debug,
},
})
: null
if (topWall) {
walls.push(topWall)
}
const touchingMouse = () =>
Query.point(
engine.current.world.bodies,
mouseConstraint.current?.mouse.position || { x: 0, y: 0 }
).length > 0
if (grabCursor) {
Events.on(engine.current, "beforeUpdate", (event) => {
if (canvas.current) {
if (!mouseDown.current && !touchingMouse()) {
canvas.current.style.cursor = "default"
} else if (touchingMouse()) {
canvas.current.style.cursor = mouseDown.current
? "grabbing"
: "grab"
}
}
})
canvas.current.addEventListener("mousedown", (event) => {
mouseDown.current = true
if (canvas.current) {
if (touchingMouse()) {
canvas.current.style.cursor = "grabbing"
} else {
canvas.current.style.cursor = "default"
}
}
})
canvas.current.addEventListener("mouseup", (event) => {
mouseDown.current = false
if (canvas.current) {
if (touchingMouse()) {
canvas.current.style.cursor = "grab"
} else {
canvas.current.style.cursor = "default"
}
}
})
}
World.add(engine.current.world, [mouseConstraint.current, ...walls])
render.current.mouse = mouse
runner.current = Runner.create()
Render.run(render.current)
updateElements()
runner.current.enabled = false
if (autoStart) {
runner.current.enabled = true
startEngine()
}
}, [updateElements, debug, autoStart])
// Clear the Matter.js world
const clearRenderer = useCallback(() => {
if (frameId.current) {
cancelAnimationFrame(frameId.current)
}
if (mouseConstraint.current) {
World.remove(engine.current.world, mouseConstraint.current)
}
if (render.current) {
Mouse.clearSourceEvents(render.current.mouse)
Render.stop(render.current)
render.current.canvas.remove()
}
if (runner.current) {
Runner.stop(runner.current)
}
if (engine.current) {
World.clear(engine.current.world, false)
Engine.clear(engine.current)
}
bodiesMap.current.clear()
}, [])
const handleResize = useCallback(() => {
if (!canvas.current || !resetOnResize) return
const newWidth = canvas.current.offsetWidth
const newHeight = canvas.current.offsetHeight
setCanvasSize({ width: newWidth, height: newHeight })
// Clear and reinitialize
clearRenderer()
initializeRenderer()
}, [clearRenderer, initializeRenderer, resetOnResize])
const startEngine = useCallback(() => {
if (runner.current) {
runner.current.enabled = true
Runner.run(runner.current, engine.current)
}
if (render.current) {
Render.run(render.current)
}
frameId.current = requestAnimationFrame(updateElements)
isRunning.current = true
}, [updateElements, canvasSize])
const stopEngine = useCallback(() => {
if (!isRunning.current) return
if (runner.current) {
Runner.stop(runner.current)
}
if (render.current) {
Render.stop(render.current)
}
if (frameId.current) {
cancelAnimationFrame(frameId.current)
}
isRunning.current = false
}, [])
const reset = useCallback(() => {
stopEngine()
bodiesMap.current.forEach(({ element, body, props }) => {
body.angle = props.angle || 0
const x = calculatePosition(
props.x,
canvasSize.width,
element.offsetWidth
)
const y = calculatePosition(
props.y,
canvasSize.height,
element.offsetHeight
)
body.position.x = x
body.position.y = y
})
updateElements()
handleResize()
}, [])
useImperativeHandle(
ref,
() => ({
start: startEngine,
stop: stopEngine,
reset,
}),
[startEngine, stopEngine]
)
useEffect(() => {
if (!resetOnResize) return
const debouncedResize = debounce(handleResize, 500)
window.addEventListener("resize", debouncedResize)
return () => {
window.removeEventListener("resize", debouncedResize)
debouncedResize.cancel()
}
}, [handleResize, resetOnResize])
useEffect(() => {
initializeRenderer()
return clearRenderer
}, [initializeRenderer, clearRenderer])
return (
<GravityContext.Provider value={{ registerElement, unregisterElement }}>
<div
ref={canvas}
className={cn(className, "absolute top-0 left-0 w-full h-full")}
{...props}
>
{children}
</div>
</GravityContext.Provider>
)
}
)
Gravity.displayName = "Gravity"
export default Gravity

View file

@ -0,0 +1,188 @@
"use client"
import { useEffect, useState } from "react"
import { motion } from "motion/react"
import { cn } from "@/lib/utils"
interface ScrambleHoverProps {
text: string
scrambleSpeed?: number
maxIterations?: number
sequential?: boolean
revealDirection?: "start" | "end" | "center"
useOriginalCharsOnly?: boolean
characters?: string
className?: string
scrambledClassName?: string
}
const ScrambleHover: React.FC<ScrambleHoverProps> = ({
text,
scrambleSpeed = 50,
maxIterations = 10,
useOriginalCharsOnly = false,
characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!@#$%^&*()_+",
className,
scrambledClassName,
sequential = false,
revealDirection = "start",
...props
}) => {
const [displayText, setDisplayText] = useState(text)
const [isHovering, setIsHovering] = useState(false)
const [isScrambling, setIsScrambling] = useState(false)
const [revealedIndices, setRevealedIndices] = useState(new Set<number>())
useEffect(() => {
let interval: NodeJS.Timeout
let currentIteration = 0
const getNextIndex = () => {
const textLength = text.length
switch (revealDirection) {
case "start":
return revealedIndices.size
case "end":
return textLength - 1 - revealedIndices.size
case "center":
const middle = Math.floor(textLength / 2)
const offset = Math.floor(revealedIndices.size / 2)
const nextIndex =
revealedIndices.size % 2 === 0
? middle + offset
: middle - offset - 1
if (
nextIndex >= 0 &&
nextIndex < textLength &&
!revealedIndices.has(nextIndex)
) {
return nextIndex
}
for (let i = 0; i < textLength; i++) {
if (!revealedIndices.has(i)) return i
}
return 0
default:
return revealedIndices.size
}
}
const shuffleText = (text: string) => {
if (useOriginalCharsOnly) {
const positions = text.split("").map((char, i) => ({
char,
isSpace: char === " ",
index: i,
isRevealed: revealedIndices.has(i),
}))
const nonSpaceChars = positions
.filter((p) => !p.isSpace && !p.isRevealed)
.map((p) => p.char)
// Shuffle remaining non-revealed, non-space characters
for (let i = nonSpaceChars.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[nonSpaceChars[i], nonSpaceChars[j]] = [
nonSpaceChars[j],
nonSpaceChars[i],
]
}
let charIndex = 0
return positions
.map((p) => {
if (p.isSpace) return " "
if (p.isRevealed) return text[p.index]
return nonSpaceChars[charIndex++]
})
.join("")
} else {
return text
.split("")
.map((char, i) => {
if (char === " ") return " "
if (revealedIndices.has(i)) return text[i]
return availableChars[
Math.floor(Math.random() * availableChars.length)
]
})
.join("")
}
}
const availableChars = useOriginalCharsOnly
? Array.from(new Set(text.split(""))).filter((char) => char !== " ")
: characters.split("")
if (isHovering) {
setIsScrambling(true)
interval = setInterval(() => {
if (sequential) {
if (revealedIndices.size < text.length) {
const nextIndex = getNextIndex()
revealedIndices.add(nextIndex)
setDisplayText(shuffleText(text))
} else {
clearInterval(interval)
setIsScrambling(false)
}
} else {
setDisplayText(shuffleText(text))
currentIteration++
if (currentIteration >= maxIterations) {
clearInterval(interval)
setIsScrambling(false)
setDisplayText(text)
}
}
}, scrambleSpeed)
} else {
setDisplayText(text)
revealedIndices.clear()
}
return () => {
if (interval) clearInterval(interval)
}
}, [
isHovering,
text,
characters,
scrambleSpeed,
useOriginalCharsOnly,
sequential,
revealDirection,
maxIterations,
])
return (
<motion.span
onHoverStart={() => setIsHovering(true)}
onHoverEnd={() => setIsHovering(false)}
className={cn("inline-block whitespace-pre-wrap", className)}
{...props}
>
<span className="sr-only">{displayText}</span>
<span aria-hidden="true">
{displayText.split("").map((char, index) => (
<span
key={index}
className={cn(
!revealedIndices.has(index) && isScrambling && isHovering
? scrambledClassName
: undefined
)}
>
{char}
</span>
))}
</span>
</motion.span>
)
}
export default ScrambleHover

View file

@ -0,0 +1,150 @@
"use client"
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useState,
} from "react"
interface ScrambleInProps {
text: string
scrambleSpeed?: number
scrambledLetterCount?: number
characters?: string
className?: string
scrambledClassName?: string
autoStart?: boolean
onStart?: () => void
onComplete?: () => void
}
export interface ScrambleInHandle {
start: () => void
reset: () => void
}
const ScrambleIn = forwardRef<ScrambleInHandle, ScrambleInProps>(
(
{
text,
scrambleSpeed = 30,
scrambledLetterCount = 2,
characters = "abcdefghijklmnopqrstuvwxyz!@#$%^&*()_+",
className = "",
scrambledClassName = "",
autoStart = true,
onStart,
onComplete,
},
ref
) => {
const [displayText, setDisplayText] = useState("")
const [isAnimating, setIsAnimating] = useState(false)
const [visibleLetterCount, setVisibleLetterCount] = useState(0)
const [scrambleOffset, setScrambleOffset] = useState(0)
const startAnimation = useCallback(() => {
setIsAnimating(true)
setVisibleLetterCount(0)
setScrambleOffset(0)
onStart?.()
}, [onStart])
const reset = useCallback(() => {
setIsAnimating(false)
setVisibleLetterCount(0)
setScrambleOffset(0)
setDisplayText("")
}, [])
useImperativeHandle(ref, () => ({
start: startAnimation,
reset,
}))
useEffect(() => {
if (autoStart) {
startAnimation()
}
}, [autoStart, startAnimation])
useEffect(() => {
let interval: NodeJS.Timeout
if (isAnimating) {
interval = setInterval(() => {
// Increase visible text length
if (visibleLetterCount < text.length) {
setVisibleLetterCount((prev) => prev + 1)
}
// Start sliding scrambled text out
else if (scrambleOffset < scrambledLetterCount) {
setScrambleOffset((prev) => prev + 1)
}
// Complete animation
else {
clearInterval(interval)
setIsAnimating(false)
onComplete?.()
}
// Calculate how many scrambled letters we can show
const remainingSpace = Math.max(0, text.length - visibleLetterCount)
const currentScrambleCount = Math.min(
remainingSpace,
scrambledLetterCount
)
// Generate scrambled text
const scrambledPart = Array(currentScrambleCount)
.fill(0)
.map(
() => characters[Math.floor(Math.random() * characters.length)]
)
.join("")
setDisplayText(text.slice(0, visibleLetterCount) + scrambledPart)
}, scrambleSpeed)
}
return () => {
if (interval) clearInterval(interval)
}
}, [
isAnimating,
text,
visibleLetterCount,
scrambleOffset,
scrambledLetterCount,
characters,
scrambleSpeed,
onComplete,
])
const renderText = () => {
const revealed = displayText.slice(0, visibleLetterCount)
const scrambled = displayText.slice(visibleLetterCount)
return (
<>
<span className={className}>{revealed}</span>
<span className={scrambledClassName}>{scrambled}</span>
</>
)
}
return (
<>
<span className="sr-only">{text}</span>
<span className="inline-block whitespace-pre-wrap" aria-hidden="true">
{renderText()}
</span>
</>
)
}
)
ScrambleIn.displayName = "ScrambleIn"
export default ScrambleIn

View file

@ -0,0 +1,208 @@
"use client"
import {
ElementType,
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from "react"
import { motion, Transition, useInView, UseInViewOptions } from "motion/react"
import { cn } from "@/lib/utils"
type HighlightDirection = "ltr" | "rtl" | "ttb" | "btt"
type TextHighlighterProps = {
/**
* The text content to be highlighted
*/
children: React.ReactNode
/**
* HTML element to render as
* @default "p"
*/
as?: ElementType
/**
* How to trigger the animation
* @default "inView"
*/
triggerType?: "hover" | "ref" | "inView" | "auto"
/**
* Animation transition configuration
* @default { duration: 0.4, type: "spring", bounce: 0 }
*/
transition?: Transition
/**
* Options for useInView hook when triggerType is "inView"
*/
useInViewOptions?: UseInViewOptions
/**
* Class name for the container element
*/
className?: string
/**
* Highlight color (CSS color string). Also can be a function that returns a color string, eg:
* @default 'hsl(60, 90%, 68%)' (yellow)
*/
highlightColor?: string
/**
* Direction of the highlight animation
* @default "ltr" (left to right)
*/
direction?: HighlightDirection
} & React.HTMLAttributes<HTMLElement>
export type TextHighlighterRef = {
/**
* Trigger the highlight animation
* @param direction - Optional direction override for this animation
*/
animate: (direction?: HighlightDirection) => void
/**
* Reset the highlight animation
*/
reset: () => void
}
export const TextHighlighter = forwardRef<
TextHighlighterRef,
TextHighlighterProps
>(
(
{
children,
as = "span",
triggerType = "inView",
transition = { type: "spring", duration: 1, delay: 0, bounce: 0 },
useInViewOptions = {
once: true,
initial: false,
amount: 0.1,
},
className,
highlightColor = "hsl(25, 90%, 80%)",
direction = "ltr",
...props
},
ref
) => {
const componentRef = useRef<HTMLDivElement>(null)
const [isAnimating, setIsAnimating] = useState(false)
const [isHovered, setIsHovered] = useState(false)
const [currentDirection, setCurrentDirection] =
useState<HighlightDirection>(direction)
// this allows us to change the direction whenever the direction prop changes
useEffect(() => {
setCurrentDirection(direction)
}, [direction])
const isInView = useInView(componentRef, useInViewOptions)
useImperativeHandle(ref, () => ({
animate: (animationDirection?: HighlightDirection) => {
if (animationDirection) {
setCurrentDirection(animationDirection)
}
setIsAnimating(true)
},
reset: () => setIsAnimating(false),
}))
const shouldAnimate =
triggerType === "hover"
? isHovered
: triggerType === "inView"
? isInView
: triggerType === "ref"
? isAnimating
: triggerType === "auto"
? true
: false
const ElementTag = as || "span"
// Get background size based on direction
const getBackgroundSize = (animated: boolean) => {
switch (currentDirection) {
case "ltr":
return animated ? "100% 100%" : "0% 100%"
case "rtl":
return animated ? "100% 100%" : "0% 100%"
case "ttb":
return animated ? "100% 100%" : "100% 0%"
case "btt":
return animated ? "100% 100%" : "100% 0%"
default:
return animated ? "100% 100%" : "0% 100%"
}
}
// Get background position based on direction
const getBackgroundPosition = () => {
switch (currentDirection) {
case "ltr":
return "0% 0%"
case "rtl":
return "100% 0%"
case "ttb":
return "0% 0%"
case "btt":
return "0% 100%"
default:
return "0% 0%"
}
}
const animatedSize = useMemo(() => getBackgroundSize(shouldAnimate), [shouldAnimate, currentDirection])
const initialSize = useMemo(() => getBackgroundSize(false), [currentDirection])
const backgroundPosition = useMemo(() => getBackgroundPosition(), [currentDirection])
const highlightStyle = {
backgroundImage: `linear-gradient(${highlightColor}, ${highlightColor})`,
backgroundRepeat: "no-repeat",
backgroundPosition: backgroundPosition,
backgroundSize: animatedSize,
boxDecorationBreak: "clone",
WebkitBoxDecorationBreak: "clone",
} as React.CSSProperties
return (
<ElementTag
ref={componentRef}
onMouseEnter={() => triggerType === "hover" && setIsHovered(true)}
onMouseLeave={() => triggerType === "hover" && setIsHovered(false)}
{...props}
>
<motion.span
className={cn("inline", className)}
style={highlightStyle}
animate={{
backgroundSize: animatedSize,
}}
initial={{
backgroundSize: initialSize,
}}
transition={transition}
>
{children}
</motion.span>
</ElementTag>
)
}
)
TextHighlighter.displayName = "TextHighlighter"
export default TextHighlighter

View file

@ -0,0 +1,441 @@
"use client"
import {
ElementType,
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useState,
} from "react"
import {
AnimatePresence,
AnimatePresenceProps,
motion,
MotionProps,
Transition,
} from "motion/react"
import { cn } from "@/lib/utils"
// handy function to split text into characters with support for unicode and emojis
const splitIntoCharacters = (text: string): string[] => {
if (typeof Intl !== "undefined" && "Segmenter" in Intl) {
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" })
return Array.from(segmenter.segment(text), ({ segment }) => segment)
}
// Fallback for browsers that don't support Intl.Segmenter
return Array.from(text)
}
interface TextRotateProps {
/**
* Array of text strings to rotate through.
* Required prop with no default value.
*/
texts: string[]
/**
* render as HTML Tag
*/
as?: ElementType
/**
* Time in milliseconds between text rotations.
* @default 2000
*/
rotationInterval?: number
/**
* Initial animation state or array of states.
* @default { y: "100%", opacity: 0 }
*/
initial?: MotionProps["initial"] | MotionProps["initial"][]
/**
* Animation state to animate to or array of states.
* @default { y: 0, opacity: 1 }
*/
animate?: MotionProps["animate"] | MotionProps["animate"][]
/**
* Animation state when exiting or array of states.
* @default { y: "-120%", opacity: 0 }
*/
exit?: MotionProps["exit"] | MotionProps["exit"][]
/**
* AnimatePresence mode
* @default "wait"
*/
animatePresenceMode?: AnimatePresenceProps["mode"]
/**
* Whether to run initial animation on first render.
* @default false
*/
animatePresenceInitial?: boolean
/**
* Duration of stagger delay between elements in seconds.
* @default 0
*/
staggerDuration?: number
/**
* Direction to stagger animations from.
* @default "first"
*/
staggerFrom?: "first" | "last" | "center" | number | "random"
/**
* Animation transition configuration.
* @default { type: "spring", damping: 25, stiffness: 300 }
*/
transition?: Transition
/**
* Whether to loop through texts continuously.
* @default true
*/
loop?: boolean
/**
* Whether to auto-rotate texts.
* @default true
*/
auto?: boolean
/**
* How to split the text for animation.
* @default "characters"
*/
splitBy?: "words" | "characters" | "lines" | string
/**
* Callback function triggered when rotating to next text.
* @default undefined
*/
onNext?: (index: number) => void
/**
* Class name for the main container element.
* @default undefined
*/
mainClassName?: string
/**
* Class name for the split level wrapper elements.
* @default undefined
*/
splitLevelClassName?: string
/**
* Class name for individual animated elements.
* @default undefined
*/
elementLevelClassName?: string
}
/**
* Interface for the ref object exposed by TextRotate component.
* Provides methods to control text rotation programmatically.
* This allows external components to trigger text changes
* without relying on the automatic rotation.
*/
export interface TextRotateRef {
/**
* Advance to next text in sequence.
* If at the end, will loop to beginning if loop prop is true.
*/
next: () => void
/**
* Go back to previous text in sequence.
* If at the start, will loop to end if loop prop is true.
*/
previous: () => void
/**
* Jump to specific text by index.
* Will clamp index between 0 and texts.length - 1.
*/
jumpTo: (index: number) => void
/**
* Reset back to first text.
* Equivalent to jumpTo(0).
*/
reset: () => void
}
/**
* Internal interface for representing words when splitting text by characters.
* Used to maintain proper word spacing and line breaks while allowing
* character-by-character animation. This prevents words from breaking
* across lines during animation.
*/
interface WordObject {
/**
* Array of individual characters in the word.
* Uses Intl.Segmenter when available for proper Unicode handling.
*/
characters: string[]
/**
* Whether this word needs a space after it.
* True for all words except the last one in a sequence.
*/
needsSpace: boolean
}
const TextRotate = forwardRef<TextRotateRef, TextRotateProps>(
(
{
texts,
as = "p",
transition = { type: "spring", damping: 25, stiffness: 300 },
initial = { y: "100%", opacity: 0 },
animate = { y: 0, opacity: 1 },
exit = { y: "-120%", opacity: 0 },
animatePresenceMode = "wait",
animatePresenceInitial = false,
rotationInterval = 2000,
staggerDuration = 0,
staggerFrom = "first",
loop = true,
auto = true,
splitBy = "characters",
onNext,
mainClassName,
splitLevelClassName,
elementLevelClassName,
...props
},
ref
) => {
const [currentTextIndex, setCurrentTextIndex] = useState(0)
// Splitting the text into animation segments
const elements = useMemo(() => {
const currentText = texts[currentTextIndex]
if (splitBy === "characters") {
const text = currentText.split(" ")
return text.map((word, i) => ({
characters: splitIntoCharacters(word),
needsSpace: i !== text.length - 1,
}))
}
return splitBy === "words"
? currentText.split(" ")
: splitBy === "lines"
? currentText.split("\n")
: currentText.split(splitBy)
}, [texts, currentTextIndex, splitBy])
// Helper function to calculate stagger delay for each text segment
const getStaggerDelay = useCallback(
(index: number, totalChars: number) => {
const total = totalChars
if (staggerFrom === "first") return index * staggerDuration
if (staggerFrom === "last") return (total - 1 - index) * staggerDuration
if (staggerFrom === "center") {
const center = Math.floor(total / 2)
return Math.abs(center - index) * staggerDuration
}
if (staggerFrom === "random") {
const randomIndex = Math.floor(Math.random() * total)
return Math.abs(randomIndex - index) * staggerDuration
}
return Math.abs(staggerFrom - index) * staggerDuration
},
[staggerFrom, staggerDuration]
)
// Helper function to handle index changes and trigger callback
const handleIndexChange = useCallback(
(newIndex: number) => {
setCurrentTextIndex(newIndex)
onNext?.(newIndex)
},
[onNext]
)
// Go to next text
const next = useCallback(() => {
const nextIndex =
currentTextIndex === texts.length - 1
? loop
? 0
: currentTextIndex
: currentTextIndex + 1
if (nextIndex !== currentTextIndex) {
handleIndexChange(nextIndex)
}
}, [currentTextIndex, texts.length, loop, handleIndexChange])
// Go back to previous text
const previous = useCallback(() => {
const prevIndex =
currentTextIndex === 0
? loop
? texts.length - 1
: currentTextIndex
: currentTextIndex - 1
if (prevIndex !== currentTextIndex) {
handleIndexChange(prevIndex)
}
}, [currentTextIndex, texts.length, loop, handleIndexChange])
// Jump to specific text by index
const jumpTo = useCallback(
(index: number) => {
const validIndex = Math.max(0, Math.min(index, texts.length - 1))
if (validIndex !== currentTextIndex) {
handleIndexChange(validIndex)
}
},
[texts.length, currentTextIndex, handleIndexChange]
)
// Reset back to first text
const reset = useCallback(() => {
if (currentTextIndex !== 0) {
handleIndexChange(0)
}
}, [currentTextIndex, handleIndexChange])
// Get animation props for each text segment. If array is provided, states will be mapped to text segments cyclically.
const getAnimationProps = useCallback(
(index: number) => {
const getProp = (
prop:
| MotionProps["initial"]
| MotionProps["initial"][]
| MotionProps["animate"]
| MotionProps["animate"][]
| MotionProps["exit"]
| MotionProps["exit"][]
) => {
if (Array.isArray(prop)) {
return prop[index % prop.length]
}
return prop
}
return {
initial: getProp(initial) as MotionProps["initial"],
animate: getProp(animate) as MotionProps["animate"],
exit: getProp(exit) as MotionProps["exit"],
}
},
[initial, animate, exit]
)
// Expose all navigation functions via ref
useImperativeHandle(
ref,
() => ({
next,
previous,
jumpTo,
reset,
}),
[next, previous, jumpTo, reset]
)
// Auto-rotate text
useEffect(() => {
if (!auto) return
const intervalId = setInterval(next, rotationInterval)
return () => clearInterval(intervalId)
}, [next, rotationInterval, auto])
// Custom motion component to render the text as a custom HTML tag provided via prop
const MotionComponent = useMemo(() => motion.create(as ?? "p"), [as])
return (
<MotionComponent
className={cn("flex flex-wrap whitespace-pre-wrap", mainClassName)}
transition={transition}
layout
{...props}
>
<span className="sr-only">{texts[currentTextIndex]}</span>
<AnimatePresence
mode={animatePresenceMode}
initial={animatePresenceInitial}
>
<motion.span
key={currentTextIndex}
className={cn(
"flex flex-wrap justify-center",
splitBy === "lines" && "flex-col w-full"
)}
aria-hidden
layout
>
{(splitBy === "characters"
? (elements as WordObject[])
: (elements as string[]).map((el, i) => ({
characters: [el],
needsSpace: i !== elements.length - 1,
}))
).map((wordObj, wordIndex, array) => {
const previousCharsCount = array
.slice(0, wordIndex)
.reduce((sum, word) => sum + word.characters.length, 0)
return (
<span
key={wordIndex}
className={cn("inline-flex", splitLevelClassName)}
>
{wordObj.characters.map((char, charIndex) => {
const totalIndex = previousCharsCount + charIndex
const animationProps = getAnimationProps(totalIndex)
return (
<span
key={totalIndex}
className={cn(elementLevelClassName)}
>
<motion.span
{...animationProps}
key={charIndex}
transition={{
...transition,
delay: getStaggerDelay(
previousCharsCount + charIndex,
array.reduce(
(sum, word) => sum + word.characters.length,
0
)
),
}}
className={"inline-block"}
>
{char}
</motion.span>
</span>
)
})}
{wordObj.needsSpace && (
<span className="whitespace-pre"> </span>
)}
</span>
)
})}
</motion.span>
</AnimatePresence>
</MotionComponent>
)
}
)
TextRotate.displayName = "TextRotate"
export default TextRotate

View file

@ -0,0 +1,230 @@
"use client"
import { AnimationOptions, motion } from "motion/react"
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from "react"
import { cn } from "@/lib/utils"
interface TextProps {
children: React.ReactNode
reverse?: boolean
transition?: AnimationOptions
splitBy?: "words" | "characters" | "lines" | string
staggerDuration?: number
staggerFrom?: "first" | "last" | "center" | "random" | number
containerClassName?: string
wordLevelClassName?: string
elementLevelClassName?: string
onClick?: () => void
onStart?: () => void
onComplete?: () => void
autoStart?: boolean // Whether to start the animation automatically
}
// Ref interface to allow external control of the animation
export interface VerticalCutRevealRef {
startAnimation: () => void
reset: () => void
}
interface WordObject {
characters: string[]
needsSpace: boolean
}
const VerticalCutReveal = forwardRef<VerticalCutRevealRef, TextProps>(
(
{
children,
reverse = false,
transition = {
type: "spring",
stiffness: 190,
damping: 22,
},
splitBy = "words",
staggerDuration = 0.2,
staggerFrom = "first",
containerClassName,
wordLevelClassName,
elementLevelClassName,
onClick,
onStart,
onComplete,
autoStart = true,
...props
},
ref
) => {
const containerRef = useRef<HTMLSpanElement>(null)
const text =
typeof children === "string" ? children : children?.toString() || ""
const [isAnimating, setIsAnimating] = useState(false)
// handy function to split text into characters with support for unicode and emojis
const splitIntoCharacters = (text: string): string[] => {
if (typeof Intl !== "undefined" && "Segmenter" in Intl) {
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" })
return Array.from(segmenter.segment(text), ({ segment }) => segment)
}
// Fallback for browsers that don't support Intl.Segmenter
return Array.from(text)
}
// Split text based on splitBy parameter
const elements = useMemo(() => {
const words = text.split(" ")
if (splitBy === "characters") {
return words.map((word, i) => ({
characters: splitIntoCharacters(word),
needsSpace: i !== words.length - 1,
}))
}
return splitBy === "words"
? text.split(" ")
: splitBy === "lines"
? text.split("\n")
: text.split(splitBy)
}, [text, splitBy])
// Calculate stagger delays based on staggerFrom
const getStaggerDelay = useCallback(
(index: number) => {
const total =
splitBy === "characters"
? elements.reduce(
(acc, word) =>
acc +
(typeof word === "string"
? 1
: word.characters.length + (word.needsSpace ? 1 : 0)),
0
)
: elements.length
if (staggerFrom === "first") return index * staggerDuration
if (staggerFrom === "last") return (total - 1 - index) * staggerDuration
if (staggerFrom === "center") {
const center = Math.floor(total / 2)
return Math.abs(center - index) * staggerDuration
}
if (staggerFrom === "random") {
const randomIndex = Math.floor(Math.random() * total)
return Math.abs(randomIndex - index) * staggerDuration
}
return Math.abs(staggerFrom - index) * staggerDuration
},
[elements.length, staggerFrom, staggerDuration]
)
const startAnimation = useCallback(() => {
setIsAnimating(true)
onStart?.()
}, [onStart])
// Expose the startAnimation function via ref
useImperativeHandle(ref, () => ({
startAnimation,
reset: () => setIsAnimating(false),
}))
// Auto start animation when element enters viewport
useEffect(() => {
if (!autoStart || !containerRef.current) return
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
startAnimation()
observer.disconnect()
}
},
{ threshold: 0.1 }
)
observer.observe(containerRef.current)
return () => observer.disconnect()
}, [autoStart, startAnimation])
const variants = {
hidden: { y: reverse ? "-100%" : "100%" },
visible: (i: number) => ({
y: 0,
transition: {
...transition,
delay: ((transition?.delay as number) || 0) + getStaggerDelay(i),
},
}),
}
return (
<span
className={cn(
containerClassName,
"flex flex-wrap whitespace-pre-wrap",
splitBy === "lines" && "flex-col"
)}
onClick={onClick}
ref={containerRef}
{...props}
>
<span className="sr-only">{text}</span>
{(splitBy === "characters"
? (elements as WordObject[])
: (elements as string[]).map((el, i) => ({
characters: [el],
needsSpace: i !== elements.length - 1,
}))
).map((wordObj, wordIndex, array) => {
const previousCharsCount = array
.slice(0, wordIndex)
.reduce((sum, word) => sum + word.characters.length, 0)
return (
<span
key={wordIndex}
aria-hidden="true"
className={cn("inline-flex overflow-hidden", wordLevelClassName)}
>
{wordObj.characters.map((char, charIndex) => (
<span
className={cn(
elementLevelClassName,
"whitespace-pre-wrap relative"
)}
key={charIndex}
>
<motion.span
custom={previousCharsCount + charIndex}
initial="hidden"
animate={isAnimating ? "visible" : "hidden"}
variants={variants}
onAnimationComplete={
wordIndex === elements.length - 1 &&
charIndex === wordObj.characters.length - 1
? onComplete
: undefined
}
className="inline-block"
>
{char}
</motion.span>
</span>
))}
{wordObj.needsSpace && <span> </span>}
</span>
)
})}
</span>
)
}
)
VerticalCutReveal.displayName = "VerticalCutReveal"
export default VerticalCutReveal

View file

@ -0,0 +1,31 @@
import { RefObject, useEffect, useState } from "react"
interface Dimensions {
width: number
height: number
}
export function useDimensions(
ref: RefObject<HTMLElement | SVGElement | null>
): Dimensions {
const [dimensions, setDimensions] = useState<Dimensions>({
width: 0,
height: 0,
})
useEffect(() => {
const updateDimensions = () => {
if (ref.current) {
const { width, height } = ref.current.getBoundingClientRect()
setDimensions({ width, height })
}
}
updateDimensions()
window.addEventListener("resize", updateDimensions)
return () => window.removeEventListener("resize", updateDimensions)
}, [ref])
return dimensions
}

View file

@ -0,0 +1,42 @@
import { RefObject, useEffect, useRef } from "react"
export const useMousePositionRef = (
containerRef?: RefObject<HTMLElement | SVGElement | null>
) => {
const positionRef = useRef({ x: 0, y: 0 })
useEffect(() => {
const updatePosition = (x: number, y: number) => {
if (containerRef && containerRef.current) {
const rect = containerRef.current.getBoundingClientRect()
const relativeX = x - rect.left
const relativeY = y - rect.top
// Calculate relative position even when outside the container
positionRef.current = { x: relativeX, y: relativeY }
} else {
positionRef.current = { x, y }
}
}
const handleMouseMove = (ev: MouseEvent) => {
updatePosition(ev.clientX, ev.clientY)
}
const handleTouchMove = (ev: TouchEvent) => {
const touch = ev.touches[0]
updatePosition(touch.clientX, touch.clientY)
}
// Listen for both mouse and touch events
window.addEventListener("mousemove", handleMouseMove)
window.addEventListener("touchmove", handleTouchMove)
return () => {
window.removeEventListener("mousemove", handleMouseMove)
window.removeEventListener("touchmove", handleTouchMove)
}
}, [containerRef])
return positionRef
}

View file

@ -1,47 +1,108 @@
{
"nav": {
"demo": "Demo",
"docs": "Dokumentácia",
"docs": "Dokumentace",
"wild": "V praxi",
"contact": "Kontakt"
},
"hero": {
"tagline": "Zbav sa reťazí.",
"subtitle": "Vlastni svoj obchod.",
"description": "Open-source eCommerce platforma bez SaaS závislostí. Tvoj obchod, tvoje dáta, žiadni páni.",
"cta": "Vyskúšať demo",
"docs": "Dokumentácia"
"tagline": "Zbav se řetězí.",
"subtitleRotations": [
"Vlastni svůj obchod.",
"Tvá data, tvá pravidla.",
"Žádní pánové nad tebou."
],
"description": "Open-source eCommerce platforma bez SaaS závislostí. Tvůj obchod, tvá data, žádní pánové.",
"cta": "Vyzkoušet demo",
"docs": "Dokumentace"
},
"problem": {
"title": "Dosť bolo slop-u.",
"title": "Dost bylo slop-u.",
"subtitle": "Tisíce eur za WordPress z roku 2015",
"cards": [
{ "title": "4.2s load time", "desc": "Tvoj zákazník odchádza kým sa stránka načíta. V roku 2025." },
{ "title": "2.1MB payload", "desc": "47 pluginov, 12 skriptov na trackovanie, 0 výkon." },
{ "title": "€99/mesiac", "desc": "Premium plugin. Potom ďalší. A ďalší. Na TVOJOM webe." },
{ "title": "€3,500 setup", "desc": "Za starú šablónu a import 20 produktov. Bez školenia." }
{
"title": "4.2s load time",
"suffix": "%",
"desc": "Tvůj zákazník odchází než se stránka načte. V roce 2025."
},
{
"title": "2.1MB payload",
"suffix": "×",
"desc": "47 pluginů, 12 skriptů pro trackování, 0 výkon."
},
{
"title": "€99/měsíc",
"suffix": "€+",
"desc": "Premium plugin. Pak další. A další. Na TVÉM webu."
},
{
"title": "€3,500 setup",
"suffix": "+",
"desc": "Za starou šablonu a import 20 produktů. Bez školení."
}
]
},
"features": {
"title": "Všetko čo potrebuješ.",
"subtitle": "Nič čo nepotrebuješ.",
"items": [
{ "icon": "Shield", "title": "Žiadne SaaS", "desc": "Žiadny Algolia, Cookiebot, ani mesačné poplatky. Všetko beží na tvojom serveri."},
{ "icon": "Globe2", "title": "Multi-channel", "desc": "Jeden admin, nekonečné kanály. SK, DE, CZ domény s vlastnými šablónami a jazykmi."},
{ "icon": "BarChart3", "title": "First-party analytika", "desc": "Všetky GA4 eventy ako tvoje vlastné dáta. GDPR friendly, žiadne cookie bannery."},
{ "icon": "Blocks", "title": "Blokový CMS", "desc": "Dynamické stránky, blog, landing pages. Blokový editor ako v Notion."},
{ "icon": "Mail", "title": "Email builder", "desc": "Vstavaný generátor emailov. Blokový systém, vlastné šablóny, žiadny Mailchimp."},
{ "icon": "Tag", "title": "Zľavy & kupóny", "desc": "Cart rules, kupóny s metrikami, influencer marketing, buy X get Y."},
{ "icon": "Users", "title": "Premium používatelia", "desc": "Manuálne VIP statusy, oprávnenia na médiá, pripravené na subscription model."},
{ "icon": "Layers", "title": "Taxonómie", "desc": "Kategórie, tagy, vlastné hierarchie. Všetko s prekladmi a SEO."},
{ "icon": "Zap", "title": "Pod 1MB", "desc": "Celý backend bez závislostí. Čistý, udržiavateľný, rýchly ako blesk."}
]
"title": "Vše co potřebuješ.",
"subtitle": "Nic co nepotřebuješ.",
"items": [
{
"icon": "Shield",
"title": "Žádné SaaS",
"desc": "Žádný Algolia, Cookiebot, ani měsíční poplatky. Vše běží na tvém serveru."
},
{
"icon": "Globe2",
"title": "Multi-channel",
"desc": "Jeden admin, nekonečné kanály. CZ, DE, CZ domény s vlastními šablonami a jazyky."
},
{
"icon": "BarChart3",
"title": "First-party analytika",
"desc": "Všechny GA4 eventy jako tvoje vlastní data. GDPR friendly, žádné cookie bannery."
},
{
"icon": "Blocks",
"title": "Blokový CMS",
"desc": "Dynamické stránky, blog, landing pages. Blokový editor jako v Notion."
},
{
"icon": "Mail",
"title": "Email builder",
"desc": "Vestavěný generátor emailů. Blokový systém, vlastní šablony, žádný Mailchimp."
},
{
"icon": "Tag",
"title": "Slevy & kupony",
"desc": "Cart rules, kupony s metrikami, influencer marketing, buy X get Y."
},
{
"icon": "Users",
"title": "Premium uživatelé",
"desc": "VIP statusy, oprávnění na média, připravené k subscription model."
},
{
"icon": "Layers",
"title": "Taxonomie",
"desc": "Kategorie, tagy, vlastní hierarchie. Vše s překlady a SEO."
},
{
"icon": "Zap",
"title": "Pod 1MB",
"desc": "Celý backend bez závislostí. Čistý, udržovatelný, rychlý jako blesk."
}
]
},
"speed": {
"title": "Rýchlosť záleží.",
"subtitle": "Hlavne na 3G v slovenskej dedinke.",
"selector": "Vyber pripojenie:",
"networks": ["WiFi", "4G", "3G", "2G"],
"title": "Rychlost záleží.",
"subtitle": "Hlavně na 3G ve slovenské vesničce.",
"selector": "Vyber připojení:",
"networks": [
"WiFi",
"4G",
"3G",
"2G"
],
"units": {
"time": "ms",
"size": "KB",
@ -49,39 +110,168 @@
},
"metrics": {
"productPage": "Produktová stránka",
"categoryNav": "Navigácia + filtre",
"search": "Vyhľadávanie",
"categoryNav": "Navigace + filtry",
"search": "Vyhledávání",
"checkout": "Checkout flow",
"admin": "Admin panel"
},
"showMore": "Ukaž zbytek",
"showLess": "Skrýt"
},
"iceberg": {
"title": "Ledovec WordPress-u.",
"subtitle": "Agentura ti ukáže špičku. Zbytek zjistíš později.",
"tipLabel": "Co ti agentura řekne",
"hiddenLabel": "Co zjistíš po 3 měsících",
"revealBtn": "Ukaž zbytek",
"hideBtn": "Skrýt",
"tip": [
{
"icon": "setup",
"label": "Setup webu",
"desc": "\"Profesionální e-shop za super cenu\""
},
{
"icon": "theme",
"label": "Premium šablona",
"desc": "\"Krásný design, hotový za týden\""
}
],
"hidden": [
{
"icon": "plugin",
"label": "Premium pluginy",
"desc": "Elementor Pro, WooCommerce extensions, SEO, zálohy... každý s vlastním předplatným.",
"severity": 2
},
{
"icon": "hosting",
"label": "Managed hosting",
"desc": "\"Doporučený\" hosting za €30-50/měsíc. Nebo WordPress potřebuje výkon.",
"severity": 1
},
{
"icon": "maintenance",
"label": "Měsíční \"údržba\"",
"desc": "Agentura ti účtuje za proklik na 'Update All'. Každý měsíc.",
"severity": 2
},
{
"icon": "conflict",
"label": "Plugin konflikty",
"desc": "Aktualizuješ jeden plugin, rozbije se checkout. Urgentní ticket.",
"severity": 3
},
{
"icon": "security",
"label": "Bezpečnostní záplaty",
"desc": "WordPress je #1 cíl hackerů. Záplaty, firewall pluginy, monitoring.",
"severity": 2
},
{
"icon": "change",
"label": "\"To nejde bez pluginu\"",
"desc": "Chceš jednoduchou změnu? Nový plugin. Nebo 8h custom vývoje ve spaghetti kódu.",
"severity": 3
},
{
"icon": "cookie",
"label": "Cookie banner služba",
"desc": "Cookiebot, CookieYes... další měsíční poplatek za GDPR compliance.",
"severity": 1
},
{
"icon": "analytics",
"label": "Analytika třetích stran",
"desc": "GA4 + tag manager + consent mode. Nebo plať za Plausible/Fathem.",
"severity": 1
},
{
"icon": "email",
"label": "Email marketing",
"desc": "Mailchimp, Klaviyo... další měsíční služba s vlastním ceníkem.",
"severity": 1
},
{
"icon": "dev",
"label": "Dvě hodiny × 2",
"desc": "Plugin spaghetti = každá úprava trvá dvakrát déle. A stojí dvakrát víc.",
"severity": 3
}
],
"dragHint": "táhni karty · gravitace funguje",
"AhojSvet": {
"title": "AhojSvet",
"subtitle": "Žádný ledovec. Žádná překvapení.",
"points": [
{
"label": "Jednorázový setup",
"desc": "Zaplatíš jednou. Vlastníš navždy."
},
{
"label": "€0 měsíčně za software",
"desc": "Žádné pluginy, žádné předplatné. Tvůj server, tvé náklady."
},
{
"label": "Vše vestavěné",
"desc": "Analytika, emaily, CMS, vyhledávání — bez třetích stran."
},
{
"label": "Čistý Laravel kód",
"desc": "Každý developer ho přečte. Rozšíření za zlomek času."
},
{
"label": "Tvá data, tvá pravidla",
"desc": "GDPR friendly by default. Žádné cookie bannery."
}
],
"cta": "Zeptej se na cenu →"
}
},
"screenshots": {
"title": "Pozri sa dovnútra.",
"tabs": ["Admin", "Obchod", "Objednávky", "CMS"]
"title": "Podívej se dovnitř.",
"tabs": [
"Admin",
"Obchod",
"Objednávky",
"CMS"
]
},
"wild": {
"title": "V divočine",
"subtitle": "Projekty bežiace na AhojSvet",
"cta": "Pridaj svoj projekt"
"title": "V divočině",
"subtitle": "Projekty běžící na AhojSvet",
"cta": "Přidej svůj projekt"
},
"contact": {
"title": "Poďme sa baviť.",
"subtitle": "Všeobecné otázky alebo partnership",
"name": "Meno",
"title": "Pojďme se bavit.",
"subtitle": "Obecné dotazy nebo partnership",
"name": "Jméno",
"email": "Email",
"company": "Spoločnosť",
"type": "Typ správy",
"types": ["Všeobecný dotaz", "Budúci projekt", "Partnerstvo", "Podpora"],
"message": "Správa",
"send": "Poslať správu",
"success": "Správa bola odoslaná"
"company": "Společnost",
"type": "Typ zprávy",
"types": [
"Obecný dotaz",
"Příští projekt",
"Partnerství",
"Podpora"
],
"message": "Zpráva",
"send": "Poslat zprávu",
"success": "Zpráva byla odeslána",
"errors": {
"name": "Jméno je povinné",
"email": "Email je povinný",
"emailInvalid": "Neplatný email",
"message": "Zpráva je povinná",
"send": "Odeslání se nezdařilo. Zkuste to prosím znovu."
}
},
"cta": {
"title": "Pripravení na zmenu?",
"subtitle": "Vyberte AhojSvet a začnite revolúciu",
"button": "Začať hneď"
"title": "Připraveni ke změně?",
"subtitle": "Prohlédni si demo knížkárny — skutečný obchod, živá data.",
"button": "Otevřít demo obchod"
},
"footer": {
"tagline": "© 2024 AhojSvet.eu. Všetky práva vyhradené."
"tagline": "© 2026 AhojSvet.eu. Všechna práva vyhrazena."
}
}

View file

@ -1,87 +1,277 @@
{
"nav": {
"demo": "Demo",
"docs": "Dokumentácia",
"wild": "V praxi",
"contact": "Kontakt"
"docs": "Documentation",
"wild": "In practice",
"contact": "Contact"
},
"hero": {
"tagline": "Zbav sa reťazí.",
"subtitle": "Vlastni svoj obchod.",
"description": "Open-source eCommerce platforma bez SaaS závislostí. Tvoj obchod, tvoje dáta, žiadni páni.",
"cta": "Vyskúšať demo",
"docs": "Dokumentácia"
"tagline": "Break free.",
"subtitleRotations": [
"Own your store.",
"Your data, your rules.",
"No masters over you."
],
"description": "Open-source eCommerce platform without SaaS dependencies. Your store, your data, no masters.",
"cta": "Try the demo",
"docs": "Documentation"
},
"problem": {
"title": "Dosť bolo slop-u.",
"subtitle": "Tisíce eur za WordPress z roku 2015",
"title": "Enough of the slop.",
"subtitle": "Thousands of euros for WordPress from 2015",
"cards": [
{ "title": "4.2s load time", "desc": "Tvoj zákazník odchádza kým sa stránka načíta. V roku 2025." },
{ "title": "2.1MB payload", "desc": "47 pluginov, 12 skriptov na trackovanie, 0 výkon." },
{ "title": "€99/mesiac", "desc": "Premium plugin. Potom ďalší. A ďalší. Na TVOJOM webe." },
{ "title": "€3,500 setup", "desc": "Za starú šablónu a import 20 produktov. Bez školenia." }
{
"title": "4.2s load time",
"suffix": "%",
"desc": "Your customer leaves before the page loads. In 2025."
},
{
"title": "2.1MB payload",
"suffix": "×",
"desc": "47 plugins, 12 tracking scripts, 0 performance."
},
{
"title": "€99/month",
"suffix": "€+",
"desc": "Premium plugin. Then another. And another. On YOUR website."
},
{
"title": "€3,500 setup",
"suffix": "+",
"desc": "For an old template and 20 product imports. No training."
}
]
},
"features": {
"title": "Všetko čo potrebuješ.",
"subtitle": "Nič čo nepotrebuješ.",
"items": [
{ "icon": "Shield", "title": "Žiadne SaaS", "desc": "Žiadny Algolia, Cookiebot, ani mesačné poplatky. Všetko beží na tvojom serveri."},
{ "icon": "Globe2", "title": "Multi-channel", "desc": "Jeden admin, nekonečné kanály. SK, DE, CZ domény s vlastnými šablónami a jazykmi."},
{ "icon": "BarChart3", "title": "First-party analytika", "desc": "Všetky GA4 eventy ako tvoje vlastné dáta. GDPR friendly, žiadne cookie bannery."},
{ "icon": "Blocks", "title": "Blokový CMS", "desc": "Dynamické stránky, blog, landing pages. Blokový editor ako v Notion."},
{ "icon": "Mail", "title": "Email builder", "desc": "Vstavaný generátor emailov. Blokový systém, vlastné šablóny, žiadny Mailchimp."},
{ "icon": "Tag", "title": "Zľavy & kupóny", "desc": "Cart rules, kupóny s metrikami, influencer marketing, buy X get Y."},
{ "icon": "Users", "title": "Premium používatelia", "desc": "Manuálne VIP statusy, oprávnenia na médiá, pripravené na subscription model."},
{ "icon": "Layers", "title": "Taxonómie", "desc": "Kategórie, tagy, vlastné hierarchie. Všetko s prekladmi a SEO."},
{ "icon": "Zap", "title": "Pod 1MB", "desc": "Celý backend bez závislostí. Čistý, udržiavateľný, rýchly ako blesk."}
]
"title": "Everything you need.",
"subtitle": "Nothing you don't need.",
"items": [
{
"icon": "Shield",
"title": "No SaaS",
"desc": "No Algolia, Cookiebot, or monthly fees. Everything runs on your server."
},
{
"icon": "Globe2",
"title": "Multi-channel",
"desc": "One admin, endless channels. SK, DE, CZ domains with custom templates and languages."
},
{
"icon": "BarChart3",
"title": "First-party analytics",
"desc": "All GA4 events as your own data. GDPR friendly, no cookie banners."
},
{
"icon": "Blocks",
"title": "Block CMS",
"desc": "Dynamic sites, blog, landing pages. Block editor like in Notion."
},
{
"icon": "Mail",
"title": "Email builder",
"desc": "Built-in email generator. Block system, custom templates, no Mailchimp."
},
{
"icon": "Tag",
"title": "Discounts & Coupons",
"desc": "Cart rules, coupons with metrics, influencer marketing, buy X get Y."
},
{
"icon": "Users",
"title": "Premium users",
"desc": "VIP statuses, media permissions, subscription model ready."
},
{
"icon": "Layers",
"title": "Taxonomies",
"desc": "Categories, tags, custom hierarchies. All with translations and SEO."
},
{
"icon": "On",
"title": "Under 1MB",
"desc": "Entire backend without dependencies. Clean, maintainable, lightning fast."
}
]
},
"speed": {
"title": "Rýchlosť záleží.",
"subtitle": "Hlavne na 3G v slovenskej dedinke.",
"selector": "Vyber pripojenie:",
"networks": ["WiFi", "4G", "3G", "2G"],
"title": "Speed matters.",
"subtitle": "Mainly 3G in a Slovak village.",
"selector": "Select connection:",
"networks": [
"WiFi",
"4G",
"3G",
"2G"
],
"units": {
"time": "ms",
"size": "KB",
"requests": "req"
},
"metrics": {
"productPage": "Produktová stránka",
"categoryNav": "Navigácia + filtre",
"search": "Vyhľadávanie",
"productPage": "Product page",
"categoryNav": "Navigation + filters",
"search": "Search",
"checkout": "Checkout flow",
"admin": "Admin panel"
},
"showMore": "Show the rest",
"showLess": "Hide"
},
"iceberg": {
"title": "WordPress iceberg.",
"subtitle": "The agency will show you the tip. You'll find out the rest later.",
"tipLabel": "What the agency will tell you",
"hiddenLabel": "What you'll find out after 3 months",
"revealBtn": "Show the rest",
"hideBtn": "Hide",
"tip": [
{
"icon": "setup",
"label": "Website setup",
"desc": "\"Professional e-shop for a great price\""
},
{
"icon": "theme",
"label": "Premium template",
"desc": "\"Beautiful design, ready in a week\""
}
],
"hidden": [
{
"icon": "plugin",
"label": "Premium plugins",
"desc": "Elementor Pro, WooCommerce extensions, SEO, backups... each with its own subscription.",
"severity": 2
},
{
"icon": "hosting",
"label": "Managed hosting",
"desc": "\"Recommended\" hosting for €30-50/month. Because WordPress needs performance.",
"severity": 1
},
{
"icon": "maintenance",
"label": "Monthly \"maintenance\"",
"desc": "The agency charges you for clicking 'Update All'. Every month.",
"severity": 2
},
{
"icon": "conflict",
"label": "Plugin conflicts",
"desc": "You update one plugin, checkout breaks. Urgent ticket.",
"severity": 3
},
{
"icon": "security",
"label": "Security patches",
"desc": "WordPress is the #1 target for hackers. Patches, firewall plugins, monitoring.",
"severity": 2
},
{
"icon": "change",
"label": "\"It can't be done without a plugin\"",
"desc": "You want a simple change? A new plugin. Or 8 hours of custom development in spaghetti code.",
"severity": 3
},
{
"icon": "cookie",
"label": "Cookie banner service",
"desc": "Cookiebot, CookieYes... another monthly fee for GDPR compliance.",
"severity": 1
},
{
"icon": "analytics",
"label": "Third-party analytics",
"desc": "GA4 + tag manager + consent mode. Or pay for Plausible/Fathom.",
"severity": 1
},
{
"icon": "email",
"label": "Email marketing",
"desc": "Mailchimp, Klaviyo... another monthly service with its own price list.",
"severity": 1
},
{
"icon": "dev",
"label": "Dev hours × 2",
"desc": "Plugin spaghetti = each edit takes twice as long. And costs twice as much.",
"severity": 3
}
],
"dragHint": "drag cards · gravity works",
"ahojsvet": {
"title": "AhojSvet",
"subtitle": "No iceberg. No surprises.",
"points": [
{
"label": "One-time setup",
"desc": "Pay once. Own forever."
},
{
"label": "€0 per month for software",
"desc": "No plugins, no subscriptions. Your server, your costs."
},
{
"label": "Everything built-in",
"desc": "Analytics, emails, CMS, search — no third parties."
},
{
"label": "Pure Laravel code",
"desc": "Any developer can read it. Extensions in a fraction of the time."
},
{
"label": "Your data, your rules",
"desc": "GDPR friendly by default. No cookie banners."
}
],
"cta": "Ask for a price →"
}
},
"screenshots": {
"title": "Pozri sa dovnútra.",
"tabs": ["Admin", "Obchod", "Objednávky", "CMS"]
"title": "Take a look inside.",
"tabs": [
"Admin",
"Shop",
"Orders",
"CMS"
]
},
"wild": {
"title": "V divočine",
"subtitle": "Projekty bežiace na AhojSvet",
"cta": "Pridaj svoj projekt"
"title": "In the Wild",
"subtitle": "Projects Running on AhojSvet",
"cta": "Add Your Project"
},
"contact": {
"title": "Poďme sa baviť.",
"subtitle": "Všeobecné otázky alebo partnership",
"name": "Meno",
"title": "Let's Have Fun.",
"subtitle": "General Questions or Partnerships",
"name": "Name",
"email": "Email",
"company": "Spoločnosť",
"type": "Typ správy",
"types": ["Všeobecný dotaz", "Budúci projekt", "Partnerstvo", "Podpora"],
"message": "Správa",
"send": "Poslať správu",
"success": "Správa bola odoslaná"
"company": "Company",
"type": "Message Type",
"types": [
"General Inquiry",
"Upcoming Project",
"Partnership",
"Support"
],
"message": "Message",
"send": "Send Message",
"success": "Message Sent",
"errors": {
"name": "Name is required",
"email": "Email is required",
"emailInvalid": "Invalid email address",
"message": "Message is required",
"send": "Failed to send. Please try again."
}
},
"cta": {
"title": "Pripravení na zmenu?",
"subtitle": "Vyberte AhojSvet a začnite revolúciu",
"button": "Začať hneď"
"title": "Ready for a Change?",
"subtitle": "View Demo Bookstore — Real Store, Live Data.",
"button": "Open Demo Store"
},
"footer": {
"tagline": "© 2024 AhojSvet.eu. Všetky práva vyhradené."
"tagline": "© 2026 AhojSvet.eu. All rights reserved."
}
}

View file

@ -7,7 +7,11 @@
},
"hero": {
"tagline": "Zbav sa reťazí.",
"subtitle": "Vlastni svoj obchod.",
"subtitleRotations": [
"Vlastni svoj obchod.",
"Tvoje dáta, tvoje pravidlá.",
"Žiadni páni nad tebou."
],
"description": "Open-source eCommerce platforma bez SaaS závislostí. Tvoj obchod, tvoje dáta, žiadni páni.",
"cta": "Vyskúšať demo",
"docs": "Dokumentácia"
@ -16,32 +20,89 @@
"title": "Dosť bolo slop-u.",
"subtitle": "Tisíce eur za WordPress z roku 2015",
"cards": [
{ "title": "4.2s load time", "desc": "Tvoj zákazník odchádza kým sa stránka načíta. V roku 2025." },
{ "title": "2.1MB payload", "desc": "47 pluginov, 12 skriptov na trackovanie, 0 výkon." },
{ "title": "€99/mesiac", "desc": "Premium plugin. Potom ďalší. A ďalší. Na TVOJOM webe." },
{ "title": "€3,500 setup", "desc": "Za starú šablónu a import 20 produktov. Bez školenia." }
{
"title": "4.2s load time",
"suffix": "%",
"desc": "Tvoj zákazník odchádza kým sa stránka načíta. V roku 2025."
},
{
"title": "2.1MB payload",
"suffix": "×",
"desc": "47 pluginov, 12 skriptov na trackovanie, 0 výkon."
},
{
"title": "€99/mesiac",
"suffix": "€+",
"desc": "Premium plugin. Potom ďalší. A ďalší. Na TVOJOM webe."
},
{
"title": "€3,500 setup",
"suffix": "+",
"desc": "Za starú šablónu a import 20 produktov. Bez školenia."
}
]
},
"features": {
"title": "Všetko čo potrebuješ.",
"subtitle": "Nič čo nepotrebuješ.",
"items": [
{ "icon": "Shield", "title": "Žiadne SaaS", "desc": "Žiadny Algolia, Cookiebot, ani mesačné poplatky. Všetko beží na tvojom serveri."},
{ "icon": "Globe2", "title": "Multi-channel", "desc": "Jeden admin, nekonečné kanály. SK, DE, CZ domény s vlastnými šablónami a jazykmi."},
{ "icon": "BarChart3", "title": "First-party analytika", "desc": "Všetky GA4 eventy ako tvoje vlastné dáta. GDPR friendly, žiadne cookie bannery."},
{ "icon": "Blocks", "title": "Blokový CMS", "desc": "Dynamické stránky, blog, landing pages. Blokový editor ako v Notion."},
{ "icon": "Mail", "title": "Email builder", "desc": "Vstavaný generátor emailov. Blokový systém, vlastné šablóny, žiadny Mailchimp."},
{ "icon": "Tag", "title": "Zľavy & kupóny", "desc": "Cart rules, kupóny s metrikami, influencer marketing, buy X get Y."},
{ "icon": "Users", "title": "Premium používatelia", "desc": "Manuálne VIP statusy, oprávnenia na médiá, pripravené na subscription model."},
{ "icon": "Layers", "title": "Taxonómie", "desc": "Kategórie, tagy, vlastné hierarchie. Všetko s prekladmi a SEO."},
{ "icon": "Zap", "title": "Pod 1MB", "desc": "Celý backend bez závislostí. Čistý, udržiavateľný, rýchly ako blesk."}
]
"title": "Všetko čo potrebuješ.",
"subtitle": "Nič čo nepotrebuješ.",
"items": [
{
"icon": "Shield",
"title": "Žiadne SaaS",
"desc": "Žiadny Algolia, Cookiebot, ani mesačné poplatky. Všetko beží na tvojom serveri."
},
{
"icon": "Globe2",
"title": "Multi-channel",
"desc": "Jeden admin, nekonečné kanály. SK, DE, CZ domény s vlastnými šablónami a jazykmi."
},
{
"icon": "BarChart3",
"title": "First-party analytika",
"desc": "Všetky GA4 eventy ako tvoje vlastné dáta. GDPR friendly, žiadne cookie bannery."
},
{
"icon": "Blocks",
"title": "Blokový CMS",
"desc": "Dynamické stránky, blog, landing pages. Blokový editor ako v Notion."
},
{
"icon": "Mail",
"title": "Email builder",
"desc": "Vstavaný generátor emailov. Blokový systém, vlastné šablóny, žiadny Mailchimp."
},
{
"icon": "Tag",
"title": "Zľavy & kupóny",
"desc": "Cart rules, kupóny s metrikami, influencer marketing, buy X get Y."
},
{
"icon": "Users",
"title": "Premium používatelia",
"desc": "VIP statusy, oprávnenia na médiá, pripravené na subscription model."
},
{
"icon": "Layers",
"title": "Taxonómie",
"desc": "Kategórie, tagy, vlastné hierarchie. Všetko s prekladmi a SEO."
},
{
"icon": "Zap",
"title": "Pod 1MB",
"desc": "Celý backend bez závislostí. Čistý, udržiavateľný, rýchly ako blesk."
}
]
},
"speed": {
"title": "Rýchlosť záleží.",
"subtitle": "Hlavne na 3G v slovenskej dedinke.",
"selector": "Vyber pripojenie:",
"networks": ["WiFi", "4G", "3G", "2G"],
"networks": [
"WiFi",
"4G",
"3G",
"2G"
],
"units": {
"time": "ms",
"size": "KB",
@ -53,7 +114,9 @@
"search": "Vyhľadávanie",
"checkout": "Checkout flow",
"admin": "Admin panel"
}
},
"showMore": "Ukáž zvyšok",
"showLess": "Skryť"
},
"iceberg": {
"title": "Ľadovec WordPress-u.",
@ -63,37 +126,116 @@
"revealBtn": "Ukáž zvyšok",
"hideBtn": "Skryť",
"tip": [
{ "icon": "setup", "label": "Setup webu", "desc": "\"Profesionálny e-shop za super cenu\"" },
{ "icon": "theme", "label": "Premium šablóna", "desc": "\"Krásny dizajn, hotový za týždeň\"" }
{
"icon": "setup",
"label": "Setup webu",
"desc": "\"Profesionálny e-shop za super cenu\""
},
{
"icon": "theme",
"label": "Premium šablóna",
"desc": "\"Krásny dizajn, hotový za týždeň\""
}
],
"hidden": [
{ "icon": "plugin", "label": "Premium pluginy", "desc": "Elementor Pro, WooCommerce extensions, SEO, zálohy... každý s vlastným predplatným.", "severity": 2 },
{ "icon": "hosting", "label": "Managed hosting", "desc": "\"Odporúčaný\" hosting za €30-50/mesiac. Lebo WordPress potrebuje výkon.", "severity": 1 },
{ "icon": "maintenance", "label": "Mesačná \"údržba\"", "desc": "Agentúra ti účtuje za kliknutie na 'Update All'. Každý mesiac.", "severity": 2 },
{ "icon": "conflict", "label": "Plugin konflikty", "desc": "Aktualizuješ jeden plugin, rozbije sa checkout. Urgentný ticket.", "severity": 3 },
{ "icon": "security", "label": "Bezpečnostné záplaty", "desc": "WordPress je #1 cieľ hackerov. Záplaty, firewall pluginy, monitoring.", "severity": 2 },
{ "icon": "change", "label": "\"To nejde bez pluginu\"", "desc": "Chceš jednoduchú zmenu? Nový plugin. Alebo 8h custom vývoja v spaghetti kóde.", "severity": 3 },
{ "icon": "cookie", "label": "Cookie banner služba", "desc": "Cookiebot, CookieYes... ďalší mesačný poplatok za GDPR compliance.", "severity": 1 },
{ "icon": "analytics", "label": "Analytika tretích strán", "desc": "GA4 + tag manager + consent mode. Alebo plať za Plausible/Fathom.", "severity": 1 },
{ "icon": "email", "label": "Email marketing", "desc": "Mailchimp, Klaviyo... ďalšia mesačná služba s vlastným cenníkom.", "severity": 1 },
{ "icon": "dev", "label": "Dev hodiny × 2", "desc": "Plugin spaghetti = každá úprava trvá dvakrát dlhšie. A stojí dvakrát viac.", "severity": 3 }
{
"icon": "plugin",
"label": "Premium pluginy",
"desc": "Elementor Pro, WooCommerce extensions, SEO, zálohy... každý s vlastným predplatným.",
"severity": 2
},
{
"icon": "hosting",
"label": "Managed hosting",
"desc": "\"Odporúčaný\" hosting za €30-50/mesiac. Lebo WordPress potrebuje výkon.",
"severity": 1
},
{
"icon": "maintenance",
"label": "Mesačná \"údržba\"",
"desc": "Agentúra ti účtuje za kliknutie na 'Update All'. Každý mesiac.",
"severity": 2
},
{
"icon": "conflict",
"label": "Plugin konflikty",
"desc": "Aktualizuješ jeden plugin, rozbije sa checkout. Urgentný ticket.",
"severity": 3
},
{
"icon": "security",
"label": "Bezpečnostné záplaty",
"desc": "WordPress je #1 cieľ hackerov. Záplaty, firewall pluginy, monitoring.",
"severity": 2
},
{
"icon": "change",
"label": "\"To nejde bez pluginu\"",
"desc": "Chceš jednoduchú zmenu? Nový plugin. Alebo 8h custom vývoja v spaghetti kóde.",
"severity": 3
},
{
"icon": "cookie",
"label": "Cookie banner služba",
"desc": "Cookiebot, CookieYes... ďalší mesačný poplatok za GDPR compliance.",
"severity": 1
},
{
"icon": "analytics",
"label": "Analytika tretích strán",
"desc": "GA4 + tag manager + consent mode. Alebo plať za Plausible/Fathom.",
"severity": 1
},
{
"icon": "email",
"label": "Email marketing",
"desc": "Mailchimp, Klaviyo... ďalšia mesačná služba s vlastným cenníkom.",
"severity": 1
},
{
"icon": "dev",
"label": "Dev hodiny × 2",
"desc": "Plugin spaghetti = každá úprava trvá dvakrát dlhšie. A stojí dvakrát viac.",
"severity": 3
}
],
"dragHint": "ťahaj karty · gravitácia funguje",
"ahojsvet": {
"title": "AhojSvet",
"subtitle": "Žiadny ľadovec. Žiadne prekvapenia.",
"points": [
{ "label": "Jednorázový setup", "desc": "Zaplatíš raz. Vlastníš navždy." },
{ "label": "€0 mesačne za softvér", "desc": "Žiadne pluginy, žiadne predplatné. Tvoj server, tvoje náklady." },
{ "label": "Všetko vstavané", "desc": "Analytika, emaily, CMS, vyhľadávanie — bez tretích strán." },
{ "label": "Čistý Laravel kód", "desc": "Každý developer ho prečíta. Rozšírenia za zlomok času." },
{ "label": "Tvoje dáta, tvoje pravidlá", "desc": "GDPR friendly by default. Žiadne cookie bannery." }
{
"label": "Jednorázový setup",
"desc": "Zaplatíš raz. Vlastníš navždy."
},
{
"label": "€0 mesačne za softvér",
"desc": "Žiadne pluginy, žiadne predplatné. Tvoj server, tvoje náklady."
},
{
"label": "Všetko vstavané",
"desc": "Analytika, emaily, CMS, vyhľadávanie — bez tretích strán."
},
{
"label": "Čistý Laravel kód",
"desc": "Každý developer ho prečíta. Rozšírenia za zlomok času."
},
{
"label": "Tvoje dáta, tvoje pravidlá",
"desc": "GDPR friendly by default. Žiadne cookie bannery."
}
],
"cta": "Spýtaj sa na cenu →"
}
},
"screenshots": {
"title": "Pozri sa dovnútra.",
"tabs": ["Admin", "Obchod", "Objednávky", "CMS"]
"tabs": [
"Admin",
"Obchod",
"Objednávky",
"CMS"
]
},
"wild": {
"title": "V divočine",
@ -107,17 +249,29 @@
"email": "Email",
"company": "Spoločnosť",
"type": "Typ správy",
"types": ["Všeobecný dotaz", "Budúci projekt", "Partnerstvo", "Podpora"],
"types": [
"Všeobecný dotaz",
"Budúci projekt",
"Partnerstvo",
"Podpora"
],
"message": "Správa",
"send": "Poslať správu",
"success": "Správa bola odoslaná"
"success": "Správa bola odoslaná",
"errors": {
"name": "Meno je povinné",
"email": "Email je povinný",
"emailInvalid": "Neplatná emailová adresa",
"message": "Správa je povinná",
"send": "Odoslanie zlyhalo. Skúste to prosím znovu."
}
},
"cta": {
"title": "Pripravení na zmenu?",
"subtitle": "Vyberte AhojSvet a začnite revolúciu",
"button": "Začať hneď"
"subtitle": "Prezri si demo knižkárne — skutočný obchod, živé dáta.",
"button": "Otvoriť demo obchod"
},
"footer": {
"tagline": "© 2024 AhojSvet.eu. Všetky práva vyhradené."
"tagline": "© 2026 AhojSvet.eu. Všetky práva vyhradené."
}
}

6
src/lib/utils.ts Normal file
View file

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View file

@ -0,0 +1,19 @@
export function calculatePosition(
value: number | string | undefined,
containerSize: number,
elementSize: number
): number {
// Handle percentage strings (e.g. "50%")
if (typeof value === "string" && value.endsWith("%")) {
const percentage = parseFloat(value) / 100
return containerSize * percentage
}
// Handle direct pixel values
if (typeof value === "number") {
return value
}
// If no value provided, center the element
return (containerSize - elementSize) / 2
}

View file

@ -0,0 +1,38 @@
import SVGPathCommander from "svg-path-commander"
// Function to convert SVG path `d` to vertices
export function parsePathToVertices(path: string, sampleLength = 15) {
// Convert path to absolute commands
const commander = new SVGPathCommander(path)
const points: { x: number; y: number }[] = []
let lastPoint: { x: number; y: number } | null = null
// Get total length of the path
const totalLength = commander.getTotalLength()
let length = 0
// Sample points along the path
while (length < totalLength) {
const point = commander.getPointAtLength(length)
// Only add point if it's different from the last one
if (!lastPoint || point.x !== lastPoint.x || point.y !== lastPoint.y) {
points.push({ x: point.x, y: point.y })
lastPoint = point
}
length += sampleLength
}
// Ensure we get the last point
const finalPoint = commander.getPointAtLength(totalLength)
if (
lastPoint &&
(finalPoint.x !== lastPoint.x || finalPoint.y !== lastPoint.y)
) {
points.push({ x: finalPoint.x, y: finalPoint.y })
}
return points
}

1
src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

24
tsconfig.json Normal file
View file

@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

View file

@ -1,7 +1,13 @@
import { defineConfig } from 'vite'
import path from "path"
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
})
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
})