diff --git a/components.json b/components.json new file mode 100644 index 0000000..3edf7c2 --- /dev/null +++ b/components.json @@ -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" + } +} \ No newline at end of file diff --git a/package.json b/package.json index 3048d58..0ae8b37 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/public/api/contact.php b/public/api/contact.php index f870a9c..c392a51 100644 --- a/public/api/contact.php +++ b/public/api/contact.php @@ -1,68 +1,52 @@ 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; } -?> \ No newline at end of file + +$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']); +} +?> diff --git a/public/screenshots/admin.png b/public/screenshots/admin.png new file mode 100644 index 0000000..2e7719c Binary files /dev/null and b/public/screenshots/admin.png differ diff --git a/public/screenshots/cms.png b/public/screenshots/cms.png new file mode 100644 index 0000000..492b28f Binary files /dev/null and b/public/screenshots/cms.png differ diff --git a/public/screenshots/obchod.png b/public/screenshots/obchod.png new file mode 100644 index 0000000..208b68c Binary files /dev/null and b/public/screenshots/obchod.png differ diff --git a/public/screenshots/objednavky.png b/public/screenshots/objednavky.png new file mode 100644 index 0000000..5e0113b Binary files /dev/null and b/public/screenshots/objednavky.png differ diff --git a/src/App.jsx b/src/App.tsx similarity index 77% rename from src/App.jsx rename to src/App.tsx index 4b88ef4..215fac0 100644 --- a/src/App.jsx +++ b/src/App.tsx @@ -25,16 +25,18 @@ export default function App() { return (
- - - - - - - - - - +
+ + + + + + + + {/* */} + + +
); diff --git a/src/components/AmbientGlow.tsx b/src/components/AmbientGlow.tsx new file mode 100644 index 0000000..107458a --- /dev/null +++ b/src/components/AmbientGlow.tsx @@ -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 (0–1 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({ + 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 ( +
+
+
+ ); +} diff --git a/src/components/CTA.jsx b/src/components/CTA.jsx deleted file mode 100644 index 7ca2070..0000000 --- a/src/components/CTA.jsx +++ /dev/null @@ -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 ( -
-
-
-
-
-

- {cta.title} -

-

- {cta.subtitle} -

- - {cta.button} - -
-
- ); -} \ No newline at end of file diff --git a/src/components/CTA.tsx b/src/components/CTA.tsx new file mode 100644 index 0000000..22cb943 --- /dev/null +++ b/src/components/CTA.tsx @@ -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(null); + const h2Ref = useRef(null); + const isH2InView = useInView(h2Ref, { once: true }); + + useEffect(() => { + if (isH2InView) scrambleRef.current?.start(); + }, [isH2InView]); + + return ( +
+ + +
+ {/* Title */} +

+ +

+ + {/* Subtitle */} +

+ + {cta.subtitle} + +

+ + {/* CTA button */} + + + + +
+
+ ); +} diff --git a/src/components/ContactForm.jsx b/src/components/ContactForm.tsx similarity index 50% rename from src/components/ContactForm.jsx rename to src/components/ContactForm.tsx index ab40d6b..10afbed 100644 --- a/src/components/ContactForm.jsx +++ b/src/components/ContactForm.tsx @@ -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(null); + const scrambleRef = useRef(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>({}); 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 = {}; + 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 ( -
+
-

- {contact.title} +

+

{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 &&

{errors.name}

}
@@ -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 &&

{errors.message}

}
{/* Honeypot fields - hidden from users */} @@ -140,9 +197,10 @@ export default function ContactForm() { {formSent && ( @@ -150,6 +208,12 @@ export default function ContactForm() {
{contact.success}
)} + + {formError && ( +
+
{formError}
+
+ )} diff --git a/src/components/Features.jsx b/src/components/Features.jsx deleted file mode 100644 index f492907..0000000 --- a/src/components/Features.jsx +++ /dev/null @@ -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 ( -
-
-
-

- {t('features.title')} -

-

- {t('features.subtitle')} -

-
- -
- {t('features.items', { returnObjects: true }).map((feature, i) => { - const Icon = Icons[feature.icon]; - return ( -
-
- -
-

{feature.title}

-

{feature.desc}

-
- ); - })} -
-
-
- ); -} \ No newline at end of file diff --git a/src/components/Features.tsx b/src/components/Features.tsx new file mode 100644 index 0000000..489ad62 --- /dev/null +++ b/src/components/Features.tsx @@ -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 = { + Shield, Globe2, BarChart3, Blocks, Mail, Tag, Users, Layers, Zap, +}; + +// ── animated feature card ──────────────────────────────────────────────────── + +function FeatureCard({ feature, index }: { feature: FeatureItem; index: number }) { + const ref = useRef(null); + const isInView = useInView(ref, { once: true, amount: 0.4 }); + const Icon = Icons[feature.icon] ?? Blocks; + + return ( + + {/* Icon + counter */} +
+
+ +
+ + {String(index + 1).padStart(2, '0')} + +
+ + {/* Title with highlight sweep */} +

+ + {feature.title} + +

+ +

+ {feature.desc} +

+
+ ); +} + +// ── main component ─────────────────────────────────────────────────────────── + +export default function Features() { + const { t } = useTranslation(); + const { title, subtitle, items } = t('features', { returnObjects: true }) as FeaturesTranslation; + + const h2Ref = useRef(null); + const scrambleRef = useRef(null); + const isH2InView = useInView(h2Ref, { once: true }); + useEffect(() => { + if (isH2InView) scrambleRef.current?.start(); + }, [isH2InView]); + + return ( +
+
+ + {/* ── Section header ──────────────────────────────────────────── */} +
+

+ +

+

+ + {subtitle} + +

+
+ + {/* ── 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 + */} +
+ {items.map((feature, i) => ( + + ))} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/Footer.jsx b/src/components/Footer.jsx deleted file mode 100644 index ec26c5c..0000000 --- a/src/components/Footer.jsx +++ /dev/null @@ -1,35 +0,0 @@ -import { useTranslation } from 'react-i18next'; - -export default function Footer() { - const { t } = useTranslation(); - const footer = t('footer', { returnObjects: true }); - - return ( - - ); -} \ No newline at end of file diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 0000000..2834a31 --- /dev/null +++ b/src/components/Footer.tsx @@ -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 ( +
  • + + + +
  • + ); +} + +// ── component ───────────────────────────────────────────────────────────────── + +export default function Footer() { + const { t } = useTranslation(); + const footer = t('footer', { returnObjects: true }) as FooterTranslation; + + const footerRef = useRef(null); + const scrambleRef = useRef(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. + */ +
    + {/* Single relative + overflow-hidden container fills the footer */} +
    + + {/* ── Logo + nav + tagline row ──────────────────────────────── */} +
    + + {/* Logo mark */} +
    +
    + A +
    + AhojSvet.eu +
    + + {/* Nav columns */} +
    +
      + {NAV_PRIMARY.map((link) => ( + + ))} +
    +
      + {NAV_SECONDARY.map((link) => ( + + ))} +
    +
    + + {/* Tagline */} +

    + {footer.tagline} +

    +
    + + {/* + ── 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. + */} + + +
    +
    + ); +} \ No newline at end of file diff --git a/src/components/Hero.jsx b/src/components/Hero.jsx deleted file mode 100644 index ffb0a05..0000000 --- a/src/components/Hero.jsx +++ /dev/null @@ -1,47 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { BookOpen, Play, ChevronRight } from 'lucide-react'; - -export default function Hero() { - const { t } = useTranslation(); - - return ( -
    -
    -
    -
    -
    - -
    -
    -

    - {t('hero.tagline')} -

    -

    - {t('hero.subtitle')} -

    -
    -

    - {t('hero.description')} -

    - -
    - -
    - -
    -
    - ); -} \ No newline at end of file diff --git a/src/components/Hero.tsx b/src/components/Hero.tsx new file mode 100644 index 0000000..3c9d59c --- /dev/null +++ b/src/components/Hero.tsx @@ -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(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 ( +
    + + {/* ── Ambient blobs ──────────────────────────────────────────────── */} + + + +
    + + {/* ── Main headline — ScrambleIn ──────────────────────────────── */} +
    +

    + +

    + + {/* ── Rotating subtitle — TextRotate ─────────────────────── */} +

    + +

    +
    + + {/* ── Description — VerticalCutReveal ────────────────────────── */} +
    + + {hero.description} + +
    + + {/* ── CTAs ───────────────────────────────────────────────────── */} + +
    + + {/* ── Scroll hint ────────────────────────────────────────────────── */} +
    + +
    +
    + ); +} \ No newline at end of file diff --git a/src/components/HiddenCostsIcebergSection.jsx b/src/components/HiddenCostsIcebergSection.jsx deleted file mode 100644 index 95d0697..0000000 --- a/src/components/HiddenCostsIcebergSection.jsx +++ /dev/null @@ -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 ( -
    -
    - {/* Header */} -
    -

    - {t('iceberg.title')} -

    -

    - {t('iceberg.subtitle')} -

    -
    - -
    - {/* WordPress Iceberg */} -
    - {/* Tip */} -
    -
    - - - {t('iceberg.tipLabel')} - -
    -
    - {tipItems.map((item, i) => { - const Icon = iconMap[item.icon]; - return ( -
    -
    - -
    -
    -
    {item.label}
    -
    {item.desc}
    -
    -
    - ); - })} -
    -
    - - {/* Waterline + Reveal Button */} -
    -
    -
    -
    -
    - -
    -
    - - {/* Hidden Costs */} -
    -
    - - - {t('iceberg.hiddenLabel')} - -
    -
    - {hiddenItems.map((item, i) => { - const Icon = iconMap[item.icon]; - const visible = i < visibleCount; - return ( -
    -
    - -
    -
    -
    - {item.label} - -
    -
    {item.desc}
    -
    -
    - ); - })} -
    - - {/* Summary */} - {visibleCount >= hiddenItems.length && ( -
    -
    - 10+ skrytých nákladov -
    -
    - ...a to sme ešte nezačali riešiť škálovanie. -
    -
    - )} -
    -
    - - {/* AhojSvet - Clean Side */} -
    -
    -
    -
    - Bez prekvapení -
    -

    {t('iceberg.ahojsvet.title')}

    -

    {t('iceberg.ahojsvet.subtitle')}

    -
    -
    - {ahojsvetPoints.map((p, i) => ( -
    -
    - -
    -
    -
    {p.label}
    -
    {p.desc}
    -
    -
    - ))} -
    - -
    -
    -
    -
    - - -
    - ); -} \ No newline at end of file diff --git a/src/components/HiddenCostsIcebergSection.tsx b/src/components/HiddenCostsIcebergSection.tsx new file mode 100644 index 0000000..b1f130a --- /dev/null +++ b/src/components/HiddenCostsIcebergSection.tsx @@ -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 = { + 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 = { + 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 = { + 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(null); + const scrambleRef = useRef(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 ( +
    +
    + + {/* Header */} +
    +

    + +

    +

    + {t("iceberg.subtitle")} +

    +
    + +
    + + {/* ── WordPress Iceberg (physics side) ─────────────────────────── */} +
    + + {/* Visible tip items */} +
    +
    + + + {t("iceberg.tipLabel")} + +
    +
    + {tipItems.map((item, i) => { + const Icon = iconMap[item.icon] ?? HelpCircle; + return ( +
    +
    + +
    +
    +
    {item.label}
    +
    {item.desc}
    +
    +
    + ); + })} +
    +
    + + {/* Waterline + toggle button */} +
    +
    +
    +
    +
    + +
    +
    + + {/* ── Physics drop zone ────────────────────────────────────── */} + {revealed && ( +
    + {/* Label */} +
    + + + {t("iceberg.hiddenLabel")} + +
    + + + {hiddenItems.map((item: any, i: number) => { + const Icon = iconMap[item.icon] ?? HelpCircle; + const pos = staggered(i, hiddenItems.length); + return ( + +
    + {/* Severity dot */} + + +
    +
    + {item.label} +
    +
    + {item.desc} +
    +
    +
    +
    + ); + })} +
    + +
    + )} + +
    + {/* ── AhojSvet side ────────────────────────────────────────────── */} +
    +
    +
    +
    + {t("iceberg.ahojsvet.badge", { defaultValue: "Bez prekvapení" })} +
    +

    + {t("iceberg.ahojsvet.title")} +

    +

    + {t("iceberg.ahojsvet.subtitle")} +

    +
    +
    + {ahojsvetPts.map((p: any, i: number) => ( +
    +
    + +
    +
    +
    {p.label}
    +
    {p.desc}
    +
    +
    + ))} +
    + +
    +
    + +
    +
    +
    + ); +} \ No newline at end of file diff --git a/src/components/InTheWild.jsx b/src/components/InTheWild.jsx deleted file mode 100644 index fca42c3..0000000 --- a/src/components/InTheWild.jsx +++ /dev/null @@ -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 ( -
    -
    -
    -

    - {wild.title} -

    -

    - {wild.subtitle} -

    -
    - - - - -
    -
    - ); -} \ No newline at end of file diff --git a/src/components/InTheWild.tsx b/src/components/InTheWild.tsx new file mode 100644 index 0000000..4f07107 --- /dev/null +++ b/src/components/InTheWild.tsx @@ -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(null); + const scrambleRef = useRef(null); + const isH2InView = useInView(h2Ref, { once: true }); + useEffect(() => { + if (isH2InView) scrambleRef.current?.start(); + }, [isH2InView]); + + return ( +
    +
    + + {/* ── Header ─────────────────────────────────────────────────── */} +
    +

    + +

    +

    + +

    +
    + + {/* + ── 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. + */} + + + + + {/* ── CTA link ────────────────────────────────────────────────── */} + + +
    +
    + ); +} diff --git a/src/components/Navigation.jsx b/src/components/Navigation.jsx deleted file mode 100644 index b1c0be3..0000000 --- a/src/components/Navigation.jsx +++ /dev/null @@ -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 ( - - ); -} \ No newline at end of file diff --git a/src/components/Navigation.tsx b/src/components/Navigation.tsx new file mode 100644 index 0000000..de99aff --- /dev/null +++ b/src/components/Navigation.tsx @@ -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 ( + + ); +} + +function FlagCZ({ className }: { className?: string }) { + return ( + + ); +} + +function FlagEN({ className }: { className?: string }) { + return ( + + ); +} + +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(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 ( +
    + + + {open && ( +
    + {languages.filter(l => l.code !== i18n.language).map(({ code, label, Flag }) => ( + + ))} +
    + )} +
    + ); +} + +// ── Navigation ────────────────────────────────────────────────────────────── + +export default function Navigation({ scrollY }: { scrollY: number }) { + const { t } = useTranslation(); + const [menuOpen, setMenuOpen] = useState(false); + + return ( + + ); +} \ No newline at end of file diff --git a/src/components/Screenshots.jsx b/src/components/Screenshots.jsx deleted file mode 100644 index 8c5681e..0000000 --- a/src/components/Screenshots.jsx +++ /dev/null @@ -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 ( -
    -
    -

    - {t('screenshots.title')} -

    - -
    - {tabs.map((tab, i) => ( - - ))} -
    - -
    -
    -
    - -

    - {tabs[activeTab]} screenshot -

    -
    -
    -
    -
    -
    - ); -} \ No newline at end of file diff --git a/src/components/Screenshots.tsx b/src/components/Screenshots.tsx new file mode 100644 index 0000000..895d4ad --- /dev/null +++ b/src/components/Screenshots.tsx @@ -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(null); + const scrambleRef = useRef(null); + const isH2InView = useInView(h2Ref, { once: true }); + useEffect(() => { + if (isH2InView) scrambleRef.current?.start(); + }, [isH2InView]); + + const imageMap: Record = { + 0: '/screenshots/admin.png', + 1: '/screenshots/obchod.png', + 2: '/screenshots/objednavky.png', + 3: '/screenshots/cms.png', + }; + + return ( +
    +
    +

    + +

    + +
    + {tabs.map((tab, i) => ( + + ))} +
    + +
    +
    + {tabs[activeTab]} +
    +
    +
    +
    + ); +} \ No newline at end of file diff --git a/src/components/SpeedComparison.jsx b/src/components/SpeedComparison.jsx deleted file mode 100644 index fd7bbc3..0000000 --- a/src/components/SpeedComparison.jsx +++ /dev/null @@ -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 ( -
    -
    -
    -

    - {t('speed.title')} -

    -

    - {t('speed.subtitle')} -

    -
    - {t('speed.selector')} - {t('speed.networks', { returnObjects: true }).map(net => ( - - ))} -
    -
    - -
    - {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 ( -
    -

    {label}

    -
    -
    -
    AhojSvet
    -
    -
    - Load time: - {asData[0]}{t('speed.units.time')} -
    -
    - Data: - {asData[1]}{t('speed.units.size')} -
    -
    - Requests: - {asData[2]} {t('speed.units.requests')} -
    -
    -
    -
    -
    WordPress + WooCommerce
    -
    -
    - Load time: - {wpData[0]}{t('speed.units.time')} -
    -
    - Data: - {wpData[1]}{t('speed.units.size')} -
    -
    - Requests: - {wpData[2]} {t('speed.units.requests')} -
    -
    -
    -
    -
    - - {percentFaster}% rýchlejšie - -
    -
    - ); - })} -
    -
    -
    - ); -} \ No newline at end of file diff --git a/src/components/SpeedComparison.tsx b/src/components/SpeedComparison.tsx new file mode 100644 index 0000000..78bf075 --- /dev/null +++ b/src/components/SpeedComparison.tsx @@ -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 ( +
    +

    {label}

    +
    +
    +
    AhojSvet
    +
    +
    + Load time: + {asData[0]}{t('speed.units.time')} +
    +
    + Data: + {asData[1]}{t('speed.units.size')} +
    +
    + Requests: + {asData[2]} {t('speed.units.requests')} +
    +
    +
    +
    +
    WordPress + WooCommerce
    +
    +
    + Load time: + {wpData[0]}{t('speed.units.time')} +
    +
    + Data: + {wpData[1]}{t('speed.units.size')} +
    +
    + Requests: + {wpData[2]} {t('speed.units.requests')} +
    +
    +
    +
    +
    + + {percentFaster}% rýchlejšie + +
    +
    + ); +} + +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); + + const h2Ref = useRef(null); + const scrambleRef = useRef(null); + const isH2InView = useInView(h2Ref, { once: true }); + useEffect(() => { + if (isH2InView) scrambleRef.current?.start(); + }, [isH2InView]); + + return ( +
    +
    +
    +

    + +

    +

    + + {t('speed.subtitle')} + +

    +
    + {t('speed.selector')} + {(t('speed.networks', { returnObjects: true }) as string[]).map(net => ( + + ))} +
    +
    + +
    +
    + {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 ( + + ); + })} + +
    + {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 ( + + + + ); + })} +
    +
    +
    + + {/* Gradient overlay + reveal button */} + {!revealed && ( +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    + )} +
    + + {/* Collapse button after all items */} + {revealed && ( +
    +
    +
    +
    +
    + +
    +
    + )} +
    +
    + ); +} \ No newline at end of file diff --git a/src/components/TheProblem.jsx b/src/components/TheProblem.jsx deleted file mode 100644 index 532d843..0000000 --- a/src/components/TheProblem.jsx +++ /dev/null @@ -1,40 +0,0 @@ -import { useTranslation } from 'react-i18next'; - -export default function TheProblem() { - const { t } = useTranslation(); - - return ( -
    -
    -
    -

    - {t('problem.title')} -

    -

    - {t('problem.subtitle')} -

    -
    - -
    - {t('problem.cards', { returnObjects: true }).map((card, i) => ( -
    -
    {card.title}
    -

    {card.desc}

    -
    - ))} -
    -
    - - {/* Bleeding Sword Divider */} -
    -
    - -
    -
    -
    - ); -} \ No newline at end of file diff --git a/src/components/TheProblem.tsx b/src/components/TheProblem.tsx new file mode 100644 index 0000000..8a6aea3 --- /dev/null +++ b/src/components/TheProblem.tsx @@ -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(null); + const isInView = useInView(ref, { once: true, margin: '0px 0px -80px 0px' }); + + return ( + +
    + {card.title} +
    +

    {card.desc}

    +
    + ); +} + +// ── 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(null); + const canvasRef = useRef(null); + const starsRef = useRef(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(null); + const h2ScrambleRef = useRef(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 ( +
    +
    +
    + + + +

    + +

    +

    + {subtitle} +

    +
    +
    + {cards.map((card, i) => ( +
    +
    + {card.title} +
    +

    + {card.desc} +

    +
    + ))} +
    +
    +
    + ); + } + + // ── full experience ─────────────────────────────────────────────────────── + + return ( +
    +
    + {/* Lightspeed canvas (behind content) */} + + + {/* Content layer */} + +
    + {/* Section header */} +
    +

    + +

    +

    + + {subtitle} + +

    +
    + + {/* Stat cards */} +
    + {cards.map((card, i) => ( + + ))} +
    +
    +
    + + {/* Fade to white — seamless transition into Features */} + +
    +
    + ); +} diff --git a/src/components/fancy/blocks/screensaver.tsx b/src/components/fancy/blocks/screensaver.tsx new file mode 100644 index 0000000..31861ce --- /dev/null +++ b/src/components/fancy/blocks/screensaver.tsx @@ -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 + speed?: number + startPosition?: { x: number; y: number } // x,y as percentages (0-100) + startAngle?: number // in degrees + className?: string +} + +const Screensaver: React.FC = ({ + children, + speed = 3, + startPosition = { x: 0, y: 0 }, + startAngle = 45, + containerRef, + className, +}) => { + const elementRef = useRef(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 ( + + {children} + + ) +} + +export default Screensaver diff --git a/src/components/fancy/blocks/stacking-cards.tsx b/src/components/fancy/blocks/stacking-cards.tsx new file mode 100644 index 0000000..e2c7c06 --- /dev/null +++ b/src/components/fancy/blocks/stacking-cards.tsx @@ -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 { + scrollOptions?: UseScrollOptions + scaleMultiplier?: number + totalCards: number +} + +interface StackingCardItemProps + extends HTMLAttributes, + PropsWithChildren { + index: number + topPosition?: string +} + +export default function StackingCards({ + children, + className, + scrollOptions, + scaleMultiplier, + totalCards, + ...props +}: StackingCardsProps) { + const targetRef = useRef(null) + const { scrollYProgress } = useScroll({ + offset: ["start start", "end end"], + ...scrollOptions, + target: targetRef, + }) + + return ( + +
    + {children} +
    +
    + ) +} + +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 ( +
    + + {children} + +
    + ) +} + +const StackingCardsContext = createContext<{ + progress: MotionValue + 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 } diff --git a/src/components/fancy/image/parallax-floating.tsx b/src/components/fancy/image/parallax-floating.tsx new file mode 100644 index 0000000..1bb4530 --- /dev/null +++ b/src/components/fancy/image/parallax-floating.tsx @@ -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(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(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 ( + +
    + {children} +
    +
    + ) +} + +export default Floating + +interface FloatingElementProps { + children: ReactNode + className?: string + depth?: number +} + +export const FloatingElement = ({ + children, + className, + depth = 1, +}: FloatingElementProps) => { + const elementRef = useRef(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 ( +
    + {children} +
    + ) +} diff --git a/src/components/fancy/physics/gravity.tsx b/src/components/fancy/physics/gravity.tsx new file mode 100644 index 0000000..51f118e --- /dev/null +++ b/src/components/fancy/physics/gravity.tsx @@ -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(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 ( +
    + {children} +
    + ) +} + +const Gravity = forwardRef( + ( + { + children, + debug = false, + gravity = { x: 0, y: 1 }, + grabCursor = true, + resetOnResize = true, + addTopWall = true, + autoStart = true, + className, + ...props + }, + ref + ) => { + const canvas = useRef(null) + const engine = useRef(Engine.create()) + const render = useRef(undefined) + const runner = useRef(undefined) + const bodiesMap = useRef(new Map()) + const frameId = useRef(undefined) + const mouseConstraint = useRef(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 ( + +
    + {children} +
    +
    + ) + } +) + +Gravity.displayName = "Gravity" +export default Gravity diff --git a/src/components/fancy/text/scramble-hover.tsx b/src/components/fancy/text/scramble-hover.tsx new file mode 100644 index 0000000..01e60c6 --- /dev/null +++ b/src/components/fancy/text/scramble-hover.tsx @@ -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 = ({ + 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()) + + 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 ( + setIsHovering(true)} + onHoverEnd={() => setIsHovering(false)} + className={cn("inline-block whitespace-pre-wrap", className)} + {...props} + > + {displayText} + + + ) +} + +export default ScrambleHover diff --git a/src/components/fancy/text/scramble-in.tsx b/src/components/fancy/text/scramble-in.tsx new file mode 100644 index 0000000..2a542e0 --- /dev/null +++ b/src/components/fancy/text/scramble-in.tsx @@ -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( + ( + { + 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 ( + <> + {revealed} + {scrambled} + + ) + } + + return ( + <> + {text} + + + ) + } +) + +ScrambleIn.displayName = "ScrambleIn" +export default ScrambleIn diff --git a/src/components/fancy/text/text-highlighter.tsx b/src/components/fancy/text/text-highlighter.tsx new file mode 100644 index 0000000..c6c6d37 --- /dev/null +++ b/src/components/fancy/text/text-highlighter.tsx @@ -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 + +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(null) + const [isAnimating, setIsAnimating] = useState(false) + const [isHovered, setIsHovered] = useState(false) + const [currentDirection, setCurrentDirection] = + useState(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 ( + triggerType === "hover" && setIsHovered(true)} + onMouseLeave={() => triggerType === "hover" && setIsHovered(false)} + {...props} + > + + {children} + + + ) + } +) + +TextHighlighter.displayName = "TextHighlighter" + +export default TextHighlighter diff --git a/src/components/fancy/text/text-rotate.tsx b/src/components/fancy/text/text-rotate.tsx new file mode 100644 index 0000000..4cc8219 --- /dev/null +++ b/src/components/fancy/text/text-rotate.tsx @@ -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( + ( + { + 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 ( + + {texts[currentTextIndex]} + + + + {(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 ( + + {wordObj.characters.map((char, charIndex) => { + const totalIndex = previousCharsCount + charIndex + const animationProps = getAnimationProps(totalIndex) + return ( + + sum + word.characters.length, + 0 + ) + ), + }} + className={"inline-block"} + > + {char} + + + ) + })} + {wordObj.needsSpace && ( + + )} + + ) + })} + + + + ) + } +) + +TextRotate.displayName = "TextRotate" + +export default TextRotate \ No newline at end of file diff --git a/src/components/fancy/text/vertical-cut-reveal.tsx b/src/components/fancy/text/vertical-cut-reveal.tsx new file mode 100644 index 0000000..9037104 --- /dev/null +++ b/src/components/fancy/text/vertical-cut-reveal.tsx @@ -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( + ( + { + 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(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 ( + + {text} + + {(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 ( + + ) + })} + + ) + } +) + +VerticalCutReveal.displayName = "VerticalCutReveal" +export default VerticalCutReveal diff --git a/src/hooks/use-dimensions.ts b/src/hooks/use-dimensions.ts new file mode 100644 index 0000000..72e5e3a --- /dev/null +++ b/src/hooks/use-dimensions.ts @@ -0,0 +1,31 @@ +import { RefObject, useEffect, useState } from "react" + +interface Dimensions { + width: number + height: number +} + +export function useDimensions( + ref: RefObject +): Dimensions { + const [dimensions, setDimensions] = useState({ + 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 +} diff --git a/src/hooks/use-mouse-position-ref.ts b/src/hooks/use-mouse-position-ref.ts new file mode 100644 index 0000000..8b18e88 --- /dev/null +++ b/src/hooks/use-mouse-position-ref.ts @@ -0,0 +1,42 @@ +import { RefObject, useEffect, useRef } from "react" + +export const useMousePositionRef = ( + containerRef?: RefObject +) => { + 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 +} diff --git a/src/i18n/locales/cs.json b/src/i18n/locales/cs.json index b2eedb0..0034600 100644 --- a/src/i18n/locales/cs.json +++ b/src/i18n/locales/cs.json @@ -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." } } \ No newline at end of file diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index b2eedb0..e1503f3 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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." } } \ No newline at end of file diff --git a/src/i18n/locales/sk.json b/src/i18n/locales/sk.json index 829e442..25ec68a 100644 --- a/src/i18n/locales/sk.json +++ b/src/i18n/locales/sk.json @@ -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é." } } \ No newline at end of file diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..a7dc3a1 --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} \ No newline at end of file diff --git a/src/main.jsx b/src/main.tsx similarity index 100% rename from src/main.jsx rename to src/main.tsx diff --git a/src/utils/calculate-position.ts b/src/utils/calculate-position.ts new file mode 100644 index 0000000..d0f52ec --- /dev/null +++ b/src/utils/calculate-position.ts @@ -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 +} diff --git a/src/utils/svg-path-to-vertices.ts b/src/utils/svg-path-to-vertices.ts new file mode 100644 index 0000000..e38eae6 --- /dev/null +++ b/src/utils/svg-path-to-vertices.ts @@ -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 +} \ No newline at end of file diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..151aa68 --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..ab4edaa --- /dev/null +++ b/tsconfig.json @@ -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"] +} \ No newline at end of file diff --git a/vite.config.js b/vite.config.js index 0616e59..e74194c 100644 --- a/vite.config.js +++ b/vite.config.js @@ -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"), + }, + }, +}) \ No newline at end of file