0.9
This commit is contained in:
parent
a5d273c8fc
commit
8c0e589376
51 changed files with 4882 additions and 908 deletions
19
components.json
Normal file
19
components.json
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "default",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.ts",
|
||||||
|
"css": "app/globals.css",
|
||||||
|
"baseColor": "slate",
|
||||||
|
"cssVariables": true
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils"
|
||||||
|
},
|
||||||
|
"registries": {
|
||||||
|
"@fancy": "https://www.fancycomponents.dev/r/{name}.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
17
package.json
17
package.json
|
|
@ -10,18 +10,28 @@
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@types/matter-js": "^0.20.2",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"i18next": "^25.8.4",
|
"i18next": "^25.8.4",
|
||||||
"i18next-browser-languagedetector": "^8.2.0",
|
"i18next-browser-languagedetector": "^8.2.0",
|
||||||
|
"lodash": "^4.17.23",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
|
"matter-js": "^0.20.0",
|
||||||
|
"motion": "^12.34.2",
|
||||||
|
"poly-decomp": "^0.3.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-i18next": "^16.5.4",
|
"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": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@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",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
"autoprefixer": "^10.4.23",
|
"autoprefixer": "^10.4.23",
|
||||||
|
|
@ -30,7 +40,8 @@
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.2.0",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.2.4"
|
"vite": "^7.2.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,68 +1,52 @@
|
||||||
<?php
|
<?php
|
||||||
require_once __DIR__ . '/vendor/autoload.php';
|
|
||||||
|
|
||||||
use Dotenv\Dotenv;
|
|
||||||
use PHPMailer\PHPMailer\PHPMailer;
|
|
||||||
use PHPMailer\PHPMailer\Exception;
|
|
||||||
|
|
||||||
// Load environment variables
|
|
||||||
$dotenv = Dotenv::createImmutable(__DIR__);
|
|
||||||
$dotenv->load();
|
|
||||||
|
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
header('Access-Control-Allow-Origin: *');
|
header('Access-Control-Allow-Origin: *');
|
||||||
header('Access-Control-Allow-Methods: POST');
|
header('Access-Control-Allow-Methods: POST');
|
||||||
header('Access-Control-Allow-Headers: Content-Type');
|
header('Access-Control-Allow-Headers: Content-Type');
|
||||||
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
$data = json_decode(file_get_contents('php://input'), true);
|
http_response_code(405);
|
||||||
|
echo json_encode(['error' => 'Method not allowed']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
// Check honeypot
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
if (!empty($data['website']) || !empty($data['phone_check'])) {
|
|
||||||
|
// Check honeypot
|
||||||
|
if (!empty($data['website']) || !empty($data['phone_check'])) {
|
||||||
http_response_code(200);
|
http_response_code(200);
|
||||||
echo json_encode(['success' => true]);
|
echo json_encode(['success' => true]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate and sanitize
|
// Validate and sanitize
|
||||||
$name = htmlspecialchars($data['name']);
|
$name = htmlspecialchars($data['name'] ?? '');
|
||||||
$email = filter_var($data['email'], FILTER_SANITIZE_EMAIL);
|
$email = filter_var($data['email'] ?? '', FILTER_SANITIZE_EMAIL);
|
||||||
$company = htmlspecialchars($data['company']);
|
$company = htmlspecialchars($data['company'] ?? '');
|
||||||
$type = htmlspecialchars($data['type']);
|
$type = htmlspecialchars($data['type'] ?? '');
|
||||||
$message = htmlspecialchars($data['message']);
|
$message = htmlspecialchars($data['message'] ?? '');
|
||||||
|
|
||||||
try {
|
if (!$name || !$email || !$message || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||||
$mail = new PHPMailer(true);
|
http_response_code(422);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid input']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
// SMTP Configuration
|
$to = 'contact@ahojsvet.eu'; // TODO: replace with your actual address
|
||||||
$mail->isSMTP();
|
$subject = 'New Contact Form: ' . $type;
|
||||||
$mail->Host = $_ENV['SMTP_HOST'];
|
$body = "Name: $name\nEmail: $email\nCompany: $company\nType: $type\n\nMessage:\n$message";
|
||||||
$mail->SMTPAuth = true;
|
$headers = implode("\r\n", [
|
||||||
$mail->Username = $_ENV['SMTP_USERNAME'];
|
'From: noreply@ahojsvet.eu',
|
||||||
$mail->Password = $_ENV['SMTP_PASSWORD'];
|
'Reply-To: ' . $name . ' <' . $email . '>',
|
||||||
$mail->SMTPSecure = $_ENV['SMTP_ENCRYPTION'];
|
'X-Mailer: PHP/' . phpversion(),
|
||||||
$mail->Port = $_ENV['SMTP_PORT'];
|
'Content-Type: text/plain; charset=UTF-8',
|
||||||
|
]);
|
||||||
// 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();
|
|
||||||
|
|
||||||
|
if (mail($to, $subject, $body, $headers)) {
|
||||||
http_response_code(200);
|
http_response_code(200);
|
||||||
echo json_encode(['success' => true]);
|
echo json_encode(['success' => true]);
|
||||||
} catch (Exception $e) {
|
|
||||||
http_response_code(500);
|
|
||||||
echo json_encode(['success' => false, 'error' => $mail->ErrorInfo]);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
http_response_code(405);
|
http_response_code(500);
|
||||||
echo json_encode(['error' => 'Method not allowed']);
|
echo json_encode(['success' => false, 'error' => 'mail() failed']);
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
BIN
public/screenshots/admin.png
Normal file
BIN
public/screenshots/admin.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 167 KiB |
BIN
public/screenshots/cms.png
Normal file
BIN
public/screenshots/cms.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 300 KiB |
BIN
public/screenshots/obchod.png
Normal file
BIN
public/screenshots/obchod.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 692 KiB |
BIN
public/screenshots/objednavky.png
Normal file
BIN
public/screenshots/objednavky.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 188 KiB |
|
|
@ -25,6 +25,7 @@ export default function App() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-900 text-white min-h-screen font-sans antialiased">
|
<div className="bg-gray-900 text-white min-h-screen font-sans antialiased">
|
||||||
|
<main className="relative z-10 bg-gray-900">
|
||||||
<Navigation scrollY={scrollY} />
|
<Navigation scrollY={scrollY} />
|
||||||
<Hero />
|
<Hero />
|
||||||
<TheProblem />
|
<TheProblem />
|
||||||
|
|
@ -32,9 +33,10 @@ export default function App() {
|
||||||
<SpeedComparison />
|
<SpeedComparison />
|
||||||
<HiddenCostsIcebergSection />
|
<HiddenCostsIcebergSection />
|
||||||
<Screenshots />
|
<Screenshots />
|
||||||
<InTheWild />
|
{/* <InTheWild /> */}
|
||||||
<ContactForm />
|
<ContactForm />
|
||||||
<CTA />
|
<CTA />
|
||||||
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
90
src/components/AmbientGlow.tsx
Normal file
90
src/components/AmbientGlow.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
interface GlowState {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AmbientGlowProps {
|
||||||
|
/** Base width in px */
|
||||||
|
baseWidth?: number;
|
||||||
|
/** Base height in px */
|
||||||
|
baseHeight?: number;
|
||||||
|
/** Max horizontal drift from center in px */
|
||||||
|
driftX?: number;
|
||||||
|
/** Max vertical drift from center in px */
|
||||||
|
driftY?: number;
|
||||||
|
/** How much the size can vary (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<GlowState>({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
w: baseWidth,
|
||||||
|
h: baseHeight,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const morph = () =>
|
||||||
|
setGlow(randomState(baseWidth, baseHeight, driftX, driftY, sizeFuzz));
|
||||||
|
|
||||||
|
// Start at a random position immediately
|
||||||
|
morph();
|
||||||
|
const id = setInterval(morph, interval);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [baseWidth, baseHeight, driftX, driftY, sizeFuzz, interval]);
|
||||||
|
|
||||||
|
const duration = Math.round(interval * 0.85);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`absolute rounded-full blur-3xl ${className}`}
|
||||||
|
style={{
|
||||||
|
width: glow.w,
|
||||||
|
height: glow.h,
|
||||||
|
top: anchorY,
|
||||||
|
left: anchorX,
|
||||||
|
transform: `translate(calc(-50% + ${glow.x}px), calc(-50% + ${glow.y}px))`,
|
||||||
|
transition: `transform ${duration}ms cubic-bezier(0.45, 0, 0.55, 1), width ${duration}ms ease-in-out, height ${duration}ms ease-in-out`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Play } from 'lucide-react';
|
|
||||||
|
|
||||||
export default function CTA() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const cta = t('cta', { returnObjects: true });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="py-32 bg-gray-900 relative overflow-hidden">
|
|
||||||
<div className="absolute inset-0">
|
|
||||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] bg-red-700/10 rounded-full blur-3xl" />
|
|
||||||
</div>
|
|
||||||
<div className="max-w-4xl mx-auto px-6 text-center relative z-10">
|
|
||||||
<h2 className="text-4xl md:text-6xl font-black mb-6 text-white">
|
|
||||||
{cta.title}
|
|
||||||
</h2>
|
|
||||||
<p className="text-xl text-gray-400 mb-12">
|
|
||||||
{cta.subtitle}
|
|
||||||
</p>
|
|
||||||
<a
|
|
||||||
href="https://demo.ahojsvet.eu"
|
|
||||||
className="inline-flex items-center gap-2 bg-red-700 hover:bg-red-600 text-white px-10 py-5 rounded-xl font-bold text-xl transition-all glow hover:scale-105"
|
|
||||||
>
|
|
||||||
<Play size={24} /> {cta.button}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
83
src/components/CTA.tsx
Normal file
83
src/components/CTA.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
|
||||||
|
import { useRef, useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { BookOpen } from 'lucide-react';
|
||||||
|
import ScrambleIn, { ScrambleInHandle } from './fancy/text/scramble-in';
|
||||||
|
import { useInView } from 'motion/react';
|
||||||
|
import ScrambleHover from './fancy/text/scramble-hover';
|
||||||
|
import VerticalCutReveal from './fancy/text/vertical-cut-reveal';
|
||||||
|
import AmbientGlow from './AmbientGlow';
|
||||||
|
|
||||||
|
// ── types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface CTATranslation {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
button: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── component ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function CTA() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const cta = t('cta', { returnObjects: true }) as CTATranslation;
|
||||||
|
|
||||||
|
const scrambleRef = useRef<ScrambleInHandle>(null);
|
||||||
|
const h2Ref = useRef<HTMLHeadingElement>(null);
|
||||||
|
const isH2InView = useInView(h2Ref, { once: true });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isH2InView) scrambleRef.current?.start();
|
||||||
|
}, [isH2InView]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="relative overflow-hidden bg-gray-900 py-64">
|
||||||
|
<AmbientGlow className="bg-red-700/10" />
|
||||||
|
|
||||||
|
<div className="relative z-10 flex flex-col items-center gap-8 text-center px-6">
|
||||||
|
{/* Title */}
|
||||||
|
<h2 ref={h2Ref} className="text-4xl md:text-6xl font-black text-white">
|
||||||
|
<ScrambleIn
|
||||||
|
ref={scrambleRef}
|
||||||
|
text={cta.title}
|
||||||
|
characters="ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%^&*"
|
||||||
|
scrambleSpeed={35}
|
||||||
|
autoStart={false}
|
||||||
|
className="inline"
|
||||||
|
/>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Subtitle */}
|
||||||
|
<p className="text-xl text-gray-400 max-w-2xl mx-auto">
|
||||||
|
<VerticalCutReveal
|
||||||
|
splitBy="words"
|
||||||
|
staggerFrom="first"
|
||||||
|
staggerDuration={0.04}
|
||||||
|
transition={{ type: 'spring', damping: 26, stiffness: 140, delay: 0.5 }}
|
||||||
|
autoStart
|
||||||
|
containerClassName="justify-center flex-wrap"
|
||||||
|
elementLevelClassName="pb-1"
|
||||||
|
>
|
||||||
|
{cta.subtitle}
|
||||||
|
</VerticalCutReveal>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* CTA button */}
|
||||||
|
<a
|
||||||
|
href="https://demo.ahojsvet.eu"
|
||||||
|
className="group inline-flex items-center gap-3 bg-red-700 hover:bg-red-600 text-white px-10 py-5 rounded-xl font-bold text-xl transition-colors glow select-none ring-2 ring-red-700/40 hover:ring-red-500/60 ring-offset-2 ring-offset-gray-900"
|
||||||
|
>
|
||||||
|
<BookOpen size={24} className="shrink-0 transition-transform group-hover:-rotate-6" />
|
||||||
|
<ScrambleHover
|
||||||
|
text={cta.button}
|
||||||
|
scrambleSpeed={40}
|
||||||
|
maxIterations={8}
|
||||||
|
characters="ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||||
|
className="text-white font-bold"
|
||||||
|
scrambledClassName="text-red-300"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,42 @@
|
||||||
import { useState } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
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() {
|
export default function ContactForm() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const contact = t('contact', { returnObjects: true });
|
const contact = t('contact', { returnObjects: true }) as ContactTranslation;
|
||||||
|
const h2Ref = useRef<HTMLHeadingElement>(null);
|
||||||
|
const scrambleRef = useRef<ScrambleInHandle>(null);
|
||||||
|
const isH2InView = useInView(h2Ref, { once: true });
|
||||||
|
useEffect(() => {
|
||||||
|
if (isH2InView) scrambleRef.current?.start();
|
||||||
|
}, [isH2InView]);
|
||||||
|
|
||||||
const [formSent, setFormSent] = useState(false);
|
const [formSent, setFormSent] = useState(false);
|
||||||
|
const [formError, setFormError] = useState('');
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
email: '',
|
email: '',
|
||||||
|
|
@ -15,33 +47,55 @@ export default function ContactForm() {
|
||||||
phone_check: ''
|
phone_check: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const validate = () => {
|
||||||
// Check honeypot fields
|
const next: Record<string, string> = {};
|
||||||
if (formData.website || formData.phone_check) {
|
if (!formData.name.trim()) next.name = contact.errors?.name ?? 'Required';
|
||||||
return; // Ignore if honeypot fields are filled
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
||||||
// TODO: Replace with actual form submission endpoint
|
|
||||||
console.log('Form submitted:', formData);
|
|
||||||
setFormSent(true);
|
setFormSent(true);
|
||||||
setTimeout(() => setFormSent(false), 3000);
|
setTimeout(() => setFormSent(false), 3000);
|
||||||
setFormData({
|
setFormData({ name: '', email: '', company: '', type: contact.types[0], message: '', website: '', phone_check: '' });
|
||||||
name: '',
|
} catch {
|
||||||
email: '',
|
setFormError(contact.errors?.send ?? 'Failed to send. Please try again.');
|
||||||
company: '',
|
} finally {
|
||||||
type: contact.types[0],
|
setIsSubmitting(false);
|
||||||
message: '',
|
}
|
||||||
website: '',
|
|
||||||
phone_check: ''
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="contact" className="py-32 bg-gray-900">
|
<section id="contact" className="pt-32 bg-gray-900">
|
||||||
<div className="max-w-3xl mx-auto px-6">
|
<div className="max-w-3xl mx-auto px-6">
|
||||||
<div className="text-center mb-16">
|
<div className="text-center mb-16">
|
||||||
<h2 className="text-4xl md:text-6xl font-black mb-4 text-white">
|
<h2 ref={h2Ref} className="text-4xl md:text-6xl font-black mb-4 text-white">
|
||||||
{contact.title}
|
<ScrambleIn ref={scrambleRef} text={contact.title} autoStart={false} />
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xl text-gray-400">
|
<p className="text-xl text-gray-400">
|
||||||
{contact.subtitle}
|
{contact.subtitle}
|
||||||
|
|
@ -59,8 +113,9 @@ export default function ContactForm() {
|
||||||
required
|
required
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-3 text-white focus:border-red-700 focus:outline-none transition-colors"
|
className={`w-full bg-gray-900 border rounded-lg px-4 py-3 text-white focus:outline-none transition-colors ${errors.name ? 'border-red-500 focus:border-red-500' : 'border-gray-700 focus:border-red-700'}`}
|
||||||
/>
|
/>
|
||||||
|
{errors.name && <p className="mt-1 text-xs text-red-400">{errors.name}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-bold mb-2 text-white">
|
<label className="block text-sm font-bold mb-2 text-white">
|
||||||
|
|
@ -71,8 +126,9 @@ export default function ContactForm() {
|
||||||
required
|
required
|
||||||
value={formData.email}
|
value={formData.email}
|
||||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-3 text-white focus:border-red-700 focus:outline-none transition-colors"
|
className={`w-full bg-gray-900 border rounded-lg px-4 py-3 text-white focus:outline-none transition-colors ${errors.email ? 'border-red-500 focus:border-red-500' : 'border-gray-700 focus:border-red-700'}`}
|
||||||
/>
|
/>
|
||||||
|
{errors.email && <p className="mt-1 text-xs text-red-400">{errors.email}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -114,8 +170,9 @@ export default function ContactForm() {
|
||||||
rows={6}
|
rows={6}
|
||||||
value={formData.message}
|
value={formData.message}
|
||||||
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
|
||||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-3 text-white focus:border-red-700 focus:outline-none transition-colors resize-none"
|
className={`w-full bg-gray-900 border rounded-lg px-4 py-3 text-white focus:outline-none transition-colors resize-none ${errors.message ? 'border-red-500 focus:border-red-500' : 'border-gray-700 focus:border-red-700'}`}
|
||||||
/>
|
/>
|
||||||
|
{errors.message && <p className="mt-1 text-xs text-red-400">{errors.message}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Honeypot fields - hidden from users */}
|
{/* Honeypot fields - hidden from users */}
|
||||||
|
|
@ -140,9 +197,10 @@ export default function ContactForm() {
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
className="w-full bg-red-700 hover:bg-red-600 text-white px-8 py-4 rounded-xl font-bold text-lg transition-all glow hover:scale-[1.02]"
|
disabled={isSubmitting}
|
||||||
|
className="w-full bg-red-700 hover:bg-red-600 disabled:opacity-60 disabled:cursor-not-allowed text-white px-8 py-4 rounded-xl font-bold text-lg transition-all glow hover:scale-[1.02]"
|
||||||
>
|
>
|
||||||
{contact.send}
|
{isSubmitting ? '...' : contact.send}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{formSent && (
|
{formSent && (
|
||||||
|
|
@ -150,6 +208,12 @@ export default function ContactForm() {
|
||||||
<div className="text-green-400 font-bold">{contact.success}</div>
|
<div className="text-green-400 font-bold">{contact.success}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{formError && (
|
||||||
|
<div className="bg-red-900 border border-red-700 rounded-lg px-6 py-4 text-center">
|
||||||
|
<div className="text-red-400 font-bold">{formError}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Shield, Globe2, BarChart3, Blocks, Mail, Tag, Users, Layers, Zap } from 'lucide-react';
|
|
||||||
|
|
||||||
const Icons = { Shield, Globe2, BarChart3, Blocks, Mail, Tag, Users, Layers, Zap };
|
|
||||||
|
|
||||||
export default function Features() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="pb-32 pt-12 bg-white text-gray-900">
|
|
||||||
<div className="max-w-6xl mx-auto px-6">
|
|
||||||
<div className="text-center mb-20">
|
|
||||||
<h2 className="text-4xl md:text-6xl font-black mb-4">
|
|
||||||
{t('features.title')}
|
|
||||||
</h2>
|
|
||||||
<p className="text-2xl md:text-3xl font-bold text-red-700">
|
|
||||||
{t('features.subtitle')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
{t('features.items', { returnObjects: true }).map((feature, i) => {
|
|
||||||
const Icon = Icons[feature.icon];
|
|
||||||
return (
|
|
||||||
<div key={i} className="card-hover bg-gray-100 rounded-2xl p-8 border border-gray-200">
|
|
||||||
<div className="w-14 h-14 bg-red-700 rounded-xl flex items-center justify-center mb-6">
|
|
||||||
<Icon size={28} className="text-white" />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-xl font-bold mb-3">{feature.title}</h3>
|
|
||||||
<p className="text-gray-600 leading-relaxed">{feature.desc}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
133
src/components/Features.tsx
Normal file
133
src/components/Features.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
import { useRef, useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Shield, Globe2, BarChart3, Blocks, Mail, Tag, Users, Layers, Zap } from 'lucide-react';
|
||||||
|
import { motion, useInView } from 'motion/react';
|
||||||
|
import { TextHighlighter } from './fancy/text/text-highlighter';
|
||||||
|
import VerticalCutReveal from './fancy/text/vertical-cut-reveal';
|
||||||
|
import ScrambleIn, { ScrambleInHandle } from '@/components/fancy/text/scramble-in';
|
||||||
|
|
||||||
|
// ── types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface FeatureItem {
|
||||||
|
icon: string;
|
||||||
|
title: string;
|
||||||
|
desc: string; // matches your JSON key
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FeaturesTranslation {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
items: FeatureItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── icon map ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const Icons: Record<string, React.ElementType> = {
|
||||||
|
Shield, Globe2, BarChart3, Blocks, Mail, Tag, Users, Layers, Zap,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── animated feature card ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function FeatureCard({ feature, index }: { feature: FeatureItem; index: number }) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const isInView = useInView(ref, { once: true, amount: 0.4 });
|
||||||
|
const Icon = Icons[feature.icon] ?? Blocks;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
ref={ref}
|
||||||
|
initial={{ opacity: 0, y: 40 }}
|
||||||
|
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||||
|
transition={{
|
||||||
|
type: 'spring',
|
||||||
|
damping: 22,
|
||||||
|
stiffness: 120,
|
||||||
|
delay: index * 0.08,
|
||||||
|
}}
|
||||||
|
className="group bg-gray-100/60 backdrop-blur-sm rounded-2xl p-8 border border-gray-700/50
|
||||||
|
hover:border-red-700/40 transition-colors duration-300"
|
||||||
|
>
|
||||||
|
{/* Icon + counter */}
|
||||||
|
<div className="flex items-start justify-between mb-5">
|
||||||
|
<div className="w-12 h-12 bg-red-700/80 rounded-xl flex items-center justify-center
|
||||||
|
group-hover:bg-red-600 transition-colors duration-300">
|
||||||
|
<Icon size={24} className="text-white" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-mono text-gray-600 select-none">
|
||||||
|
{String(index + 1).padStart(2, '0')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title with highlight sweep */}
|
||||||
|
<h3 className="text-lg font-bold mb-2 text-gray-800">
|
||||||
|
<TextHighlighter
|
||||||
|
highlightColor="rgba(185,28,28,0.3)"
|
||||||
|
direction="ltr"
|
||||||
|
triggerType="inView"
|
||||||
|
useInViewOptions={{ once: true, amount: 0.8 }}
|
||||||
|
transition={{ type: 'spring', duration: 0.8, bounce: 0, delay: 0.1 }}
|
||||||
|
>
|
||||||
|
{feature.title}
|
||||||
|
</TextHighlighter>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p className="text-gray-900 leading-relaxed text-sm">
|
||||||
|
{feature.desc}
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── main component ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function Features() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { title, subtitle, items } = t('features', { returnObjects: true }) as FeaturesTranslation;
|
||||||
|
|
||||||
|
const h2Ref = useRef<HTMLHeadingElement>(null);
|
||||||
|
const scrambleRef = useRef<ScrambleInHandle>(null);
|
||||||
|
const isH2InView = useInView(h2Ref, { once: true });
|
||||||
|
useEffect(() => {
|
||||||
|
if (isH2InView) scrambleRef.current?.start();
|
||||||
|
}, [isH2InView]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="pb-24 pt-16 bg-white text-gray-900">
|
||||||
|
<div className="max-w-6xl mx-auto px-6">
|
||||||
|
|
||||||
|
{/* ── Section header ──────────────────────────────────────────── */}
|
||||||
|
<div className="text-center mb-20">
|
||||||
|
<h2 ref={h2Ref} className="text-4xl md:text-6xl font-black mb-4">
|
||||||
|
<ScrambleIn ref={scrambleRef} text={title} autoStart={false} />
|
||||||
|
</h2>
|
||||||
|
<p className="text-2xl md:text-3xl font-bold text-red-500">
|
||||||
|
<VerticalCutReveal
|
||||||
|
splitBy="words"
|
||||||
|
staggerFrom="first"
|
||||||
|
staggerDuration={0.04}
|
||||||
|
transition={{ type: 'spring', damping: 26, stiffness: 140, delay: 0.3 }}
|
||||||
|
autoStart
|
||||||
|
containerClassName="justify-center flex-wrap"
|
||||||
|
elementLevelClassName="pb-1"
|
||||||
|
>
|
||||||
|
{subtitle}
|
||||||
|
</VerticalCutReveal>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Feature grid ────────────────────────────────────────────── */}
|
||||||
|
{/*
|
||||||
|
3-column grid instead of stacking cards:
|
||||||
|
- 9 items scan in ~2 seconds vs ~15s of scrolling
|
||||||
|
- Staggered fade-in still gives motion and life
|
||||||
|
- Much less scroll fatigue on a landing page
|
||||||
|
*/}
|
||||||
|
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||||
|
{items.map((feature, i) => (
|
||||||
|
<FeatureCard key={i} feature={feature} index={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
export default function Footer() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const footer = t('footer', { returnObjects: true });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<footer className="py-12 border-t border-gray-800 bg-gray-900">
|
|
||||||
<div className="max-w-6xl mx-auto px-6">
|
|
||||||
<div className="flex flex-col md:flex-row items-center justify-between gap-6">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-8 h-8 bg-red-700 rounded-lg flex items-center justify-center font-black text-white">
|
|
||||||
A
|
|
||||||
</div>
|
|
||||||
<span className="font-bold text-white">AhojSvet.eu</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-8 text-gray-400">
|
|
||||||
<a href="https://demo.ahojsvet.eu" className="hover:text-white transition-colors">
|
|
||||||
Demo
|
|
||||||
</a>
|
|
||||||
<a href="https://navod.ahojsvet.eu" className="hover:text-white transition-colors">
|
|
||||||
Docs
|
|
||||||
</a>
|
|
||||||
<a href="#contact" className="hover:text-white transition-colors">
|
|
||||||
Kontakt
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<p className="text-gray-500 text-sm">
|
|
||||||
{footer.tagline}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
141
src/components/Footer.tsx
Normal file
141
src/components/Footer.tsx
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
import { useRef, useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import ScrambleHover from './fancy/text/scramble-hover';
|
||||||
|
import ScrambleIn, { ScrambleInHandle } from './fancy/text/scramble-in';
|
||||||
|
import { useInView } from 'motion/react';
|
||||||
|
|
||||||
|
// ── types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface FooterTranslation {
|
||||||
|
tagline: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── nav structure ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface NavLink {
|
||||||
|
label: string;
|
||||||
|
href: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NAV_PRIMARY: NavLink[] = [
|
||||||
|
{ label: 'Demo', href: 'https://demo.ahojsvet.eu' },
|
||||||
|
{ label: 'Docs', href: 'https://navod.ahojsvet.eu' },
|
||||||
|
{ label: 'Kontakt', href: '#contact' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const NAV_SECONDARY: NavLink[] = [
|
||||||
|
{ label: 'GitHub', href: 'https://github.com/JRoshthen1' },
|
||||||
|
{ label: 'Telegram', href: 'https://t.me/peter_dirichlet' },
|
||||||
|
//{ label: 'X (Twitter)', href: '#' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── scramble hover link ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function NavItem({ label, href }: NavLink) {
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target={href.startsWith('http') ? '_blank' : undefined}
|
||||||
|
rel={href.startsWith('http') ? 'noopener noreferrer' : undefined}
|
||||||
|
className="hover:text-red-500 transition-colors cursor-pointer block"
|
||||||
|
>
|
||||||
|
<ScrambleHover
|
||||||
|
text={label}
|
||||||
|
characters="ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||||
|
sequential
|
||||||
|
revealDirection="start"
|
||||||
|
scrambleSpeed={60}
|
||||||
|
className="font-medium"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── component ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function Footer() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const footer = t('footer', { returnObjects: true }) as FooterTranslation;
|
||||||
|
|
||||||
|
const footerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const scrambleRef = useRef<ScrambleInHandle>(null);
|
||||||
|
const isInView = useInView(footerRef, { once: true, margin: '0px 0px -60px 0px' });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isInView) scrambleRef.current?.start();
|
||||||
|
}, [isInView]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
/*
|
||||||
|
Pattern from fancycomponents.dev/docs/components/blocks/sticky-footer:
|
||||||
|
- footer: sticky bottom-0 z-0 left-0 + fixed height (h-80)
|
||||||
|
- main content above must have: relative z-10
|
||||||
|
The main content slides over this footer as you scroll.
|
||||||
|
*/
|
||||||
|
<footer
|
||||||
|
ref={footerRef}
|
||||||
|
className="sticky z-0 bottom-0 left-0 w-full h-80 bg-gray-900 border-t border-gray-800"
|
||||||
|
>
|
||||||
|
{/* Single relative + overflow-hidden container fills the footer */}
|
||||||
|
<div className="relative overflow-hidden w-full h-full flex justify-end items-start px-8 md:px-12 py-10">
|
||||||
|
|
||||||
|
{/* ── Logo + nav + tagline row ──────────────────────────────── */}
|
||||||
|
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-8 w-full">
|
||||||
|
|
||||||
|
{/* Logo mark */}
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<div className="w-8 h-8 bg-red-700 rounded-lg flex items-center justify-center font-black text-white select-none">
|
||||||
|
A
|
||||||
|
</div>
|
||||||
|
<span className="font-bold text-white">AhojSvet.eu</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nav columns */}
|
||||||
|
<div className="flex gap-12 md:gap-20 text-gray-400 text-sm md:text-base">
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{NAV_PRIMARY.map((link) => (
|
||||||
|
<NavItem key={link.label} {...link} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{NAV_SECONDARY.map((link) => (
|
||||||
|
<NavItem key={link.label} {...link} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tagline */}
|
||||||
|
<p className="text-gray-500 text-sm max-w-xs text-left md:text-right">
|
||||||
|
{footer.tagline}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
── Oversized brand name ──────────────────────────────────────
|
||||||
|
Absolute-positioned at bottom-left of the footer container,
|
||||||
|
translate-y-1/3 clips the bottom third via overflow-hidden.
|
||||||
|
Matches the fancy.dev reference pattern exactly.
|
||||||
|
*/}
|
||||||
|
<h2
|
||||||
|
className="absolute bottom-0 left-0 translate-y-1/3 font-black text-gray-800 leading-none whitespace-nowrap pointer-events-none select-none"
|
||||||
|
style={{ fontSize: 'clamp(80px, 14vw, 192px)' }}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<ScrambleIn
|
||||||
|
ref={scrambleRef}
|
||||||
|
text="AhojSvet"
|
||||||
|
characters="ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||||
|
scrambleSpeed={30}
|
||||||
|
revealSpeed={50}
|
||||||
|
revealDirection="start"
|
||||||
|
autoStart={false}
|
||||||
|
className="inline"
|
||||||
|
/>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { BookOpen, Play, ChevronRight } from 'lucide-react';
|
|
||||||
|
|
||||||
export default function Hero() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="min-h-screen flex items-center justify-center relative overflow-hidden pt-20 bg-gray-900">
|
|
||||||
<div className="absolute inset-0 overflow-hidden">
|
|
||||||
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-red-700/20 rounded-full blur-3xl pulse-slow" />
|
|
||||||
<div className="absolute bottom-1/4 right-1/4 w-64 h-64 bg-red-900/20 rounded-full blur-3xl pulse-slow" style={{ animationDelay: '2s' }} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="max-w-6xl mx-auto px-6 text-center relative z-10">
|
|
||||||
<div className="float">
|
|
||||||
<h1 className="text-5xl sm:text-7xl md:text-8xl font-black mb-2 tracking-tight text-white">
|
|
||||||
{t('hero.tagline')}
|
|
||||||
</h1>
|
|
||||||
<h1 className="text-5xl sm:text-7xl md:text-8xl font-black pb-8 tracking-tight gradient-text">
|
|
||||||
{t('hero.subtitle')}
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
<p className="text-xl md:text-2xl text-gray-400 max-w-2xl mx-auto mb-12 font-medium">
|
|
||||||
{t('hero.description')}
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
|
||||||
<a
|
|
||||||
href="https://demo.ahojsvet.eu"
|
|
||||||
className="inline-flex items-center justify-center gap-2 bg-red-700 hover:bg-red-600 text-white px-8 py-4 rounded-xl font-bold text-lg transition-all glow hover:scale-105"
|
|
||||||
>
|
|
||||||
<Play size={20} /> {t('hero.cta')}
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="https://navod.ahojsvet.eu"
|
|
||||||
className="inline-flex items-center justify-center gap-2 bg-gray-800 hover:bg-gray-700 text-white px-8 py-4 rounded-xl font-bold text-lg transition-colors"
|
|
||||||
>
|
|
||||||
<BookOpen size={20} /> {t('hero.docs')}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 animate-bounce">
|
|
||||||
<ChevronRight size={32} className="rotate-90 text-gray-600" />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
129
src/components/Hero.tsx
Normal file
129
src/components/Hero.tsx
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { BookOpen, Play, ChevronRight } from 'lucide-react';
|
||||||
|
import ScrambleIn, { ScrambleInHandle } from '@/components/fancy/text/scramble-in';
|
||||||
|
import TextRotate from '@/components/fancy/text/text-rotate';
|
||||||
|
import VerticalCutReveal from '@/components/fancy/text/vertical-cut-reveal';
|
||||||
|
import { useRef, useEffect } from 'react';
|
||||||
|
import AmbientGlow from './AmbientGlow';
|
||||||
|
import ScrambleHover from './fancy/text/scramble-hover';
|
||||||
|
|
||||||
|
// ── types ────────────────────────────────────────────────────────────────────
|
||||||
|
interface HeroTranslation {
|
||||||
|
tagline: string;
|
||||||
|
/** Array of pain-point strings cycled by TextRotate */
|
||||||
|
subtitleRotations: string[];
|
||||||
|
description: string;
|
||||||
|
cta: string;
|
||||||
|
docs: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Hero() {
|
||||||
|
const { t, ready } = useTranslation();
|
||||||
|
const hero = t('hero', { returnObjects: true }) as HeroTranslation;
|
||||||
|
const scrambleRef = useRef<ScrambleInHandle>(null);
|
||||||
|
|
||||||
|
// Re-fire the scramble whenever the tagline changes (e.g. language switch)
|
||||||
|
useEffect(() => {
|
||||||
|
scrambleRef.current?.start();
|
||||||
|
}, [hero.tagline]);
|
||||||
|
|
||||||
|
// Don't render animated components until i18n is hydrated
|
||||||
|
if (!ready || !Array.isArray(hero.subtitleRotations)) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="min-h-screen flex items-center justify-center relative overflow-hidden pt-20 bg-gray-900">
|
||||||
|
|
||||||
|
{/* ── Ambient blobs ──────────────────────────────────────────────── */}
|
||||||
|
<AmbientGlow className="bg-red-700/20" baseWidth={384} baseHeight={384} anchorX="25%" anchorY="25%" driftX={80} driftY={60} interval={6000} />
|
||||||
|
<AmbientGlow className="bg-red-900/20" baseWidth={256} baseHeight={256} anchorX="75%" anchorY="75%" driftX={60} driftY={50} interval={7500} />
|
||||||
|
|
||||||
|
<div className="max-w-6xl mx-auto px-6 text-center relative z-10">
|
||||||
|
|
||||||
|
{/* ── Main headline — ScrambleIn ──────────────────────────────── */}
|
||||||
|
<div className="float">
|
||||||
|
<h1 className="text-5xl sm:text-7xl md:text-8xl font-black mb-2 tracking-tight text-white">
|
||||||
|
<ScrambleIn
|
||||||
|
ref={scrambleRef}
|
||||||
|
text={hero.tagline}
|
||||||
|
characters="ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%^&*"
|
||||||
|
scrambleSpeed={40}
|
||||||
|
scrambledLetterCount={5}
|
||||||
|
autoStart={false}
|
||||||
|
className="inline"
|
||||||
|
/>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* ── Rotating subtitle — TextRotate ─────────────────────── */}
|
||||||
|
<h2 className="text-5xl sm:text-7xl md:text-8xl font-black pb-8 tracking-tight gradient-text overflow-hidden flex justify-center h-24 sm:h-36 md:h-48">
|
||||||
|
<TextRotate
|
||||||
|
texts={hero.subtitleRotations}
|
||||||
|
auto
|
||||||
|
rotationInterval={2800}
|
||||||
|
splitBy="words"
|
||||||
|
initial={{ y: '100%', opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
exit={{ y: '-120%', opacity: 0 }}
|
||||||
|
transition={{ type: 'spring', damping: 22, stiffness: 200 }}
|
||||||
|
staggerDuration={0.06}
|
||||||
|
mainClassName="inline-flex text-center flex-wrap justify-center gap-x-4"
|
||||||
|
splitLevelClassName="overflow-hidden"
|
||||||
|
elementLevelClassName="inline-block"
|
||||||
|
/>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Description — VerticalCutReveal ────────────────────────── */}
|
||||||
|
<div className="text-xl md:text-2xl text-gray-400 max-w-2xl mx-auto mb-12 font-medium">
|
||||||
|
<VerticalCutReveal
|
||||||
|
splitBy="words"
|
||||||
|
staggerFrom="first"
|
||||||
|
staggerDuration={0.04}
|
||||||
|
transition={{ type: 'spring', damping: 26, stiffness: 180, delay: 0.6 }}
|
||||||
|
autoStart
|
||||||
|
containerClassName="justify-center flex-wrap"
|
||||||
|
elementLevelClassName="pb-1"
|
||||||
|
>
|
||||||
|
{hero.description}
|
||||||
|
</VerticalCutReveal>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── CTAs ───────────────────────────────────────────────────── */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||||
|
<a
|
||||||
|
href="https://demo.ahojsvet.eu"
|
||||||
|
className="inline-flex items-center justify-center gap-2 bg-red-700 hover:bg-red-600 text-white px-8 py-4 rounded-xl font-bold text-lg transition-all glow hover:scale-105"
|
||||||
|
>
|
||||||
|
<Play size={20} />
|
||||||
|
<ScrambleHover
|
||||||
|
text={hero.cta}
|
||||||
|
scrambleSpeed={40}
|
||||||
|
maxIterations={8}
|
||||||
|
characters="ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||||
|
scrambledClassName="text-red-300"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="https://navod.ahojsvet.eu"
|
||||||
|
className="inline-flex items-center justify-center gap-2 bg-gray-800 hover:bg-gray-700 text-white px-8 py-4 rounded-xl font-bold text-lg transition-colors"
|
||||||
|
>
|
||||||
|
<BookOpen size={20} />
|
||||||
|
<ScrambleHover
|
||||||
|
text={hero.docs}
|
||||||
|
scrambleSpeed={40}
|
||||||
|
maxIterations={8}
|
||||||
|
characters="ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||||
|
scrambledClassName="text-red-300"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Scroll hint ────────────────────────────────────────────────── */}
|
||||||
|
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 animate-bounce">
|
||||||
|
<ChevronRight size={32} className="rotate-90 text-gray-600" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,212 +0,0 @@
|
||||||
import { useState, useEffect, useRef } from "react";
|
|
||||||
import { Eye, EyeOff, ChevronDown, Puzzle, RefreshCw, Shield, CreditCard, Code2, Cookie, BarChart3, Mail, AlertTriangle, Clock, Check } from "lucide-react";
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
const iconMap = {
|
|
||||||
setup: CreditCard, theme: Code2, plugin: Puzzle, hosting: BarChart3,
|
|
||||||
maintenance: RefreshCw, conflict: AlertTriangle, security: Shield,
|
|
||||||
change: Clock, cookie: Cookie, analytics: BarChart3, email: Mail, dev: Code2,
|
|
||||||
};
|
|
||||||
|
|
||||||
const severityColor = {
|
|
||||||
1: "border-yellow-500/40 bg-yellow-500/5",
|
|
||||||
2: "border-orange-500/40 bg-orange-500/5",
|
|
||||||
3: "border-red-500/40 bg-red-500/5",
|
|
||||||
};
|
|
||||||
const severityDot = { 1: "bg-yellow-500", 2: "bg-orange-500", 3: "bg-red-500" };
|
|
||||||
|
|
||||||
export default function CostIceberg() {
|
|
||||||
const [revealed, setRevealed] = useState(false);
|
|
||||||
const [visibleCount, setVisibleCount] = useState(0);
|
|
||||||
const sectionRef = useRef(null);
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!revealed) { setVisibleCount(0); return; }
|
|
||||||
let i = 0;
|
|
||||||
const hiddenItems = t('iceberg.hidden', { returnObjects: true });
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
i++;
|
|
||||||
setVisibleCount(i);
|
|
||||||
if (i >= hiddenItems.length) clearInterval(interval);
|
|
||||||
}, 120);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [revealed, t]);
|
|
||||||
|
|
||||||
const tipItems = t('iceberg.tip', { returnObjects: true });
|
|
||||||
const hiddenItems = t('iceberg.hidden', { returnObjects: true });
|
|
||||||
const ahojsvetPoints = t('iceberg.ahojsvet.points', { returnObjects: true });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section ref={sectionRef} className="py-20 md:py-32 text-white" style={{ background: "#111827" }}>
|
|
||||||
<div className="max-w-6xl mx-auto px-4 md:px-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="text-center mb-16 md:mb-20">
|
|
||||||
<h2 className="text-4xl md:text-6xl font-black mb-4" style={{ fontFamily: "'Work Sans', sans-serif" }}>
|
|
||||||
{t('iceberg.title')}
|
|
||||||
</h2>
|
|
||||||
<p className="text-lg md:text-2xl font-medium" style={{ color: "#f87171" }}>
|
|
||||||
{t('iceberg.subtitle')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid lg:grid-cols-2 gap-8 md:gap-12 items-start">
|
|
||||||
{/* WordPress Iceberg */}
|
|
||||||
<div>
|
|
||||||
{/* Tip */}
|
|
||||||
<div className="mb-2">
|
|
||||||
<div className="flex items-center gap-2 mb-4">
|
|
||||||
<Eye size={18} style={{ color: "#9ca3af" }} />
|
|
||||||
<span className="text-sm font-bold uppercase tracking-wider" style={{ color: "#9ca3af" }}>
|
|
||||||
{t('iceberg.tipLabel')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{tipItems.map((item, i) => {
|
|
||||||
const Icon = iconMap[item.icon];
|
|
||||||
return (
|
|
||||||
<div key={i} className="rounded-xl p-4 md:p-5 border flex items-start gap-4"
|
|
||||||
style={{ background: "#1f2937", borderColor: "#374151" }}>
|
|
||||||
<div className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0"
|
|
||||||
style={{ background: "#374151" }}>
|
|
||||||
<Icon size={20} style={{ color: "#9ca3af" }} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="font-bold">{item.label}</div>
|
|
||||||
<div className="text-sm" style={{ color: "#9ca3af" }}>{item.desc}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Waterline + Reveal Button */}
|
|
||||||
<div className="relative my-6">
|
|
||||||
<div className="absolute inset-0 flex items-center">
|
|
||||||
<div className="w-full border-t-2 border-dashed" style={{ borderColor: "#3b82f6", opacity: 0.4 }} />
|
|
||||||
</div>
|
|
||||||
<div className="relative flex justify-center">
|
|
||||||
<button
|
|
||||||
onClick={() => setRevealed(!revealed)}
|
|
||||||
className="flex items-center gap-2 px-5 py-2.5 rounded-full font-bold text-sm transition-all"
|
|
||||||
style={{
|
|
||||||
background: revealed ? "#1f2937" : "#b91c1c",
|
|
||||||
color: "#fff",
|
|
||||||
boxShadow: revealed ? "none" : "0 0 30px rgba(185,28,28,0.4)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{revealed ? <EyeOff size={16} /> : <ChevronDown size={16} />}
|
|
||||||
{revealed ? t('iceberg.hideBtn') : t('iceberg.revealBtn')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Hidden Costs */}
|
|
||||||
<div style={{
|
|
||||||
maxHeight: revealed ? "2000px" : "0px",
|
|
||||||
opacity: revealed ? 1 : 0,
|
|
||||||
overflow: "hidden",
|
|
||||||
transition: "max-height 0.8s ease, opacity 0.5s ease",
|
|
||||||
}}>
|
|
||||||
<div className="flex items-center gap-2 mb-4">
|
|
||||||
<EyeOff size={18} style={{ color: "#ef4444" }} />
|
|
||||||
<span className="text-sm font-bold uppercase tracking-wider" style={{ color: "#ef4444" }}>
|
|
||||||
{t('iceberg.hiddenLabel')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{hiddenItems.map((item, i) => {
|
|
||||||
const Icon = iconMap[item.icon];
|
|
||||||
const visible = i < visibleCount;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className={`rounded-xl p-4 md:p-5 border flex items-start gap-4 ${severityColor[item.severity]}`}
|
|
||||||
style={{
|
|
||||||
opacity: visible ? 1 : 0,
|
|
||||||
transform: visible ? "translateY(0)" : "translateY(12px)",
|
|
||||||
transition: "opacity 0.35s ease, transform 0.35s ease",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0"
|
|
||||||
style={{ background: "rgba(255,255,255,0.05)" }}>
|
|
||||||
<Icon size={20} style={{ color: "#f87171" }} />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-2 mb-0.5">
|
|
||||||
<span className="font-bold">{item.label}</span>
|
|
||||||
<span className={`w-2 h-2 rounded-full ${severityDot[item.severity]}`} />
|
|
||||||
</div>
|
|
||||||
<div className="text-sm" style={{ color: "#9ca3af" }}>{item.desc}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Summary */}
|
|
||||||
{visibleCount >= hiddenItems.length && (
|
|
||||||
<div className="mt-6 rounded-xl p-5 border text-center"
|
|
||||||
style={{
|
|
||||||
borderColor: "#ef4444",
|
|
||||||
background: "rgba(239,68,68,0.08)",
|
|
||||||
animation: "fadeIn 0.5s ease",
|
|
||||||
}}>
|
|
||||||
<div className="text-2xl md:text-3xl font-black" style={{ color: "#ef4444" }}>
|
|
||||||
10+ skrytých nákladov
|
|
||||||
</div>
|
|
||||||
<div className="text-sm mt-1" style={{ color: "#9ca3af" }}>
|
|
||||||
...a to sme ešte nezačali riešiť škálovanie.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* AhojSvet - Clean Side */}
|
|
||||||
<div className="lg:sticky lg:top-32">
|
|
||||||
<div className="rounded-2xl p-6 md:p-8 border-4" style={{ background: "#f0fdf4", borderColor: "#16a34a" }}>
|
|
||||||
<div className="text-center mb-8">
|
|
||||||
<div className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full text-sm font-bold mb-4"
|
|
||||||
style={{ background: "#16a34a", color: "#fff" }}>
|
|
||||||
Bez prekvapení
|
|
||||||
</div>
|
|
||||||
<h3 className="text-3xl font-black" style={{ color: "#111827" }}>{t('iceberg.ahojsvet.title')}</h3>
|
|
||||||
<p className="text-base mt-1" style={{ color: "#6b7280" }}>{t('iceberg.ahojsvet.subtitle')}</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-5">
|
|
||||||
{ahojsvetPoints.map((p, i) => (
|
|
||||||
<div key={i} className="flex items-start gap-3">
|
|
||||||
<div className="w-6 h-6 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5"
|
|
||||||
style={{ background: "#16a34a" }}>
|
|
||||||
<Check size={14} color="#fff" strokeWidth={3} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="font-bold" style={{ color: "#111827" }}>{p.label}</div>
|
|
||||||
<div className="text-sm" style={{ color: "#6b7280" }}>{p.desc}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="mt-8 text-center">
|
|
||||||
<a href="#contact"
|
|
||||||
className="inline-block px-8 py-4 rounded-xl font-bold text-lg transition-all"
|
|
||||||
style={{
|
|
||||||
background: "#b91c1c", color: "#fff",
|
|
||||||
boxShadow: "0 0 40px rgba(185,28,28,0.3)",
|
|
||||||
}}>
|
|
||||||
{t('iceberg.ahojsvet.cta')}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>{`
|
|
||||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
|
|
||||||
`}</style>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
287
src/components/HiddenCostsIcebergSection.tsx
Normal file
287
src/components/HiddenCostsIcebergSection.tsx
Normal file
|
|
@ -0,0 +1,287 @@
|
||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Eye, EyeOff, ChevronDown, Puzzle, RefreshCw, Shield,
|
||||||
|
CreditCard, Code2, Cookie, BarChart3, Mail, AlertTriangle, Clock, Check, HelpCircle,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import Gravity, { MatterBody } from "./fancy/physics/gravity";
|
||||||
|
import { useInView } from 'motion/react';
|
||||||
|
import ScrambleIn, { ScrambleInHandle } from '@/components/fancy/text/scramble-in';
|
||||||
|
|
||||||
|
// ─── maps ────────────────────────────────────────────────────────────────────
|
||||||
|
const iconMap: Record<string, React.ElementType> = {
|
||||||
|
setup: CreditCard, theme: Code2, plugin: Puzzle, hosting: BarChart3,
|
||||||
|
maintenance: RefreshCw, conflict: AlertTriangle, security: Shield,
|
||||||
|
change: Clock, cookie: Cookie, analytics: BarChart3, email: Mail, dev: Code2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const severityBorder: Record<number, string> = {
|
||||||
|
1: "border-yellow-500/40 bg-yellow-500/5",
|
||||||
|
2: "border-orange-500/40 bg-orange-500/5",
|
||||||
|
3: "border-red-500/40 bg-red-500/5",
|
||||||
|
};
|
||||||
|
|
||||||
|
const severityDotColor: Record<number, string> = {
|
||||||
|
1: "#eab308",
|
||||||
|
2: "#f97316",
|
||||||
|
3: "#ef4444",
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const staggered = (index: number, total: number) => {
|
||||||
|
const cols = 3;
|
||||||
|
const col = index % cols;
|
||||||
|
const row = Math.floor(index / cols);
|
||||||
|
return {
|
||||||
|
x: `${15 + col * 30}%`, // 15%, 45%, 75%
|
||||||
|
y: `${4 + row * 9}%`, // start inside the box, spaced vertically
|
||||||
|
angle: (index % 2 === 0 ? -1 : 1) * (3 + (index % 5)),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/* ── Inside the revealed && (...) block ── */
|
||||||
|
|
||||||
|
// ─── component ───────────────────────────────────────────────────────────────
|
||||||
|
export default function CostIceberg() {
|
||||||
|
const [revealed, setRevealed] = useState(false);
|
||||||
|
const [gravityKey, setGravityKey] = useState(0);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const tipItems = t("iceberg.tip", { returnObjects: true }) as any[];
|
||||||
|
const hiddenItems = t("iceberg.hidden", { returnObjects: true }) as any[];
|
||||||
|
const ahojsvetPts = t("iceberg.ahojsvet.points", { returnObjects: true }) as any[];
|
||||||
|
|
||||||
|
const h2Ref = useRef<HTMLHeadingElement>(null);
|
||||||
|
const scrambleRef = useRef<ScrambleInHandle>(null);
|
||||||
|
const isH2InView = useInView(h2Ref, { once: true });
|
||||||
|
useEffect(() => {
|
||||||
|
if (isH2InView) scrambleRef.current?.start();
|
||||||
|
}, [isH2InView]);
|
||||||
|
|
||||||
|
const handleToggle = () => {
|
||||||
|
if (!revealed) {
|
||||||
|
setGravityKey((k) => k + 1);
|
||||||
|
setRevealed(true);
|
||||||
|
} else {
|
||||||
|
setRevealed(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="py-20 md:py-32 text-white" style={{ background: "#111827" }}>
|
||||||
|
<div className="max-w-6xl mx-auto px-4 md:px-6">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center mb-16 md:mb-20">
|
||||||
|
<h2
|
||||||
|
ref={h2Ref}
|
||||||
|
className="text-4xl md:text-6xl font-black mb-4"
|
||||||
|
style={{ fontFamily: "'Work Sans', sans-serif" }}
|
||||||
|
>
|
||||||
|
<ScrambleIn ref={scrambleRef} text={t("iceberg.title")} autoStart={false} />
|
||||||
|
</h2>
|
||||||
|
<p className="text-lg md:text-2xl font-medium" style={{ color: "#f87171" }}>
|
||||||
|
{t("iceberg.subtitle")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid lg:grid-cols-2 gap-8 md:gap-12 items-start">
|
||||||
|
|
||||||
|
{/* ── WordPress Iceberg (physics side) ─────────────────────────── */}
|
||||||
|
<div>
|
||||||
|
|
||||||
|
{/* Visible tip items */}
|
||||||
|
<div className="mb-2">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<Eye size={18} style={{ color: "#9ca3af" }} />
|
||||||
|
<span
|
||||||
|
className="text-sm font-bold uppercase tracking-wider"
|
||||||
|
style={{ color: "#9ca3af" }}
|
||||||
|
>
|
||||||
|
{t("iceberg.tipLabel")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{tipItems.map((item, i) => {
|
||||||
|
const Icon = iconMap[item.icon] ?? HelpCircle;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="rounded-xl p-4 md:p-5 border flex items-start gap-4"
|
||||||
|
style={{ background: "#1f2937", borderColor: "#374151" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||||
|
style={{ background: "#374151" }}
|
||||||
|
>
|
||||||
|
<Icon size={20} style={{ color: "#9ca3af" }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-bold">{item.label}</div>
|
||||||
|
<div className="text-sm" style={{ color: "#9ca3af" }}>{item.desc}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Waterline + toggle button */}
|
||||||
|
<div className="relative my-6">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<div
|
||||||
|
className="w-full border-t-2 border-dashed"
|
||||||
|
style={{ borderColor: "#3b82f6", opacity: 0.4 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center">
|
||||||
|
<button
|
||||||
|
onClick={handleToggle}
|
||||||
|
className="flex items-center gap-2 px-5 py-2.5 rounded-full font-bold text-sm transition-all"
|
||||||
|
style={{
|
||||||
|
background: revealed ? "#1f2937" : "#b91c1c",
|
||||||
|
color: "#fff",
|
||||||
|
boxShadow: revealed ? "none" : "0 0 30px rgba(185,28,28,0.4)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{revealed ? <EyeOff size={16} /> : <ChevronDown size={16} />}
|
||||||
|
{revealed ? t("iceberg.hideBtn") : t("iceberg.revealBtn")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Physics drop zone ────────────────────────────────────── */}
|
||||||
|
{revealed && (
|
||||||
|
<div
|
||||||
|
className="relative rounded-2xl overflow-hidden"
|
||||||
|
style={{
|
||||||
|
// scale height to item count so there's room to breathe
|
||||||
|
height: Math.max(520, hiddenItems.length * 64),
|
||||||
|
background: "rgba(239,68,68,0.04)",
|
||||||
|
border: "1px solid rgba(239,68,68,0.15)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Label */}
|
||||||
|
<div className="absolute top-3 left-4 flex items-center gap-2 z-10 pointer-events-none">
|
||||||
|
<EyeOff size={16} style={{ color: "#ef4444" }} />
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider" style={{ color: "#ef4444" }}>
|
||||||
|
{t("iceberg.hiddenLabel")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Gravity
|
||||||
|
key={gravityKey}
|
||||||
|
gravity={{ x: 0, y: 1.6 }}
|
||||||
|
className="w-full h-full"
|
||||||
|
grabCursor
|
||||||
|
// ❌ no resetOnResize — this was causing the scroll-triggered rescatter
|
||||||
|
>
|
||||||
|
{hiddenItems.map((item: any, i: number) => {
|
||||||
|
const Icon = iconMap[item.icon] ?? HelpCircle;
|
||||||
|
const pos = staggered(i, hiddenItems.length);
|
||||||
|
return (
|
||||||
|
<MatterBody
|
||||||
|
key={i}
|
||||||
|
x={pos.x}
|
||||||
|
y={pos.y}
|
||||||
|
angle={pos.angle}
|
||||||
|
matterBodyOptions={{
|
||||||
|
friction: 0.9, // was 0.6 — higher = settle faster
|
||||||
|
restitution: 0.15, // was 0.25 — less bouncy
|
||||||
|
density: 0.004, // was 0.002 — heavier = harder to push around
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
rounded-lg border flex items-start gap-2
|
||||||
|
cursor-grab active:cursor-grabbing select-none
|
||||||
|
${severityBorder[item.severity] ?? severityBorder[1]}
|
||||||
|
`}
|
||||||
|
style={{ padding: "8px 12px", maxWidth: 200, minWidth: 140 }}
|
||||||
|
>
|
||||||
|
{/* Severity dot */}
|
||||||
|
<span
|
||||||
|
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||||
|
style={{ background: severityDotColor[item.severity] ?? "#eab308" }}
|
||||||
|
/>
|
||||||
|
<Icon size={14} style={{ color: "#f87171", flexShrink: 0 }} />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div
|
||||||
|
className="font-bold text-white leading-tight"
|
||||||
|
style={{ fontSize: 14 }}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="leading-snug"
|
||||||
|
style={{ fontSize: 10, color: "#9ca3af", marginTop: 1 }}
|
||||||
|
>
|
||||||
|
{item.desc}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MatterBody>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Gravity>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{/* ── AhojSvet side ────────────────────────────────────────────── */}
|
||||||
|
<div className="lg:sticky lg:top-32">
|
||||||
|
<div
|
||||||
|
className="rounded-2xl p-6 md:p-8 border-4"
|
||||||
|
style={{ background: "#5ac37a45", borderColor: "rgba(19, 141, 64, 0.77)" }}
|
||||||
|
>
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full text-sm font-bold mb-4"
|
||||||
|
style={{ background: "#16a34a", color: "#fff" }}
|
||||||
|
>
|
||||||
|
{t("iceberg.ahojsvet.badge", { defaultValue: "Bez prekvapení" })}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-3xl font-black" style={{ color: "#fff" }}>
|
||||||
|
{t("iceberg.ahojsvet.title")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-base mt-1" style={{ color: "#6b7280" }}>
|
||||||
|
{t("iceberg.ahojsvet.subtitle")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-5">
|
||||||
|
{ahojsvetPts.map((p: any, i: number) => (
|
||||||
|
<div key={i} className="flex items-start gap-3">
|
||||||
|
<div
|
||||||
|
className="w-6 h-6 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5"
|
||||||
|
style={{ background: "#16a34a" }}
|
||||||
|
>
|
||||||
|
<Check size={14} color="#fff" strokeWidth={3} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-bold" style={{ color: "#fff" }}>{p.label}</div>
|
||||||
|
<div className="text-sm" style={{ color: "#bac1d0ce" }}>{p.desc}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-8 text-center">
|
||||||
|
<a
|
||||||
|
href="#contact"
|
||||||
|
className="inline-block px-8 py-4 rounded-xl font-bold text-lg transition-all"
|
||||||
|
style={{
|
||||||
|
background: "#b91c1c",
|
||||||
|
color: "#fff",
|
||||||
|
boxShadow: "0 0 40px rgba(185,28,28,0.3)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("iceberg.ahojsvet.cta")}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { ExternalLink, ChevronRight } from 'lucide-react';
|
|
||||||
|
|
||||||
export default function InTheWild() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const wild = t('wild', { returnObjects: true });
|
|
||||||
|
|
||||||
const projects = [
|
|
||||||
{ name: 'Projekt 1', desc: 'Pridaj sem svoj projekt', url: '#', placeholder: true },
|
|
||||||
{ name: 'Projekt 2', desc: 'Pridaj sem svoj projekt', url: '#', placeholder: true }
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section id="wild" className="py-32 bg-white text-gray-900">
|
|
||||||
<div className="max-w-6xl mx-auto px-6">
|
|
||||||
<div className="text-center mb-16">
|
|
||||||
<h2 className="text-4xl md:text-6xl font-black mb-4">
|
|
||||||
{wild.title}
|
|
||||||
</h2>
|
|
||||||
<p className="text-xl text-gray-500">
|
|
||||||
{wild.subtitle}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid md:grid-cols-2 gap-8 max-w-4xl mx-auto mb-12">
|
|
||||||
{projects.map((project, i) => (
|
|
||||||
<a
|
|
||||||
key={i}
|
|
||||||
href={project.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="card-hover bg-gray-100 rounded-2xl overflow-hidden border border-gray-200 block"
|
|
||||||
>
|
|
||||||
<div className="aspect-video bg-gray-200 flex items-center justify-center">
|
|
||||||
{project.placeholder ? (
|
|
||||||
<ExternalLink size={32} className="text-gray-400" />
|
|
||||||
) : (
|
|
||||||
<img src={project.image} alt={project.name} className="w-full h-full object-cover" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="p-6">
|
|
||||||
<h3 className="font-bold text-lg mb-1">{project.name}</h3>
|
|
||||||
<p className="text-gray-500 text-sm">{project.desc}</p>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center">
|
|
||||||
<a
|
|
||||||
href="mailto:kontakt@ahojsvet.eu"
|
|
||||||
className="inline-flex items-center gap-2 text-red-700 font-bold hover:underline"
|
|
||||||
>
|
|
||||||
{wild.cta} <ChevronRight size={20} />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
133
src/components/InTheWild.tsx
Normal file
133
src/components/InTheWild.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
import { useRef, useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { ExternalLink, ChevronRight } from 'lucide-react';
|
||||||
|
import Floating, { FloatingElement } from './fancy/image/parallax-floating';
|
||||||
|
import VerticalCutReveal from './fancy/text/vertical-cut-reveal';
|
||||||
|
import { useInView } from 'motion/react';
|
||||||
|
import ScrambleIn, { ScrambleInHandle } from '@/components/fancy/text/scramble-in';
|
||||||
|
|
||||||
|
// ── types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface WildTranslation {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
cta: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Project {
|
||||||
|
name: string;
|
||||||
|
desc: string;
|
||||||
|
url: string;
|
||||||
|
image?: string;
|
||||||
|
placeholder?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── component ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const projects: Project[] = [
|
||||||
|
{ name: 'Projekt 1', desc: 'Pridaj sem svoj projekt', url: '#', placeholder: true },
|
||||||
|
{ name: 'Projekt 2', desc: 'Pridaj sem svoj projekt', url: '#', placeholder: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Alternating depths: card 1 moves toward cursor, card 2 gently away.
|
||||||
|
// This pulls the two cards apart on hover giving a parallax depth illusion.
|
||||||
|
const DEPTHS: number[] = [0.8, -0.5];
|
||||||
|
|
||||||
|
export default function InTheWild() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const wild = t('wild', { returnObjects: true }) as WildTranslation;
|
||||||
|
|
||||||
|
const h2Ref = useRef<HTMLHeadingElement>(null);
|
||||||
|
const scrambleRef = useRef<ScrambleInHandle>(null);
|
||||||
|
const isH2InView = useInView(h2Ref, { once: true });
|
||||||
|
useEffect(() => {
|
||||||
|
if (isH2InView) scrambleRef.current?.start();
|
||||||
|
}, [isH2InView]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="wild" className="py-32 bg-white text-gray-900">
|
||||||
|
<div className="max-w-6xl mx-auto px-6">
|
||||||
|
|
||||||
|
{/* ── Header ─────────────────────────────────────────────────── */}
|
||||||
|
<div className="text-center mb-16">
|
||||||
|
<h2 ref={h2Ref} className="text-4xl md:text-6xl font-black mb-4">
|
||||||
|
<ScrambleIn ref={scrambleRef} text={wild.title} autoStart={false} />
|
||||||
|
</h2>
|
||||||
|
<p className="text-xl text-gray-500">
|
||||||
|
<VerticalCutReveal
|
||||||
|
text={wild.subtitle}
|
||||||
|
splitBy="words"
|
||||||
|
staggerFrom="first"
|
||||||
|
staggerDuration={0.04}
|
||||||
|
transition={{ type: 'spring', damping: 26, stiffness: 140, delay: 0.3 }}
|
||||||
|
autoStart
|
||||||
|
containerClassName="justify-center flex-wrap"
|
||||||
|
elementLevelClassName="pb-1"
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
── Parallax card grid ────────────────────────────────────────
|
||||||
|
Floating tracks the cursor across the entire grid container.
|
||||||
|
Each FloatingElement drifts at its own depth — card 1 moves
|
||||||
|
toward the cursor, card 2 slightly away — creating a tactile
|
||||||
|
sense of depth that rewards slow mouse movement.
|
||||||
|
|
||||||
|
sensitivity={0.06}: gentle enough not to feel seasick on fast
|
||||||
|
mouse sweeps but noticeable on deliberate hover.
|
||||||
|
easingFactor={0.04}: very smooth lag — elements trail the
|
||||||
|
cursor like they have real weight.
|
||||||
|
|
||||||
|
NOTE: FloatingElement does NOT change layout — it only applies
|
||||||
|
a CSS transform. The grid spacing is handled by the inner div.
|
||||||
|
*/}
|
||||||
|
<Floating
|
||||||
|
sensitivity={0.06}
|
||||||
|
easingFactor={0.04}
|
||||||
|
className="relative"
|
||||||
|
>
|
||||||
|
<div className="grid md:grid-cols-2 gap-8 max-w-4xl mx-auto mb-12">
|
||||||
|
{projects.map((project, i) => (
|
||||||
|
<FloatingElement key={i} depth={DEPTHS[i] ?? 0.5}>
|
||||||
|
<a
|
||||||
|
href={project.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="card-hover bg-gray-100 rounded-2xl overflow-hidden border border-gray-200 block transition-shadow hover:shadow-xl"
|
||||||
|
>
|
||||||
|
<div className="aspect-video bg-gray-200 flex items-center justify-center overflow-hidden">
|
||||||
|
{project.placeholder ? (
|
||||||
|
<ExternalLink size={32} className="text-gray-400" />
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={project.image}
|
||||||
|
alt={project.name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-6">
|
||||||
|
<h3 className="font-bold text-lg mb-1">{project.name}</h3>
|
||||||
|
<p className="text-gray-500 text-sm">{project.desc}</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</FloatingElement>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Floating>
|
||||||
|
|
||||||
|
{/* ── CTA link ────────────────────────────────────────────────── */}
|
||||||
|
<div className="text-center">
|
||||||
|
<a
|
||||||
|
href="mailto:kontakt@ahojsvet.eu"
|
||||||
|
className="inline-flex items-center gap-2 text-red-700 font-bold hover:underline"
|
||||||
|
>
|
||||||
|
{wild.cta} <ChevronRight size={20} />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
import { useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Menu, X } from 'lucide-react';
|
|
||||||
|
|
||||||
export default function Navigation({ scrollY }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<nav className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${scrollY > 50 ? 'bg-gray-900/95 backdrop-blur-sm' : ''}`}>
|
|
||||||
<div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-10 h-10 bg-red-700 rounded-lg flex items-center justify-center font-black text-xl">A</div>
|
|
||||||
<span className="font-extrabold text-xl text-white">AhojSvet</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="hidden md:flex items-center gap-8">
|
|
||||||
<a href="https://demo.ahojsvet.eu" className="flex items-center gap-2 bg-gray-800 hover:bg-gray-700 px-4 py-2 rounded-lg transition-colors font-medium text-white">
|
|
||||||
{t('nav.demo')}
|
|
||||||
</a>
|
|
||||||
<a href="https://navod.ahojsvet.eu" className="text-gray-400 hover:text-white transition-colors font-medium">
|
|
||||||
{t('nav.docs')}
|
|
||||||
</a>
|
|
||||||
<a href="#wild" className="text-gray-400 hover:text-white transition-colors font-medium">
|
|
||||||
{t('nav.wild')}
|
|
||||||
</a>
|
|
||||||
<a href="#contact" className="text-gray-400 hover:text-white transition-colors font-medium">
|
|
||||||
{t('nav.contact')}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button onClick={() => setMenuOpen(!menuOpen)} className="md:hidden p-2 text-white">
|
|
||||||
{menuOpen ? <X size={24} /> : <Menu size={24} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{menuOpen && (
|
|
||||||
<div className="md:hidden bg-gray-900 border-t border-gray-800 px-6 py-4 flex flex-col gap-4">
|
|
||||||
<a href="https://demo.ahojsvet.eu" className="text-gray-400 hover:text-white py-2">
|
|
||||||
{t('nav.demo')}
|
|
||||||
</a>
|
|
||||||
<a href="https://navod.ahojsvet.eu" className="text-gray-400 hover:text-white py-2">
|
|
||||||
{t('nav.docs')}
|
|
||||||
</a>
|
|
||||||
<a href="#wild" className="text-gray-400 hover:text-white py-2">
|
|
||||||
{t('nav.wild')}
|
|
||||||
</a>
|
|
||||||
<a href="#contact" className="text-gray-400 hover:text-white py-2">
|
|
||||||
{t('nav.contact')}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
132
src/components/Navigation.tsx
Normal file
132
src/components/Navigation.tsx
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Menu, X } from 'lucide-react';
|
||||||
|
|
||||||
|
// ── Simple SVG flags (4:3, 20×15) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
function FlagSK({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><mask id="SVGuywqVbel"><circle cx="256" cy="256" r="256" fill="#fff"/></mask><g mask="url(#SVGuywqVbel)"><path fill="#0052b4" d="m0 160l256-32l256 32v192l-256 32L0 352z"/><path fill="#eee" d="M0 0h512v160H0z"/><path fill="#d80027" d="M0 352h512v160H0z"/><path fill="#eee" d="M64 63v217c0 104 144 137 144 137s144-33 144-137V63z"/><path fill="#d80027" d="M96 95v185a83 78 0 0 0 9 34h206a83 77 0 0 0 9-34V95z"/><path fill="#eee" d="M288 224h-64v-32h32v-32h-32v-32h-32v32h-32v32h32v32h-64v32h64v32h32v-32h64z"/><path fill="#0052b4" d="M152 359a247 231 0 0 0 56 24c12-3 34-11 56-24a123 115 0 0 0 47-45a60 56 0 0 0-34-10l-14 2a60 56 0 0 0-110 0a60 56 0 0 0-14-2c-12 0-24 4-34 10a123 115 0 0 0 47 45"/></g></svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FlagCZ({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><mask id="SVGuywqVbel"><circle cx="256" cy="256" r="256" fill="#fff"/></mask><g mask="url(#SVGuywqVbel)"><path fill="#eee" d="M0 0h512v256l-265 45.2z"/><path fill="#d80027" d="M210 256h302v256H0z"/><path fill="#0052b4" d="M0 0v512l256-256z"/></g></svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FlagEN({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><mask id="circleFlagsEn0"><circle cx="256" cy="256" r="256" fill="#fff"/></mask><g mask="url(#circleFlagsEn0)"><path fill="#eee" d="m0 0l8 22l-8 23v23l32 54l-32 54v32l32 48l-32 48v32l32 54l-32 54v68l22-8l23 8h23l54-32l54 32h32l48-32l48 32h32l54-32l54 32h68l-8-22l8-23v-23l-32-54l32-54v-32l-32-48l32-48v-32l-32-54l32-54V0l-22 8l-23-8h-23l-54 32l-54-32h-32l-48 32l-48-32h-32l-54 32L68 0z"/><path fill="#0052b4" d="M336 0v108L444 0Zm176 68L404 176h108zM0 176h108L0 68ZM68 0l108 108V0Zm108 512V404L68 512ZM0 444l108-108H0Zm512-108H404l108 108Zm-68 176L336 404v108z"/><path fill="#d80027" d="M0 0v45l131 131h45zm208 0v208H0v96h208v208h96V304h208v-96H304V0zm259 0L336 131v45L512 0zM176 336L0 512h45l131-131zm160 0l176 176v-45L381 336z"/></g></svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const languages = [
|
||||||
|
{ code: 'sk', label: 'SK', Flag: FlagSK },
|
||||||
|
{ code: 'cs', label: 'CZ', Flag: FlagCZ },
|
||||||
|
{ code: 'en', label: 'EN', Flag: FlagEN },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// ── Language switcher ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function LanguageSwitcher() {
|
||||||
|
const { i18n } = useTranslation();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const current = languages.find(l => l.code === i18n.language) ?? languages[0];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClick(e: MouseEvent) {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClick);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClick);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
className="flex items-center gap-1.5 px-2 py-1.5 rounded-lg bg-gray-800 hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
<current.Flag className="w-5 h-auto" />
|
||||||
|
<span className="text-xs font-bold text-gray-300">{current.label}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="absolute right-0 mt-1 bg-gray-800 border border-gray-700 rounded-lg overflow-hidden shadow-lg">
|
||||||
|
{languages.filter(l => l.code !== i18n.language).map(({ code, label, Flag }) => (
|
||||||
|
<button
|
||||||
|
key={code}
|
||||||
|
onClick={() => { i18n.changeLanguage(code); setOpen(false); }}
|
||||||
|
className="flex items-center gap-2 w-full px-3 py-2 hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Flag className="w-5 h-auto" />
|
||||||
|
<span className="text-xs font-bold text-gray-300">{label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Navigation ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function Navigation({ scrollY }: { scrollY: number }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${scrollY > 50 ? 'bg-gray-900/95 backdrop-blur-sm' : ''}`}>
|
||||||
|
<div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-10 h-10 bg-red-700 rounded-lg flex items-center justify-center font-black text-xl">A</div>
|
||||||
|
<span className="font-extrabold text-xl text-white">AhojSvet</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hidden md:flex items-center gap-8">
|
||||||
|
<a href="https://demo.ahojsvet.eu" className="flex items-center gap-2 bg-gray-800 hover:bg-gray-700 px-4 py-2 rounded-lg transition-colors font-medium text-white">
|
||||||
|
{t('nav.demo')}
|
||||||
|
</a>
|
||||||
|
<a href="https://navod.ahojsvet.eu" className="text-gray-400 hover:text-white transition-colors font-medium">
|
||||||
|
{t('nav.docs')}
|
||||||
|
</a>
|
||||||
|
<a href="#wild" className="text-gray-400 hover:text-white transition-colors font-medium">
|
||||||
|
{t('nav.wild')}
|
||||||
|
</a>
|
||||||
|
<a href="#contact" className="text-gray-400 hover:text-white transition-colors font-medium">
|
||||||
|
{t('nav.contact')}
|
||||||
|
</a>
|
||||||
|
<LanguageSwitcher />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 md:hidden">
|
||||||
|
<LanguageSwitcher />
|
||||||
|
<button onClick={() => setMenuOpen(!menuOpen)} className="p-2 text-white">
|
||||||
|
{menuOpen ? <X size={24} /> : <Menu size={24} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{menuOpen && (
|
||||||
|
<div className="md:hidden bg-gray-900 border-t border-gray-800 px-6 py-4 flex flex-col gap-4">
|
||||||
|
<a href="https://demo.ahojsvet.eu" className="text-gray-400 hover:text-white py-2">
|
||||||
|
{t('nav.demo')}
|
||||||
|
</a>
|
||||||
|
<a href="https://navod.ahojsvet.eu" className="text-gray-400 hover:text-white py-2">
|
||||||
|
{t('nav.docs')}
|
||||||
|
</a>
|
||||||
|
<a href="#wild" className="text-gray-400 hover:text-white py-2">
|
||||||
|
{t('nav.wild')}
|
||||||
|
</a>
|
||||||
|
<a href="#contact" className="text-gray-400 hover:text-white py-2">
|
||||||
|
{t('nav.contact')}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
import { useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { BarChart3, ShoppingBag, FileText, Blocks } from 'lucide-react';
|
|
||||||
|
|
||||||
export default function Screenshots() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [activeTab, setActiveTab] = useState(0);
|
|
||||||
const tabs = t('screenshots.tabs', { returnObjects: true });
|
|
||||||
|
|
||||||
const iconMap = {
|
|
||||||
0: BarChart3,
|
|
||||||
1: ShoppingBag,
|
|
||||||
2: FileText,
|
|
||||||
3: Blocks
|
|
||||||
};
|
|
||||||
|
|
||||||
const CurrentIcon = iconMap[activeTab];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="py-32 bg-gray-900">
|
|
||||||
<div className="max-w-6xl mx-auto px-6">
|
|
||||||
<h2 className="text-4xl md:text-6xl font-black text-center mb-16 text-white">
|
|
||||||
{t('screenshots.title')}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 justify-center gap-2 mb-8">
|
|
||||||
{tabs.map((tab, i) => (
|
|
||||||
<button
|
|
||||||
key={i}
|
|
||||||
onClick={() => setActiveTab(i)}
|
|
||||||
className={`px-6 py-3 rounded-lg font-bold transition-all ${
|
|
||||||
activeTab === i
|
|
||||||
? 'bg-red-700 text-white'
|
|
||||||
: 'bg-gray-800 text-gray-400 hover:bg-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{tab}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-gray-800 rounded-2xl p-4 glow">
|
|
||||||
<div className="bg-gray-950 rounded-xl aspect-video flex items-center justify-center">
|
|
||||||
<div className="text-center">
|
|
||||||
<CurrentIcon size={64} className="mx-auto mb-4 text-red-500" />
|
|
||||||
<p className="text-gray-500 font-medium text-lg">
|
|
||||||
{tabs[activeTab]} screenshot
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
60
src/components/Screenshots.tsx
Normal file
60
src/components/Screenshots.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useInView } from 'motion/react';
|
||||||
|
import ScrambleIn, { ScrambleInHandle } from '@/components/fancy/text/scramble-in';
|
||||||
|
|
||||||
|
export default function Screenshots() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [activeTab, setActiveTab] = useState(0);
|
||||||
|
const tabs = t('screenshots.tabs', { returnObjects: true }) as string[];
|
||||||
|
|
||||||
|
const h2Ref = useRef<HTMLHeadingElement>(null);
|
||||||
|
const scrambleRef = useRef<ScrambleInHandle>(null);
|
||||||
|
const isH2InView = useInView(h2Ref, { once: true });
|
||||||
|
useEffect(() => {
|
||||||
|
if (isH2InView) scrambleRef.current?.start();
|
||||||
|
}, [isH2InView]);
|
||||||
|
|
||||||
|
const imageMap: Record<number, string> = {
|
||||||
|
0: '/screenshots/admin.png',
|
||||||
|
1: '/screenshots/obchod.png',
|
||||||
|
2: '/screenshots/objednavky.png',
|
||||||
|
3: '/screenshots/cms.png',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="py-32 bg-gray-900">
|
||||||
|
<div className="max-w-6xl mx-auto px-6">
|
||||||
|
<h2 ref={h2Ref} className="text-4xl md:text-6xl font-black text-center mb-16 text-white">
|
||||||
|
<ScrambleIn ref={scrambleRef} text={t('screenshots.title')} autoStart={false} />
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 justify-center gap-2 mb-8">
|
||||||
|
{tabs.map((tab, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => setActiveTab(i)}
|
||||||
|
className={`px-6 py-3 rounded-lg font-bold transition-all ${
|
||||||
|
activeTab === i
|
||||||
|
? 'bg-red-700 text-white'
|
||||||
|
: 'bg-gray-800 text-gray-400 hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-800 rounded-2xl p-4 glow">
|
||||||
|
<div className="bg-gray-950 rounded-xl aspect-video overflow-hidden">
|
||||||
|
<img
|
||||||
|
src={imageMap[activeTab]}
|
||||||
|
alt={tabs[activeTab]}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
import { useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { speedData } from '../constants/speedData';
|
|
||||||
|
|
||||||
export default function SpeedComparison() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [network, setNetwork] = useState('WiFi');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="py-32 bg-gray-900">
|
|
||||||
<div className="max-w-6xl mx-auto px-6">
|
|
||||||
<div className="text-center mb-16">
|
|
||||||
<h2 className="text-4xl md:text-6xl font-black mb-4 text-white">
|
|
||||||
{t('speed.title')}
|
|
||||||
</h2>
|
|
||||||
<p className="text-xl text-gray-400 mb-8">
|
|
||||||
{t('speed.subtitle')}
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap justify-center gap-3 mb-12">
|
|
||||||
<span className="text-gray-400 font-medium self-center">{t('speed.selector')}</span>
|
|
||||||
{t('speed.networks', { returnObjects: true }).map(net => (
|
|
||||||
<button
|
|
||||||
key={net}
|
|
||||||
onClick={() => setNetwork(net)}
|
|
||||||
className={`px-4 py-2 rounded-lg font-bold transition-all ${
|
|
||||||
network === net
|
|
||||||
? 'bg-red-700 text-white'
|
|
||||||
: 'bg-gray-800 text-gray-400 hover:bg-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{net}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-8">
|
|
||||||
{Object.entries(t('speed.metrics', { returnObjects: true })).map(([key, label]) => {
|
|
||||||
const asData = speedData[network].ahojsvet[key];
|
|
||||||
const wpData = speedData[network].wordpress[key];
|
|
||||||
const percentFaster = Math.round((1 - asData[0] / wpData[0]) * 100);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={key} className="bg-gray-800 rounded-2xl p-6">
|
|
||||||
<h3 className="text-2xl font-bold mb-6 text-center text-white">{label}</h3>
|
|
||||||
<div className="grid md:grid-cols-2 gap-6">
|
|
||||||
<div className="bg-green-950 border-2 border-green-700 rounded-xl p-6">
|
|
||||||
<div className="text-sm font-bold text-green-400 mb-3">AhojSvet</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-400">Load time:</span>
|
|
||||||
<span className="font-bold text-white">{asData[0]}{t('speed.units.time')}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-400">Data:</span>
|
|
||||||
<span className="font-bold text-white">{asData[1]}{t('speed.units.size')}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-400">Requests:</span>
|
|
||||||
<span className="font-bold text-white">{asData[2]} {t('speed.units.requests')}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-gray-900 border-2 border-gray-700 rounded-xl p-6">
|
|
||||||
<div className="text-sm font-bold text-gray-500 mb-3">WordPress + WooCommerce</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-500">Load time:</span>
|
|
||||||
<span className="font-bold text-gray-400">{wpData[0]}{t('speed.units.time')}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-500">Data:</span>
|
|
||||||
<span className="font-bold text-gray-400">{wpData[1]}{t('speed.units.size')}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-500">Requests:</span>
|
|
||||||
<span className="font-bold text-gray-400">{wpData[2]} {t('speed.units.requests')}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 text-center">
|
|
||||||
<span className="inline-block bg-red-700 text-white px-4 py-2 rounded-lg font-bold">
|
|
||||||
{percentFaster}% rýchlejšie
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
223
src/components/SpeedComparison.tsx
Normal file
223
src/components/SpeedComparison.tsx
Normal file
|
|
@ -0,0 +1,223 @@
|
||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { motion, useInView } from 'motion/react';
|
||||||
|
import ScrambleIn, { ScrambleInHandle } from '@/components/fancy/text/scramble-in';
|
||||||
|
import VerticalCutReveal from '@/components/fancy/text/vertical-cut-reveal';
|
||||||
|
import { ChevronDown, EyeOff } from 'lucide-react';
|
||||||
|
import { speedData } from '../constants/speedData';
|
||||||
|
|
||||||
|
function MetricCard({ label, asData, wpData, percentFaster, t }: {
|
||||||
|
label: string;
|
||||||
|
asData: number[];
|
||||||
|
wpData: number[];
|
||||||
|
percentFaster: number;
|
||||||
|
t: (key: string) => string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-800 rounded-2xl p-6">
|
||||||
|
<h3 className="text-2xl font-bold mb-6 text-center text-white">{label}</h3>
|
||||||
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
|
<div className="bg-green-950 border-2 border-green-700 rounded-xl p-6">
|
||||||
|
<div className="text-sm font-bold text-green-400 mb-3">AhojSvet</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-400">Load time:</span>
|
||||||
|
<span className="font-bold text-white">{asData[0]}{t('speed.units.time')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-400">Data:</span>
|
||||||
|
<span className="font-bold text-white">{asData[1]}{t('speed.units.size')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-400">Requests:</span>
|
||||||
|
<span className="font-bold text-white">{asData[2]} {t('speed.units.requests')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-900 border-2 border-gray-700 rounded-xl p-6">
|
||||||
|
<div className="text-sm font-bold text-gray-500 mb-3">WordPress + WooCommerce</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-500">Load time:</span>
|
||||||
|
<span className="font-bold text-gray-400">{wpData[0]}{t('speed.units.time')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-500">Data:</span>
|
||||||
|
<span className="font-bold text-gray-400">{wpData[1]}{t('speed.units.size')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-500">Requests:</span>
|
||||||
|
<span className="font-bold text-gray-400">{wpData[2]} {t('speed.units.requests')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 text-center">
|
||||||
|
<span className="inline-block bg-red-700 text-white px-4 py-2 rounded-lg font-bold">
|
||||||
|
{percentFaster}% rýchlejšie
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SpeedComparison() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [network, setNetwork] = useState('WiFi');
|
||||||
|
const [revealed, setRevealed] = useState(false);
|
||||||
|
|
||||||
|
const metrics = Object.entries(t('speed.metrics', { returnObjects: true }) as Record<string, string>);
|
||||||
|
|
||||||
|
const h2Ref = useRef<HTMLHeadingElement>(null);
|
||||||
|
const scrambleRef = useRef<ScrambleInHandle>(null);
|
||||||
|
const isH2InView = useInView(h2Ref, { once: true });
|
||||||
|
useEffect(() => {
|
||||||
|
if (isH2InView) scrambleRef.current?.start();
|
||||||
|
}, [isH2InView]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="py-32 bg-gray-900">
|
||||||
|
<div className="max-w-6xl mx-auto px-6">
|
||||||
|
<div className="text-center mb-16">
|
||||||
|
<h2 ref={h2Ref} className="text-4xl md:text-6xl font-black mb-4 text-white">
|
||||||
|
<ScrambleIn ref={scrambleRef} text={t('speed.title')} autoStart={false} />
|
||||||
|
</h2>
|
||||||
|
<p className="text-xl text-gray-400 mb-8">
|
||||||
|
<VerticalCutReveal
|
||||||
|
splitBy="words"
|
||||||
|
staggerFrom="first"
|
||||||
|
staggerDuration={0.04}
|
||||||
|
transition={{
|
||||||
|
type: 'spring',
|
||||||
|
damping: 26,
|
||||||
|
stiffness: 140,
|
||||||
|
delay: 0.35,
|
||||||
|
}}
|
||||||
|
autoStart
|
||||||
|
containerClassName="justify-center flex-wrap"
|
||||||
|
elementLevelClassName="pb-1"
|
||||||
|
>
|
||||||
|
{t('speed.subtitle')}
|
||||||
|
</VerticalCutReveal>
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mb-12">
|
||||||
|
<span className="text-gray-400 font-medium self-center">{t('speed.selector')}</span>
|
||||||
|
{(t('speed.networks', { returnObjects: true }) as string[]).map(net => (
|
||||||
|
<button
|
||||||
|
key={net}
|
||||||
|
onClick={() => setNetwork(net)}
|
||||||
|
className={`px-4 py-2 rounded-lg font-bold transition-all ${
|
||||||
|
network === net
|
||||||
|
? 'bg-red-700 text-white'
|
||||||
|
: 'bg-gray-800 text-gray-400 hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{net}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<div className="grid gap-8">
|
||||||
|
{metrics.slice(0, 2).map(([key, label]) => {
|
||||||
|
const asData = speedData[network].ahojsvet[key];
|
||||||
|
const wpData = speedData[network].wordpress[key];
|
||||||
|
const percentFaster = Math.round((1 - asData[0] / wpData[0]) * 100);
|
||||||
|
return (
|
||||||
|
<MetricCard key={key} label={label} asData={asData} wpData={wpData} percentFaster={percentFaster} t={t} />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<motion.div
|
||||||
|
initial={false}
|
||||||
|
animate={{ height: revealed ? 'auto' : 0 }}
|
||||||
|
transition={{ duration: 0.55, ease: [0.04, 0.62, 0.23, 0.98] }}
|
||||||
|
style={{ overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
<div className="grid gap-8">
|
||||||
|
{metrics.slice(2).map(([key, label], i) => {
|
||||||
|
const asData = speedData[network].ahojsvet[key];
|
||||||
|
const wpData = speedData[network].wordpress[key];
|
||||||
|
const percentFaster = Math.round((1 - asData[0] / wpData[0]) * 100);
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={key}
|
||||||
|
initial={{ opacity: 0, y: 24 }}
|
||||||
|
animate={revealed ? { opacity: 1, y: 0 } : { opacity: 0, y: 24 }}
|
||||||
|
transition={{ duration: 0.4, delay: revealed ? i * 0.08 : 0, ease: 'easeOut' }}
|
||||||
|
>
|
||||||
|
<MetricCard label={label} asData={asData} wpData={wpData} percentFaster={percentFaster} t={t} />
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Gradient overlay + reveal button */}
|
||||||
|
{!revealed && (
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 flex flex-col items-center"
|
||||||
|
style={{ height: '40%', pointerEvents: 'none' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full flex-1"
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(to bottom, transparent 0%, rgb(17 24 39) 70%)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="w-full bg-gray-900 pb-4" style={{ pointerEvents: 'auto' }}>
|
||||||
|
<div className="relative my-2">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<div
|
||||||
|
className="w-full border-t-2 border-dashed"
|
||||||
|
style={{ borderColor: '#21427a' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center">
|
||||||
|
<button
|
||||||
|
onClick={() => setRevealed(true)}
|
||||||
|
className="flex items-center gap-2 px-5 py-2.5 rounded-full font-bold text-sm transition-all"
|
||||||
|
style={{
|
||||||
|
background: '#b91c1c',
|
||||||
|
color: '#fff',
|
||||||
|
boxShadow: '0 0 30px rgba(185,28,28,0.4)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronDown size={16} />
|
||||||
|
{t('speed.showMore')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Collapse button after all items */}
|
||||||
|
{revealed && (
|
||||||
|
<div className="relative mt-8">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<div
|
||||||
|
className="w-full border-t-2 border-dashed"
|
||||||
|
style={{ borderColor: '#21427a' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center">
|
||||||
|
<button
|
||||||
|
onClick={() => setRevealed(false)}
|
||||||
|
className="flex items-center gap-2 px-5 py-2.5 rounded-full font-bold text-sm transition-all"
|
||||||
|
style={{
|
||||||
|
background: '#1f2937',
|
||||||
|
color: '#fff',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EyeOff size={16} />
|
||||||
|
{t('speed.showLess')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
export default function TheProblem() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="pt-32 bg-gray-800">
|
|
||||||
<div className="max-w-6xl mx-auto px-6 pb-24">
|
|
||||||
<div className="text-center mb-20">
|
|
||||||
<h2 className="text-4xl md:text-6xl font-black mb-4 text-white">
|
|
||||||
{t('problem.title')}
|
|
||||||
</h2>
|
|
||||||
<p className="text-xl md:text-2xl text-red-400 font-medium max-w-3xl mx-auto">
|
|
||||||
{t('problem.subtitle')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
||||||
{t('problem.cards', { returnObjects: true }).map((card, i) => (
|
|
||||||
<div key={i} className="bg-gray-900 rounded-2xl p-8 border border-gray-700 text-center">
|
|
||||||
<div className="text-3xl font-black text-red-500 mb-3">{card.title}</div>
|
|
||||||
<p className="text-gray-400 leading-relaxed">{card.desc}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bleeding Sword Divider */}
|
|
||||||
<div className="bg-white">
|
|
||||||
<div className="w-full mx-auto">
|
|
||||||
<img
|
|
||||||
src="katana.svg"
|
|
||||||
className="w-full h-auto"
|
|
||||||
alt=""
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
378
src/components/TheProblem.tsx
Normal file
378
src/components/TheProblem.tsx
Normal file
|
|
@ -0,0 +1,378 @@
|
||||||
|
import { useRef, useEffect, useCallback } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
useInView,
|
||||||
|
motion,
|
||||||
|
useScroll,
|
||||||
|
useTransform,
|
||||||
|
useMotionValueEvent,
|
||||||
|
useReducedMotion,
|
||||||
|
} from 'motion/react';
|
||||||
|
import VerticalCutReveal from './fancy/text/vertical-cut-reveal';
|
||||||
|
import ScrambleIn, { ScrambleInHandle } from '@/components/fancy/text/scramble-in';
|
||||||
|
import AmbientGlow from './AmbientGlow';
|
||||||
|
|
||||||
|
// ── types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ProblemCard {
|
||||||
|
title: string;
|
||||||
|
desc: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProblemTranslation {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
cards: ProblemCard[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── lightspeed star ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface Star {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
z: number;
|
||||||
|
speed: number;
|
||||||
|
hue: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStars(count: number): Star[] {
|
||||||
|
return Array.from({ length: count }, () => ({
|
||||||
|
x: (Math.random() - 0.5) * 2,
|
||||||
|
y: (Math.random() - 0.5) * 2,
|
||||||
|
z: Math.random() * 0.98 + 0.02,
|
||||||
|
speed: Math.random() * 0.8 + 0.4,
|
||||||
|
hue: 200 + Math.random() * 60,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function respawnStar(star: Star) {
|
||||||
|
star.x = (Math.random() - 0.5) * 2;
|
||||||
|
star.y = (Math.random() - 0.5) * 2;
|
||||||
|
star.z = 0.9 + Math.random() * 0.1;
|
||||||
|
star.speed = Math.random() * 0.8 + 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── animated card ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function StatCard({ card, index }: { card: ProblemCard; index: number }) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const isInView = useInView(ref, { once: true, margin: '0px 0px -80px 0px' });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
ref={ref}
|
||||||
|
initial={{ opacity: 0, y: 32 }}
|
||||||
|
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||||
|
transition={{
|
||||||
|
type: 'spring',
|
||||||
|
damping: 24,
|
||||||
|
stiffness: 120,
|
||||||
|
delay: index * 0.12,
|
||||||
|
}}
|
||||||
|
className="bg-gray-900 rounded-2xl p-8 border border-gray-700 text-center flex flex-col items-center gap-2"
|
||||||
|
>
|
||||||
|
<div className="text-2xl sm:text-3xl font-black text-red-500 mb-1 leading-tight">
|
||||||
|
{card.title}
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-400 leading-relaxed text-sm">{card.desc}</p>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── main component ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const STAR_COUNT = 400;
|
||||||
|
const BASE_SPEED = 0.02;
|
||||||
|
const WARP_BOOST = 3.5;
|
||||||
|
|
||||||
|
export default function TheProblem() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { title, subtitle, cards } = t('problem', {
|
||||||
|
returnObjects: true,
|
||||||
|
}) as ProblemTranslation;
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const starsRef = useRef<Star[]>(createStars(STAR_COUNT));
|
||||||
|
const warpRef = useRef(0);
|
||||||
|
const rafRef = useRef(0);
|
||||||
|
const lastTimeRef = useRef(0);
|
||||||
|
const isActiveRef = useRef(false);
|
||||||
|
const prefersReducedMotion = useReducedMotion();
|
||||||
|
|
||||||
|
const h2Ref = useRef<HTMLHeadingElement>(null);
|
||||||
|
const h2ScrambleRef = useRef<ScrambleInHandle>(null);
|
||||||
|
const h2InView = useInView(h2Ref, { once: true });
|
||||||
|
useEffect(() => {
|
||||||
|
if (h2InView) h2ScrambleRef.current?.start();
|
||||||
|
}, [h2InView]);
|
||||||
|
|
||||||
|
// ── scroll-driven values ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const { scrollYProgress } = useScroll({
|
||||||
|
target: containerRef,
|
||||||
|
offset: ['start start', 'end end'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Content visible initially, fades out mid-scroll
|
||||||
|
const contentOpacity = useTransform(
|
||||||
|
scrollYProgress,
|
||||||
|
[0, 0.15, 0.28, 0.38],
|
||||||
|
[1, 1, 1, 0],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Canvas fades in as content fades (baseline 0.2 so faint stars are visible immediately)
|
||||||
|
const canvasOpacity = useTransform(scrollYProgress, [0.0, 0.15, 0.35], [0.2, 0.4, 1]);
|
||||||
|
|
||||||
|
// Warp factor for star speed (tapers gradually, movement until the very end)
|
||||||
|
const warp = useTransform(
|
||||||
|
scrollYProgress,
|
||||||
|
[0.25, 0.40, 0.65, 0.80, 1.0],
|
||||||
|
[0, 0.3, 1.0, 0.4, 0],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fade to white for transition into Features (more gradual)
|
||||||
|
const whiteOverlayOpacity = useTransform(scrollYProgress, [0.78, 1.0], [0, 1]);
|
||||||
|
|
||||||
|
// Cinematic exit: blur + brightness + scale — "arriving from hyperspace"
|
||||||
|
const canvasBlur = useTransform(scrollYProgress, [0.80, 0.95], [0, 12]);
|
||||||
|
const canvasBrightness = useTransform(scrollYProgress, [0.85, 0.95], [1, 1.8]);
|
||||||
|
const canvasFilter = useTransform(
|
||||||
|
[canvasBlur, canvasBrightness] as any,
|
||||||
|
([b, br]: number[]) => `blur(${b}px) brightness(${br})`,
|
||||||
|
);
|
||||||
|
const canvasScale = useTransform(scrollYProgress, [0.75, 1.0], [1, 1.5]);
|
||||||
|
|
||||||
|
useMotionValueEvent(warp, 'change', (v) => {
|
||||||
|
warpRef.current = v;
|
||||||
|
});
|
||||||
|
|
||||||
|
useMotionValueEvent(scrollYProgress, 'change', (v) => {
|
||||||
|
isActiveRef.current = v > 0.01 && v < 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── canvas draw loop ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const draw = useCallback((time: number) => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
rafRef.current = requestAnimationFrame(draw);
|
||||||
|
|
||||||
|
if (!isActiveRef.current) {
|
||||||
|
lastTimeRef.current = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const dt = lastTimeRef.current
|
||||||
|
? Math.min((time - lastTimeRef.current) / 1000, 0.05)
|
||||||
|
: 0.016;
|
||||||
|
lastTimeRef.current = time;
|
||||||
|
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const w = canvas.width / dpr;
|
||||||
|
const h = canvas.height / dpr;
|
||||||
|
const cx = w / 2;
|
||||||
|
const cy = h / 2;
|
||||||
|
const warpLevel = warpRef.current;
|
||||||
|
const fov = Math.max(w, h) * 0.5;
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
|
||||||
|
// Red ambient glow — echoes Hero's blobs, fades out as warp kicks in
|
||||||
|
if (warpLevel < 0.2) {
|
||||||
|
const fadeOut = 1 - warpLevel / 0.2;
|
||||||
|
const glow = ctx.createRadialGradient(cx * 0.7, cy * 0.8, 0, cx * 0.7, cy * 0.8, fov * 0.6);
|
||||||
|
glow.addColorStop(0, `rgba(185, 28, 28, ${fadeOut * 0.05})`);
|
||||||
|
glow.addColorStop(0.5, `rgba(185, 28, 28, ${fadeOut * 0.02})`);
|
||||||
|
glow.addColorStop(1, 'rgba(185, 28, 28, 0)');
|
||||||
|
ctx.fillStyle = glow;
|
||||||
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Central glow during warp
|
||||||
|
if (warpLevel > 0.15) {
|
||||||
|
const intensity = Math.min(1, (warpLevel - 0.15) / 0.85);
|
||||||
|
const gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, fov * 0.7);
|
||||||
|
gradient.addColorStop(0, `rgba(120, 140, 255, ${intensity * 0.12})`);
|
||||||
|
gradient.addColorStop(0.4, `rgba(80, 100, 255, ${intensity * 0.04})`);
|
||||||
|
gradient.addColorStop(1, 'rgba(80, 100, 255, 0)');
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stars = starsRef.current;
|
||||||
|
|
||||||
|
for (const star of stars) {
|
||||||
|
const prevZ = star.z;
|
||||||
|
star.z -= (BASE_SPEED + warpLevel * WARP_BOOST) * star.speed * dt;
|
||||||
|
|
||||||
|
if (star.z <= 0.001) {
|
||||||
|
respawnStar(star);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// project current & previous position
|
||||||
|
const sx = cx + (star.x * fov) / star.z;
|
||||||
|
const sy = cy + (star.y * fov) / star.z;
|
||||||
|
const px = cx + (star.x * fov) / prevZ;
|
||||||
|
const py = cy + (star.y * fov) / prevZ;
|
||||||
|
|
||||||
|
if (sx < -50 || sx > w + 50 || sy < -50 || sy > h + 50) {
|
||||||
|
respawnStar(star);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const brightness = Math.min(1, (1 - star.z) * 1.8);
|
||||||
|
const streakLen = Math.hypot(sx - px, sy - py);
|
||||||
|
|
||||||
|
if (warpLevel > 0.05 && streakLen > 2) {
|
||||||
|
// streak
|
||||||
|
ctx.strokeStyle = `hsla(${star.hue}, 80%, 90%, ${brightness})`;
|
||||||
|
ctx.lineWidth = Math.max(0.5, (1 - star.z) * 2.5);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(px, py);
|
||||||
|
ctx.lineTo(sx, sy);
|
||||||
|
ctx.stroke();
|
||||||
|
} else {
|
||||||
|
// dot
|
||||||
|
const r = Math.max(0.5, (1 - star.z) * 1.5);
|
||||||
|
ctx.fillStyle = `hsla(${star.hue}, 50%, 88%, ${brightness * 0.7})`;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(sx, sy, r, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── canvas setup ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (prefersReducedMotion) return;
|
||||||
|
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const resize = () => {
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
const vh = window.innerHeight;
|
||||||
|
canvas.width = vw * dpr;
|
||||||
|
canvas.height = vh * dpr;
|
||||||
|
canvas.style.width = `${vw}px`;
|
||||||
|
canvas.style.height = `${vh}px`;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (ctx) ctx.scale(dpr, dpr);
|
||||||
|
};
|
||||||
|
|
||||||
|
resize();
|
||||||
|
window.addEventListener('resize', resize);
|
||||||
|
rafRef.current = requestAnimationFrame(draw);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', resize);
|
||||||
|
cancelAnimationFrame(rafRef.current);
|
||||||
|
};
|
||||||
|
}, [draw, prefersReducedMotion]);
|
||||||
|
|
||||||
|
// ── reduced-motion fallback ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (prefersReducedMotion) {
|
||||||
|
return (
|
||||||
|
<section className="pt-32 pb-24 bg-gray-900">
|
||||||
|
<div className="max-w-6xl mx-auto px-6">
|
||||||
|
<div className="text-center mb-20">
|
||||||
|
<AmbientGlow className="bg-red-700/20" baseWidth={384} baseHeight={384} anchorX="25%" anchorY="25%" driftX={80} driftY={60} interval={6000} />
|
||||||
|
<AmbientGlow className="bg-red-900/20" baseWidth={256} baseHeight={256} anchorX="75%" anchorY="75%" driftX={60} driftY={50} interval={7500} />
|
||||||
|
|
||||||
|
<h2 ref={h2Ref} className="text-4xl md:text-6xl font-black mb-4 text-white">
|
||||||
|
<ScrambleIn ref={h2ScrambleRef} text={title} autoStart={false} />
|
||||||
|
</h2>
|
||||||
|
<p className="text-xl md:text-2xl text-red-400 font-medium max-w-3xl mx-auto">
|
||||||
|
{subtitle}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
{cards.map((card, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="bg-gray-900 rounded-2xl p-8 border border-gray-700 text-center flex flex-col items-center gap-2"
|
||||||
|
>
|
||||||
|
<div className="text-2xl sm:text-3xl font-black text-red-500 mb-1 leading-tight">
|
||||||
|
{card.title}
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-400 leading-relaxed text-sm">
|
||||||
|
{card.desc}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── full experience ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section ref={containerRef} className="relative" style={{ height: '250vh' }}>
|
||||||
|
<div className="sticky top-0 h-screen w-full overflow-hidden bg-gray-900">
|
||||||
|
{/* Lightspeed canvas (behind content) */}
|
||||||
|
<motion.canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className="absolute inset-0 z-0"
|
||||||
|
style={{ opacity: canvasOpacity, filter: canvasFilter, scale: canvasScale }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Content layer */}
|
||||||
|
<motion.div
|
||||||
|
className="absolute inset-0 z-10 bg-gray-900 flex items-center"
|
||||||
|
style={{ opacity: contentOpacity }}
|
||||||
|
>
|
||||||
|
<div className="max-w-6xl mx-auto px-6 w-full">
|
||||||
|
{/* Section header */}
|
||||||
|
<div className="text-center mb-20">
|
||||||
|
<h2 ref={h2Ref} className="text-4xl md:text-6xl font-black mb-4 text-white">
|
||||||
|
<ScrambleIn ref={h2ScrambleRef} text={title} autoStart={false} />
|
||||||
|
</h2>
|
||||||
|
<p className="text-xl md:text-2xl text-red-400 font-medium max-w-3xl mx-auto">
|
||||||
|
<VerticalCutReveal
|
||||||
|
splitBy="words"
|
||||||
|
staggerFrom="first"
|
||||||
|
staggerDuration={0.04}
|
||||||
|
transition={{
|
||||||
|
type: 'spring',
|
||||||
|
damping: 26,
|
||||||
|
stiffness: 140,
|
||||||
|
delay: 0.35,
|
||||||
|
}}
|
||||||
|
autoStart
|
||||||
|
containerClassName="justify-center flex-wrap"
|
||||||
|
elementLevelClassName="pb-1"
|
||||||
|
>
|
||||||
|
{subtitle}
|
||||||
|
</VerticalCutReveal>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stat cards */}
|
||||||
|
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
{cards.map((card, i) => (
|
||||||
|
<StatCard key={i} card={card} index={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Fade to white — seamless transition into Features */}
|
||||||
|
<motion.div
|
||||||
|
className="absolute inset-0 z-20 pointer-events-none"
|
||||||
|
style={{ opacity: whiteOverlayOpacity, backgroundColor: '#ffffff' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
src/components/fancy/blocks/screensaver.tsx
Normal file
103
src/components/fancy/blocks/screensaver.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import React, { useEffect, useRef } from "react"
|
||||||
|
import {
|
||||||
|
motion,
|
||||||
|
useAnimationFrame,
|
||||||
|
useMotionValue,
|
||||||
|
} from "motion/react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { useDimensions } from "@/hooks/use-dimensions"
|
||||||
|
|
||||||
|
type ScreensaverProps = {
|
||||||
|
children: React.ReactNode
|
||||||
|
containerRef: React.RefObject<HTMLElement | null>
|
||||||
|
speed?: number
|
||||||
|
startPosition?: { x: number; y: number } // x,y as percentages (0-100)
|
||||||
|
startAngle?: number // in degrees
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const Screensaver: React.FC<ScreensaverProps> = ({
|
||||||
|
children,
|
||||||
|
speed = 3,
|
||||||
|
startPosition = { x: 0, y: 0 },
|
||||||
|
startAngle = 45,
|
||||||
|
containerRef,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
const elementRef = useRef<HTMLDivElement>(null)
|
||||||
|
const x = useMotionValue(0)
|
||||||
|
const y = useMotionValue(0)
|
||||||
|
const angle = useRef((startAngle * Math.PI) / 180)
|
||||||
|
|
||||||
|
const containerDimensions = useDimensions(containerRef)
|
||||||
|
const elementDimensions = useDimensions(elementRef)
|
||||||
|
|
||||||
|
// Set initial position based on container dimensions and percentage
|
||||||
|
useEffect(() => {
|
||||||
|
if (containerDimensions.width && containerDimensions.height) {
|
||||||
|
const initialX =
|
||||||
|
(startPosition.x / 100) *
|
||||||
|
(containerDimensions.width - (elementDimensions.width || 0))
|
||||||
|
const initialY =
|
||||||
|
(startPosition.y / 100) *
|
||||||
|
(containerDimensions.height - (elementDimensions.height || 0))
|
||||||
|
x.set(initialX)
|
||||||
|
y.set(initialY)
|
||||||
|
}
|
||||||
|
}, [containerDimensions, elementDimensions, startPosition])
|
||||||
|
|
||||||
|
useAnimationFrame(() => {
|
||||||
|
const velocity = speed
|
||||||
|
const dx = Math.cos(angle.current) * velocity
|
||||||
|
const dy = Math.sin(angle.current) * velocity
|
||||||
|
|
||||||
|
let newX = x.get() + dx
|
||||||
|
let newY = y.get() + dy
|
||||||
|
|
||||||
|
// Check for collisions with container boundaries
|
||||||
|
if (
|
||||||
|
newX <= 0 ||
|
||||||
|
newX + elementDimensions.width >= containerDimensions.width
|
||||||
|
) {
|
||||||
|
angle.current = Math.PI - angle.current
|
||||||
|
newX = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(newX, containerDimensions.width - elementDimensions.width)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
newY <= 0 ||
|
||||||
|
newY + elementDimensions.height >= containerDimensions.height
|
||||||
|
) {
|
||||||
|
angle.current = -angle.current
|
||||||
|
newY = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(newY, containerDimensions.height - elementDimensions.height)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
x.set(newX)
|
||||||
|
y.set(newY)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
ref={elementRef}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
}}
|
||||||
|
className={cn("transform will-change-transform", className)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Screensaver
|
||||||
103
src/components/fancy/blocks/stacking-cards.tsx
Normal file
103
src/components/fancy/blocks/stacking-cards.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useRef,
|
||||||
|
type HTMLAttributes,
|
||||||
|
type PropsWithChildren,
|
||||||
|
} from "react"
|
||||||
|
import {
|
||||||
|
motion,
|
||||||
|
useScroll,
|
||||||
|
useTransform,
|
||||||
|
type MotionValue,
|
||||||
|
type UseScrollOptions,
|
||||||
|
} from "motion/react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
interface StackingCardsProps
|
||||||
|
extends PropsWithChildren,
|
||||||
|
HTMLAttributes<HTMLDivElement> {
|
||||||
|
scrollOptions?: UseScrollOptions
|
||||||
|
scaleMultiplier?: number
|
||||||
|
totalCards: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StackingCardItemProps
|
||||||
|
extends HTMLAttributes<HTMLDivElement>,
|
||||||
|
PropsWithChildren {
|
||||||
|
index: number
|
||||||
|
topPosition?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StackingCards({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
scrollOptions,
|
||||||
|
scaleMultiplier,
|
||||||
|
totalCards,
|
||||||
|
...props
|
||||||
|
}: StackingCardsProps) {
|
||||||
|
const targetRef = useRef<HTMLDivElement>(null)
|
||||||
|
const { scrollYProgress } = useScroll({
|
||||||
|
offset: ["start start", "end end"],
|
||||||
|
...scrollOptions,
|
||||||
|
target: targetRef,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StackingCardsContext.Provider
|
||||||
|
value={{ progress: scrollYProgress, scaleMultiplier, totalCards }}
|
||||||
|
>
|
||||||
|
<div className={cn(className)} ref={targetRef} {...props}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</StackingCardsContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const StackingCardItem = ({
|
||||||
|
index,
|
||||||
|
topPosition,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: StackingCardItemProps) => {
|
||||||
|
const {
|
||||||
|
progress,
|
||||||
|
scaleMultiplier,
|
||||||
|
totalCards = 0,
|
||||||
|
} = useStackingCardsContext() // Get from Context
|
||||||
|
const scaleTo = 1 - (totalCards - index) * (scaleMultiplier ?? 0.03)
|
||||||
|
const rangeScale = [index * (1 / totalCards), 1]
|
||||||
|
const scale = useTransform(progress, rangeScale, [1, scaleTo])
|
||||||
|
const top = topPosition ?? `${5 + index * 3}%`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("h-full sticky top-0", className)} {...props}>
|
||||||
|
<motion.div
|
||||||
|
className={"origin-top relative h-full"}
|
||||||
|
style={{ top, scale }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const StackingCardsContext = createContext<{
|
||||||
|
progress: MotionValue<number>
|
||||||
|
scaleMultiplier?: number
|
||||||
|
totalCards?: number
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
|
export const useStackingCardsContext = () => {
|
||||||
|
const context = useContext(StackingCardsContext)
|
||||||
|
if (!context)
|
||||||
|
throw new Error("StackingCardItem must be used within StackingCards")
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
export { StackingCardItem }
|
||||||
134
src/components/fancy/image/parallax-floating.tsx
Normal file
134
src/components/fancy/image/parallax-floating.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
} from "react"
|
||||||
|
import { useAnimationFrame } from "motion/react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { useMousePositionRef } from "@/hooks/use-mouse-position-ref"
|
||||||
|
|
||||||
|
interface FloatingContextType {
|
||||||
|
registerElement: (id: string, element: HTMLDivElement, depth: number) => void
|
||||||
|
unregisterElement: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const FloatingContext = createContext<FloatingContextType | null>(null)
|
||||||
|
|
||||||
|
interface FloatingProps {
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
sensitivity?: number
|
||||||
|
easingFactor?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const Floating = ({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
sensitivity = 1,
|
||||||
|
easingFactor = 0.05,
|
||||||
|
...props
|
||||||
|
}: FloatingProps) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const elementsMap = useRef(
|
||||||
|
new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
element: HTMLDivElement
|
||||||
|
depth: number
|
||||||
|
currentPosition: { x: number; y: number }
|
||||||
|
}
|
||||||
|
>()
|
||||||
|
)
|
||||||
|
const mousePositionRef = useMousePositionRef(containerRef)
|
||||||
|
|
||||||
|
const registerElement = useCallback(
|
||||||
|
(id: string, element: HTMLDivElement, depth: number) => {
|
||||||
|
elementsMap.current.set(id, {
|
||||||
|
element,
|
||||||
|
depth,
|
||||||
|
currentPosition: { x: 0, y: 0 },
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const unregisterElement = useCallback((id: string) => {
|
||||||
|
elementsMap.current.delete(id)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useAnimationFrame(() => {
|
||||||
|
if (!containerRef.current) return
|
||||||
|
|
||||||
|
elementsMap.current.forEach((data) => {
|
||||||
|
const strength = (data.depth * sensitivity) / 20
|
||||||
|
|
||||||
|
// Calculate new target position
|
||||||
|
const newTargetX = mousePositionRef.current.x * strength
|
||||||
|
const newTargetY = mousePositionRef.current.y * strength
|
||||||
|
|
||||||
|
// Check if we need to update
|
||||||
|
const dx = newTargetX - data.currentPosition.x
|
||||||
|
const dy = newTargetY - data.currentPosition.y
|
||||||
|
|
||||||
|
// Update position only if we're still moving
|
||||||
|
data.currentPosition.x += dx * easingFactor
|
||||||
|
data.currentPosition.y += dy * easingFactor
|
||||||
|
|
||||||
|
data.element.style.transform = `translate3d(${data.currentPosition.x}px, ${data.currentPosition.y}px, 0)`
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FloatingContext.Provider value={{ registerElement, unregisterElement }}>
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={cn("absolute top-0 left-0 w-full h-full", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</FloatingContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Floating
|
||||||
|
|
||||||
|
interface FloatingElementProps {
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
depth?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FloatingElement = ({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
depth = 1,
|
||||||
|
}: FloatingElementProps) => {
|
||||||
|
const elementRef = useRef<HTMLDivElement>(null)
|
||||||
|
const idRef = useRef(Math.random().toString(36).substring(7))
|
||||||
|
const context = useContext(FloatingContext)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!elementRef.current || !context) return
|
||||||
|
|
||||||
|
const nonNullDepth = depth ?? 0.01
|
||||||
|
|
||||||
|
context.registerElement(idRef.current, elementRef.current, nonNullDepth)
|
||||||
|
return () => context.unregisterElement(idRef.current)
|
||||||
|
}, [depth])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={elementRef}
|
||||||
|
className={cn("absolute will-change-transform", className)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
512
src/components/fancy/physics/gravity.tsx
Normal file
512
src/components/fancy/physics/gravity.tsx
Normal file
|
|
@ -0,0 +1,512 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
forwardRef,
|
||||||
|
ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react"
|
||||||
|
import { calculatePosition } from "@/utils/calculate-position"
|
||||||
|
import { parsePathToVertices } from "@/utils/svg-path-to-vertices"
|
||||||
|
import { debounce } from "lodash"
|
||||||
|
import decomp from "poly-decomp"
|
||||||
|
import Matter, {
|
||||||
|
Bodies,
|
||||||
|
Common,
|
||||||
|
Engine,
|
||||||
|
Events,
|
||||||
|
Mouse,
|
||||||
|
MouseConstraint,
|
||||||
|
Query,
|
||||||
|
Render,
|
||||||
|
Runner,
|
||||||
|
World,
|
||||||
|
} from "matter-js"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
type GravityProps = {
|
||||||
|
children: ReactNode
|
||||||
|
debug?: boolean
|
||||||
|
gravity?: { x: number; y: number }
|
||||||
|
resetOnResize?: boolean
|
||||||
|
grabCursor?: boolean
|
||||||
|
addTopWall?: boolean
|
||||||
|
autoStart?: boolean
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type PhysicsBody = {
|
||||||
|
element: HTMLElement
|
||||||
|
body: Matter.Body
|
||||||
|
props: MatterBodyProps
|
||||||
|
}
|
||||||
|
|
||||||
|
type MatterBodyProps = {
|
||||||
|
children: ReactNode
|
||||||
|
matterBodyOptions?: Matter.IBodyDefinition
|
||||||
|
isDraggable?: boolean
|
||||||
|
bodyType?: "rectangle" | "circle" | "svg"
|
||||||
|
sampleLength?: number
|
||||||
|
x?: number | string
|
||||||
|
y?: number | string
|
||||||
|
angle?: number
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GravityRef = {
|
||||||
|
start: () => void
|
||||||
|
stop: () => void
|
||||||
|
reset: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const GravityContext = createContext<{
|
||||||
|
registerElement: (
|
||||||
|
id: string,
|
||||||
|
element: HTMLElement,
|
||||||
|
props: MatterBodyProps
|
||||||
|
) => void
|
||||||
|
unregisterElement: (id: string) => void
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
|
export const MatterBody = ({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
matterBodyOptions = {
|
||||||
|
friction: 0.1,
|
||||||
|
restitution: 0.1,
|
||||||
|
density: 0.001,
|
||||||
|
isStatic: false,
|
||||||
|
},
|
||||||
|
bodyType = "rectangle",
|
||||||
|
isDraggable = true,
|
||||||
|
sampleLength = 15,
|
||||||
|
x = 0,
|
||||||
|
y = 0,
|
||||||
|
angle = 0,
|
||||||
|
...props
|
||||||
|
}: MatterBodyProps) => {
|
||||||
|
const elementRef = useRef<HTMLDivElement>(null)
|
||||||
|
const idRef = useRef(Math.random().toString(36).substring(7))
|
||||||
|
const context = useContext(GravityContext)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!elementRef.current || !context) return
|
||||||
|
context.registerElement(idRef.current, elementRef.current, {
|
||||||
|
children,
|
||||||
|
matterBodyOptions,
|
||||||
|
bodyType,
|
||||||
|
sampleLength,
|
||||||
|
isDraggable,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
angle,
|
||||||
|
...props,
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => context.unregisterElement(idRef.current)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={elementRef}
|
||||||
|
className={cn(
|
||||||
|
"absolute",
|
||||||
|
className,
|
||||||
|
isDraggable && "pointer-events-none"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Gravity = forwardRef<GravityRef, GravityProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
children,
|
||||||
|
debug = false,
|
||||||
|
gravity = { x: 0, y: 1 },
|
||||||
|
grabCursor = true,
|
||||||
|
resetOnResize = true,
|
||||||
|
addTopWall = true,
|
||||||
|
autoStart = true,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const canvas = useRef<HTMLDivElement>(null)
|
||||||
|
const engine = useRef(Engine.create())
|
||||||
|
const render = useRef<Render>(undefined)
|
||||||
|
const runner = useRef<Runner>(undefined)
|
||||||
|
const bodiesMap = useRef(new Map<string, PhysicsBody>())
|
||||||
|
const frameId = useRef<number>(undefined)
|
||||||
|
const mouseConstraint = useRef<Matter.MouseConstraint>(undefined)
|
||||||
|
const mouseDown = useRef(false)
|
||||||
|
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 })
|
||||||
|
|
||||||
|
const isRunning = useRef(false)
|
||||||
|
|
||||||
|
// Register Matter.js body in the physics world
|
||||||
|
const registerElement = useCallback(
|
||||||
|
(id: string, element: HTMLElement, props: MatterBodyProps) => {
|
||||||
|
if (!canvas.current) return
|
||||||
|
const width = element.offsetWidth
|
||||||
|
const height = element.offsetHeight
|
||||||
|
const canvasRect = canvas.current!.getBoundingClientRect()
|
||||||
|
|
||||||
|
const angle = (props.angle || 0) * (Math.PI / 180)
|
||||||
|
|
||||||
|
const x = calculatePosition(props.x, canvasRect.width, width)
|
||||||
|
const y = calculatePosition(props.y, canvasRect.height, height)
|
||||||
|
|
||||||
|
let body
|
||||||
|
if (props.bodyType === "circle") {
|
||||||
|
const radius = Math.max(width, height) / 2
|
||||||
|
body = Bodies.circle(x, y, radius, {
|
||||||
|
...props.matterBodyOptions,
|
||||||
|
angle: angle,
|
||||||
|
render: {
|
||||||
|
fillStyle: debug ? "#888888" : "#00000000",
|
||||||
|
strokeStyle: debug ? "#333333" : "#00000000",
|
||||||
|
lineWidth: debug ? 3 : 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else if (props.bodyType === "svg") {
|
||||||
|
const paths = element.querySelectorAll("path")
|
||||||
|
const vertexSets: Matter.Vector[][] = []
|
||||||
|
|
||||||
|
paths.forEach((path) => {
|
||||||
|
const d = path.getAttribute("d")
|
||||||
|
const p = parsePathToVertices(d!, props.sampleLength)
|
||||||
|
vertexSets.push(p)
|
||||||
|
})
|
||||||
|
|
||||||
|
body = Bodies.fromVertices(x, y, vertexSets, {
|
||||||
|
...props.matterBodyOptions,
|
||||||
|
angle: angle,
|
||||||
|
render: {
|
||||||
|
fillStyle: debug ? "#888888" : "#00000000",
|
||||||
|
strokeStyle: debug ? "#333333" : "#00000000",
|
||||||
|
lineWidth: debug ? 3 : 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
body = Bodies.rectangle(x, y, width, height, {
|
||||||
|
...props.matterBodyOptions,
|
||||||
|
angle: angle,
|
||||||
|
render: {
|
||||||
|
fillStyle: debug ? "#888888" : "#00000000",
|
||||||
|
strokeStyle: debug ? "#333333" : "#00000000",
|
||||||
|
lineWidth: debug ? 3 : 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body) {
|
||||||
|
World.add(engine.current.world, [body])
|
||||||
|
bodiesMap.current.set(id, { element, body, props })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[debug]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Unregister Matter.js body from the physics world
|
||||||
|
const unregisterElement = useCallback((id: string) => {
|
||||||
|
const body = bodiesMap.current.get(id)
|
||||||
|
if (body) {
|
||||||
|
World.remove(engine.current.world, body.body)
|
||||||
|
bodiesMap.current.delete(id)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Keep react elements in sync with the physics world
|
||||||
|
const updateElements = useCallback(() => {
|
||||||
|
bodiesMap.current.forEach(({ element, body }) => {
|
||||||
|
const { x, y } = body.position
|
||||||
|
const rotation = body.angle * (180 / Math.PI)
|
||||||
|
|
||||||
|
element.style.transform = `translate(${
|
||||||
|
x - element.offsetWidth / 2
|
||||||
|
}px, ${y - element.offsetHeight / 2}px) rotate(${rotation}deg)`
|
||||||
|
})
|
||||||
|
|
||||||
|
frameId.current = requestAnimationFrame(updateElements)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const initializeRenderer = useCallback(() => {
|
||||||
|
if (!canvas.current) return
|
||||||
|
|
||||||
|
const height = canvas.current.offsetHeight
|
||||||
|
const width = canvas.current.offsetWidth
|
||||||
|
|
||||||
|
Common.setDecomp(decomp)
|
||||||
|
|
||||||
|
engine.current.gravity.x = gravity.x
|
||||||
|
engine.current.gravity.y = gravity.y
|
||||||
|
|
||||||
|
render.current = Render.create({
|
||||||
|
element: canvas.current,
|
||||||
|
engine: engine.current,
|
||||||
|
options: {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
wireframes: false,
|
||||||
|
background: "#00000000",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const mouse = Mouse.create(render.current.canvas)
|
||||||
|
mouseConstraint.current = MouseConstraint.create(engine.current, {
|
||||||
|
mouse: mouse,
|
||||||
|
constraint: {
|
||||||
|
stiffness: 0.2,
|
||||||
|
render: {
|
||||||
|
visible: debug,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add walls
|
||||||
|
const walls = [
|
||||||
|
// Floor
|
||||||
|
Bodies.rectangle(width / 2, height + 10, width, 20, {
|
||||||
|
isStatic: true,
|
||||||
|
friction: 1,
|
||||||
|
render: {
|
||||||
|
visible: debug,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Right wall
|
||||||
|
Bodies.rectangle(width + 10, height / 2, 20, height, {
|
||||||
|
isStatic: true,
|
||||||
|
friction: 1,
|
||||||
|
render: {
|
||||||
|
visible: debug,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Left wall
|
||||||
|
Bodies.rectangle(-10, height / 2, 20, height, {
|
||||||
|
isStatic: true,
|
||||||
|
friction: 1,
|
||||||
|
render: {
|
||||||
|
visible: debug,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|
||||||
|
const topWall = addTopWall
|
||||||
|
? Bodies.rectangle(width / 2, -10, width, 20, {
|
||||||
|
isStatic: true,
|
||||||
|
friction: 1,
|
||||||
|
render: {
|
||||||
|
visible: debug,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (topWall) {
|
||||||
|
walls.push(topWall)
|
||||||
|
}
|
||||||
|
|
||||||
|
const touchingMouse = () =>
|
||||||
|
Query.point(
|
||||||
|
engine.current.world.bodies,
|
||||||
|
mouseConstraint.current?.mouse.position || { x: 0, y: 0 }
|
||||||
|
).length > 0
|
||||||
|
|
||||||
|
if (grabCursor) {
|
||||||
|
Events.on(engine.current, "beforeUpdate", (event) => {
|
||||||
|
if (canvas.current) {
|
||||||
|
if (!mouseDown.current && !touchingMouse()) {
|
||||||
|
canvas.current.style.cursor = "default"
|
||||||
|
} else if (touchingMouse()) {
|
||||||
|
canvas.current.style.cursor = mouseDown.current
|
||||||
|
? "grabbing"
|
||||||
|
: "grab"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
canvas.current.addEventListener("mousedown", (event) => {
|
||||||
|
mouseDown.current = true
|
||||||
|
|
||||||
|
if (canvas.current) {
|
||||||
|
if (touchingMouse()) {
|
||||||
|
canvas.current.style.cursor = "grabbing"
|
||||||
|
} else {
|
||||||
|
canvas.current.style.cursor = "default"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
canvas.current.addEventListener("mouseup", (event) => {
|
||||||
|
mouseDown.current = false
|
||||||
|
|
||||||
|
if (canvas.current) {
|
||||||
|
if (touchingMouse()) {
|
||||||
|
canvas.current.style.cursor = "grab"
|
||||||
|
} else {
|
||||||
|
canvas.current.style.cursor = "default"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
World.add(engine.current.world, [mouseConstraint.current, ...walls])
|
||||||
|
|
||||||
|
render.current.mouse = mouse
|
||||||
|
|
||||||
|
runner.current = Runner.create()
|
||||||
|
Render.run(render.current)
|
||||||
|
updateElements()
|
||||||
|
runner.current.enabled = false
|
||||||
|
|
||||||
|
if (autoStart) {
|
||||||
|
runner.current.enabled = true
|
||||||
|
startEngine()
|
||||||
|
}
|
||||||
|
}, [updateElements, debug, autoStart])
|
||||||
|
|
||||||
|
// Clear the Matter.js world
|
||||||
|
const clearRenderer = useCallback(() => {
|
||||||
|
if (frameId.current) {
|
||||||
|
cancelAnimationFrame(frameId.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mouseConstraint.current) {
|
||||||
|
World.remove(engine.current.world, mouseConstraint.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (render.current) {
|
||||||
|
Mouse.clearSourceEvents(render.current.mouse)
|
||||||
|
Render.stop(render.current)
|
||||||
|
render.current.canvas.remove()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runner.current) {
|
||||||
|
Runner.stop(runner.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (engine.current) {
|
||||||
|
World.clear(engine.current.world, false)
|
||||||
|
Engine.clear(engine.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
bodiesMap.current.clear()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleResize = useCallback(() => {
|
||||||
|
if (!canvas.current || !resetOnResize) return
|
||||||
|
|
||||||
|
const newWidth = canvas.current.offsetWidth
|
||||||
|
const newHeight = canvas.current.offsetHeight
|
||||||
|
|
||||||
|
setCanvasSize({ width: newWidth, height: newHeight })
|
||||||
|
|
||||||
|
// Clear and reinitialize
|
||||||
|
clearRenderer()
|
||||||
|
initializeRenderer()
|
||||||
|
}, [clearRenderer, initializeRenderer, resetOnResize])
|
||||||
|
|
||||||
|
const startEngine = useCallback(() => {
|
||||||
|
if (runner.current) {
|
||||||
|
runner.current.enabled = true
|
||||||
|
|
||||||
|
Runner.run(runner.current, engine.current)
|
||||||
|
}
|
||||||
|
if (render.current) {
|
||||||
|
Render.run(render.current)
|
||||||
|
}
|
||||||
|
frameId.current = requestAnimationFrame(updateElements)
|
||||||
|
isRunning.current = true
|
||||||
|
}, [updateElements, canvasSize])
|
||||||
|
|
||||||
|
const stopEngine = useCallback(() => {
|
||||||
|
if (!isRunning.current) return
|
||||||
|
|
||||||
|
if (runner.current) {
|
||||||
|
Runner.stop(runner.current)
|
||||||
|
}
|
||||||
|
if (render.current) {
|
||||||
|
Render.stop(render.current)
|
||||||
|
}
|
||||||
|
if (frameId.current) {
|
||||||
|
cancelAnimationFrame(frameId.current)
|
||||||
|
}
|
||||||
|
isRunning.current = false
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
stopEngine()
|
||||||
|
bodiesMap.current.forEach(({ element, body, props }) => {
|
||||||
|
body.angle = props.angle || 0
|
||||||
|
|
||||||
|
const x = calculatePosition(
|
||||||
|
props.x,
|
||||||
|
canvasSize.width,
|
||||||
|
element.offsetWidth
|
||||||
|
)
|
||||||
|
const y = calculatePosition(
|
||||||
|
props.y,
|
||||||
|
canvasSize.height,
|
||||||
|
element.offsetHeight
|
||||||
|
)
|
||||||
|
body.position.x = x
|
||||||
|
body.position.y = y
|
||||||
|
})
|
||||||
|
updateElements()
|
||||||
|
handleResize()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useImperativeHandle(
|
||||||
|
ref,
|
||||||
|
() => ({
|
||||||
|
start: startEngine,
|
||||||
|
stop: stopEngine,
|
||||||
|
reset,
|
||||||
|
}),
|
||||||
|
[startEngine, stopEngine]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!resetOnResize) return
|
||||||
|
|
||||||
|
const debouncedResize = debounce(handleResize, 500)
|
||||||
|
window.addEventListener("resize", debouncedResize)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", debouncedResize)
|
||||||
|
debouncedResize.cancel()
|
||||||
|
}
|
||||||
|
}, [handleResize, resetOnResize])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
initializeRenderer()
|
||||||
|
return clearRenderer
|
||||||
|
}, [initializeRenderer, clearRenderer])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GravityContext.Provider value={{ registerElement, unregisterElement }}>
|
||||||
|
<div
|
||||||
|
ref={canvas}
|
||||||
|
className={cn(className, "absolute top-0 left-0 w-full h-full")}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</GravityContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Gravity.displayName = "Gravity"
|
||||||
|
export default Gravity
|
||||||
188
src/components/fancy/text/scramble-hover.tsx
Normal file
188
src/components/fancy/text/scramble-hover.tsx
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { motion } from "motion/react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
interface ScrambleHoverProps {
|
||||||
|
text: string
|
||||||
|
scrambleSpeed?: number
|
||||||
|
maxIterations?: number
|
||||||
|
sequential?: boolean
|
||||||
|
revealDirection?: "start" | "end" | "center"
|
||||||
|
useOriginalCharsOnly?: boolean
|
||||||
|
characters?: string
|
||||||
|
className?: string
|
||||||
|
scrambledClassName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ScrambleHover: React.FC<ScrambleHoverProps> = ({
|
||||||
|
text,
|
||||||
|
scrambleSpeed = 50,
|
||||||
|
maxIterations = 10,
|
||||||
|
useOriginalCharsOnly = false,
|
||||||
|
characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!@#$%^&*()_+",
|
||||||
|
className,
|
||||||
|
scrambledClassName,
|
||||||
|
sequential = false,
|
||||||
|
revealDirection = "start",
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const [displayText, setDisplayText] = useState(text)
|
||||||
|
const [isHovering, setIsHovering] = useState(false)
|
||||||
|
const [isScrambling, setIsScrambling] = useState(false)
|
||||||
|
const [revealedIndices, setRevealedIndices] = useState(new Set<number>())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let interval: NodeJS.Timeout
|
||||||
|
let currentIteration = 0
|
||||||
|
|
||||||
|
const getNextIndex = () => {
|
||||||
|
const textLength = text.length
|
||||||
|
switch (revealDirection) {
|
||||||
|
case "start":
|
||||||
|
return revealedIndices.size
|
||||||
|
case "end":
|
||||||
|
return textLength - 1 - revealedIndices.size
|
||||||
|
case "center":
|
||||||
|
const middle = Math.floor(textLength / 2)
|
||||||
|
const offset = Math.floor(revealedIndices.size / 2)
|
||||||
|
const nextIndex =
|
||||||
|
revealedIndices.size % 2 === 0
|
||||||
|
? middle + offset
|
||||||
|
: middle - offset - 1
|
||||||
|
|
||||||
|
if (
|
||||||
|
nextIndex >= 0 &&
|
||||||
|
nextIndex < textLength &&
|
||||||
|
!revealedIndices.has(nextIndex)
|
||||||
|
) {
|
||||||
|
return nextIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < textLength; i++) {
|
||||||
|
if (!revealedIndices.has(i)) return i
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
default:
|
||||||
|
return revealedIndices.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const shuffleText = (text: string) => {
|
||||||
|
if (useOriginalCharsOnly) {
|
||||||
|
const positions = text.split("").map((char, i) => ({
|
||||||
|
char,
|
||||||
|
isSpace: char === " ",
|
||||||
|
index: i,
|
||||||
|
isRevealed: revealedIndices.has(i),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const nonSpaceChars = positions
|
||||||
|
.filter((p) => !p.isSpace && !p.isRevealed)
|
||||||
|
.map((p) => p.char)
|
||||||
|
|
||||||
|
// Shuffle remaining non-revealed, non-space characters
|
||||||
|
for (let i = nonSpaceChars.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1))
|
||||||
|
;[nonSpaceChars[i], nonSpaceChars[j]] = [
|
||||||
|
nonSpaceChars[j],
|
||||||
|
nonSpaceChars[i],
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
let charIndex = 0
|
||||||
|
return positions
|
||||||
|
.map((p) => {
|
||||||
|
if (p.isSpace) return " "
|
||||||
|
if (p.isRevealed) return text[p.index]
|
||||||
|
return nonSpaceChars[charIndex++]
|
||||||
|
})
|
||||||
|
.join("")
|
||||||
|
} else {
|
||||||
|
return text
|
||||||
|
.split("")
|
||||||
|
.map((char, i) => {
|
||||||
|
if (char === " ") return " "
|
||||||
|
if (revealedIndices.has(i)) return text[i]
|
||||||
|
return availableChars[
|
||||||
|
Math.floor(Math.random() * availableChars.length)
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.join("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableChars = useOriginalCharsOnly
|
||||||
|
? Array.from(new Set(text.split(""))).filter((char) => char !== " ")
|
||||||
|
: characters.split("")
|
||||||
|
|
||||||
|
if (isHovering) {
|
||||||
|
setIsScrambling(true)
|
||||||
|
interval = setInterval(() => {
|
||||||
|
if (sequential) {
|
||||||
|
if (revealedIndices.size < text.length) {
|
||||||
|
const nextIndex = getNextIndex()
|
||||||
|
revealedIndices.add(nextIndex)
|
||||||
|
setDisplayText(shuffleText(text))
|
||||||
|
} else {
|
||||||
|
clearInterval(interval)
|
||||||
|
setIsScrambling(false)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setDisplayText(shuffleText(text))
|
||||||
|
currentIteration++
|
||||||
|
if (currentIteration >= maxIterations) {
|
||||||
|
clearInterval(interval)
|
||||||
|
setIsScrambling(false)
|
||||||
|
setDisplayText(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, scrambleSpeed)
|
||||||
|
} else {
|
||||||
|
setDisplayText(text)
|
||||||
|
revealedIndices.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (interval) clearInterval(interval)
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
isHovering,
|
||||||
|
text,
|
||||||
|
characters,
|
||||||
|
scrambleSpeed,
|
||||||
|
useOriginalCharsOnly,
|
||||||
|
sequential,
|
||||||
|
revealDirection,
|
||||||
|
maxIterations,
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.span
|
||||||
|
onHoverStart={() => setIsHovering(true)}
|
||||||
|
onHoverEnd={() => setIsHovering(false)}
|
||||||
|
className={cn("inline-block whitespace-pre-wrap", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="sr-only">{displayText}</span>
|
||||||
|
<span aria-hidden="true">
|
||||||
|
{displayText.split("").map((char, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className={cn(
|
||||||
|
!revealedIndices.has(index) && isScrambling && isHovering
|
||||||
|
? scrambledClassName
|
||||||
|
: undefined
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{char}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</motion.span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ScrambleHover
|
||||||
150
src/components/fancy/text/scramble-in.tsx
Normal file
150
src/components/fancy/text/scramble-in.tsx
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useState,
|
||||||
|
} from "react"
|
||||||
|
|
||||||
|
interface ScrambleInProps {
|
||||||
|
text: string
|
||||||
|
scrambleSpeed?: number
|
||||||
|
scrambledLetterCount?: number
|
||||||
|
characters?: string
|
||||||
|
className?: string
|
||||||
|
scrambledClassName?: string
|
||||||
|
autoStart?: boolean
|
||||||
|
onStart?: () => void
|
||||||
|
onComplete?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScrambleInHandle {
|
||||||
|
start: () => void
|
||||||
|
reset: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ScrambleIn = forwardRef<ScrambleInHandle, ScrambleInProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
text,
|
||||||
|
scrambleSpeed = 30,
|
||||||
|
scrambledLetterCount = 2,
|
||||||
|
characters = "abcdefghijklmnopqrstuvwxyz!@#$%^&*()_+",
|
||||||
|
className = "",
|
||||||
|
scrambledClassName = "",
|
||||||
|
autoStart = true,
|
||||||
|
onStart,
|
||||||
|
onComplete,
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const [displayText, setDisplayText] = useState("")
|
||||||
|
const [isAnimating, setIsAnimating] = useState(false)
|
||||||
|
const [visibleLetterCount, setVisibleLetterCount] = useState(0)
|
||||||
|
const [scrambleOffset, setScrambleOffset] = useState(0)
|
||||||
|
|
||||||
|
const startAnimation = useCallback(() => {
|
||||||
|
setIsAnimating(true)
|
||||||
|
setVisibleLetterCount(0)
|
||||||
|
setScrambleOffset(0)
|
||||||
|
onStart?.()
|
||||||
|
}, [onStart])
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
setIsAnimating(false)
|
||||||
|
setVisibleLetterCount(0)
|
||||||
|
setScrambleOffset(0)
|
||||||
|
setDisplayText("")
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
start: startAnimation,
|
||||||
|
reset,
|
||||||
|
}))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoStart) {
|
||||||
|
startAnimation()
|
||||||
|
}
|
||||||
|
}, [autoStart, startAnimation])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let interval: NodeJS.Timeout
|
||||||
|
|
||||||
|
if (isAnimating) {
|
||||||
|
interval = setInterval(() => {
|
||||||
|
// Increase visible text length
|
||||||
|
if (visibleLetterCount < text.length) {
|
||||||
|
setVisibleLetterCount((prev) => prev + 1)
|
||||||
|
}
|
||||||
|
// Start sliding scrambled text out
|
||||||
|
else if (scrambleOffset < scrambledLetterCount) {
|
||||||
|
setScrambleOffset((prev) => prev + 1)
|
||||||
|
}
|
||||||
|
// Complete animation
|
||||||
|
else {
|
||||||
|
clearInterval(interval)
|
||||||
|
setIsAnimating(false)
|
||||||
|
onComplete?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate how many scrambled letters we can show
|
||||||
|
const remainingSpace = Math.max(0, text.length - visibleLetterCount)
|
||||||
|
const currentScrambleCount = Math.min(
|
||||||
|
remainingSpace,
|
||||||
|
scrambledLetterCount
|
||||||
|
)
|
||||||
|
|
||||||
|
// Generate scrambled text
|
||||||
|
const scrambledPart = Array(currentScrambleCount)
|
||||||
|
.fill(0)
|
||||||
|
.map(
|
||||||
|
() => characters[Math.floor(Math.random() * characters.length)]
|
||||||
|
)
|
||||||
|
.join("")
|
||||||
|
|
||||||
|
setDisplayText(text.slice(0, visibleLetterCount) + scrambledPart)
|
||||||
|
}, scrambleSpeed)
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (interval) clearInterval(interval)
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
isAnimating,
|
||||||
|
text,
|
||||||
|
visibleLetterCount,
|
||||||
|
scrambleOffset,
|
||||||
|
scrambledLetterCount,
|
||||||
|
characters,
|
||||||
|
scrambleSpeed,
|
||||||
|
onComplete,
|
||||||
|
])
|
||||||
|
|
||||||
|
const renderText = () => {
|
||||||
|
const revealed = displayText.slice(0, visibleLetterCount)
|
||||||
|
const scrambled = displayText.slice(visibleLetterCount)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className={className}>{revealed}</span>
|
||||||
|
<span className={scrambledClassName}>{scrambled}</span>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className="sr-only">{text}</span>
|
||||||
|
<span className="inline-block whitespace-pre-wrap" aria-hidden="true">
|
||||||
|
{renderText()}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ScrambleIn.displayName = "ScrambleIn"
|
||||||
|
export default ScrambleIn
|
||||||
208
src/components/fancy/text/text-highlighter.tsx
Normal file
208
src/components/fancy/text/text-highlighter.tsx
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
ElementType,
|
||||||
|
forwardRef,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react"
|
||||||
|
import { motion, Transition, useInView, UseInViewOptions } from "motion/react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
type HighlightDirection = "ltr" | "rtl" | "ttb" | "btt"
|
||||||
|
|
||||||
|
type TextHighlighterProps = {
|
||||||
|
/**
|
||||||
|
* The text content to be highlighted
|
||||||
|
*/
|
||||||
|
children: React.ReactNode
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTML element to render as
|
||||||
|
* @default "p"
|
||||||
|
*/
|
||||||
|
as?: ElementType
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How to trigger the animation
|
||||||
|
* @default "inView"
|
||||||
|
*/
|
||||||
|
triggerType?: "hover" | "ref" | "inView" | "auto"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Animation transition configuration
|
||||||
|
* @default { duration: 0.4, type: "spring", bounce: 0 }
|
||||||
|
*/
|
||||||
|
transition?: Transition
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for useInView hook when triggerType is "inView"
|
||||||
|
*/
|
||||||
|
useInViewOptions?: UseInViewOptions
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class name for the container element
|
||||||
|
*/
|
||||||
|
className?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Highlight color (CSS color string). Also can be a function that returns a color string, eg:
|
||||||
|
* @default 'hsl(60, 90%, 68%)' (yellow)
|
||||||
|
*/
|
||||||
|
highlightColor?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Direction of the highlight animation
|
||||||
|
* @default "ltr" (left to right)
|
||||||
|
*/
|
||||||
|
direction?: HighlightDirection
|
||||||
|
} & React.HTMLAttributes<HTMLElement>
|
||||||
|
|
||||||
|
export type TextHighlighterRef = {
|
||||||
|
/**
|
||||||
|
* Trigger the highlight animation
|
||||||
|
* @param direction - Optional direction override for this animation
|
||||||
|
*/
|
||||||
|
animate: (direction?: HighlightDirection) => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the highlight animation
|
||||||
|
*/
|
||||||
|
reset: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TextHighlighter = forwardRef<
|
||||||
|
TextHighlighterRef,
|
||||||
|
TextHighlighterProps
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
children,
|
||||||
|
as = "span",
|
||||||
|
triggerType = "inView",
|
||||||
|
transition = { type: "spring", duration: 1, delay: 0, bounce: 0 },
|
||||||
|
useInViewOptions = {
|
||||||
|
once: true,
|
||||||
|
initial: false,
|
||||||
|
amount: 0.1,
|
||||||
|
},
|
||||||
|
className,
|
||||||
|
highlightColor = "hsl(25, 90%, 80%)",
|
||||||
|
direction = "ltr",
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const componentRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [isAnimating, setIsAnimating] = useState(false)
|
||||||
|
const [isHovered, setIsHovered] = useState(false)
|
||||||
|
const [currentDirection, setCurrentDirection] =
|
||||||
|
useState<HighlightDirection>(direction)
|
||||||
|
|
||||||
|
// this allows us to change the direction whenever the direction prop changes
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentDirection(direction)
|
||||||
|
}, [direction])
|
||||||
|
|
||||||
|
const isInView = useInView(componentRef, useInViewOptions)
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
animate: (animationDirection?: HighlightDirection) => {
|
||||||
|
if (animationDirection) {
|
||||||
|
setCurrentDirection(animationDirection)
|
||||||
|
}
|
||||||
|
setIsAnimating(true)
|
||||||
|
},
|
||||||
|
reset: () => setIsAnimating(false),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const shouldAnimate =
|
||||||
|
triggerType === "hover"
|
||||||
|
? isHovered
|
||||||
|
: triggerType === "inView"
|
||||||
|
? isInView
|
||||||
|
: triggerType === "ref"
|
||||||
|
? isAnimating
|
||||||
|
: triggerType === "auto"
|
||||||
|
? true
|
||||||
|
: false
|
||||||
|
|
||||||
|
const ElementTag = as || "span"
|
||||||
|
|
||||||
|
// Get background size based on direction
|
||||||
|
const getBackgroundSize = (animated: boolean) => {
|
||||||
|
switch (currentDirection) {
|
||||||
|
case "ltr":
|
||||||
|
return animated ? "100% 100%" : "0% 100%"
|
||||||
|
case "rtl":
|
||||||
|
return animated ? "100% 100%" : "0% 100%"
|
||||||
|
case "ttb":
|
||||||
|
return animated ? "100% 100%" : "100% 0%"
|
||||||
|
case "btt":
|
||||||
|
return animated ? "100% 100%" : "100% 0%"
|
||||||
|
default:
|
||||||
|
return animated ? "100% 100%" : "0% 100%"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get background position based on direction
|
||||||
|
const getBackgroundPosition = () => {
|
||||||
|
switch (currentDirection) {
|
||||||
|
case "ltr":
|
||||||
|
return "0% 0%"
|
||||||
|
case "rtl":
|
||||||
|
return "100% 0%"
|
||||||
|
case "ttb":
|
||||||
|
return "0% 0%"
|
||||||
|
case "btt":
|
||||||
|
return "0% 100%"
|
||||||
|
default:
|
||||||
|
return "0% 0%"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const animatedSize = useMemo(() => getBackgroundSize(shouldAnimate), [shouldAnimate, currentDirection])
|
||||||
|
const initialSize = useMemo(() => getBackgroundSize(false), [currentDirection])
|
||||||
|
const backgroundPosition = useMemo(() => getBackgroundPosition(), [currentDirection])
|
||||||
|
|
||||||
|
const highlightStyle = {
|
||||||
|
backgroundImage: `linear-gradient(${highlightColor}, ${highlightColor})`,
|
||||||
|
backgroundRepeat: "no-repeat",
|
||||||
|
backgroundPosition: backgroundPosition,
|
||||||
|
backgroundSize: animatedSize,
|
||||||
|
boxDecorationBreak: "clone",
|
||||||
|
WebkitBoxDecorationBreak: "clone",
|
||||||
|
} as React.CSSProperties
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ElementTag
|
||||||
|
ref={componentRef}
|
||||||
|
onMouseEnter={() => triggerType === "hover" && setIsHovered(true)}
|
||||||
|
onMouseLeave={() => triggerType === "hover" && setIsHovered(false)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<motion.span
|
||||||
|
className={cn("inline", className)}
|
||||||
|
style={highlightStyle}
|
||||||
|
animate={{
|
||||||
|
backgroundSize: animatedSize,
|
||||||
|
}}
|
||||||
|
initial={{
|
||||||
|
backgroundSize: initialSize,
|
||||||
|
}}
|
||||||
|
transition={transition}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.span>
|
||||||
|
</ElementTag>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
TextHighlighter.displayName = "TextHighlighter"
|
||||||
|
|
||||||
|
export default TextHighlighter
|
||||||
441
src/components/fancy/text/text-rotate.tsx
Normal file
441
src/components/fancy/text/text-rotate.tsx
Normal file
|
|
@ -0,0 +1,441 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
ElementType,
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from "react"
|
||||||
|
import {
|
||||||
|
AnimatePresence,
|
||||||
|
AnimatePresenceProps,
|
||||||
|
motion,
|
||||||
|
MotionProps,
|
||||||
|
Transition,
|
||||||
|
} from "motion/react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
// handy function to split text into characters with support for unicode and emojis
|
||||||
|
const splitIntoCharacters = (text: string): string[] => {
|
||||||
|
if (typeof Intl !== "undefined" && "Segmenter" in Intl) {
|
||||||
|
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" })
|
||||||
|
return Array.from(segmenter.segment(text), ({ segment }) => segment)
|
||||||
|
}
|
||||||
|
// Fallback for browsers that don't support Intl.Segmenter
|
||||||
|
return Array.from(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TextRotateProps {
|
||||||
|
/**
|
||||||
|
* Array of text strings to rotate through.
|
||||||
|
* Required prop with no default value.
|
||||||
|
*/
|
||||||
|
texts: string[]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* render as HTML Tag
|
||||||
|
*/
|
||||||
|
as?: ElementType
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Time in milliseconds between text rotations.
|
||||||
|
* @default 2000
|
||||||
|
*/
|
||||||
|
rotationInterval?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initial animation state or array of states.
|
||||||
|
* @default { y: "100%", opacity: 0 }
|
||||||
|
*/
|
||||||
|
initial?: MotionProps["initial"] | MotionProps["initial"][]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Animation state to animate to or array of states.
|
||||||
|
* @default { y: 0, opacity: 1 }
|
||||||
|
*/
|
||||||
|
animate?: MotionProps["animate"] | MotionProps["animate"][]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Animation state when exiting or array of states.
|
||||||
|
* @default { y: "-120%", opacity: 0 }
|
||||||
|
*/
|
||||||
|
exit?: MotionProps["exit"] | MotionProps["exit"][]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AnimatePresence mode
|
||||||
|
* @default "wait"
|
||||||
|
*/
|
||||||
|
animatePresenceMode?: AnimatePresenceProps["mode"]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to run initial animation on first render.
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
animatePresenceInitial?: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Duration of stagger delay between elements in seconds.
|
||||||
|
* @default 0
|
||||||
|
*/
|
||||||
|
staggerDuration?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Direction to stagger animations from.
|
||||||
|
* @default "first"
|
||||||
|
*/
|
||||||
|
staggerFrom?: "first" | "last" | "center" | number | "random"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Animation transition configuration.
|
||||||
|
* @default { type: "spring", damping: 25, stiffness: 300 }
|
||||||
|
*/
|
||||||
|
transition?: Transition
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to loop through texts continuously.
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
loop?: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to auto-rotate texts.
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
auto?: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How to split the text for animation.
|
||||||
|
* @default "characters"
|
||||||
|
*/
|
||||||
|
splitBy?: "words" | "characters" | "lines" | string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback function triggered when rotating to next text.
|
||||||
|
* @default undefined
|
||||||
|
*/
|
||||||
|
onNext?: (index: number) => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class name for the main container element.
|
||||||
|
* @default undefined
|
||||||
|
*/
|
||||||
|
mainClassName?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class name for the split level wrapper elements.
|
||||||
|
* @default undefined
|
||||||
|
*/
|
||||||
|
splitLevelClassName?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class name for individual animated elements.
|
||||||
|
* @default undefined
|
||||||
|
*/
|
||||||
|
elementLevelClassName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for the ref object exposed by TextRotate component.
|
||||||
|
* Provides methods to control text rotation programmatically.
|
||||||
|
* This allows external components to trigger text changes
|
||||||
|
* without relying on the automatic rotation.
|
||||||
|
*/
|
||||||
|
export interface TextRotateRef {
|
||||||
|
/**
|
||||||
|
* Advance to next text in sequence.
|
||||||
|
* If at the end, will loop to beginning if loop prop is true.
|
||||||
|
*/
|
||||||
|
next: () => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Go back to previous text in sequence.
|
||||||
|
* If at the start, will loop to end if loop prop is true.
|
||||||
|
*/
|
||||||
|
previous: () => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Jump to specific text by index.
|
||||||
|
* Will clamp index between 0 and texts.length - 1.
|
||||||
|
*/
|
||||||
|
jumpTo: (index: number) => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset back to first text.
|
||||||
|
* Equivalent to jumpTo(0).
|
||||||
|
*/
|
||||||
|
reset: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal interface for representing words when splitting text by characters.
|
||||||
|
* Used to maintain proper word spacing and line breaks while allowing
|
||||||
|
* character-by-character animation. This prevents words from breaking
|
||||||
|
* across lines during animation.
|
||||||
|
*/
|
||||||
|
interface WordObject {
|
||||||
|
/**
|
||||||
|
* Array of individual characters in the word.
|
||||||
|
* Uses Intl.Segmenter when available for proper Unicode handling.
|
||||||
|
*/
|
||||||
|
characters: string[]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this word needs a space after it.
|
||||||
|
* True for all words except the last one in a sequence.
|
||||||
|
*/
|
||||||
|
needsSpace: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const TextRotate = forwardRef<TextRotateRef, TextRotateProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
texts,
|
||||||
|
as = "p",
|
||||||
|
transition = { type: "spring", damping: 25, stiffness: 300 },
|
||||||
|
initial = { y: "100%", opacity: 0 },
|
||||||
|
animate = { y: 0, opacity: 1 },
|
||||||
|
exit = { y: "-120%", opacity: 0 },
|
||||||
|
animatePresenceMode = "wait",
|
||||||
|
animatePresenceInitial = false,
|
||||||
|
rotationInterval = 2000,
|
||||||
|
staggerDuration = 0,
|
||||||
|
staggerFrom = "first",
|
||||||
|
loop = true,
|
||||||
|
auto = true,
|
||||||
|
splitBy = "characters",
|
||||||
|
onNext,
|
||||||
|
mainClassName,
|
||||||
|
splitLevelClassName,
|
||||||
|
elementLevelClassName,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const [currentTextIndex, setCurrentTextIndex] = useState(0)
|
||||||
|
|
||||||
|
// Splitting the text into animation segments
|
||||||
|
const elements = useMemo(() => {
|
||||||
|
const currentText = texts[currentTextIndex]
|
||||||
|
if (splitBy === "characters") {
|
||||||
|
const text = currentText.split(" ")
|
||||||
|
return text.map((word, i) => ({
|
||||||
|
characters: splitIntoCharacters(word),
|
||||||
|
needsSpace: i !== text.length - 1,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
return splitBy === "words"
|
||||||
|
? currentText.split(" ")
|
||||||
|
: splitBy === "lines"
|
||||||
|
? currentText.split("\n")
|
||||||
|
: currentText.split(splitBy)
|
||||||
|
}, [texts, currentTextIndex, splitBy])
|
||||||
|
|
||||||
|
// Helper function to calculate stagger delay for each text segment
|
||||||
|
const getStaggerDelay = useCallback(
|
||||||
|
(index: number, totalChars: number) => {
|
||||||
|
const total = totalChars
|
||||||
|
if (staggerFrom === "first") return index * staggerDuration
|
||||||
|
if (staggerFrom === "last") return (total - 1 - index) * staggerDuration
|
||||||
|
if (staggerFrom === "center") {
|
||||||
|
const center = Math.floor(total / 2)
|
||||||
|
return Math.abs(center - index) * staggerDuration
|
||||||
|
}
|
||||||
|
if (staggerFrom === "random") {
|
||||||
|
const randomIndex = Math.floor(Math.random() * total)
|
||||||
|
return Math.abs(randomIndex - index) * staggerDuration
|
||||||
|
}
|
||||||
|
return Math.abs(staggerFrom - index) * staggerDuration
|
||||||
|
},
|
||||||
|
[staggerFrom, staggerDuration]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Helper function to handle index changes and trigger callback
|
||||||
|
const handleIndexChange = useCallback(
|
||||||
|
(newIndex: number) => {
|
||||||
|
setCurrentTextIndex(newIndex)
|
||||||
|
onNext?.(newIndex)
|
||||||
|
},
|
||||||
|
[onNext]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Go to next text
|
||||||
|
const next = useCallback(() => {
|
||||||
|
const nextIndex =
|
||||||
|
currentTextIndex === texts.length - 1
|
||||||
|
? loop
|
||||||
|
? 0
|
||||||
|
: currentTextIndex
|
||||||
|
: currentTextIndex + 1
|
||||||
|
|
||||||
|
if (nextIndex !== currentTextIndex) {
|
||||||
|
handleIndexChange(nextIndex)
|
||||||
|
}
|
||||||
|
}, [currentTextIndex, texts.length, loop, handleIndexChange])
|
||||||
|
|
||||||
|
// Go back to previous text
|
||||||
|
const previous = useCallback(() => {
|
||||||
|
const prevIndex =
|
||||||
|
currentTextIndex === 0
|
||||||
|
? loop
|
||||||
|
? texts.length - 1
|
||||||
|
: currentTextIndex
|
||||||
|
: currentTextIndex - 1
|
||||||
|
|
||||||
|
if (prevIndex !== currentTextIndex) {
|
||||||
|
handleIndexChange(prevIndex)
|
||||||
|
}
|
||||||
|
}, [currentTextIndex, texts.length, loop, handleIndexChange])
|
||||||
|
|
||||||
|
// Jump to specific text by index
|
||||||
|
const jumpTo = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
const validIndex = Math.max(0, Math.min(index, texts.length - 1))
|
||||||
|
if (validIndex !== currentTextIndex) {
|
||||||
|
handleIndexChange(validIndex)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[texts.length, currentTextIndex, handleIndexChange]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reset back to first text
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
if (currentTextIndex !== 0) {
|
||||||
|
handleIndexChange(0)
|
||||||
|
}
|
||||||
|
}, [currentTextIndex, handleIndexChange])
|
||||||
|
|
||||||
|
// Get animation props for each text segment. If array is provided, states will be mapped to text segments cyclically.
|
||||||
|
const getAnimationProps = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
const getProp = (
|
||||||
|
prop:
|
||||||
|
| MotionProps["initial"]
|
||||||
|
| MotionProps["initial"][]
|
||||||
|
| MotionProps["animate"]
|
||||||
|
| MotionProps["animate"][]
|
||||||
|
| MotionProps["exit"]
|
||||||
|
| MotionProps["exit"][]
|
||||||
|
) => {
|
||||||
|
if (Array.isArray(prop)) {
|
||||||
|
return prop[index % prop.length]
|
||||||
|
}
|
||||||
|
return prop
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
initial: getProp(initial) as MotionProps["initial"],
|
||||||
|
animate: getProp(animate) as MotionProps["animate"],
|
||||||
|
exit: getProp(exit) as MotionProps["exit"],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[initial, animate, exit]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Expose all navigation functions via ref
|
||||||
|
useImperativeHandle(
|
||||||
|
ref,
|
||||||
|
() => ({
|
||||||
|
next,
|
||||||
|
previous,
|
||||||
|
jumpTo,
|
||||||
|
reset,
|
||||||
|
}),
|
||||||
|
[next, previous, jumpTo, reset]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Auto-rotate text
|
||||||
|
useEffect(() => {
|
||||||
|
if (!auto) return
|
||||||
|
const intervalId = setInterval(next, rotationInterval)
|
||||||
|
return () => clearInterval(intervalId)
|
||||||
|
}, [next, rotationInterval, auto])
|
||||||
|
|
||||||
|
// Custom motion component to render the text as a custom HTML tag provided via prop
|
||||||
|
const MotionComponent = useMemo(() => motion.create(as ?? "p"), [as])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MotionComponent
|
||||||
|
className={cn("flex flex-wrap whitespace-pre-wrap", mainClassName)}
|
||||||
|
transition={transition}
|
||||||
|
layout
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="sr-only">{texts[currentTextIndex]}</span>
|
||||||
|
|
||||||
|
<AnimatePresence
|
||||||
|
mode={animatePresenceMode}
|
||||||
|
initial={animatePresenceInitial}
|
||||||
|
>
|
||||||
|
<motion.span
|
||||||
|
key={currentTextIndex}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-wrap justify-center",
|
||||||
|
splitBy === "lines" && "flex-col w-full"
|
||||||
|
)}
|
||||||
|
aria-hidden
|
||||||
|
layout
|
||||||
|
>
|
||||||
|
{(splitBy === "characters"
|
||||||
|
? (elements as WordObject[])
|
||||||
|
: (elements as string[]).map((el, i) => ({
|
||||||
|
characters: [el],
|
||||||
|
needsSpace: i !== elements.length - 1,
|
||||||
|
}))
|
||||||
|
).map((wordObj, wordIndex, array) => {
|
||||||
|
const previousCharsCount = array
|
||||||
|
.slice(0, wordIndex)
|
||||||
|
.reduce((sum, word) => sum + word.characters.length, 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={wordIndex}
|
||||||
|
className={cn("inline-flex", splitLevelClassName)}
|
||||||
|
>
|
||||||
|
{wordObj.characters.map((char, charIndex) => {
|
||||||
|
const totalIndex = previousCharsCount + charIndex
|
||||||
|
const animationProps = getAnimationProps(totalIndex)
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={totalIndex}
|
||||||
|
className={cn(elementLevelClassName)}
|
||||||
|
>
|
||||||
|
<motion.span
|
||||||
|
{...animationProps}
|
||||||
|
key={charIndex}
|
||||||
|
transition={{
|
||||||
|
...transition,
|
||||||
|
delay: getStaggerDelay(
|
||||||
|
previousCharsCount + charIndex,
|
||||||
|
array.reduce(
|
||||||
|
(sum, word) => sum + word.characters.length,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
className={"inline-block"}
|
||||||
|
>
|
||||||
|
{char}
|
||||||
|
</motion.span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{wordObj.needsSpace && (
|
||||||
|
<span className="whitespace-pre"> </span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</motion.span>
|
||||||
|
</AnimatePresence>
|
||||||
|
</MotionComponent>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
TextRotate.displayName = "TextRotate"
|
||||||
|
|
||||||
|
export default TextRotate
|
||||||
230
src/components/fancy/text/vertical-cut-reveal.tsx
Normal file
230
src/components/fancy/text/vertical-cut-reveal.tsx
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { AnimationOptions, motion } from "motion/react"
|
||||||
|
import {
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
interface TextProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
reverse?: boolean
|
||||||
|
transition?: AnimationOptions
|
||||||
|
splitBy?: "words" | "characters" | "lines" | string
|
||||||
|
staggerDuration?: number
|
||||||
|
staggerFrom?: "first" | "last" | "center" | "random" | number
|
||||||
|
containerClassName?: string
|
||||||
|
wordLevelClassName?: string
|
||||||
|
elementLevelClassName?: string
|
||||||
|
onClick?: () => void
|
||||||
|
onStart?: () => void
|
||||||
|
onComplete?: () => void
|
||||||
|
autoStart?: boolean // Whether to start the animation automatically
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ref interface to allow external control of the animation
|
||||||
|
export interface VerticalCutRevealRef {
|
||||||
|
startAnimation: () => void
|
||||||
|
reset: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WordObject {
|
||||||
|
characters: string[]
|
||||||
|
needsSpace: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const VerticalCutReveal = forwardRef<VerticalCutRevealRef, TextProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
children,
|
||||||
|
reverse = false,
|
||||||
|
transition = {
|
||||||
|
type: "spring",
|
||||||
|
stiffness: 190,
|
||||||
|
damping: 22,
|
||||||
|
},
|
||||||
|
splitBy = "words",
|
||||||
|
staggerDuration = 0.2,
|
||||||
|
staggerFrom = "first",
|
||||||
|
containerClassName,
|
||||||
|
wordLevelClassName,
|
||||||
|
elementLevelClassName,
|
||||||
|
onClick,
|
||||||
|
onStart,
|
||||||
|
onComplete,
|
||||||
|
autoStart = true,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const containerRef = useRef<HTMLSpanElement>(null)
|
||||||
|
const text =
|
||||||
|
typeof children === "string" ? children : children?.toString() || ""
|
||||||
|
const [isAnimating, setIsAnimating] = useState(false)
|
||||||
|
|
||||||
|
// handy function to split text into characters with support for unicode and emojis
|
||||||
|
const splitIntoCharacters = (text: string): string[] => {
|
||||||
|
if (typeof Intl !== "undefined" && "Segmenter" in Intl) {
|
||||||
|
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" })
|
||||||
|
return Array.from(segmenter.segment(text), ({ segment }) => segment)
|
||||||
|
}
|
||||||
|
// Fallback for browsers that don't support Intl.Segmenter
|
||||||
|
return Array.from(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split text based on splitBy parameter
|
||||||
|
const elements = useMemo(() => {
|
||||||
|
const words = text.split(" ")
|
||||||
|
if (splitBy === "characters") {
|
||||||
|
return words.map((word, i) => ({
|
||||||
|
characters: splitIntoCharacters(word),
|
||||||
|
needsSpace: i !== words.length - 1,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
return splitBy === "words"
|
||||||
|
? text.split(" ")
|
||||||
|
: splitBy === "lines"
|
||||||
|
? text.split("\n")
|
||||||
|
: text.split(splitBy)
|
||||||
|
}, [text, splitBy])
|
||||||
|
|
||||||
|
// Calculate stagger delays based on staggerFrom
|
||||||
|
const getStaggerDelay = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
const total =
|
||||||
|
splitBy === "characters"
|
||||||
|
? elements.reduce(
|
||||||
|
(acc, word) =>
|
||||||
|
acc +
|
||||||
|
(typeof word === "string"
|
||||||
|
? 1
|
||||||
|
: word.characters.length + (word.needsSpace ? 1 : 0)),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
: elements.length
|
||||||
|
if (staggerFrom === "first") return index * staggerDuration
|
||||||
|
if (staggerFrom === "last") return (total - 1 - index) * staggerDuration
|
||||||
|
if (staggerFrom === "center") {
|
||||||
|
const center = Math.floor(total / 2)
|
||||||
|
return Math.abs(center - index) * staggerDuration
|
||||||
|
}
|
||||||
|
if (staggerFrom === "random") {
|
||||||
|
const randomIndex = Math.floor(Math.random() * total)
|
||||||
|
return Math.abs(randomIndex - index) * staggerDuration
|
||||||
|
}
|
||||||
|
return Math.abs(staggerFrom - index) * staggerDuration
|
||||||
|
},
|
||||||
|
[elements.length, staggerFrom, staggerDuration]
|
||||||
|
)
|
||||||
|
|
||||||
|
const startAnimation = useCallback(() => {
|
||||||
|
setIsAnimating(true)
|
||||||
|
onStart?.()
|
||||||
|
}, [onStart])
|
||||||
|
|
||||||
|
// Expose the startAnimation function via ref
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
startAnimation,
|
||||||
|
reset: () => setIsAnimating(false),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Auto start animation when element enters viewport
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoStart || !containerRef.current) return
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
startAnimation()
|
||||||
|
observer.disconnect()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.1 }
|
||||||
|
)
|
||||||
|
observer.observe(containerRef.current)
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [autoStart, startAnimation])
|
||||||
|
|
||||||
|
const variants = {
|
||||||
|
hidden: { y: reverse ? "-100%" : "100%" },
|
||||||
|
visible: (i: number) => ({
|
||||||
|
y: 0,
|
||||||
|
transition: {
|
||||||
|
...transition,
|
||||||
|
delay: ((transition?.delay as number) || 0) + getStaggerDelay(i),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
containerClassName,
|
||||||
|
"flex flex-wrap whitespace-pre-wrap",
|
||||||
|
splitBy === "lines" && "flex-col"
|
||||||
|
)}
|
||||||
|
onClick={onClick}
|
||||||
|
ref={containerRef}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="sr-only">{text}</span>
|
||||||
|
|
||||||
|
{(splitBy === "characters"
|
||||||
|
? (elements as WordObject[])
|
||||||
|
: (elements as string[]).map((el, i) => ({
|
||||||
|
characters: [el],
|
||||||
|
needsSpace: i !== elements.length - 1,
|
||||||
|
}))
|
||||||
|
).map((wordObj, wordIndex, array) => {
|
||||||
|
const previousCharsCount = array
|
||||||
|
.slice(0, wordIndex)
|
||||||
|
.reduce((sum, word) => sum + word.characters.length, 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={wordIndex}
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("inline-flex overflow-hidden", wordLevelClassName)}
|
||||||
|
>
|
||||||
|
{wordObj.characters.map((char, charIndex) => (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
elementLevelClassName,
|
||||||
|
"whitespace-pre-wrap relative"
|
||||||
|
)}
|
||||||
|
key={charIndex}
|
||||||
|
>
|
||||||
|
<motion.span
|
||||||
|
custom={previousCharsCount + charIndex}
|
||||||
|
initial="hidden"
|
||||||
|
animate={isAnimating ? "visible" : "hidden"}
|
||||||
|
variants={variants}
|
||||||
|
onAnimationComplete={
|
||||||
|
wordIndex === elements.length - 1 &&
|
||||||
|
charIndex === wordObj.characters.length - 1
|
||||||
|
? onComplete
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
className="inline-block"
|
||||||
|
>
|
||||||
|
{char}
|
||||||
|
</motion.span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{wordObj.needsSpace && <span> </span>}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
VerticalCutReveal.displayName = "VerticalCutReveal"
|
||||||
|
export default VerticalCutReveal
|
||||||
31
src/hooks/use-dimensions.ts
Normal file
31
src/hooks/use-dimensions.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { RefObject, useEffect, useState } from "react"
|
||||||
|
|
||||||
|
interface Dimensions {
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDimensions(
|
||||||
|
ref: RefObject<HTMLElement | SVGElement | null>
|
||||||
|
): Dimensions {
|
||||||
|
const [dimensions, setDimensions] = useState<Dimensions>({
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateDimensions = () => {
|
||||||
|
if (ref.current) {
|
||||||
|
const { width, height } = ref.current.getBoundingClientRect()
|
||||||
|
setDimensions({ width, height })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDimensions()
|
||||||
|
window.addEventListener("resize", updateDimensions)
|
||||||
|
|
||||||
|
return () => window.removeEventListener("resize", updateDimensions)
|
||||||
|
}, [ref])
|
||||||
|
|
||||||
|
return dimensions
|
||||||
|
}
|
||||||
42
src/hooks/use-mouse-position-ref.ts
Normal file
42
src/hooks/use-mouse-position-ref.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { RefObject, useEffect, useRef } from "react"
|
||||||
|
|
||||||
|
export const useMousePositionRef = (
|
||||||
|
containerRef?: RefObject<HTMLElement | SVGElement | null>
|
||||||
|
) => {
|
||||||
|
const positionRef = useRef({ x: 0, y: 0 })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updatePosition = (x: number, y: number) => {
|
||||||
|
if (containerRef && containerRef.current) {
|
||||||
|
const rect = containerRef.current.getBoundingClientRect()
|
||||||
|
const relativeX = x - rect.left
|
||||||
|
const relativeY = y - rect.top
|
||||||
|
|
||||||
|
// Calculate relative position even when outside the container
|
||||||
|
positionRef.current = { x: relativeX, y: relativeY }
|
||||||
|
} else {
|
||||||
|
positionRef.current = { x, y }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseMove = (ev: MouseEvent) => {
|
||||||
|
updatePosition(ev.clientX, ev.clientY)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTouchMove = (ev: TouchEvent) => {
|
||||||
|
const touch = ev.touches[0]
|
||||||
|
updatePosition(touch.clientX, touch.clientY)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for both mouse and touch events
|
||||||
|
window.addEventListener("mousemove", handleMouseMove)
|
||||||
|
window.addEventListener("touchmove", handleTouchMove)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("mousemove", handleMouseMove)
|
||||||
|
window.removeEventListener("touchmove", handleTouchMove)
|
||||||
|
}
|
||||||
|
}, [containerRef])
|
||||||
|
|
||||||
|
return positionRef
|
||||||
|
}
|
||||||
|
|
@ -1,47 +1,108 @@
|
||||||
{
|
{
|
||||||
"nav": {
|
"nav": {
|
||||||
"demo": "Demo",
|
"demo": "Demo",
|
||||||
"docs": "Dokumentácia",
|
"docs": "Dokumentace",
|
||||||
"wild": "V praxi",
|
"wild": "V praxi",
|
||||||
"contact": "Kontakt"
|
"contact": "Kontakt"
|
||||||
},
|
},
|
||||||
"hero": {
|
"hero": {
|
||||||
"tagline": "Zbav sa reťazí.",
|
"tagline": "Zbav se řetězí.",
|
||||||
"subtitle": "Vlastni svoj obchod.",
|
"subtitleRotations": [
|
||||||
"description": "Open-source eCommerce platforma bez SaaS závislostí. Tvoj obchod, tvoje dáta, žiadni páni.",
|
"Vlastni svůj obchod.",
|
||||||
"cta": "Vyskúšať demo",
|
"Tvá data, tvá pravidla.",
|
||||||
"docs": "Dokumentácia"
|
"Žá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": {
|
"problem": {
|
||||||
"title": "Dosť bolo slop-u.",
|
"title": "Dost bylo slop-u.",
|
||||||
"subtitle": "Tisíce eur za WordPress z roku 2015",
|
"subtitle": "Tisíce eur za WordPress z roku 2015",
|
||||||
"cards": [
|
"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": "4.2s load time",
|
||||||
{ "title": "€99/mesiac", "desc": "Premium plugin. Potom ďalší. A ďalší. Na TVOJOM webe." },
|
"suffix": "%",
|
||||||
{ "title": "€3,500 setup", "desc": "Za starú šablónu a import 20 produktov. Bez školenia." }
|
"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": {
|
"features": {
|
||||||
"title": "Všetko čo potrebuješ.",
|
"title": "Vše co potřebuješ.",
|
||||||
"subtitle": "Nič čo nepotrebuješ.",
|
"subtitle": "Nic co nepotřebuješ.",
|
||||||
"items": [
|
"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": "Shield",
|
||||||
{ "icon": "BarChart3", "title": "First-party analytika", "desc": "Všetky GA4 eventy ako tvoje vlastné dáta. GDPR friendly, žiadne cookie bannery."},
|
"title": "Žádné SaaS",
|
||||||
{ "icon": "Blocks", "title": "Blokový CMS", "desc": "Dynamické stránky, blog, landing pages. Blokový editor ako v Notion."},
|
"desc": "Žádný Algolia, Cookiebot, ani měsíční poplatky. Vše běží na tvém serveru."
|
||||||
{ "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": "Globe2",
|
||||||
{ "icon": "Layers", "title": "Taxonómie", "desc": "Kategórie, tagy, vlastné hierarchie. Všetko s prekladmi a SEO."},
|
"title": "Multi-channel",
|
||||||
{ "icon": "Zap", "title": "Pod 1MB", "desc": "Celý backend bez závislostí. Čistý, udržiavateľný, rýchly ako blesk."}
|
"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": {
|
"speed": {
|
||||||
"title": "Rýchlosť záleží.",
|
"title": "Rychlost záleží.",
|
||||||
"subtitle": "Hlavne na 3G v slovenskej dedinke.",
|
"subtitle": "Hlavně na 3G ve slovenské vesničce.",
|
||||||
"selector": "Vyber pripojenie:",
|
"selector": "Vyber připojení:",
|
||||||
"networks": ["WiFi", "4G", "3G", "2G"],
|
"networks": [
|
||||||
|
"WiFi",
|
||||||
|
"4G",
|
||||||
|
"3G",
|
||||||
|
"2G"
|
||||||
|
],
|
||||||
"units": {
|
"units": {
|
||||||
"time": "ms",
|
"time": "ms",
|
||||||
"size": "KB",
|
"size": "KB",
|
||||||
|
|
@ -49,39 +110,168 @@
|
||||||
},
|
},
|
||||||
"metrics": {
|
"metrics": {
|
||||||
"productPage": "Produktová stránka",
|
"productPage": "Produktová stránka",
|
||||||
"categoryNav": "Navigácia + filtre",
|
"categoryNav": "Navigace + filtry",
|
||||||
"search": "Vyhľadávanie",
|
"search": "Vyhledávání",
|
||||||
"checkout": "Checkout flow",
|
"checkout": "Checkout flow",
|
||||||
"admin": "Admin panel"
|
"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": {
|
"screenshots": {
|
||||||
"title": "Pozri sa dovnútra.",
|
"title": "Podívej se dovnitř.",
|
||||||
"tabs": ["Admin", "Obchod", "Objednávky", "CMS"]
|
"tabs": [
|
||||||
|
"Admin",
|
||||||
|
"Obchod",
|
||||||
|
"Objednávky",
|
||||||
|
"CMS"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"wild": {
|
"wild": {
|
||||||
"title": "V divočine",
|
"title": "V divočině",
|
||||||
"subtitle": "Projekty bežiace na AhojSvet",
|
"subtitle": "Projekty běžící na AhojSvet",
|
||||||
"cta": "Pridaj svoj projekt"
|
"cta": "Přidej svůj projekt"
|
||||||
},
|
},
|
||||||
"contact": {
|
"contact": {
|
||||||
"title": "Poďme sa baviť.",
|
"title": "Pojďme se bavit.",
|
||||||
"subtitle": "Všeobecné otázky alebo partnership",
|
"subtitle": "Obecné dotazy nebo partnership",
|
||||||
"name": "Meno",
|
"name": "Jméno",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
"company": "Spoločnosť",
|
"company": "Společnost",
|
||||||
"type": "Typ správy",
|
"type": "Typ zprávy",
|
||||||
"types": ["Všeobecný dotaz", "Budúci projekt", "Partnerstvo", "Podpora"],
|
"types": [
|
||||||
"message": "Správa",
|
"Obecný dotaz",
|
||||||
"send": "Poslať správu",
|
"Příští projekt",
|
||||||
"success": "Správa bola odoslaná"
|
"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": {
|
"cta": {
|
||||||
"title": "Pripravení na zmenu?",
|
"title": "Připraveni ke změně?",
|
||||||
"subtitle": "Vyberte AhojSvet a začnite revolúciu",
|
"subtitle": "Prohlédni si demo knížkárny — skutečný obchod, živá data.",
|
||||||
"button": "Začať hneď"
|
"button": "Otevřít demo obchod"
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"tagline": "© 2024 AhojSvet.eu. Všetky práva vyhradené."
|
"tagline": "© 2026 AhojSvet.eu. Všechna práva vyhrazena."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,87 +1,277 @@
|
||||||
{
|
{
|
||||||
"nav": {
|
"nav": {
|
||||||
"demo": "Demo",
|
"demo": "Demo",
|
||||||
"docs": "Dokumentácia",
|
"docs": "Documentation",
|
||||||
"wild": "V praxi",
|
"wild": "In practice",
|
||||||
"contact": "Kontakt"
|
"contact": "Contact"
|
||||||
},
|
},
|
||||||
"hero": {
|
"hero": {
|
||||||
"tagline": "Zbav sa reťazí.",
|
"tagline": "Break free.",
|
||||||
"subtitle": "Vlastni svoj obchod.",
|
"subtitleRotations": [
|
||||||
"description": "Open-source eCommerce platforma bez SaaS závislostí. Tvoj obchod, tvoje dáta, žiadni páni.",
|
"Own your store.",
|
||||||
"cta": "Vyskúšať demo",
|
"Your data, your rules.",
|
||||||
"docs": "Dokumentácia"
|
"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": {
|
"problem": {
|
||||||
"title": "Dosť bolo slop-u.",
|
"title": "Enough of the slop.",
|
||||||
"subtitle": "Tisíce eur za WordPress z roku 2015",
|
"subtitle": "Thousands of euros for WordPress from 2015",
|
||||||
"cards": [
|
"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": "4.2s load time",
|
||||||
{ "title": "€99/mesiac", "desc": "Premium plugin. Potom ďalší. A ďalší. Na TVOJOM webe." },
|
"suffix": "%",
|
||||||
{ "title": "€3,500 setup", "desc": "Za starú šablónu a import 20 produktov. Bez školenia." }
|
"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": {
|
"features": {
|
||||||
"title": "Všetko čo potrebuješ.",
|
"title": "Everything you need.",
|
||||||
"subtitle": "Nič čo nepotrebuješ.",
|
"subtitle": "Nothing you don't need.",
|
||||||
"items": [
|
"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": "Shield",
|
||||||
{ "icon": "BarChart3", "title": "First-party analytika", "desc": "Všetky GA4 eventy ako tvoje vlastné dáta. GDPR friendly, žiadne cookie bannery."},
|
"title": "No SaaS",
|
||||||
{ "icon": "Blocks", "title": "Blokový CMS", "desc": "Dynamické stránky, blog, landing pages. Blokový editor ako v Notion."},
|
"desc": "No Algolia, Cookiebot, or monthly fees. Everything runs on your server."
|
||||||
{ "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": "Globe2",
|
||||||
{ "icon": "Layers", "title": "Taxonómie", "desc": "Kategórie, tagy, vlastné hierarchie. Všetko s prekladmi a SEO."},
|
"title": "Multi-channel",
|
||||||
{ "icon": "Zap", "title": "Pod 1MB", "desc": "Celý backend bez závislostí. Čistý, udržiavateľný, rýchly ako blesk."}
|
"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": {
|
"speed": {
|
||||||
"title": "Rýchlosť záleží.",
|
"title": "Speed matters.",
|
||||||
"subtitle": "Hlavne na 3G v slovenskej dedinke.",
|
"subtitle": "Mainly 3G in a Slovak village.",
|
||||||
"selector": "Vyber pripojenie:",
|
"selector": "Select connection:",
|
||||||
"networks": ["WiFi", "4G", "3G", "2G"],
|
"networks": [
|
||||||
|
"WiFi",
|
||||||
|
"4G",
|
||||||
|
"3G",
|
||||||
|
"2G"
|
||||||
|
],
|
||||||
"units": {
|
"units": {
|
||||||
"time": "ms",
|
"time": "ms",
|
||||||
"size": "KB",
|
"size": "KB",
|
||||||
"requests": "req"
|
"requests": "req"
|
||||||
},
|
},
|
||||||
"metrics": {
|
"metrics": {
|
||||||
"productPage": "Produktová stránka",
|
"productPage": "Product page",
|
||||||
"categoryNav": "Navigácia + filtre",
|
"categoryNav": "Navigation + filters",
|
||||||
"search": "Vyhľadávanie",
|
"search": "Search",
|
||||||
"checkout": "Checkout flow",
|
"checkout": "Checkout flow",
|
||||||
"admin": "Admin panel"
|
"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": {
|
"screenshots": {
|
||||||
"title": "Pozri sa dovnútra.",
|
"title": "Take a look inside.",
|
||||||
"tabs": ["Admin", "Obchod", "Objednávky", "CMS"]
|
"tabs": [
|
||||||
|
"Admin",
|
||||||
|
"Shop",
|
||||||
|
"Orders",
|
||||||
|
"CMS"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"wild": {
|
"wild": {
|
||||||
"title": "V divočine",
|
"title": "In the Wild",
|
||||||
"subtitle": "Projekty bežiace na AhojSvet",
|
"subtitle": "Projects Running on AhojSvet",
|
||||||
"cta": "Pridaj svoj projekt"
|
"cta": "Add Your Project"
|
||||||
},
|
},
|
||||||
"contact": {
|
"contact": {
|
||||||
"title": "Poďme sa baviť.",
|
"title": "Let's Have Fun.",
|
||||||
"subtitle": "Všeobecné otázky alebo partnership",
|
"subtitle": "General Questions or Partnerships",
|
||||||
"name": "Meno",
|
"name": "Name",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
"company": "Spoločnosť",
|
"company": "Company",
|
||||||
"type": "Typ správy",
|
"type": "Message Type",
|
||||||
"types": ["Všeobecný dotaz", "Budúci projekt", "Partnerstvo", "Podpora"],
|
"types": [
|
||||||
"message": "Správa",
|
"General Inquiry",
|
||||||
"send": "Poslať správu",
|
"Upcoming Project",
|
||||||
"success": "Správa bola odoslaná"
|
"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": {
|
"cta": {
|
||||||
"title": "Pripravení na zmenu?",
|
"title": "Ready for a Change?",
|
||||||
"subtitle": "Vyberte AhojSvet a začnite revolúciu",
|
"subtitle": "View Demo Bookstore — Real Store, Live Data.",
|
||||||
"button": "Začať hneď"
|
"button": "Open Demo Store"
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"tagline": "© 2024 AhojSvet.eu. Všetky práva vyhradené."
|
"tagline": "© 2026 AhojSvet.eu. All rights reserved."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -7,7 +7,11 @@
|
||||||
},
|
},
|
||||||
"hero": {
|
"hero": {
|
||||||
"tagline": "Zbav sa reťazí.",
|
"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.",
|
"description": "Open-source eCommerce platforma bez SaaS závislostí. Tvoj obchod, tvoje dáta, žiadni páni.",
|
||||||
"cta": "Vyskúšať demo",
|
"cta": "Vyskúšať demo",
|
||||||
"docs": "Dokumentácia"
|
"docs": "Dokumentácia"
|
||||||
|
|
@ -16,32 +20,89 @@
|
||||||
"title": "Dosť bolo slop-u.",
|
"title": "Dosť bolo slop-u.",
|
||||||
"subtitle": "Tisíce eur za WordPress z roku 2015",
|
"subtitle": "Tisíce eur za WordPress z roku 2015",
|
||||||
"cards": [
|
"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": "4.2s load time",
|
||||||
{ "title": "€99/mesiac", "desc": "Premium plugin. Potom ďalší. A ďalší. Na TVOJOM webe." },
|
"suffix": "%",
|
||||||
{ "title": "€3,500 setup", "desc": "Za starú šablónu a import 20 produktov. Bez školenia." }
|
"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": {
|
"features": {
|
||||||
"title": "Všetko čo potrebuješ.",
|
"title": "Všetko čo potrebuješ.",
|
||||||
"subtitle": "Nič čo nepotrebuješ.",
|
"subtitle": "Nič čo nepotrebuješ.",
|
||||||
"items": [
|
"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": "Shield",
|
||||||
{ "icon": "BarChart3", "title": "First-party analytika", "desc": "Všetky GA4 eventy ako tvoje vlastné dáta. GDPR friendly, žiadne cookie bannery."},
|
"title": "Žiadne SaaS",
|
||||||
{ "icon": "Blocks", "title": "Blokový CMS", "desc": "Dynamické stránky, blog, landing pages. Blokový editor ako v Notion."},
|
"desc": "Žiadny Algolia, Cookiebot, ani mesačné poplatky. Všetko beží na tvojom serveri."
|
||||||
{ "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": "Globe2",
|
||||||
{ "icon": "Layers", "title": "Taxonómie", "desc": "Kategórie, tagy, vlastné hierarchie. Všetko s prekladmi a SEO."},
|
"title": "Multi-channel",
|
||||||
{ "icon": "Zap", "title": "Pod 1MB", "desc": "Celý backend bez závislostí. Čistý, udržiavateľný, rýchly ako blesk."}
|
"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": {
|
"speed": {
|
||||||
"title": "Rýchlosť záleží.",
|
"title": "Rýchlosť záleží.",
|
||||||
"subtitle": "Hlavne na 3G v slovenskej dedinke.",
|
"subtitle": "Hlavne na 3G v slovenskej dedinke.",
|
||||||
"selector": "Vyber pripojenie:",
|
"selector": "Vyber pripojenie:",
|
||||||
"networks": ["WiFi", "4G", "3G", "2G"],
|
"networks": [
|
||||||
|
"WiFi",
|
||||||
|
"4G",
|
||||||
|
"3G",
|
||||||
|
"2G"
|
||||||
|
],
|
||||||
"units": {
|
"units": {
|
||||||
"time": "ms",
|
"time": "ms",
|
||||||
"size": "KB",
|
"size": "KB",
|
||||||
|
|
@ -53,7 +114,9 @@
|
||||||
"search": "Vyhľadávanie",
|
"search": "Vyhľadávanie",
|
||||||
"checkout": "Checkout flow",
|
"checkout": "Checkout flow",
|
||||||
"admin": "Admin panel"
|
"admin": "Admin panel"
|
||||||
}
|
},
|
||||||
|
"showMore": "Ukáž zvyšok",
|
||||||
|
"showLess": "Skryť"
|
||||||
},
|
},
|
||||||
"iceberg": {
|
"iceberg": {
|
||||||
"title": "Ľadovec WordPress-u.",
|
"title": "Ľadovec WordPress-u.",
|
||||||
|
|
@ -63,37 +126,116 @@
|
||||||
"revealBtn": "Ukáž zvyšok",
|
"revealBtn": "Ukáž zvyšok",
|
||||||
"hideBtn": "Skryť",
|
"hideBtn": "Skryť",
|
||||||
"tip": [
|
"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": [
|
"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": "plugin",
|
||||||
{ "icon": "maintenance", "label": "Mesačná \"údržba\"", "desc": "Agentúra ti účtuje za kliknutie na 'Update All'. Každý mesiac.", "severity": 2 },
|
"label": "Premium pluginy",
|
||||||
{ "icon": "conflict", "label": "Plugin konflikty", "desc": "Aktualizuješ jeden plugin, rozbije sa checkout. Urgentný ticket.", "severity": 3 },
|
"desc": "Elementor Pro, WooCommerce extensions, SEO, zálohy... každý s vlastným predplatným.",
|
||||||
{ "icon": "security", "label": "Bezpečnostné záplaty", "desc": "WordPress je #1 cieľ hackerov. Záplaty, firewall pluginy, monitoring.", "severity": 2 },
|
"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": "hosting",
|
||||||
{ "icon": "email", "label": "Email marketing", "desc": "Mailchimp, Klaviyo... ďalšia mesačná služba s vlastným cenníkom.", "severity": 1 },
|
"label": "Managed hosting",
|
||||||
{ "icon": "dev", "label": "Dev hodiny × 2", "desc": "Plugin spaghetti = každá úprava trvá dvakrát dlhšie. A stojí dvakrát viac.", "severity": 3 }
|
"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": {
|
"ahojsvet": {
|
||||||
"title": "AhojSvet",
|
"title": "AhojSvet",
|
||||||
"subtitle": "Žiadny ľadovec. Žiadne prekvapenia.",
|
"subtitle": "Žiadny ľadovec. Žiadne prekvapenia.",
|
||||||
"points": [
|
"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": "Jednorázový setup",
|
||||||
{ "label": "Všetko vstavané", "desc": "Analytika, emaily, CMS, vyhľadávanie — bez tretích strán." },
|
"desc": "Zaplatíš raz. Vlastníš navždy."
|
||||||
{ "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": "€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 →"
|
"cta": "Spýtaj sa na cenu →"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"screenshots": {
|
"screenshots": {
|
||||||
"title": "Pozri sa dovnútra.",
|
"title": "Pozri sa dovnútra.",
|
||||||
"tabs": ["Admin", "Obchod", "Objednávky", "CMS"]
|
"tabs": [
|
||||||
|
"Admin",
|
||||||
|
"Obchod",
|
||||||
|
"Objednávky",
|
||||||
|
"CMS"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"wild": {
|
"wild": {
|
||||||
"title": "V divočine",
|
"title": "V divočine",
|
||||||
|
|
@ -107,17 +249,29 @@
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
"company": "Spoločnosť",
|
"company": "Spoločnosť",
|
||||||
"type": "Typ správy",
|
"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",
|
"message": "Správa",
|
||||||
"send": "Poslať správu",
|
"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": {
|
"cta": {
|
||||||
"title": "Pripravení na zmenu?",
|
"title": "Pripravení na zmenu?",
|
||||||
"subtitle": "Vyberte AhojSvet a začnite revolúciu",
|
"subtitle": "Prezri si demo knižkárne — skutočný obchod, živé dáta.",
|
||||||
"button": "Začať hneď"
|
"button": "Otvoriť demo obchod"
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"tagline": "© 2024 AhojSvet.eu. Všetky práva vyhradené."
|
"tagline": "© 2026 AhojSvet.eu. Všetky práva vyhradené."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
19
src/utils/calculate-position.ts
Normal file
19
src/utils/calculate-position.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
export function calculatePosition(
|
||||||
|
value: number | string | undefined,
|
||||||
|
containerSize: number,
|
||||||
|
elementSize: number
|
||||||
|
): number {
|
||||||
|
// Handle percentage strings (e.g. "50%")
|
||||||
|
if (typeof value === "string" && value.endsWith("%")) {
|
||||||
|
const percentage = parseFloat(value) / 100
|
||||||
|
return containerSize * percentage
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle direct pixel values
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no value provided, center the element
|
||||||
|
return (containerSize - elementSize) / 2
|
||||||
|
}
|
||||||
38
src/utils/svg-path-to-vertices.ts
Normal file
38
src/utils/svg-path-to-vertices.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import SVGPathCommander from "svg-path-commander"
|
||||||
|
|
||||||
|
// Function to convert SVG path `d` to vertices
|
||||||
|
export function parsePathToVertices(path: string, sampleLength = 15) {
|
||||||
|
// Convert path to absolute commands
|
||||||
|
const commander = new SVGPathCommander(path)
|
||||||
|
|
||||||
|
const points: { x: number; y: number }[] = []
|
||||||
|
let lastPoint: { x: number; y: number } | null = null
|
||||||
|
|
||||||
|
// Get total length of the path
|
||||||
|
const totalLength = commander.getTotalLength()
|
||||||
|
let length = 0
|
||||||
|
|
||||||
|
// Sample points along the path
|
||||||
|
while (length < totalLength) {
|
||||||
|
const point = commander.getPointAtLength(length)
|
||||||
|
|
||||||
|
// Only add point if it's different from the last one
|
||||||
|
if (!lastPoint || point.x !== lastPoint.x || point.y !== lastPoint.y) {
|
||||||
|
points.push({ x: point.x, y: point.y })
|
||||||
|
lastPoint = point
|
||||||
|
}
|
||||||
|
|
||||||
|
length += sampleLength
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure we get the last point
|
||||||
|
const finalPoint = commander.getPointAtLength(totalLength)
|
||||||
|
if (
|
||||||
|
lastPoint &&
|
||||||
|
(finalPoint.x !== lastPoint.x || finalPoint.y !== lastPoint.y)
|
||||||
|
) {
|
||||||
|
points.push({ x: finalPoint.x, y: finalPoint.y })
|
||||||
|
}
|
||||||
|
|
||||||
|
return points
|
||||||
|
}
|
||||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
24
tsconfig.json
Normal file
24
tsconfig.json
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
|
import path from "path"
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue