Restaurare XODO Frontend - Next.js

This commit is contained in:
root
2026-02-22 13:31:17 +01:00
commit ad45c5057d
80 changed files with 12035 additions and 0 deletions

72
.bash_history Normal file
View File

@@ -0,0 +1,72 @@
#1771133437
ls
#1771133440
cd htdocs/xodo.ro/
#1771133442
npm run build
#1771133446
npm install
#1771133517
npm run build
#1771133538
ls
#1771133563
PORT=3009 pm2 start npm --name "xodo-app" -- start
#1771133582
nano ecosystem.config.js
#1771133592
pm2 delete 0
#1771133595
pm2 start ecosystem.config.js
#1771157159
npm run build && pm2 restart 0
#1771135101
pm2 ls
#1771135106
npm run build
#1771135113
cd htdocs/xodo.ro/
#1771135116
pm2 ls
#1771135120
npm run build
#1771135167
ls
#1771135169
pm2 ls
#1771135172
pm2 restart 0
#1771135488
npm run build
#1771135696
pm2 restart 0
#1771137036
npm run build
#1771137123
pm2 restart 0
#1771135274
cd /home/xodo/htdocs/xodo.ro
#1771135275
npm run build
#1771135293
npm run build 2>&1 | tail -50
#1771135336
npm run build
#1771135354
npm run build 2>&1 | grep -A 20 "Error:"
#1771135401
npm run build
#1771135419
npm run build 2>&1
#1771135439
curl -s "https://strapi.xodo.ro/api/projects?populate=*&locale=ro" | head -100
#1771135457
npm run build
#1771135480
pm2 restart 0
#1771136975
npm run build && pm2 restart 0
#1771364561
pm2 ls
#1771364587
npm run build && pm2 restart 0

13
.bashrc Executable file
View File

@@ -0,0 +1,13 @@
# .bashrc
if [ -f /etc/bashrc/bashrc ]; then
. /etc/bashrc/bashrc
fi
if [ -f ~/.python_version ]; then
. ~/.python_version
fi
# User specific aliases and functions
umask 007

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
.next/
out/
build/
dist/
.env
.env.local

22
.profile Executable file
View File

@@ -0,0 +1,22 @@
# ~/.profile: executed by the command interpreter for login shells.
# This file is not read by bash(1), if ~/.bash_profile or ~/.bash_login
# exists.
# see /usr/share/doc/bash/examples/startup-files for examples.
# the files are located in the bash-doc package.
# the default umask is set in /etc/profile; for setting the umask
# for ssh logins, install and configure the libpam-umask package.
#umask 022
# if running bash
if [ -n "$BASH_VERSION" ]; then
# include .bashrc if it exists
if [ -f "$HOME/.bashrc" ]; then
. "$HOME/.bashrc"
fi
fi
# set PATH so it includes user's private bin if it exists
if [ -d "$HOME/bin" ] ; then
PATH="$HOME/bin:$PATH"
fi

11
ecosystem.config.js Normal file
View File

@@ -0,0 +1,11 @@
module.exports = {
apps : [{
name: "xodo-app",
script: "npm",
args: "start",
env: {
PORT: 3009,
NODE_ENV: "production"
}
}]
}

18
eslint.config.mjs Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

6
next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

16
next.config.ts Normal file
View File

@@ -0,0 +1,16 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "strapi.xodo.ro",
port: "",
pathname: "/uploads/**",
},
],
},
};
export default nextConfig;

7064
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

43
package.json Normal file
View File

@@ -0,0 +1,43 @@
{
"name": "new.xodo.ro",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@formatjs/intl-localematcher": "^0.8.1",
"@hookform/resolvers": "^5.2.2",
"@tailwindcss/typography": "^0.5.19",
"@types/nodemailer": "^7.0.9",
"clsx": "^2.1.1",
"framer-motion": "^12.33.0",
"lucide-react": "^0.563.0",
"negotiator": "^1.0.0",
"next": "16.1.6",
"next-themes": "^0.4.6",
"nodemailer": "^8.0.1",
"react": "19.2.3",
"react-confetti": "^6.4.0",
"react-dom": "19.2.3",
"react-hook-form": "^7.71.1",
"react-use": "^17.6.0",
"react-zoom-pan-pinch": "^3.7.0",
"tailwind-merge": "^3.4.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/negotiator": "^0.6.4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"tailwindcss": "^4",
"typescript": "^5"
}
}

7
postcss.config.mjs Normal file
View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
public/file.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

13
public/hero-bg.svg Normal file
View File

@@ -0,0 +1,13 @@
<svg width="1290" height="802" viewBox="0 0 1290 802" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.535156 794.81L11.9882 750.786L288.035 405.245L4.25493 40.1106L3.6676 10.0614L230.182 6.78774L426.792 258.175C484.644 176.822 536.085 86.4792 625.751 41.0878C638.966 34.3939 722.563 2.6346 730.933 2.6346H875.564L560.068 409.398L704.992 591.257C753.056 634.938 889.415 630.541 953.728 624.873C1016.52 619.352 1059.15 579.433 1067.72 516.598C1074.23 468.715 1074.23 328.876 1067.72 280.993C1060.72 229.689 1007.37 165.584 953.434 165.584H816.243C847.665 138.711 928.424 9.23077 961.021 2.8789C1039.67 -12.3656 1166.93 49.443 1216.56 110.225C1311.12 226.025 1312.54 567.95 1220.62 684.043C1106.92 827.644 728.094 828.133 583.953 741.747C512.885 699.141 485.428 619.791 420.478 573.374L234.049 794.907H0.535156V794.81Z" fill="url(#paint0_linear_291_7952)" stroke="url(#paint1_linear_291_7952)" stroke-width="1.07"/>
<defs>
<linearGradient id="paint0_linear_291_7952" x1="1623.45" y1="120.621" x2="543.138" y2="814.773" gradientUnits="userSpaceOnUse">
<stop stop-color="#E11D48" stop-opacity="0.09"/>
<stop offset="1" stop-color="#E11D48" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint1_linear_291_7952" x1="644.535" y1="0.534973" x2="644.535" y2="800.535" gradientUnits="userSpaceOnUse">
<stop stop-color="#E11D48" stop-opacity="0.46"/>
<stop offset="0.669518" stop-color="#E11D48" stop-opacity="0"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

1
public/next.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vercel.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@@ -0,0 +1,34 @@
import { Skeleton } from "@/components/ui/Skeleton";
export default function Loading() {
return (
<main className="bg-background overflow-hidden">
{/* Hero Skeleton */}
<section className="relative min-h-[80vh] flex flex-col justify-center">
<div className="container relative z-10">
<div className="max-w-6xl">
<Skeleton className="h-8 w-48 rounded-full mb-8" />
<Skeleton className="h-24 w-3/4 mb-8" />
<Skeleton className="h-8 w-1/2" />
</div>
</div>
</section>
{/* Mocking Content Sections */}
<section className="py-20">
<div className="container">
<div className="grid md:grid-cols-2 gap-12">
<div className="space-y-6">
<Skeleton className="h-10 w-2/3" />
<Skeleton className="h-32 w-full" />
</div>
<div className="space-y-6">
<Skeleton className="h-64 w-full rounded-2xl" />
<Skeleton className="h-64 w-full rounded-2xl" />
</div>
</div>
</div>
</section>
</main>
);
}

View File

@@ -0,0 +1,241 @@
import type { Metadata } from "next";
import { MoveRight, ArrowRight, Zap, Shield, Layers, Code, Server, Terminal, Box } from "lucide-react";
import Link from "next/link";
import { AnimatedText } from "@/components/ui/AnimatedText";
import { BlurReveal } from "@/components/ui/BlurReveal";
import { getServices } from "@/lib/api";
import { getDictionary } from "@/get-dictionary";
import { Locale } from "@/i18n-config";
export async function generateMetadata({ params }: { params: Promise<{ lang: Locale }> }): Promise<Metadata> {
const { lang } = await params;
const dict = await getDictionary(lang);
return {
title: dict.about_page_title,
description: dict.about_page_subtitle,
openGraph: {
title: dict.about_page_title,
description: dict.about_page_subtitle,
url: `https://xodo.ro/${lang}/about`,
siteName: "XODO",
locale: lang === 'ro' ? "ro_RO" : "en_US",
type: "website",
},
};
}
// Helper to map service titles to icons
const getServiceIcon = (title: string) => {
const lower = title.toLowerCase();
if (lower.includes("strateg") || lower.includes("consult")) return <Layers className="size-6" />;
if (lower.includes("dezvoltare") || lower.includes("web") || lower.includes("app") || lower.includes("development")) return <Code className="size-6" />;
if (lower.includes("devops") || lower.includes("cloud") || lower.includes("server")) return <Server className="size-6" />;
if (lower.includes("automatiz") || lower.includes("script") || lower.includes("automat")) return <Terminal className="size-6" />;
if (lower.includes("design") || lower.includes("ux") || lower.includes("ui")) return <Zap className="size-6" />;
if (lower.includes("gaming") || lower.includes("jocuri")) return <Shield className="size-6" />;
return <Box className="size-6" />;
};
export default async function AboutPage({ params }: { params: Promise<{ lang: Locale }> }) {
const { lang } = await params;
const dict = await getDictionary(lang);
const services = await getServices(lang);
return (
<main className="bg-background text-foreground selection:bg-primary selection:text-primary-foreground overflow-hidden">
{/* Hero Section - Unified Design */}
<section className="relative min-h-[80vh] flex flex-col justify-center">
<div className="container relative z-10">
<div className="max-w-6xl">
<BlurReveal>
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-secondary border border-border text-sm text-muted-foreground mb-8">
<span className="w-2 h-2 rounded-full bg-primary animate-pulse" />
{dict.digital_architects_since}
</div>
</BlurReveal>
<h1 className="text-7xl md:text-8xl font-bold tracking-tighter mb-8 leading-[1.1] text-foreground">
{dict.about_title} <span className="text-primary"><AnimatedText text="XODO" className="inline-block" /></span>
</h1>
<BlurReveal delay={0.2}>
<p className="text-xl md:text-2xl text-muted-foreground leading-relaxed">
{dict.about_page_subtitle}
</p>
</BlurReveal>
</div>
</div>
</section>
{/* Roots Section */}
<section className="py-20">
<div className="container border-t border-border pt-20">
<div className="grid md:grid-cols-2 gap-12 items-center max-w-6xl">
<div>
<BlurReveal>
<h2 className="text-3xl md:text-4xl font-bold mb-6 text-foreground">
{dict.roots_title} <span className="text-primary">{dict.roots_subtitle}</span>
</h2>
</BlurReveal>
<BlurReveal delay={0.1}>
<p className="text-muted-foreground text-lg leading-relaxed mb-6">
{dict.roots_desc_1}
</p>
</BlurReveal>
<BlurReveal delay={0.2}>
<p className="text-muted-foreground text-lg leading-relaxed">
{dict.roots_desc_2}
</p>
</BlurReveal>
</div>
<div className="space-y-6">
<div className="p-8 rounded-2xl bg-card border border-border hover:border-primary/30 transition-colors">
<div className="text-primary font-mono text-sm mb-2">{dict.origin_year}</div>
<h3 className="text-xl font-bold mb-3 text-foreground">Zona Invision Community</h3>
<p className="text-muted-foreground">
{dict.origin_desc}
</p>
</div>
<div className="p-8 rounded-2xl bg-card border border-border hover:border-primary/30 transition-colors">
<div className="text-primary font-mono text-sm mb-2">{dict.gaming_scalability}</div>
<h3 className="text-xl font-bold mb-3 text-foreground">{dict.collab_large_communities}</h3>
<p className="text-muted-foreground">
{dict.gaming_desc}
</p>
</div>
</div>
</div>
</div>
</section>
{/* Quote Section */}
<section className="py-24 bg-secondary/20">
<div className="container">
<div className="max-w-4xl mx-auto text-center">
<BlurReveal>
<blockquote className="text-2xl md:text-4xl font-medium leading-tight text-foreground font-caveat">
"{dict.quote_part_1} <span className="text-primary">{dict.quote_highlight}</span>{dict.quote_part_3}"
</blockquote>
</BlurReveal>
</div>
</div>
</section>
{/* Evolution Section */}
<section className="py-20">
<div className="container">
<div className="max-w-3xl mx-auto text-center mb-16">
<BlurReveal>
<h2 className="text-3xl md:text-4xl font-bold mb-6 text-foreground">Drawwwn XODO</h2>
<p className="text-lg text-muted-foreground">
{dict.evolution_desc}
</p>
</BlurReveal>
</div>
</div>
</section>
{/* Services Grid */}
<section className="py-20">
<div className="container">
<BlurReveal>
<h2 className="text-3xl md:text-5xl font-bold mb-12 text-center text-foreground">{dict.what_xodo_does}</h2>
</BlurReveal>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-7xl mx-auto">
{services.map((service, idx) => (
<BlurReveal key={service.id} delay={0.1 * idx}>
<div className="group p-8 rounded-2xl bg-card border border-border hover:bg-secondary/50 transition-all duration-300 h-full">
<div className="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center text-primary mb-6 group-hover:scale-110 transition-transform">
{getServiceIcon(service.title)}
</div>
<h3 className="text-xl font-bold mb-3 text-foreground">{service.title}</h3>
<p className="text-muted-foreground">{service.shortDescription}</p>
</div>
</BlurReveal>
))}
</div>
</div>
</section>
{/* Philosophy Section */}
<section className="py-24 border-t border-border">
<div className="container">
<div className="grid lg:grid-cols-2 gap-16 items-center">
<div>
<BlurReveal>
<h2 className="text-3xl md:text-5xl font-bold mb-8 text-foreground">
{dict.more_than_agency}
</h2>
</BlurReveal>
<div className="space-y-6 text-lg text-muted-foreground">
<BlurReveal delay={0.1}>
<p>
{dict.more_than_agency_desc_1}
</p>
</BlurReveal>
<BlurReveal delay={0.2}>
<p>
{dict.more_than_agency_desc_2}
</p>
</BlurReveal>
</div>
<div className="mt-12 p-6 rounded-xl bg-gradient-to-r from-primary/20 to-transparent border-l-4 border-primary">
<p className="text-xl italic font-medium text-foreground">
"{dict.partner_quote}"
</p>
</div>
</div>
<div className="relative aspect-square md:aspect-video lg:aspect-square rounded-3xl overflow-hidden bg-card border border-border flex items-center justify-center group">
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 via-transparent to-transparent opacity-50" />
{/* Abstract visual representation of "Structure & Logic" */}
<div className="grid grid-cols-2 gap-4 animate-float">
<div className="w-32 h-32 rounded-2xl bg-secondary border border-border/50 backdrop-blur-sm" />
<div className="w-32 h-32 rounded-2xl bg-primary/20 border border-primary/40 backdrop-blur-sm mt-8" />
<div className="w-32 h-32 rounded-2xl bg-card border border-border backdrop-blur-sm -mt-8" />
<div className="w-32 h-32 rounded-2xl bg-secondary border border-border/50 backdrop-blur-sm" />
</div>
</div>
</div>
</div>
</section>
{/* CTA Section */}
<section className="py-20">
<div className="container">
<div className="relative rounded-3xl bg-primary p-12 md:p-20 text-center overflow-hidden">
{/* Noise overlay removed - file missing */}
<div className="absolute -top-24 -right-24 w-64 h-64 bg-white/20 rounded-full blur-3xl" />
<div className="absolute -bottom-24 -left-24 w-64 h-64 bg-black/20 rounded-full blur-3xl" />
<h2 className="relative text-3xl md:text-5xl font-bold mb-6 text-primary-foreground">{dict.lets_evolve}</h2>
<p className="relative text-primary-foreground/90 text-xl mb-10 max-w-xl mx-auto">
{dict.lets_evolve_desc}
</p>
<div className="relative flex flex-col sm:flex-row gap-4 justify-center items-center">
<Link
href={`/${lang}/contact`}
className="px-8 py-4 bg-background text-foreground font-bold rounded-full hover:bg-background/90 transition-transform active:scale-95 flex items-center gap-2 group"
>
{dict.start_project}
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
</Link>
<Link
href={`/${lang}/work`}
className="px-8 py-4 bg-black/20 text-white font-medium rounded-full hover:bg-black/30 backdrop-blur-sm transition-all border border-white/10"
>
{dict.view_portfolio}
</Link>
</div>
</div>
</div>
</section>
</main>
);
}

View File

@@ -0,0 +1,73 @@
"use client";
import { motion } from "framer-motion";
import { Mail, MapPin, Phone } from "lucide-react";
import React from "react";
import { AnimatedText } from "@/components/ui/AnimatedText";
import { BlurReveal } from "@/components/ui/BlurReveal";
import { Locale } from "@/i18n-config";
import { ContactForm } from "@/components/features/ContactForm";
interface ContactContentProps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
dict: any;
lang: Locale;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export default function ContactContent({ dict, lang }: ContactContentProps) {
return (
<main className="flex-grow pt-32 pb-16 px-6 container mx-auto flex flex-col justify-center">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-16 lg:gap-24 items-start">
{/* Left: Heading & Info */}
<div>
<BlurReveal>
<h1 className="text-6xl md:text-9xl font-bold tracking-tighter mb-8 text-foreground">
{dict?.contact_page_title && (
<div dangerouslySetInnerHTML={{ __html: dict.contact_page_title }} />
) || (
<>
Let&apos;s <br />
<span className="text-primary"><AnimatedText text={dict?.intro_talk || "talk."} className="inline-block" /></span>
</>
)}
</h1>
</BlurReveal>
<BlurReveal delay={0.2}>
<p className="text-2xl text-muted-foreground mb-12 max-w-lg">
{dict?.contact_page_subtitle || "Have a project in mind? We'd love to hear about it. Send us a message and let's start something great together."}
</p>
</BlurReveal>
<div className="space-y-6 text-xl">
<a href="mailto:hello@xodo.ro" className="flex items-center gap-4 hover:text-primary transition-colors group text-foreground">
<div className="p-3 bg-secondary rounded-full group-hover:bg-primary/10 transition-colors">
<Mail className="w-6 h-6" />
</div>
hello@xodo.ro
</a>
<a href="tel:+40763217369" className="flex items-center gap-4 hover:text-primary transition-colors group text-foreground">
<div className="p-3 bg-secondary rounded-full group-hover:bg-primary/10 transition-colors">
<Phone className="w-6 h-6" />
</div>
+40 763 217 369
</a>
<div className="flex items-center gap-4 text-muted-foreground">
<div className="p-3 bg-secondary rounded-full">
<MapPin className="w-6 h-6" />
</div>
Bucharest, Romania
</div>
</div>
</div>
{/* Right: Big Email Interaction */}
<BlurReveal delay={0.4} className="bg-card p-8 md:p-12 rounded-3xl border border-border">
<ContactForm dict={dict} />
</BlurReveal>
</div>
</main>
);
}

View File

@@ -0,0 +1,12 @@
export const metadata = {
title: "Contact Us | XODO",
description: "Get in touch with XODO. Let's build something great together.",
};
export default function ContactLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}

View File

@@ -0,0 +1,27 @@
import ContactContent from "@/app/[lang]/contact/ContactContent";
import { getDictionary } from "@/get-dictionary";
import { Locale } from "@/i18n-config";
export async function generateMetadata({ params }: { params: Promise<{ lang: Locale }> }) {
const { lang } = await params;
const dict = await getDictionary(lang);
return {
title: dict.contact_us,
description: dict.contact_page_subtitle || "Contact us for your digital project.",
openGraph: {
title: dict.contact_us,
description: dict.contact_page_subtitle,
url: `https://xodo.ro/${lang}/contact`,
locale: lang === 'ro' ? "ro_RO" : "en_US",
images: ["/og-image.jpg"],
type: "website",
}
};
}
export default async function ContactPage({ params }: { params: Promise<{ lang: Locale }> }) {
const { lang } = await params;
const dict = await getDictionary(lang);
return <ContactContent dict={dict} lang={lang} />;
}

View File

@@ -0,0 +1,25 @@
export const metadata = {
title: "Cookie Policy | XODO",
description: "Information about how XODO Studio uses cookies.",
};
export default function CookiesPage() {
return (
<main className="pt-32 pb-24 container-dark">
<article className="prose prose-invert prose-lg max-w-3xl mx-auto">
<h1>Politica de Cookie-uri</h1>
<p>Site-ul <strong>xodo.ro</strong> folosește module cookie pentru a asigura o funcționare optimă și sigură.</p>
<h2>Ce cookie-uri folosim?</h2>
<ul>
<li><strong>Cookies necesare:</strong> Esențiale pentru autentificare și funcționarea coșului de cumpărături (WooCommerce).</li>
<li><strong>Cookies de sesiune:</strong> Permit site-ului rețină acțiunile tale pe parcursul unei vizite.</li>
<li><strong>Cookies analitice (opțional):</strong> Putem folosi unelte precum Google Analytics pentru a înțelege cum navighează utilizatorii, dar datele sunt anonimizate.</li>
</ul>
<h2>Controlul Cookie-urilor</h2>
<p>Poți controla sau șterge cookie-urile din setările browserului tău, însă acest lucru poate afecta funcționalitatea anumitor părți ale site-ului.</p>
</article>
</main>
);
}

97
src/app/[lang]/layout.tsx Normal file
View File

@@ -0,0 +1,97 @@
import type { Metadata } from "next";
import Script from "next/script";
import { Inter, Caveat_Brush } from "next/font/google";
import "../globals.css";
import { getGlobal } from "@/lib/api";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { i18n, type Locale } from "@/i18n-config";
import { getDictionary } from "@/get-dictionary";
const inter = Inter({
variable: "--font-inter",
subsets: ["latin"],
});
export async function generateStaticParams(): Promise<{ lang: Locale }[]> {
return i18n.locales.map((locale) => ({ lang: locale })) as { lang: Locale }[];
}
export async function generateMetadata({ params }: { params: Promise<{ lang: Locale }> }) {
const { lang } = await params;
const global = await getGlobal(lang);
const seo = global?.defaultSeo;
return {
metadataBase: new URL("https://new.xodo.ro"),
title: {
default: seo?.metaTitle || "XODO | Digital Product Studio",
template: `%s | ${global?.siteName || "XODO"}`,
},
description: seo?.metaDescription || "We build digital products for ambitious brands. Bucharest-based design & development studio.",
openGraph: {
type: "website",
locale: lang === "ro" ? "ro_RO" : "en_US",
url: "https://xodo.ro",
siteName: global?.siteName || "XODO Studio",
images: [
{
url: seo?.shareImage?.url ? `https://strapi.xodo.ro${seo.shareImage.url}` : "/og-image.jpg",
width: 1200,
height: 630,
alt: "XODO Studio",
},
],
},
twitter: {
card: "summary_large_image",
title: seo?.metaTitle || "XODO | Digital Product Studio",
description: seo?.metaDescription || "We build digital products for ambitious brands.",
images: [seo?.shareImage?.url ? `https://strapi.xodo.ro${seo.shareImage.url}` : "/og-image.jpg"],
},
};
}
const caveatBrush = Caveat_Brush({
weight: "400",
variable: "--font-caveat",
subsets: ["latin"],
});
import { ThemeProvider } from "@/components/providers/ThemeProvider";
// ... imports remain the same
export default async function RootLayout({
children,
params,
}: Readonly<{
children: React.ReactNode;
params: Promise<{ lang: string }>;
}>) {
const { lang } = (await params) as { lang: Locale };
const dict = await getDictionary(lang);
return (
<html lang={lang} suppressHydrationWarning>
<body
className={`${caveatBrush.variable} antialiased min-h-screen bg-background text-foreground selection:bg-primary selection:text-white font-sans transition-colors duration-300`}
>
<ThemeProvider
attribute="class"
defaultTheme="dark"
forcedTheme="dark"
disableTransitionOnChange
>
<Navbar lang={lang} dict={dict} />
<main>{children}</main>
<Footer lang={lang} dict={dict} />
</ThemeProvider>
<Script
src="https://analytics.xodo.ro/script.js"
data-website-id="25135ddf-c280-460a-9045-571ef80e7641"
strategy="afterInteractive"
/>
</body>
</html>
);
}

View File

@@ -0,0 +1,3 @@
export default function Loading() {
return null;
}

View File

@@ -0,0 +1,13 @@
import Link from "next/link";
export default function NotFound() {
return (
<div className="min-h-screen bg-black flex flex-col items-center justify-center text-white">
<h2 className="text-4xl font-bold mb-4">404 - Not Found</h2>
<p className="text-neutral-400 mb-8">Could not find requested resource.</p>
<Link href="/" className="px-4 py-2 border border-white/20 rounded hover:bg-white/10 transition-colors">
Return Home
</Link>
</div>
);
}

67
src/app/[lang]/page.tsx Normal file
View File

@@ -0,0 +1,67 @@
import { Navbar } from "@/components/layout/Navbar";
import { Hero } from "@/components/sections/Hero";
import { ProjectsList } from "@/components/sections/Projects";
import { StackSection } from "@/components/sections/StackSection";
import { ServicesSection } from "@/components/sections/ServicesSection";
import { TestimonialsSection } from "@/components/sections/TestimonialsSection";
import { CTASection } from "@/components/sections/CTASection";
import { Footer } from "@/components/layout/Footer";
import { getProjects, getServices } from "@/lib/api";
import { Locale } from "@/i18n-config";
import { getDictionary } from "@/get-dictionary";
import { Metadata } from "next";
export async function generateMetadata({ params }: { params: Promise<{ lang: Locale }> }): Promise<Metadata> {
const { lang } = await params;
const dict = await getDictionary(lang);
return {
title: dict.seo_home_title,
description: dict.seo_home_description,
openGraph: {
title: dict.seo_home_title,
description: dict.seo_home_description,
url: `https://xodo.ro/${lang}`,
siteName: "XODO",
images: ["/og-image.jpg"],
locale: lang === 'ro' ? "ro_RO" : "en_US",
type: "website",
},
};
}
export default async function Home({ params }: { params: Promise<{ lang: Locale }> }) {
const { lang } = await params;
const dict = await getDictionary(lang);
const projectsData = await getProjects(lang);
const servicesData = await getServices(lang);
// Extract all unique stacks from projects
const allStacks = projectsData.flatMap(p => p.stacks || []);
// Extract all testimonials from projects (handle both array and single object)
const allTestimonials = projectsData.flatMap(p => {
let testimonials = [];
if (Array.isArray(p.testimonials)) testimonials = p.testimonials;
else if (p.testimonials && 'data' in p.testimonials) testimonials = (p.testimonials as any).data;
else if (p.testimonials) testimonials = [p.testimonials];
// Attach project slug to each testimonial for linking
return testimonials.map((t: any) => ({
...t,
projectSlug: p.slug,
projectTitle: p.title
}));
}).filter(Boolean); // Filter out null/undefined
return (
<>
<Hero services={servicesData} lang={lang} dict={dict} />
<ServicesSection services={servicesData} lang={lang} dict={dict} />
<ProjectsList data={projectsData} lang={lang} dict={dict} />
<StackSection stacks={allStacks} lang={lang} dict={dict} />
<TestimonialsSection testimonials={allTestimonials} lang={lang} dict={dict} />
<CTASection lang={lang} dict={dict} />
</>
);
}

View File

@@ -0,0 +1,33 @@
export const metadata = {
title: "Privacy Policy | XODO",
description: "Learn how XODO Studio collects, uses, and protects your personal data.",
};
export default function PrivacyPage() {
return (
<main className="pt-32 pb-24 container-dark">
<article className="prose prose-invert prose-lg max-w-3xl mx-auto">
<h1>Politica de Confidențialitate</h1>
<p>La <strong>XODO Studio</strong>, protejarea datelor tale cu caracter personal este o prioritate. Această politică explică ce date colectăm, de ce și cum le folosim.</p>
<h2>Ce date colectăm?</h2>
<ul>
<li><strong>Informații de cont:</strong> Nume, adresă de email și detalii de facturare atunci când îți creezi un cont sau plasezi o comandă.</li>
<li><strong>Recenzii:</strong> Numele, funcția, compania și mesajul tău atunci când alegi lași un review pe site.</li>
<li><strong>Formulare de contact:</strong> Nume, email și detalii despre proiectul tău.</li>
<li><strong>Date tehnice:</strong> Adresa IP și cookie-uri necesare pentru funcționarea site-ului.</li>
</ul>
<h2>Cum folosim datele?</h2>
<ul>
<li>Pentru a comunica cu tine privind proiectele în desfășurare.</li>
<li>Pentru a afișa recenziile clienților (cu acordul tău).</li>
</ul>
<h2>Drepturile tale</h2>
<p>Ai dreptul soliciți accesul la datele tale, rectificarea sau ștergerea acestora. Pentru orice solicitare, ne poți contacta la <a href="mailto:hello@xodo.ro" className="text-dark-accent hover:underline">hello@xodo.ro</a>.</p>
</article>
</main>
);
}

View File

@@ -0,0 +1,98 @@
import { getProjectBySlug, getServiceBySlug, getProjects } from "@/lib/api";
import { ProjectMarquee } from "@/components/sections/ProjectMarquee";
import { AnimatedText } from "@/components/ui/AnimatedText";
import { BlurReveal } from "@/components/ui/BlurReveal";
import { CTASection } from "@/components/sections/CTASection";
import { getDictionary } from "@/get-dictionary";
import { Locale } from "@/i18n-config";
import { notFound } from "next/navigation";
import Image from "next/image";
import { STRAPI_URL } from "@/lib/config";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
export async function generateMetadata({ params }: { params: Promise<{ lang: Locale; slug: string }> }) {
const { lang, slug } = await params;
const service = await getServiceBySlug(slug, lang);
if (!service) return { title: "Service Not Found" };
return {
title: service.title,
description: service.shortDescription,
};
}
export default async function ServicePage({ params }: { params: Promise<{ lang: Locale; slug: string }> }) {
const { lang, slug } = await params;
const dict = await getDictionary(lang);
const service = await getServiceBySlug(slug, lang);
if (!service) notFound();
return (
<main className="pt-32 pb-24">
<div className="container mx-auto px-6">
<Link
href={`/${lang}/services`}
className="inline-flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors mb-8 group"
>
<ArrowLeft className="w-4 h-4 group-hover:-translate-x-1 transition-transform" />
<span className="text-base">{dict.services || "Services"}</span>
</Link>
<BlurReveal>
<header className="mb-24 max-w-4xl">
<div className="flex items-center gap-6 mb-8">
{service.icon?.url && (
<div className="relative w-16 h-16 md:w-32 md:h-32 shrink-0">
<Image
src={`${STRAPI_URL}${service.icon.url}`}
alt={service.title}
fill
className="object-contain"
sizes="(max-width: 768px) 64px, 128px"
/>
</div>
)}
<h1 className="text-5xl md:text-8xl font-bold tracking-tighter">
<span className="text-foreground">{service.title}</span>
</h1>
</div>
<p className="text-2xl md:text-3xl text-muted-foreground leading-relaxed">
{service.shortDescription}
</p>
</header>
</BlurReveal>
{service.capabilities && service.capabilities.length > 0 && (
<BlurReveal delay={0.3} className="mt-16">
<h3 className="text-sm font-bold uppercase tracking-widest text-primary mb-8">
{dict.capabilities || "Capabilities"}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-12 gap-y-4">
{service.capabilities.map((cap: any) => (
<div key={cap.id} className="flex items-center gap-4 text-xl text-foreground/80">
<div className="w-1.5 h-1.5 rounded-full bg-primary" />
{cap.title}
</div>
))}
</div>
</BlurReveal>
)}
</div>
{/* Related Projects Marquee */}
{service.projects && service.projects.length > 0 && (
<ProjectMarquee
projects={service.projects}
title={`${dict.recent_work_in_area || "Recent Work"} - ${service.title}`}
dict={dict}
lang={lang}
/>
)}
<CTASection lang={lang} dict={dict} />
</main>
);
}

View File

@@ -0,0 +1,26 @@
import { Skeleton } from "@/components/ui/Skeleton";
export default function Loading() {
return (
<main className="pt-32 pb-24 px-6 container mx-auto">
<header className="mb-24 max-w-4xl">
<Skeleton className="h-24 w-3/4 mb-8" />
<Skeleton className="h-8 w-1/2" />
</header>
<div className="space-y-16">
{/* Mocking Service Items */}
{[...Array(3)].map((_, i) => (
<div key={i} className="grid md:grid-cols-2 gap-12 items-center">
<Skeleton className="w-full aspect-video rounded-2xl" />
<div className="space-y-4">
<Skeleton className="h-10 w-1/3" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-12 w-40 rounded-full" />
</div>
</div>
))}
</div>
</main>
);
}

View File

@@ -0,0 +1,50 @@
import { getServices } from "@/lib/api";
import { ServicesList } from "@/components/sections/ServicesList";
import { AnimatedText } from "@/components/ui/AnimatedText";
import { BlurReveal } from "@/components/ui/BlurReveal";
import { getDictionary } from "@/get-dictionary";
import { Locale } from "@/i18n-config";
import { CTASection } from "@/components/sections/CTASection";
export async function generateMetadata({ params }: { params: Promise<{ lang: Locale }> }) {
const { lang } = await params;
const dict = await getDictionary(lang);
return {
title: dict.services,
description: dict.services_page_subtitle || "Comprehensive digital services to help ambitious brands grow.",
openGraph: {
title: dict.services,
description: dict.services_page_subtitle,
url: `https://xodo.ro/${lang}/services`,
locale: lang === 'ro' ? "ro_RO" : "en_US",
images: ["/og-image.jpg"],
type: "website",
}
};
}
export default async function ServicesPage({ params }: { params: Promise<{ lang: Locale }> }) {
const { lang } = await params;
const dict = await getDictionary(lang);
const services = await getServices(lang);
return (
<main className="pt-32 pb-24 px-6 container mx-auto">
<header className="mb-24 max-w-4xl">
<h1 className="text-6xl md:text-9xl font-bold tracking-tighter mb-8">
<span className="text-foreground">{dict.our_services_title_1} </span>
<span className="text-primary"><AnimatedText text={dict.our_services_title_2} className="inline-block" /></span>
</h1>
<BlurReveal delay={0.2}>
<p className="text-2xl text-muted-foreground">
{dict.services_page_subtitle}
</p>
</BlurReveal>
</header>
<ServicesList services={services} lang={lang} dict={dict} />
<CTASection lang={lang} dict={dict} />
</main>
);
}

View File

@@ -0,0 +1,16 @@
"use client";
import { motion } from "framer-motion";
export default function Template({ children }: { children: React.ReactNode }) {
return (
<motion.div
initial={{ opacity: 0, filter: "blur(5px)" }}
animate={{ opacity: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, filter: "blur(5px)" }}
transition={{ duration: 0.4, ease: "easeInOut" }}
>
{children}
</motion.div>
);
}

View File

@@ -0,0 +1,27 @@
export const metadata = {
title: "Terms & Conditions | XODO",
description: "Terms and conditions for using XODO Studio services and website.",
};
export default function TermsPage() {
return (
<main className="pt-32 pb-24 container-dark">
<article className="prose prose-invert prose-lg max-w-3xl mx-auto">
<h1>Termeni și Condiții</h1>
<p>Prin accesarea site-ului <strong>xodo.ro</strong>, ești de acord respecți acești termeni și condiții, precum și legile în vigoare.</p>
<h2>Proprietate Intelectuală</h2>
<p>Conținutul acestui site (texte, grafică, cod sursă) este proprietatea <strong>XODO Studio</strong>, cu excepția cazurilor în care este specificat altfel. Reproducerea neautorizată este interzisă.</p>
<h2>Servicii</h2>
<p>XODO Studio oferă servicii de web design, development și consultanță. Condițiile specifice fiecărui proiect vor fi stabilite prin contracte individuale.</p>
<h2>Limitarea Răspunderii</h2>
<p>XODO Studio nu poate fi tras la răspundere pentru daune indirecte rezultate din utilizarea sau imposibilitatea utilizării site-ului sau a serviciilor noastre.</p>
<h2>Litigii</h2>
<p>Orice litigiu va fi soluționat conform legilor din România și de către instanțele judecătorești competente.</p>
</article>
</main>
);
}

View File

@@ -0,0 +1,349 @@
import { getProjectBySlug, getProjects } from "@/lib/api";
import Image from "next/image";
import { notFound } from "next/navigation";
import { ArrowLeft, Quote } from "lucide-react";
import Link from "next/link";
import VisitProjectButton from "@/components/ui/VisitProjectButton";
import { ZoomableImage } from "@/components/ui/ZoomableImage";
import { RichTextRenderer } from "@/components/ui/RichTextRenderer";
import { TestimonialSlider } from "@/components/sections/TestimonialSlider";
import { BlurReveal } from "@/components/ui/BlurReveal";
import { getDictionary } from "@/get-dictionary";
import { Locale } from "@/i18n-config";
import { slugify } from "@/lib/slugify";
import { CTASection } from "@/components/sections/CTASection";
import { STRAPI_URL } from "@/lib/config";
// Generate static params for all projects at build time
export async function generateStaticParams() {
const projects = await getProjects();
return projects
.filter((project) => project.slug) // Filter out projects without slugs
.map((project) => ({
slug: project.slug,
}));
}
export async function generateMetadata({ params }: { params: Promise<{ slug: string; lang: Locale }> }) {
const { slug, lang } = await params;
const project = await getProjectBySlug(slug, lang);
const dict = await getDictionary(lang);
if (!project) return {};
// SEO Strategy: check top-level 'seo' field, otherwise find 'shared.seo' in projectContent
let seo = (project as any).seo;
if (!seo && project.projectContent) {
const seoBlock = project.projectContent.find((b: any) => b.__component === "shared.seo");
if (seoBlock) {
seo = seoBlock;
}
}
const imgUrl = project.clientBackground?.url ? `${STRAPI_URL}${project.clientBackground.url}` : null;
return {
title: seo?.metaTitle || project.title,
description: seo?.metaDescription || `${dict.case_study_for} ${project.title}.`,
openGraph: {
title: seo?.metaTitle || project.title,
description: seo?.metaDescription,
images: [seo?.shareImage?.url ? `${STRAPI_URL}${seo.shareImage.url}` : (imgUrl || "/og-image.jpg")],
locale: lang === 'ro' ? "ro_RO" : "en_US",
}
};
}
export default async function ProjectPage({
params,
}: {
params: Promise<{ slug: string; lang: Locale }>
}) {
const { slug, lang } = await params;
const project = await getProjectBySlug(slug, lang);
const dict = await getDictionary(lang);
if (!project) {
notFound();
}
const imgUrl = project.clientBackground?.url ? `${STRAPI_URL}${project.clientBackground.url}` : null;
// Normalize testimonials data
const testimonials = Array.isArray(project.testimonials)
? project.testimonials
: (project.testimonials as any)?.data || [];
const accentColor = project.mainColor || "#e11d48";
return (
<div className="min-h-screen bg-background">
{/* Hero Section - Full Screen with Blend */}
<section className="relative min-h-[100dvh] flex flex-col justify-end">
{imgUrl && (
<>
<ZoomableImage
src={imgUrl}
alt={project.title}
fill
className="object-cover"
priority
sizes="100vw"
showZoomIcon={false}
containerClassName="absolute inset-0 z-0"
zoomEnabled={false}
/>
{/* Stronger Gradient for Seamless Blend */}
<div className="absolute inset-0 z-10 bg-gradient-to-t from-background via-background/40 to-black/30" />
<div className="absolute inset-0 z-10 bg-black/20" /> {/* General dim */}
{/* Brand Color Overlay */}
<div
className="absolute inset-0 z-10 opacity-[0.15] mix-blend-overlay pointer-events-none"
style={{ backgroundColor: accentColor }}
/>
{/* Bottom fade out mask */}
<div className="absolute bottom-0 left-0 right-0 h-32 bg-gradient-to-t from-background to-transparent z-20" />
</>
)}
{/* Hero Content */}
<div className="relative z-10 pb-24 md:pb-32 container mx-auto px-6">
<Link
href={`/${lang}/work`}
className="inline-flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors mb-8 group"
>
<ArrowLeft className="w-4 h-4 group-hover:-translate-x-1 transition-transform" />
<span className="text-base">{dict.back_to_work}</span>
</Link>
<div className="flex flex-col md:flex-row md:items-end justify-between gap-8">
<div>
{/* Client Logo if handy */}
{project.clientLogo?.url && (
<div className="relative w-32 h-16 mb-6 opacity-80 grayscale hover:grayscale-0 transition-all">
<Image
src={`${STRAPI_URL}${project.clientLogo.url}`}
alt={`${project.title} Client Logo`}
fill
className="object-contain object-left"
sizes="(max-width: 768px) 128px, 128px"
/>
</div>
)}
<BlurReveal>
<h1 className="text-5xl md:text-8xl font-bold text-foreground mb-6 tracking-tighter">
{project.title}
</h1>
</BlurReveal>
<BlurReveal delay={0.2}>
<div className="flex flex-wrap items-center gap-6 text-lg text-muted-foreground">
<span>{project.completionDate || project.year || '2026'}</span>
{project.tags && project.tags.length > 0 && (
<>
<span className="w-1 h-1 bg-white/40 rounded-full" />
{project.tags.map((tag: any, i: number) => {
const label = typeof tag === 'string' ? tag : (tag.title || tag.name);
if (!label) return null;
return <span key={i}>{label}</span>;
})}
</>
)}
</div>
</BlurReveal>
</div>
{project.projectUrl && (
<VisitProjectButton url={project.projectUrl} accentColor={accentColor} label={dict.visit_project} />
)}
</div>
</div>
</section>
{/* Content Section */}
<main className="section-dark">
<div className="container mx-auto px-6">
{/* Wide Single Column Wrapper removed, now individual blocks manage width */}
<div className="space-y-32">
{/* Project Description - Consistent Width */}
{project.description && (
<BlurReveal className="max-w-4xl mx-auto">
<RichTextRenderer content={project.description} className="text-xl md:text-2xl" />
</BlurReveal>
)}
{/* Dynamic Content Blocks */}
{project.projectContent?.map((block: any, index: number) => (
<BlurReveal key={index} className="w-full">
{block.__component === "blocks.comp-highlight" && (
<div className="max-w-7xl mx-auto">
<div className={`grid grid-cols-1 lg:grid-cols-2 gap-12 lg:gap-24 items-center ${!(block.layout === "media-left") ? 'lg:grid-flow-dense' : ''}`}>
{block.media?.url && (
<div className={`relative w-full aspect-[4/3] lg:min-h-[600px] rounded-3xl overflow-hidden ${!(block.layout === "media-left") ? 'lg:col-start-2' : ''} shadow-2xl`}>
<ZoomableImage
src={`${STRAPI_URL}${block.media.url}`}
alt={block.title}
fill
className="object-cover"
sizes="(max-width: 768px) 100vw, 50vw"
/>
</div>
)}
<div className={!(block.layout === "media-left") ? 'lg:col-start-1 lg:row-start-1' : ''}>
<h3 className="text-3xl md:text-5xl font-bold text-foreground mb-8 tracking-tight leading-tight">
{block.title}
</h3>
<div className="prose prose-lg max-w-none">
<RichTextRenderer content={block.description} className="text-lg md:text-xl" />
</div>
</div>
</div>
</div>
)}
{block.__component === "blocks.comp-full-bleed-image" && (
<div className="relative w-full aspect-[16/9] md:aspect-[21/9] rounded-2xl overflow-hidden group shadow-2xl">
{block.images?.[0]?.url && (
<ZoomableImage
src={`${STRAPI_URL}${block.images[0].url}`}
alt={block.text || "Project visualization"}
fill
className="object-cover"
sizes="100vw"
>
{block.text && (
<div className="absolute inset-0 flex items-center justify-center bg-black/30 pointer-events-none z-10 transition-opacity duration-500 group-hover:opacity-0">
<p className="text-2xl md:text-5xl font-bold text-foreground text-center px-4 drop-shadow-lg max-w-4xl">
{block.text}
</p>
</div>
)}
</ZoomableImage>
)}
</div>
)}
{block.__component === "blocks.comp-full-bleed-video" && (
<div className="relative w-full aspect-[16/9] md:aspect-[21/9] rounded-2xl overflow-hidden group shadow-2xl">
{block.video?.url && (
<video
src={`${STRAPI_URL}${block.video.url}`}
poster={block.poster?.url ? `${STRAPI_URL}${block.poster.url}` : undefined}
autoPlay
muted
loop
playsInline
className="absolute inset-0 w-full h-full object-cover"
/>
)}
{block.text && (
<div className="absolute inset-0 flex items-center justify-center bg-black/30 pointer-events-none">
<p className="text-2xl md:text-5xl font-bold text-foreground text-center px-4 drop-shadow-lg max-w-4xl">
{block.text}
</p>
</div>
)}
</div>
)}
</BlurReveal>
))}
{/* Services Provided */}
{project.services && project.services.length > 0 && (
<div className="max-w-4xl mx-auto">
<div className="mb-8">
<div className="w-12 h-1 mb-4 rounded-full" style={{ backgroundColor: accentColor }} />
<h3 className="text-3xl md:text-4xl font-bold text-foreground">
{dict.services_provided}
</h3>
</div>
<div className="flex flex-wrap gap-4">
{project.services.map((service: any, i: number) => (
<Link
key={i}
href={`/${lang}/services/${service.slug}`}
className="flex items-center gap-3 px-6 py-3 bg-dark-surface border border-dark-border rounded-full group hover:border-primary/50 transition-all"
>
{service.icon?.url && (
<div className="relative w-6 h-6">
<Image
src={`${STRAPI_URL}${service.icon.url}`}
alt={service.title}
fill
className="object-contain opacity-60 group-hover:opacity-100 transition-opacity"
sizes="24px"
/>
</div>
)}
<span className="text-base font-medium text-muted-foreground group-hover:text-foreground transition-colors text-nowrap">
{service.title}
</span>
</Link>
))}
</div>
</div>
)}
{/* Tech Stack - Centered */}
{project.stacks && project.stacks.length > 0 && (
<div className="max-w-4xl mx-auto">
<div className="mb-8">
<div className="w-12 h-1 mb-4 rounded-full" style={{ backgroundColor: accentColor }} />
<h3 className="text-3xl md:text-4xl font-bold text-foreground">
{dict.technology_stack}
</h3>
</div>
<div className="flex flex-wrap gap-4">
{project.stacks.map((stack: any, i: number) => (
<Link
key={i}
href={`/${lang}/work?tech=${encodeURIComponent(stack.name)}`}
className="flex items-center gap-3 px-6 py-3 bg-dark-surface border border-dark-border rounded-full hover:border-dark-accent/50 hover:bg-white/5 transition-all group"
>
{stack.icon?.url && (
<div
className="w-5 h-5 transition-all"
style={{
backgroundColor: accentColor,
maskImage: `url(${STRAPI_URL}${stack.icon.url})`,
WebkitMaskImage: `url(${STRAPI_URL}${stack.icon.url})`,
maskSize: 'contain',
WebkitMaskSize: 'contain',
maskRepeat: 'no-repeat',
WebkitMaskRepeat: 'no-repeat',
maskPosition: 'center',
WebkitMaskPosition: 'center'
}}
/>
)}
<span className="text-base font-medium text-muted-foreground group-hover:text-foreground transition-colors">
{stack.name}
</span>
</Link>
))}
</div>
</div>
)}
{/* Testimonials - Reusing Homepage Slider Style (Static List) */}
{testimonials.length > 0 && (
<div className="py-12">
<TestimonialSlider
testimonials={testimonials}
dict={dict}
showProjectLink={false}
accentColor={accentColor}
disableSlider={true}
/>
</div>
)}
</div>
</div>
</main>
<CTASection lang={lang} dict={dict} />
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { Skeleton } from "@/components/ui/Skeleton";
export default function Loading() {
return (
<main className="pt-32 pb-24">
<div className="container">
{/* Page Header Skeleton */}
<header className="mb-24 max-w-5xl">
<Skeleton className="h-20 w-3/4 mb-8" />
<Skeleton className="h-8 w-1/2" />
</header>
{/* Projects Grid Skeleton */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 md:gap-16">
{/* Mocking 4 items */}
{[...Array(4)].map((_, i) => (
<div key={i} className="flex flex-col gap-4">
{/* Image Aspect-Ratio approximation */}
<Skeleton className="w-full aspect-[4/3] rounded-2xl" />
<div className="space-y-2">
<Skeleton className="h-8 w-1/3" />
<Skeleton className="h-5 w-1/4" />
</div>
</div>
))}
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,108 @@
import { getProjects } from "@/lib/api";
import { ProjectItem } from "@/components/ui/ProjectItem";
import Link from "next/link";
import { X } from "lucide-react";
import { AnimatedText } from "@/components/ui/AnimatedText";
import { BlurReveal } from "@/components/ui/BlurReveal";
import { getDictionary } from "@/get-dictionary";
import { Locale } from "@/i18n-config";
import { CTASection } from "@/components/sections/CTASection";
export async function generateMetadata({ searchParams, params }: { searchParams: Promise<{ tech?: string }>; params: Promise<{ lang: Locale }> }) {
const { tech } = await searchParams;
const { lang } = await params;
const dict = await getDictionary(lang);
return {
title: tech ? `${tech} Projects` : dict.work,
description: tech
? `Explore our projects built with ${tech}.`
: dict.projects_page_subtitle || "A selection of our best work.",
openGraph: {
title: tech ? `${tech} Projects` : dict.work,
description: tech ? `Explore our projects built with ${tech}.` : dict.projects_page_subtitle,
url: `https://xodo.ro/${lang}/work`,
locale: lang === 'ro' ? "ro_RO" : "en_US",
images: ["/og-image.jpg"],
type: "website",
}
};
}
export default async function WorkPage({
searchParams,
params
}: {
searchParams: Promise<{ tech?: string }>;
params: Promise<{ lang: Locale }>;
}) {
const { tech } = await searchParams;
const { lang } = await params;
const dict = await getDictionary(lang);
const allProjects = await getProjects(lang);
// Filter projects if tech param is present
const projects = tech
? allProjects.filter(p => p.stacks?.some(s => s.name === tech))
: allProjects;
return (
<main className="pt-32 pb-24">
<div className="container">
{/* Page Header */}
<header className="mb-24 max-w-5xl">
<h1 className="text-6xl md:text-9xl font-bold tracking-tighter mb-8 text-foreground">
{tech ? (
<>
<AnimatedText text={`${dict.selected_projects_filter} ${tech}`} />
</>
) : (
<>
<span className="text-foreground">{dict.our_work_title_1} </span>
<span className="text-primary"><AnimatedText text={dict.our_work_title_2} className="inline-block" /></span>
</>
)}
</h1>
<BlurReveal delay={0.2}>
<p className="text-2xl text-muted-foreground leading-relaxed">
{tech
? (lang === 'ro' ? `Vezi proiectele noastre construite cu ${tech}.` : `View our projects built with ${tech}.`)
: dict.projects_page_subtitle
}
</p>
</BlurReveal>
{/* Active Filter Badge - Keep as secondary indicator / clear button */}
{tech && (
<div className="mt-12">
<Link
href={`/${lang}/work`}
className="inline-flex items-center gap-2 px-6 py-3 bg-secondary border border-border rounded-full hover:bg-primary hover:border-primary text-foreground hover:text-primary-foreground transition-all group"
>
<span className="font-bold text-sm tracking-wider uppercase">{dict.clear_filter}: {tech}</span>
<X className="w-4 h-4 group-hover:rotate-90 transition-transform" />
</Link>
</div>
)}
</header>
{/* Projects Grid */}
{projects.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 md:gap-16">
{projects.map((project) => (
<ProjectItem key={project.id} project={project} lang={lang} />
))}
</div>
) : (
<div className="py-24 text-center text-muted-foreground">
<p className="text-xl">{dict.no_projects_found}</p>
<Link href={`/${lang}/work`} className="text-primary hover:text-foreground mt-4 inline-block">
{dict.clear_filter}
</Link>
</div>
)}
</div>
<CTASection lang={lang} dict={dict} />
</main>
);
}

View File

@@ -0,0 +1,32 @@
import { NextResponse } from 'next/server';
import { sendEmail } from '@/lib/email/send';
export async function POST(request: Request) {
try {
const body = await request.json();
const { name, email, message } = body;
if (!name || !email || !message) {
return NextResponse.json(
{ error: 'Missing required fields' },
{ status: 400 }
);
}
const result = await sendEmail({ name, email, message });
if (result.success) {
return NextResponse.json({ message: 'Email sent successfully' });
} else {
return NextResponse.json(
{ error: 'Failed to send email' },
{ status: 500 }
);
}
} catch (error) {
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

319
src/app/globals.css Normal file
View File

@@ -0,0 +1,319 @@
@import url('https://cdn.xodo.ro/public/fonts/BR-Cobane.css');
@import "tailwindcss";
@plugin "@tailwindcss/typography";
:root {
/* Dark Mode Variables - XODO Brand (Default) */
--background: #050505;
--foreground: #ffffff;
--card: #0a0a0a;
--card-foreground: #ffffff;
--popover: #050505;
--popover-foreground: #ffffff;
--primary: #e11d48;
--primary-foreground: #ffffff;
--secondary: #1a1a1a;
--secondary-foreground: #ffffff;
--muted: #1a1a1a;
--muted-foreground: #888888;
--accent: #1a1a1a;
--accent-foreground: #ffffff;
--destructive: #7f1d1d;
--destructive-foreground: #ffffff;
--border: #1a1a1a;
--input: #1a1a1a;
--ring: #e11d48;
--radius: 0.5rem;
}
.light {
/* Light Mode Variables */
--background: #ffffff;
--foreground: #0a0a0a;
--card: #f5f5f5;
--card-foreground: #0a0a0a;
--popover: #ffffff;
--popover-foreground: #0a0a0a;
--primary: #e11d48;
--primary-foreground: #ffffff;
--secondary: #f5f5f5;
--secondary-foreground: #1a1a1a;
--muted: #f5f5f5;
--muted-foreground: #737373;
--accent: #f5f5f5;
--accent-foreground: #1a1a1a;
--destructive: #ef4444;
--destructive-foreground: #ffffff;
--border: #e5e5e5;
--input: #e5e5e5;
--ring: #e11d48;
}
.dark {
/* Keep .dark for compatibility, pointing to same vars as :root */
--background: #050505;
--foreground: #ffffff;
--card: #0a0a0a;
--card-foreground: #ffffff;
--popover: #050505;
--popover-foreground: #ffffff;
--primary: #e11d48;
--primary-foreground: #ffffff;
--secondary: #1a1a1a;
--secondary-foreground: #ffffff;
--muted: #1a1a1a;
--muted-foreground: #888888;
--accent: #1a1a1a;
--accent-foreground: #ffffff;
--destructive: #7f1d1d;
--destructive-foreground: #ffffff;
--border: #1a1a1a;
--input: #1a1a1a;
--ring: #e11d48;
}
@theme {
/* Semantic Colors */
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
/* Legacy Compatibility Mappings (Ensure existing classes work & adapt) */
--color-dark-bg: var(--background);
--color-dark-surface: var(--card);
--color-dark-border: var(--border);
--color-dark-text: var(--foreground);
--color-dark-text-muted: var(--muted-foreground);
--color-dark-accent: var(--primary);
/* Typography */
--font-sans: "BR Cobane", var(--font-inter), ui-sans-serif, system-ui, sans-serif;
--font-caveat: var(--font-caveat), cursive;
--font-inter: "BR Cobane";
/* Size Scale */
--font-size-7xl: 6rem;
--font-size-6xl: 4.5rem;
--font-size-5xl: 3.75rem;
--font-size-4xl: 3rem;
--font-size-3xl: 2.25rem;
--font-size-2xl: 1.5rem;
--font-size-xl: 1.25rem;
--font-size-lg: 1.125rem;
--font-size-base: 1rem;
--font-size-sm: 0.875rem;
/* Animations */
--animate-fade-in: fade-in 0.5s ease-out forwards;
--animate-slide-up: slide-up 0.5s ease-out forwards;
--animate-float: float 6s ease-in-out infinite;
--animate-fade-in-up: slide-up 0.8s ease-out forwards;
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slide-up {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes float {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-20px);
}
}
}
/* Base Styles */
@layer base {
* {
@apply border-border;
}
:root {
/* Spacing */
--spacing-container: 2rem;
--spacing-section: 8rem;
--spacing-section-sm: 4rem;
/* Font Fallback Manually Defined */
--font-sans: "BR Cobane", var(--font-inter), ui-sans-serif, system-ui, sans-serif;
}
@media (max-width: 768px) {
:root {
--spacing-section: 4rem;
--spacing-section-sm: 2rem;
}
}
html {
scroll-behavior: smooth;
}
body {
@apply bg-background text-foreground antialiased transition-colors duration-300;
font-family: var(--font-inter), "Inter", ui-sans-serif, system-ui, sans-serif;
}
h1,
h2,
h3,
h4,
h5,
h6 {
@apply font-bold;
font-family: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
}
::selection {
background-color: var(--color-primary);
color: white;
}
}
/* Component Styles */
@layer components {
/* Container */
.container-dark,
.container {
margin-left: auto !important;
margin-right: auto !important;
width: 90% !important;
max-width: 80rem !important;
padding-left: 2rem !important;
padding-right: 2rem !important;
}
/* Section Spacing */
.section-dark {
padding-top: var(--spacing-section);
padding-bottom: var(--spacing-section);
}
.section-dark-sm {
padding-top: var(--spacing-section-sm);
padding-bottom: var(--spacing-section-sm);
}
/* Button */
.btn-dark {
@apply inline-flex items-center justify-center px-6 py-3 bg-foreground text-background font-medium rounded-lg transition-colors hover:bg-muted-foreground;
}
/* Link */
.link-dark {
@apply text-foreground transition-colors underline underline-offset-4 hover:text-primary;
}
/* Card */
.card-dark {
@apply bg-card border border-border rounded-xl p-6;
}
/* Backdrop Blur */
.backdrop-dark {
@apply bg-background/80 backdrop-blur-md;
}
/* Hover Scale */
.hover-scale {
@apply transition-transform duration-300 hover:scale-105;
}
}
/* Utility Classes */
@layer utilities {
/* Legacy Text Sizes using new variables if applicable, else native */
.text-dark-7xl {
font-size: 6rem;
line-height: 1;
}
.text-dark-6xl {
font-size: 4.5rem;
line-height: 1;
}
.text-dark-5xl {
font-size: 3.75rem;
line-height: 1.1;
}
.text-dark-4xl {
font-size: 3rem;
line-height: 1.1;
}
.text-dark-3xl {
font-size: 2.25rem;
line-height: 1.2;
}
.text-dark-2xl {
font-size: 1.5rem;
line-height: 1.3;
}
.text-dark-xl {
font-size: 1.25rem;
line-height: 1.4;
}
.text-dark-lg {
font-size: 1.125rem;
line-height: 1.5;
}
.text-dark-base {
font-size: 1rem;
line-height: 1.5;
}
.text-dark-sm {
font-size: 0.875rem;
line-height: 1.5;
}
/* Animations */
.fade-in {
animation: fadeIn 0.5s ease-in;
}
}

11
src/app/robots.ts Normal file
View File

@@ -0,0 +1,11 @@
import { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: '*',
allow: '/',
},
sitemap: 'https://xodo.ro/sitemap.xml',
};
}

54
src/app/sitemap.ts Normal file
View File

@@ -0,0 +1,54 @@
import { MetadataRoute } from 'next';
export default function sitemap(): MetadataRoute.Sitemap {
return [
{
url: 'https://xodo.ro',
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 1,
},
{
url: 'https://xodo.ro/work',
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 0.8,
},
{
url: 'https://xodo.ro/services',
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 0.8,
},
{
url: 'https://xodo.ro/about',
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 0.7,
},
{
url: 'https://xodo.ro/contact',
lastModified: new Date(),
changeFrequency: 'yearly',
priority: 0.8,
},
{
url: 'https://xodo.ro/privacy',
lastModified: new Date(),
changeFrequency: 'yearly',
priority: 0.3,
},
{
url: 'https://xodo.ro/terms',
lastModified: new Date(),
changeFrequency: 'yearly',
priority: 0.3,
},
{
url: 'https://xodo.ro/cookies',
lastModified: new Date(),
changeFrequency: 'yearly',
priority: 0.3,
},
];
}

View File

@@ -0,0 +1,359 @@
"use client";
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { ArrowRight, ArrowLeft, Check, Sparkles, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import Confetti from "react-confetti";
import { useWindowSize } from "react-use";
// --- VALIDATION SCHEMA ---
const formSchema = z.object({
name: z.string().min(2, "Name is required"),
email: z.string().email("Invalid email address"),
company: z.string().optional(),
services: z.array(z.string()).min(1, "Select at least one service"),
budget: z.string().min(1, "Select a budget range"),
message: z.string().min(10, "Tell us a bit more about your project"),
});
type FormData = z.infer<typeof formSchema>;
// --- STEPS CONFIG ---
const steps = [
{ id: 1, key: "identity" },
{ id: 2, key: "services" },
{ id: 3, key: "details" },
];
interface ContactFormProps {
dict: any;
}
export function ContactForm({ dict }: ContactFormProps) {
const [currentStep, setCurrentStep] = useState(1);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const { width, height } = useWindowSize();
const {
register,
handleSubmit,
trigger,
setValue,
watch,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
services: [],
company: "",
},
});
const selectedServices = watch("services");
const selectedBudget = watch("budget");
const processStep = async () => {
let isValid = false;
if (currentStep === 1) {
isValid = await trigger(["name", "email", "company"]);
} else if (currentStep === 2) {
isValid = await trigger("services");
}
if (isValid) {
setCurrentStep((prev) => prev + 1);
}
};
const prevStep = () => {
setCurrentStep((prev) => Math.max(prev - 1, 1));
};
const onSubmit = async (data: FormData) => {
setIsSubmitting(true);
// Simulate API call
try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (res.ok) {
setIsSuccess(true);
} else {
console.error("Submission failed");
// Optionally handle error state here
}
} catch (error) {
console.error("Submission error", error);
} finally {
setIsSubmitting(false);
}
};
const toggleService = (service: string) => {
const current = watch("services");
if (current.includes(service)) {
setValue(
"services",
current.filter((s) => s !== service)
);
} else {
setValue("services", [...current, service]);
}
trigger("services"); // Re-validate to clear error if valid
};
if (isSuccess) {
return (
<div className="flex flex-col items-center justify-center h-full text-center py-12 relative overflow-hidden">
<Confetti width={width || 300} height={height || 300} recycle={false} numberOfPieces={200} />
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 200, damping: 15 }}
className="w-24 h-24 bg-green-500 rounded-full flex items-center justify-center mb-6 shadow-xl shadow-green-500/30"
>
<Check className="w-12 h-12 text-white" />
</motion.div>
<motion.h3
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="text-3xl font-bold text-foreground mb-2"
>
{dict?.success_title || "Message Sent!"}
</motion.h3>
<motion.p
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="text-muted-foreground text-xl"
>
{dict?.success_desc || "We'll be in touch shortly."}
</motion.p>
</div>
);
}
return (
<div className="w-full">
{/* Progress Bar */}
<div className="flex items-center gap-2 mb-8">
{steps.map((step) => (
<div
key={step.id}
className={cn(
"h-1.5 flex-1 rounded-full transition-all duration-500",
step.id <= currentStep ? "bg-primary" : "bg-primary/20"
)}
/>
))}
</div>
<form onSubmit={handleSubmit(onSubmit)} className="min-h-[400px] flex flex-col justify-between">
<AnimatePresence mode="wait">
{currentStep === 1 && (
<motion.div
key="step1"
initial={{ x: 20, opacity: 0, filter: "blur(10px)" }}
animate={{ x: 0, opacity: 1, filter: "blur(0px)" }}
exit={{ x: -20, opacity: 0, filter: "blur(10px)" }}
transition={{ duration: 0.3 }}
className="space-y-6"
>
<h3 className="text-2xl font-bold text-foreground mb-6">
{dict?.step_identity || "Who are you?"}
</h3>
<div className="space-y-4">
<div className="relative group">
<input
{...register("name")}
type="text"
placeholder=" "
className="peer w-full bg-transparent border-b-2 border-border py-3 text-xl text-foreground focus:border-primary outline-none transition-colors"
/>
<label className="absolute left-0 top-3 text-muted-foreground text-xl transition-all peer-focus:-top-6 peer-focus:text-sm peer-focus:text-primary peer-[:not(:placeholder-shown)]:-top-6 peer-[:not(:placeholder-shown)]:text-sm peer-[:not(:placeholder-shown)]:text-primary pointer-events-none">
{dict?.label_name || "Your Name"}
</label>
{errors.name && <p className="text-destructive text-sm mt-1">{errors.name.message}</p>}
</div>
<div className="relative group pt-4">
<input
{...register("email")}
type="email"
placeholder=" "
className="peer w-full bg-transparent border-b-2 border-border py-3 text-xl text-foreground focus:border-primary outline-none transition-colors"
/>
<label className="absolute left-0 top-7 text-muted-foreground text-xl transition-all peer-focus:-top-2 peer-focus:text-sm peer-focus:text-primary peer-[:not(:placeholder-shown)]:-top-2 peer-[:not(:placeholder-shown)]:text-sm peer-[:not(:placeholder-shown)]:text-primary pointer-events-none">
{dict?.label_email || "Your Email"}
</label>
{errors.email && <p className="text-destructive text-sm mt-1">{errors.email.message}</p>}
</div>
<div className="relative group pt-4">
<input
{...register("company")}
type="text"
placeholder=" "
className="peer w-full bg-transparent border-b-2 border-border py-3 text-xl text-foreground focus:border-primary outline-none transition-colors"
/>
<label className="absolute left-0 top-7 text-muted-foreground text-xl transition-all peer-focus:-top-2 peer-focus:text-sm peer-focus:text-primary peer-[:not(:placeholder-shown)]:-top-2 peer-[:not(:placeholder-shown)]:text-sm peer-[:not(:placeholder-shown)]:text-primary pointer-events-none">
{dict?.label_company || "Company (Optional)"}
</label>
</div>
</div>
</motion.div>
)}
{currentStep === 2 && (
<motion.div
key="step2"
initial={{ x: 20, opacity: 0, filter: "blur(10px)" }}
animate={{ x: 0, opacity: 1, filter: "blur(0px)" }}
exit={{ x: -20, opacity: 0, filter: "blur(10px)" }}
transition={{ duration: 0.3 }}
className="space-y-6"
>
<h3 className="text-2xl font-bold text-foreground mb-6">
{dict?.step_services || "What do you need?"}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{[
{ id: "branding", label: dict?.service_branding || "Branding" },
{ id: "web_design", label: dict?.service_web || "Web Design" },
{ id: "app_development", label: dict?.service_app || "App Development" },
{ id: "marketing", label: dict?.service_marketing || "Marketing" },
].map((service) => (
<button
key={service.id}
type="button"
onClick={() => toggleService(service.label)}
className={cn(
"p-4 rounded-xl border text-left transition-all duration-300",
selectedServices.includes(service.label)
? "border-primary bg-primary/10 text-foreground"
: "border-border bg-secondary/5 text-muted-foreground hover:border-border/80 hover:text-foreground"
)}
>
<span className="text-lg font-medium">{service.label}</span>
</button>
))}
</div>
{errors.services && <p className="text-destructive text-sm">{errors.services.message}</p>}
</motion.div>
)}
{currentStep === 3 && (
<motion.div
key="step3"
initial={{ x: 20, opacity: 0, filter: "blur(10px)" }}
animate={{ x: 0, opacity: 1, filter: "blur(0px)" }}
exit={{ x: -20, opacity: 0, filter: "blur(10px)" }}
transition={{ duration: 0.3 }}
className="space-y-6"
>
<h3 className="text-2xl font-bold text-foreground mb-6">
{dict?.step_details || "Project Details"}
</h3>
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-muted-foreground mb-3">{dict?.label_budget || "Budget Range"}</label>
<div className="grid grid-cols-2 gap-3">
{[
dict?.budget_low || "< $5k",
dict?.budget_medium || "$5k - $15k",
dict?.budget_high || "$15k - $50k",
dict?.budget_enterprise || "> $50k"
].map((range) => (
<button
key={range}
type="button"
onClick={() => { setValue("budget", range); trigger("budget"); }}
className={cn(
"py-2 px-4 rounded-lg text-sm border transition-all",
selectedBudget === range
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-secondary/5 text-muted-foreground hover:bg-secondary/20"
)}
>
{range}
</button>
))}
</div>
{errors.budget && <p className="text-destructive text-sm mt-1">{errors.budget.message}</p>}
</div>
<div className="relative group">
<textarea
{...register("message")}
rows={4}
placeholder=" "
className="peer w-full bg-transparent border-b-2 border-border py-3 text-xl text-foreground focus:border-primary outline-none transition-colors resize-none"
/>
<label className="absolute left-0 top-3 text-muted-foreground text-xl transition-all peer-focus:-top-6 peer-focus:text-sm peer-focus:text-primary peer-[:not(:placeholder-shown)]:-top-6 peer-[:not(:placeholder-shown)]:text-sm peer-[:not(:placeholder-shown)]:text-primary pointer-events-none">
{dict?.label_description || "Tell us about your project"}
</label>
{errors.message && <p className="text-destructive text-sm mt-1">{errors.message.message}</p>}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Navigation Buttons */}
<div className="flex items-center justify-between mt-12">
{currentStep > 1 ? (
<button
type="button"
onClick={prevStep}
className="flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="w-5 h-5" />
{dict?.prev_step || "Back"}
</button>
) : (<div />)}
{currentStep < 3 ? (
<button
type="button"
onClick={processStep}
className="flex items-center gap-2 px-8 py-3 bg-foreground text-background rounded-full font-bold hover:bg-primary hover:text-primary-foreground transition-all shadow-lg hover:shadow-primary/25"
>
{dict?.next_step || "Next Step"}
<ArrowRight className="w-5 h-5" />
</button>
) : (
<button
type="submit"
disabled={isSubmitting}
className="flex items-center gap-2 px-8 py-3 bg-primary text-primary-foreground rounded-full font-bold hover:bg-foreground hover:text-background transition-all shadow-lg hover:shadow-foreground/25 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
{dict?.sending || "Sending..."}
</>
) : (
<>
{dict?.send_message || "Send Message"}
<Sparkles className="w-5 h-5" />
</>
)}
</button>
)}
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,89 @@
import Link from "next/link";
import { Mail, MapPin, Phone, Instagram, Linkedin, Facebook } from "lucide-react";
import { Locale } from "@/i18n-config";
interface FooterProps {
lang: Locale;
dict: any;
}
export const Footer = ({ lang, dict }: FooterProps) => (
<footer className="bg-card border-t border-border pt-16 pb-8">
<div className="container-dark">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-12 mb-16">
{/* Brand */}
<div className="space-y-6">
<Link href={`/${lang}`} className="block group">
<svg viewBox="0 0 264 164" className="h-8 w-auto fill-foreground group-hover:fill-primary transition-colors" role="img" aria-label="XODO Studio Logo">
<path d="M0 162.56L2.34 153.55L58.74 82.8297L0.76 8.09972L0.64 1.94972L46.92 1.27972L87.09 52.7297C98.91 36.0797 109.42 17.5897 127.74 8.29972C130.44 6.92972 147.52 0.429719 149.23 0.429719H178.78L114.32 83.6797L143.93 120.9C153.75 129.84 181.61 128.94 194.75 127.78C207.58 126.65 216.29 118.48 218.04 105.62C219.37 95.8197 219.37 67.1997 218.04 57.3997C216.61 46.8997 205.71 33.7797 194.69 33.7797H166.66C173.08 28.2797 189.58 1.77972 196.24 0.479719C212.31 -2.64028 238.31 10.0097 248.45 22.4497C267.77 46.1497 268.06 116.13 249.28 139.89C226.05 169.28 148.65 169.38 119.2 151.7C104.68 142.98 99.07 126.74 85.8 117.24L47.71 162.58H0V162.56Z"></path>
</svg>
</Link>
<p className="text-muted-foreground text-base leading-relaxed max-w-xs">
{dict.footer_description}
</p>
<div className="flex gap-4">
<a href="https://instagram.com" target="_blank" rel="noopener noreferrer" aria-label="Follow us on Instagram" className="p-2 bg-secondary rounded-full hover:bg-primary hover:text-primary-foreground transition-all text-foreground">
<Instagram className="w-5 h-5" />
</a>
<a href="https://linkedin.com" target="_blank" rel="noopener noreferrer" aria-label="Connect with us on LinkedIn" className="p-2 bg-secondary rounded-full hover:bg-primary hover:text-primary-foreground transition-all text-foreground">
<Linkedin className="w-5 h-5" />
</a>
<a href="https://facebook.com" target="_blank" rel="noopener noreferrer" aria-label="Follow us on Facebook" className="p-2 bg-secondary rounded-full hover:bg-primary hover:text-primary-foreground transition-all text-foreground">
<Facebook className="w-5 h-5" />
</a>
</div>
</div>
{/* Sitemap */}
<div>
<h4 className="text-foreground font-bold mb-6">{dict.navigation || "Sitemap"}</h4>
<ul className="space-y-3 text-base text-muted-foreground">
<li><Link href={`/${lang}`} className="hover:text-primary transition-colors">{dict.home}</Link></li>
<li><Link href={`/${lang}/work`} className="hover:text-primary transition-colors">{dict.work}</Link></li>
<li><Link href={`/${lang}/services`} className="hover:text-primary transition-colors">{dict.services}</Link></li>
<li><Link href={`/${lang}/about`} className="hover:text-primary transition-colors">{dict.about}</Link></li>
<li><Link href={`/${lang}/contact`} className="hover:text-primary transition-colors">{dict.contact}</Link></li>
</ul>
</div>
{/* Contact */}
<div>
<h4 className="text-foreground font-bold mb-6">{dict.contact_us || "Contact"}</h4>
<ul className="space-y-4 text-base text-muted-foreground">
<li className="flex items-start gap-3">
<MapPin className="w-5 h-5 mt-0.5 text-primary" />
<span>Bucharest, Romania</span>
</li>
<li>
<a href="tel:+40763217369" className="flex items-center gap-3 hover:text-foreground transition-colors">
<Phone className="w-5 h-5 text-primary" />
+40 763 217 369
</a>
</li>
<li>
<a href="mailto:hello@xodo.ro" className="flex items-center gap-3 hover:text-foreground transition-colors">
<Mail className="w-5 h-5 text-primary" />
hello@xodo.ro
</a>
</li>
</ul>
</div>
{/* Legal */}
<div>
<h4 className="text-foreground font-bold mb-6">{dict.legal || "Legal"}</h4>
<ul className="space-y-3 text-base text-muted-foreground">
<li><Link href={`/${lang}/privacy`} className="hover:text-primary transition-colors">{dict.privacy_policy || "Privacy Policy"}</Link></li>
<li><Link href={`/${lang}/terms`} className="hover:text-primary transition-colors">{dict.terms_of_service || "Terms of Service"}</Link></li>
<li><Link href={`/${lang}/cookies`} className="hover:text-primary transition-colors">{dict.cookie_policy || "Cookie Policy"}</Link></li>
</ul>
</div>
</div>
<div className="pt-8 border-t border-border flex flex-col md:flex-row justify-between items-center gap-4 text-sm text-muted-foreground">
<p>© {new Date().getFullYear()} XODO Studio. {dict.all_rights_reserved || "All rights reserved."}</p>
<p>{dict.footer_credits}</p>
</div>
</div>
</footer>
);

View File

@@ -0,0 +1,7 @@
export const GridBackground = () => (
<div className="fixed inset-0 z-0 pointer-events-none">
{/* Very subtle grid lines akin to Linear/Vercel */}
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px]"></div>
<div className="absolute inset-0 bg-gradient-to-b from-black via-transparent to-black"></div>
</div>
);

View File

@@ -0,0 +1,287 @@
"use client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useState, useEffect } from "react";
import { Menu, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { motion, AnimatePresence } from "framer-motion";
import type { Locale } from "@/i18n-config";
interface NavbarProps {
lang: Locale;
dict: any;
}
// Flags as components for cleaner usage
const FlagRO = ({ className }: { className?: string }) => (
<svg viewBox="0 0 3 2" className={className} aria-hidden="true" preserveAspectRatio="xMidYMid slice">
<path fill="#002B7F" d="M0 0h1v2H0z" />
<path fill="#FCD116" d="M1 0h1v2H1z" />
<path fill="#CE1126" d="M2 0h1v2H2z" />
</svg>
);
const FlagGB = ({ className }: { className?: string }) => (
<svg viewBox="0 0 60 30" className={className} aria-hidden="true" preserveAspectRatio="xMidYMid slice">
<clipPath id="s">
<path d="M0,0 v30 h60 v-30 z" />
</clipPath>
<clipPath id="t">
<path d="M30,15 h30 v15 z v15 h-30 z h-30 v-15 z v-15 h30 z" />
</clipPath>
<g clipPath="url(#s)">
<path d="M0,0 v30 h60 v-30 z" fill="#012169" />
<path d="M0,0 L60,30 M60,0 L0,30" stroke="#fff" strokeWidth="6" />
<path d="M0,0 L60,30 M60,0 L0,30" clipPath="url(#t)" stroke="#C8102E" strokeWidth="4" />
<path d="M30,0 v30 M0,15 h60" stroke="#fff" strokeWidth="10" />
<path d="M30,0 v30 M0,15 h60" stroke="#C8102E" strokeWidth="6" />
</g>
</svg>
);
export function Navbar({ lang, dict }: NavbarProps) {
const pathname = usePathname();
const router = useRouter();
const [isOpen, setIsOpen] = useState(false);
const [scrolled, setScrolled] = useState(typeof window !== 'undefined' ? window.scrollY > 50 : false);
// Navigation items including Home (Logo)
// Removed "Contact" to avoid duplication with the "Get in Touch" button
// We treat the logo as just another item for the pill animation
const navItems = [
{
name: "Home", // Internal name for logic
href: `/${lang}`,
isLogo: true,
label: null
},
{
name: "Services",
href: `/${lang}/services`,
isLogo: false,
label: dict.services
},
{
name: "Work",
href: `/${lang}/work`,
isLogo: false,
label: dict.work
},
{
name: "About",
href: `/${lang}/about`,
isLogo: false,
label: dict.about
},
];
useEffect(() => {
const handleScroll = () => {
setScrolled(window.scrollY > 50);
};
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, []);
const toggleLanguage = () => {
const newLang = lang === 'ro' ? 'en' : 'ro';
const newPath = pathname.replace(`/${lang}`, `/${newLang}`);
router.push(newPath);
};
// Check if Contact page is active
const isContactActive = pathname === `/${lang}/contact`;
return (
<>
<motion.nav
layoutId="main-navbar"
className={cn(
"fixed top-6 left-1/2 -translate-x-1/2 z-50 transition-all duration-300",
"w-[95%] max-w-5xl rounded-full bg-background/80 backdrop-blur-xl border border-border/50 shadow-lg",
// Increased padding for height
scrolled ? "py-3 px-4" : "py-4 px-6"
)}
>
<div className="flex items-center justify-between relative pl-2 pr-2">
{/* Unified Navigation Container including Logo */}
<div className="flex items-center gap-2">
{navItems.map((item) => {
// Active state logic:
// 1. Exact match
// 2. Starts with href (for sub-routes), but exclude home '/' to avoid always matching
const isHome = item.href === `/${lang}`;
const isActive = isHome
? pathname === item.href
: pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
className={cn(
"relative items-center justify-center transition-colors rounded-full z-10",
// Logo: always visible (flex)
// Links: hidden on mobile, flex on desktop
item.isLogo ? "flex w-12 h-12 p-0" : "hidden md:flex px-5 py-2.5 text-base font-medium",
isActive ? "text-primary-foreground" : "text-muted-foreground hover:text-foreground"
)}
>
{isActive && (
<motion.div
layoutId="navbar-pill"
className={cn(
"absolute inset-0 bg-primary shadow-md",
// Ensure perfect circle for logo, rounded-full for others
item.isLogo ? "rounded-full" : "rounded-full"
)}
transition={{ type: "spring", bounce: 0.15, duration: 0.6 }}
/>
)}
<span className="relative z-10 flex items-center justify-center w-full h-full">
{item.isLogo ? (
<div className="w-8 h-8 flex items-center justify-center">
<svg viewBox="0 0 264 164" className={cn("w-full h-auto transition-colors", isActive ? "fill-primary-foreground" : "fill-foreground")} role="img" aria-label="XODO Studio Logo">
<path d="M0 162.56L2.34 153.55L58.74 82.8297L0.76 8.09972L0.64 1.94972L46.92 1.27972L87.09 52.7297C98.91 36.0797 109.42 17.5897 127.74 8.29972C130.44 6.92972 147.52 0.429719 149.23 0.429719H178.78L114.32 83.6797L143.93 120.9C153.75 129.84 181.61 128.94 194.75 127.78C207.58 126.65 216.29 118.48 218.04 105.62C219.37 95.8197 219.37 67.1997 218.04 57.3997C216.61 46.8997 205.71 33.7797 194.69 33.7797H166.66C173.08 28.2797 189.58 1.77972 196.24 0.479719C212.31 -2.64028 238.31 10.0097 248.45 22.4497C267.77 46.1497 268.06 116.13 249.28 139.89C226.05 169.28 148.65 169.38 119.2 151.7C104.68 142.98 99.07 126.74 85.8 117.24L47.71 162.58H0V162.56Z"></path>
</svg>
</div>
) : (
<AnimatePresence mode="wait">
<motion.span
key={lang}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
{item.label}
</motion.span>
</AnimatePresence>
)}
</span>
</Link>
);
})}
</div>
{/* Right Side: Language & CTA */}
<div className="flex items-center gap-3 pl-6 border-l border-border/50 ml-4 h-10">
{/* Fancy Language Toggler */}
<button
onClick={toggleLanguage}
className="group relative flex items-center justify-center w-10 h-10 rounded-full bg-secondary border border-border/50 hover:border-primary/50 transition-all overflow-hidden"
aria-label={`Switch to ${lang === 'ro' ? 'English' : 'Romanian'}`}
>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={lang}
initial={{ y: -20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 20, opacity: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 20 }}
className="absolute inset-0 flex items-center justify-center p-0" // Removed padding p-2
>
{lang === 'ro' ? (
<FlagRO className="w-full h-full object-cover" />
) : (
<FlagGB className="w-full h-full object-cover" />
)}
</motion.div>
</AnimatePresence>
<div className="absolute inset-0 rounded-full ring-1 ring-inset ring-black/10 dark:ring-white/10 group-hover:ring-primary/50 transition-all pointer-events-none" />
</button>
<Link
href={`/${lang}/contact`}
className={cn(
"hidden sm:inline-flex items-center justify-center px-6 py-2.5 text-sm font-bold transition-all rounded-full shadow-sm hover:brightness-110",
isContactActive
? "bg-primary text-primary-foreground ring-2 ring-primary ring-offset-2 ring-offset-background"
: "bg-secondary text-foreground hover:bg-secondary/80"
)}
>
<span className="relative z-10">{dict?.get_in_touch || "Start Project"}</span>
</Link>
<button
className="md:hidden p-2 text-foreground hover:text-primary transition-colors"
onClick={() => setIsOpen(!isOpen)}
aria-label={isOpen ? "Close menu" : "Open menu"}
>
{isOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
</button>
</div>
</div>
</motion.nav>
{/* Mobile Menu Overlay */}
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
className="fixed inset-0 z-40 bg-background/95 backdrop-blur-3xl flex flex-col items-center justify-center gap-8 md:hidden"
>
{navItems.filter(item => !item.isLogo).map((link, i) => (
<motion.div
key={link.href}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 + i * 0.1 }}
>
<Link
href={link.href}
className="text-4xl font-bold text-foreground hover:text-primary transition-colors tracking-tight"
onClick={() => setIsOpen(false)}
>
{link.label}
</Link>
</motion.div>
))}
{/* Add Contact link back for Mobile Menu since it was removed from navItems */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
<Link
href={`/${lang}/contact`}
className={cn(
"text-4xl font-bold transition-colors tracking-tight",
pathname === `/${lang}/contact` ? "text-primary" : "text-foreground hover:text-primary"
)}
onClick={() => setIsOpen(false)}
>
{dict.contact}
</Link>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="mt-8 flex items-center gap-6"
>
<button
onClick={() => {
toggleLanguage();
setIsOpen(false);
}}
className="flex items-center gap-3 px-6 py-3 text-lg font-medium border border-border rounded-full hover:bg-secondary transition-colors"
>
<div className="w-8 h-8 rounded-full overflow-hidden relative border border-border">
{lang === 'ro' ? <FlagGB className="w-full h-full object-cover" /> : <FlagRO className="w-full h-full object-cover" />}
</div>
{lang === 'ro' ? 'Switch to English' : 'Comuta in Romana'}
</button>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</>
);
};

View File

@@ -0,0 +1,11 @@
"use client"
import * as React from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

View File

@@ -0,0 +1,48 @@
"use client";
import Link from "next/link";
import { motion } from "framer-motion";
import { BlurReveal } from "@/components/ui/BlurReveal";
import { AnimatedText } from "@/components/ui/AnimatedText";
import { Locale } from "@/i18n-config";
interface CTASectionProps {
lang: Locale;
dict: any;
}
export function CTASection({ lang, dict }: CTASectionProps) {
return (
<section className="section-dark relative overflow-hidden py-24 md:py-40">
<div className="container-dark relative z-10 text-center">
<BlurReveal>
<h2 className="text-5xl md:text-8xl font-bold tracking-tighter mb-8">
<AnimatedText text={dict.cta_title || "Ready to start?"} immediate />
</h2>
</BlurReveal>
<BlurReveal delay={0.2}>
<p className="text-xl md:text-2xl text-muted-foreground max-w-2xl mx-auto mb-12 leading-relaxed">
{dict.cta_description || "Let's build something exceptional together. Reach out and tell us about your project."}
</p>
</BlurReveal>
<BlurReveal delay={0.4}>
<Link
href={`/${lang}/contact`}
className="inline-flex items-center justify-center px-10 py-5 text-xl font-bold tracking-wide text-primary-foreground transition-all bg-primary rounded-full hover:bg-foreground hover:text-background border border-transparent group"
>
<span className="relative z-10">{dict.get_in_touch || "Get in Touch"}</span>
<motion.span
className="ml-3 inline-block"
animate={{ x: [0, 5, 0] }}
transition={{ duration: 1.5, repeat: Infinity, ease: "easeInOut" }}
>
</motion.span>
</Link>
</BlurReveal>
</div>
</section>
);
}

View File

@@ -0,0 +1,140 @@
"use client";
import { useState, useEffect } from "react";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { Service } from "@/types";
import { motion, useScroll, useTransform, useSpring, useMotionValue } from "framer-motion";
import { AnimatedText } from "@/components/ui/AnimatedText";
import { Locale } from "@/i18n-config";
interface HeroProps {
services?: Service[];
lang?: Locale;
dict?: any;
}
export const Hero = ({ services = [], lang, dict }: HeroProps) => {
const [activeIndex, setActiveIndex] = useState<number | null>(0);
// Fallback services if none provided
const displayServices = services.length > 0 ? services : [
{
id: 1,
documentId: "fallback-1",
title: "Branding",
shortDescription: "We build strong visual and verbal identities, design the assets you need, and create clear brand guidelines so your message stays consistent everywhere."
},
{
id: 2,
documentId: "fallback-2",
title: "Digital Products",
shortDescription: "We combine smart design with behavioral science to make digital products that feel human."
},
{
id: 3,
documentId: "fallback-3",
title: "Websites",
shortDescription: "Your digital presence starts with your website. We design sites that clearly show who you are."
},
{
id: 4,
documentId: "fallback-4",
title: "Development",
shortDescription: "Our app and web developers care about both looks and performance."
}
];
// Mouse move effect for background
const mouseX = useMotionValue(0);
const mouseY = useMotionValue(0);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
mouseX.set(e.clientX);
mouseY.set(e.clientY);
};
window.addEventListener("mousemove", handleMouseMove);
return () => window.removeEventListener("mousemove", handleMouseMove);
}, [mouseX, mouseY]);
const [windowSize, setWindowSize] = useState({ width: 0, height: 0 });
useEffect(() => {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight
});
const handleResize = () => {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight
});
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
const bgX = useSpring(useTransform(mouseX, [0, windowSize.width || 1000], [20, -20]), { stiffness: 50, damping: 20 });
const bgY = useSpring(useTransform(mouseY, [0, windowSize.height || 1000], [20, -20]), { stiffness: 50, damping: 20 });
return (
<section className="section-dark pt-32 md:pt-40 relative overflow-hidden min-h-[90vh] flex flex-col justify-center">
{/* Background Effect */}
<motion.div
className="absolute inset-0 z-0 pointer-events-none select-none"
initial={{ opacity: 0, filter: "blur(20px)", scale: 1.1 }}
animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
transition={{ duration: 1.5, ease: "easeOut" }}
style={{ x: bgX, y: bgY }}
>
<div
className="w-full h-full bg-no-repeat bg-[length:auto_120%] bg-center sm:bg-contain sm:bg-right opacity-80"
style={{ backgroundImage: "url('/hero-bg.svg')" }}
/>
</motion.div>
<div className="container-dark relative z-10">
{/* Massive Headline */}
<div className="max-w-6xl mb-16 md:mb-24">
<motion.h1
initial={{ opacity: 0, y: 20, filter: "blur(10px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
transition={{ duration: 0.8, delay: 0.2 }}
className="text-4xl md:text-7xl font-bold text-foreground leading-none mb-12 tracking-tighter"
>
{dict?.hero_title_1} <br />
<span className="text-muted-foreground font-caveat"><AnimatedText text={dict?.hero_title_2 || "digital product"} className="inline-block" immediate={true} /></span> <br />
<span className="text-primary"><AnimatedText text={dict?.hero_title_3 || "studio."} className="inline-block" immediate={true} /></span>
</motion.h1>
<motion.p
initial={{ opacity: 0, y: 20, filter: "blur(10px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
transition={{ duration: 0.8, delay: 0.4 }}
className="text-lg md:text-2xl text-muted-foreground mb-12 max-w-2xl leading-relaxed"
>
{dict?.hero_description}
</motion.p>
<motion.div
initial={{ opacity: 0, y: 10, filter: "blur(5px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
transition={{ delay: 0.8, duration: 1 }}
className="flex items-center gap-4"
>
<a
href={lang ? `/${lang}/contact` : "/contact"}
className="inline-flex items-center text-2xl md:text-3xl text-muted-foreground hover:text-primary transition-colors group"
>
{dict?.start_project || "Start a project"}
<span className="ml-2 transition-transform group-hover:translate-x-2"></span>
</a>
</motion.div>
</div>
</div>
</section>
);
};

View File

@@ -0,0 +1,60 @@
"use client";
import { motion } from "framer-motion";
import { ProjectItem } from "@/components/ui/ProjectItem";
import { BlurReveal } from "@/components/ui/BlurReveal";
import { Project } from "@/types";
import { Locale } from "@/i18n-config";
interface ProjectMarqueeProps {
projects: Project[];
title?: string;
dict?: any;
lang: Locale;
}
export function ProjectMarquee({ projects, title, dict, lang }: ProjectMarqueeProps) {
if (!projects || projects.length === 0) return null;
// Duplicate list for seamless loop if we want marquee,
// but usually for projects, a scrolling grid or a slow marquee is better.
// Let's implement a marquee style as requested.
const marqueeProjects = [...projects, ...projects, ...projects];
return (
<section className="py-24 overflow-hidden bg-background">
<div className="container mx-auto px-6 mb-12">
<BlurReveal>
<h2 className="text-3xl md:text-5xl font-bold text-foreground">
{title || "Related Projects"}
</h2>
</BlurReveal>
</div>
<div className="relative">
{/* Fade Edges */}
<div className="absolute left-0 top-0 bottom-0 w-32 bg-gradient-to-r from-background to-transparent z-10 pointer-events-none" />
<div className="absolute right-0 top-0 bottom-0 w-32 bg-gradient-to-l from-background to-transparent z-10 pointer-events-none" />
<motion.div
className="flex gap-12 px-12"
animate={{ x: ["0%", "-33.33%"] }}
transition={{
duration: 60, // Slower, more premium feel
ease: "linear",
repeat: Infinity,
}}
style={{ width: "fit-content" }}
whileHover={{ transition: { duration: 120 } }} // Slow down on hover
>
{marqueeProjects.map((project, index) => (
<div key={`${project.id}-${index}`} className="w-[85vw] md:w-[600px] shrink-0">
<ProjectItem project={project} dict={dict} lang={lang} />
</div>
))}
</motion.div>
</div>
</section>
);
}

View File

@@ -0,0 +1,46 @@
import Link from "next/link";
import { ProjectItem } from "@/components/ui/ProjectItem";
import { Project } from "@/types";
import { BlurReveal } from "@/components/ui/BlurReveal";
import { Locale } from "@/i18n-config";
interface ProjectsListProps {
data: Project[];
lang?: Locale;
dict?: any;
}
export const ProjectsList = ({ data, lang, dict }: ProjectsListProps) => {
// Show only first 6 projects on homepage
const featuredProjects = data.slice(0, 6);
return (
<section id="work" className="section-dark-sm">
<div className="container-dark">
{/* Section Header */}
<BlurReveal className="mb-24 md:mb-32 text-center max-w-3xl mx-auto">
<h2 className="text-5xl md:text-7xl font-bold text-foreground mb-6 tracking-tighter">
{dict?.selected_work || "Selected Work"}
</h2>
<p className="text-xl md:text-2xl text-muted-foreground">
{dict?.sections_subtitle || "We partner with ambitious brands to create digital experiences that drive growth and innovation."}
</p>
</BlurReveal>
{/* Projects Grid - 2 columns */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 md:gap-16">
{featuredProjects.map((project) => (
<ProjectItem key={project.id} project={project} dict={dict} lang={lang!} />
))}
</div>
{/* View All Link */}
<div className="mt-24 text-center pb-8">
<Link href={lang ? `/${lang}/work` : "/work"} className="text-foreground text-xl font-medium inline-block border-b-2 border-transparent hover:border-primary pb-1 transition-colors">
{dict?.view_all_projects || "View All Projects"}
</Link>
</div>
</div>
</section>
);
};

View File

@@ -0,0 +1,37 @@
import { Service } from "@/types";
interface ServicesProps {
data: Service[];
}
export const Services = ({ data }: ServicesProps) => {
return (
<section id="services" className="py-20 border-t border-border">
<div className="container">
{/* Section Header */}
<div className="mb-16">
<h2 className="text-5xl font-bold text-foreground mb-4">
What We Do
</h2>
<p className="text-lg text-muted-foreground max-w-2xl">
We offer end-to-end digital services, from strategy and design to development and growth.
</p>
</div>
{/* Services List - Minimal Typography */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-12">
{data.map((service) => (
<div key={service.id} className="space-y-4">
<h3 className="text-2xl font-bold text-foreground">
{service.title}
</h3>
<p className="text-base text-muted-foreground leading-relaxed">
{service.shortDescription}
</p>
</div>
))}
</div>
</div>
</section>
);
};

View File

@@ -0,0 +1,136 @@
"use client";
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Plus, Minus, HelpCircle } from "lucide-react";
import { Service } from "@/types";
import Image from "next/image";
import Link from "next/link";
import { BlurReveal } from "@/components/ui/BlurReveal";
import { slugify } from "@/lib/slugify";
import { Locale } from "@/i18n-config";
interface ServicesListProps {
services: Service[];
dict?: any;
lang?: Locale;
}
import { STRAPI_URL } from "@/lib/config";
export function ServicesList({ services, dict, lang }: ServicesListProps) {
const [openIndex, setOpenIndex] = useState<number | null>(0);
if (!services || services.length === 0) return null;
return (
<div className="space-y-4">
{services.map((service, index) => (
<BlurReveal key={service.id || index} delay={index * 0.1} className="border-b border-border last:border-0 border-t border-border">
<button
onClick={() => setOpenIndex(openIndex === index ? null : index)}
className="w-full py-10 flex items-center justify-between group text-left"
>
<div className="flex items-center gap-6 md:gap-8">
<div className="flex-shrink-0 transition-transform duration-500 group-hover:scale-110 relative w-12 h-12 md:w-16 md:h-16 flex items-center justify-center">
{service.icon?.url ? (
<Image
src={`${STRAPI_URL}${service.icon.url}`}
alt={service.title}
fill
className="object-contain opacity-80 group-hover:opacity-100 transition-opacity"
sizes="64px"
/>
) : (
<HelpCircle className="w-8 h-8 md:w-12 md:h-12 text-muted-foreground opacity-60" />
)}
</div>
<span className={`text-3xl md:text-5xl font-bold transition-colors ${openIndex === index ? 'text-foreground' : 'text-muted-foreground group-hover:text-foreground'}`}>
{service.title}
</span>
</div>
<div className={`p-2 rounded-full border border-border transition-colors ${openIndex === index ? 'bg-primary border-primary text-primary-foreground' : 'group-hover:bg-foreground group-hover:text-background'}`}>
{openIndex === index ? <Minus className="w-6 h-6" /> : <Plus className="w-6 h-6" />}
</div>
</button>
<AnimatePresence>
{openIndex === index && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.3, ease: "easeInOut" }}
className="overflow-hidden"
>
<div className="pb-10 pt-2 grid grid-cols-1 md:grid-cols-3 gap-8">
<div className="md:col-span-2">
<p className="text-xl text-muted-foreground leading-relaxed mb-6">
{service.shortDescription}
</p>
<Link
href={`/${lang}/services/${service.slug}`}
className="inline-flex items-center text-primary font-medium hover:underline text-lg"
>
{dict?.view_details || "View Details"}
</Link>
</div>
{service.capabilities && service.capabilities.length > 0 && (
<div className="md:col-span-1">
<h4 className="text-sm font-bold uppercase tracking-wider text-foreground mb-4">{dict?.capabilities || "Capabilities"}</h4>
<ul className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-1 gap-2">
{service.capabilities.map((cap) => (
<li key={cap.id} className="text-muted-foreground flex items-center gap-2">
<span className="w-1.5 h-1.5 bg-primary rounded-full flex-shrink-0" />
<span className="text-sm">{cap.title}</span>
</li>
))}
</ul>
</div>
)}
{/* Related Projects */}
{service.projects && service.projects.length > 0 && (
<div className="md:col-span-3 mt-8 pt-8 border-t border-border">
<h4 className="text-sm font-bold uppercase tracking-wider text-foreground mb-6">{dict?.recent_work_in_area || "Recent Work in this area"}</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{service.projects.map((proj) => (
<Link
key={proj.id}
href={`/work/${proj.slug}`}
className="group/proj relative flex flex-col gap-3 p-3 bg-secondary/50 border border-border rounded-2xl hover:border-primary/50 hover:bg-secondary transition-all overflow-hidden"
>
{proj.clientBackground?.url && (
<div className="relative aspect-video rounded-xl overflow-hidden mb-1">
<Image
src={`${STRAPI_URL}${proj.clientBackground.url}`}
alt={proj.title}
fill
className="object-cover transition-transform duration-700 group-hover/proj:scale-110"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 25vw"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent opacity-0 group-hover/proj:opacity-100 transition-opacity" />
</div>
)}
<div className="flex flex-col">
<span className="text-base font-bold text-foreground group-hover/proj:text-primary transition-colors">
{proj.title}
</span>
<span className="text-sm text-muted-foreground">
{dict?.view_case_study || "View Case Study"}
</span>
</div>
</Link>
))}
</div>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</BlurReveal>
))}
</div>
);
}

View File

@@ -0,0 +1,54 @@
"use client";
import { motion } from "framer-motion";
import { Service } from "@/types";
import { ArrowUpRight } from "lucide-react";
import Link from "next/link";
import { ServicesList } from "@/components/sections/ServicesList";
import { BlurReveal } from "@/components/ui/BlurReveal";
import { Locale } from "@/i18n-config";
interface ServicesSectionProps {
services: Service[];
lang?: Locale;
dict?: any;
}
export function ServicesSection({ services, lang, dict }: ServicesSectionProps) {
if (!services || services.length === 0) return null;
return (
<section className="py-32 bg-background border-t border-border">
<div className="container mx-auto px-6">
<BlurReveal className="mb-16 flex flex-col md:flex-row md:items-end justify-between gap-6">
<div>
<div className="flex items-center gap-3 mb-4">
<span className="w-10 h-1 bg-primary rounded-full" />
<h2 className="text-xl font-medium text-muted-foreground uppercase tracking-widest">
{dict?.our_expertise || "Our Expertise"}
</h2>
</div>
<h3 className="text-4xl md:text-6xl font-bold text-foreground mb-6">
{dict?.services_title ? (
<>
{dict.services_title.split('\n')[0]} <br />
<span className="text-primary">{dict.services_title.split('\n')[1] || "help your brand grow."}</span>
</>
) : (
<>
Digital services to <br />
<span className="text-primary">help your brand grow.</span>
</>
)}
</h3>
</div>
<Link href={lang ? `/${lang}/services` : "/services"} className="px-6 py-3 bg-secondary border border-border rounded-full text-foreground font-medium hover:bg-primary hover:border-primary hover:text-primary-foreground transition-all flex items-center gap-2 group mb-6 md:mb-0">
/services <ArrowUpRight className="w-4 h-4 group-hover:-translate-y-1 group-hover:translate-x-1 transition-transform" />
</Link>
</BlurReveal>
<ServicesList services={services} lang={lang} dict={dict} />
</div >
</section >
);
}

View File

@@ -0,0 +1,45 @@
"use client";
import { motion } from "framer-motion";
import Image from "next/image";
import Link from "next/link";
import { TechMarquee } from "@/components/sections/TechMarquee";
import { BlurReveal } from "@/components/ui/BlurReveal";
import { Locale } from "@/i18n-config";
interface Stack {
id?: number;
name: string;
icon?: {
url: string;
width?: number;
height?: number;
};
}
interface StackSectionProps {
stacks: Stack[];
lang?: Locale;
dict?: any;
}
export function StackSection({ stacks, dict }: StackSectionProps) {
if (!stacks || stacks.length === 0) return null;
// Deduplicate stacks by name
const uniqueStacks = Array.from(new Map(stacks.map(s => [s.name, s])).values());
return (
<section className="py-32 bg-background border-t border-border">
<div className="container mx-auto px-6">
<BlurReveal className="mb-12 text-center">
<h2 className="text-xl font-medium text-muted-foreground uppercase tracking-widest">
{dict?.technologies_title || "Technologies we work with"}
</h2>
</BlurReveal>
<TechMarquee stacks={stacks} />
</div>
</section>
);
}

View File

@@ -0,0 +1,79 @@
"use client";
import { motion } from "framer-motion";
import Image from "next/image";
import Link from "next/link";
import { useMemo } from "react";
interface Stack {
id?: number;
name: string;
icon?: {
url: string;
width?: number;
height?: number;
};
}
interface TechMarqueeProps {
stacks: Stack[];
}
export function TechMarquee({ stacks }: TechMarqueeProps) {
// Deduplicate stacks
const uniqueStacks = useMemo(() => {
return Array.from(new Map(stacks.map(s => [s.name, s])).values());
}, [stacks]);
// Duplicate list for seamless loop
const marqueeStacks = [...uniqueStacks, ...uniqueStacks, ...uniqueStacks];
if (uniqueStacks.length === 0) return null;
return (
<div className="w-full overflow-hidden py-10 relative">
{/* Fade Edges */}
<div className="absolute left-0 top-0 bottom-0 w-32 bg-gradient-to-r from-background to-transparent z-10 pointer-events-none" />
<div className="absolute right-0 top-0 bottom-0 w-32 bg-gradient-to-l from-background to-transparent z-10 pointer-events-none" />
<motion.div
className="flex items-center gap-16 min-w-max"
animate={{ x: ["0%", "-33.33%"] }}
transition={{
duration: 30,
ease: "linear",
repeat: Infinity,
}}
>
{marqueeStacks.map((stack, index) => (
<Link
key={`${stack.name}-${index}`}
href={`/work?tech=${encodeURIComponent(stack.name)}`}
className="group flex items-center gap-4 opacity-50 hover:opacity-100 transition-opacity cursor-pointer"
>
<div className="relative w-12 h-12 transition-all duration-500">
{stack.icon?.url && (
<div
className="w-full h-full bg-primary"
style={{
maskImage: `url(https://strapi.xodo.ro${stack.icon.url})`,
WebkitMaskImage: `url(https://strapi.xodo.ro${stack.icon.url})`,
maskSize: 'contain',
WebkitMaskSize: 'contain',
maskRepeat: 'no-repeat',
WebkitMaskRepeat: 'no-repeat',
maskPosition: 'center',
WebkitMaskPosition: 'center'
}}
/>
)}
</div>
<span className="text-2xl font-bold text-muted-foreground group-hover:text-foreground transition-colors">
{stack.name}
</span>
</Link>
))}
</motion.div>
</div>
);
}

View File

@@ -0,0 +1,165 @@
"use client";
import { useState, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { ArrowLeft, ArrowRight, Quote } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { Testimonial } from "@/types";
import { cn } from "@/lib/utils";
import { STRAPI_URL } from "@/lib/config";
interface TestimonialSliderProps {
testimonials: any[];
dict?: any;
showProjectLink?: boolean;
accentColor?: string;
disableSlider?: boolean;
fullWidth?: boolean;
}
export function TestimonialSlider({ testimonials, dict, showProjectLink = true, accentColor, disableSlider = false, fullWidth = false }: TestimonialSliderProps) {
const [currentIndex, setCurrentIndex] = useState(0);
// Auto-advance only if slider is enabled and has multiple items
useEffect(() => {
if (disableSlider || testimonials.length <= 1) return;
const timer = setInterval(() => {
setCurrentIndex((prev) => (prev + 1) % testimonials.length);
}, 6000); // Shorter interval (6s instead of 8s)
return () => clearInterval(timer);
}, [testimonials.length, disableSlider]);
const next = () => setCurrentIndex((prev) => (prev + 1) % testimonials.length);
const prev = () => setCurrentIndex((prev) => (prev - 1 + testimonials.length) % testimonials.length);
if (!testimonials.length) return null;
// Helper to render a single testimonial card
const renderTestimonial = (testimonial: any, index: number) => {
const quoteStyle = accentColor ? { color: accentColor } : {};
const borderStyle = accentColor ? { borderColor: `${accentColor}4D` } : {};
const bgStyle = accentColor ? { backgroundColor: `${accentColor}33`, color: accentColor } : {};
return (
<div className="max-w-4xl mx-auto text-center">
<Quote
className={`w-16 h-16 mb-8 mx-auto ${!accentColor ? 'text-primary/20' : ''}`}
style={accentColor ? { color: accentColor, opacity: 0.2 } : {}}
/>
<h3 className="text-3xl md:text-5xl font-caveat text-foreground leading-tight mb-8">
"{testimonial.content}"
</h3>
<div className="flex items-center gap-4 mb-8 justify-center">
{testimonial.clientAvatar?.url ? (
<div
className={`relative w-16 h-16 rounded-full overflow-hidden border-2 ${!accentColor ? 'border-border' : ''}`}
style={borderStyle}
>
<Image
src={`${STRAPI_URL}${testimonial.clientAvatar.url}`}
alt={testimonial.clientName}
fill
className="object-cover"
sizes="64px"
/>
</div>
) : (
<div
className={`w-16 h-16 rounded-full flex items-center justify-center font-bold text-xl ${!accentColor ? 'bg-primary/20 text-primary' : ''}`}
style={bgStyle}
>
{testimonial.clientName.charAt(0)}
</div>
)}
<div className="text-left">
<div className="text-xl font-bold text-foreground">{testimonial.clientName}</div>
<div className="text-muted-foreground">{testimonial.clientRole}</div>
</div>
</div>
{showProjectLink && testimonial.projectSlug && (
<Link
href={`/work/${testimonial.projectSlug}`}
className={`inline-flex items-center gap-2 hover:text-foreground transition-colors group ${!accentColor ? 'text-primary' : ''}`}
style={accentColor ? { color: accentColor } : {}}
>
<span className="font-bold">{dict?.view_project || "View Project"}</span>
<span
className="w-8 h-[1px] bg-current group-hover:w-12 transition-all"
/>
</Link>
)}
</div>
);
};
if (disableSlider) {
return (
<div className="relative max-w-6xl mx-auto px-6 space-y-24">
{testimonials.map((testimonial, index) => (
<div key={index}>
{renderTestimonial(testimonial, index)}
</div>
))}
</div>
);
}
const current = testimonials[currentIndex];
return (
<div className={`relative mx-auto px-6 ${fullWidth ? 'w-full max-w-[1920px] px-4 md:px-12' : 'max-w-6xl'}`}>
<AnimatePresence mode="wait">
<motion.div
key={currentIndex}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.5 }}
>
{renderTestimonial(current, currentIndex)}
</motion.div>
</AnimatePresence>
{/* Navigation Controls and Dots */}
{testimonials.length > 1 && (
<div className="mt-12 flex flex-col items-center gap-8">
{/* Dots */}
<div className="flex gap-2">
{testimonials.map((_, i) => (
<button
key={i}
onClick={() => setCurrentIndex(i)}
className={cn(
"w-2 h-2 rounded-full transition-all duration-300",
currentIndex === i ? "bg-primary w-6" : "bg-border hover:bg-muted-foreground"
)}
aria-label={`Go to testimonial ${i + 1}`}
/>
))}
</div>
<div className="flex gap-4">
<button
onClick={prev}
className="p-3 rounded-full border border-border hover:bg-foreground hover:text-background transition-all text-muted-foreground"
aria-label="Previous testimonial"
>
<ArrowLeft className="w-5 h-5" />
</button>
<button
onClick={next}
className="p-3 rounded-full border border-border hover:bg-foreground hover:text-background transition-all text-muted-foreground"
aria-label="Next testimonial"
>
<ArrowRight className="w-5 h-5" />
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,44 @@
"use client";
import { motion } from "framer-motion";
import { Quote } from "lucide-react";
import Link from "next/link";
import { TestimonialSlider } from "@/components/sections/TestimonialSlider";
import { BlurReveal } from "@/components/ui/BlurReveal";
import { Locale } from "@/i18n-config";
interface Testimonial {
id: number;
clientName: string;
clientRole: string;
content: string;
projectSlug?: string;
projectTitle?: string;
}
interface TestimonialsSectionProps {
testimonials: Testimonial[];
lang?: Locale;
dict?: any;
}
export function TestimonialsSection({ testimonials, dict }: TestimonialsSectionProps) {
if (!testimonials || testimonials.length === 0) return null;
return (
<section className="pt-32 pb-40 bg-background border-t border-border">
<div className="container mx-auto px-6">
<BlurReveal className="mb-24 text-center">
<h2 className="text-3xl md:text-5xl font-bold text-foreground mb-6">
{dict?.client_stories || "Client Stories"}
</h2>
<p className="text-xl text-muted-foreground">
{dict?.testimonials_subtitle || "Don't just take our word for it."}
</p>
</BlurReveal>
</div>
<TestimonialSlider testimonials={testimonials} dict={dict} fullWidth={true} />
</section>
);
}

View File

@@ -0,0 +1,83 @@
"use client";
import { motion } from "framer-motion";
interface AnimatedTextProps {
text: string;
className?: string;
once?: boolean;
immediate?: boolean; // If true, animates immediately without waiting for viewport
}
export function AnimatedText({ text, className, once = true, immediate = false }: AnimatedTextProps) {
const container = {
hidden: { opacity: 0 },
visible: (i = 1) => ({
opacity: 1,
transition: { staggerChildren: 0.03, delayChildren: 0.04 * i },
}),
};
const child = {
visible: {
opacity: 1,
y: 0,
filter: "blur(0px)",
transition: {
type: "spring",
damping: 12,
stiffness: 100,
} as const,
},
hidden: {
opacity: 0,
y: 20,
filter: "blur(10px)",
transition: {
type: "spring",
damping: 12,
stiffness: 100,
} as const,
},
};
return (
<motion.span
style={{
display: "inline-block",
verticalAlign: "bottom",
WebkitBackfaceVisibility: "hidden",
backfaceVisibility: "hidden",
}}
variants={container}
initial="hidden"
{...(immediate
? { animate: "visible" }
: { whileInView: "visible", viewport: { once: true, amount: 0.2 } }
)}
className={className}
>
{text.split(" ").map((word, wordIndex) => (
<span key={wordIndex} className="inline-block whitespace-nowrap">
{word.split("").map((letter, letterIndex) => (
<motion.span
variants={child}
key={letterIndex}
className="inline-block"
style={{
WebkitBackfaceVisibility: "hidden",
backfaceVisibility: "hidden",
transform: "translateZ(0)",
}}
>
{letter}
</motion.span>
))}
{wordIndex !== text.split(" ").length - 1 && (
<span className="inline-block" style={{ width: '0.25em' }}>&nbsp;</span>
)}
</span>
))}
</motion.span>
);
}

View File

@@ -0,0 +1,38 @@
"use client";
import { motion, useInView } from "framer-motion";
import { useRef } from "react";
import { cn } from "@/lib/utils";
interface BlurRevealProps {
children: React.ReactNode;
className?: string;
delay?: number;
duration?: number;
yOffset?: number;
blurAmount?: number;
}
export const BlurReveal = ({
children,
className,
delay = 0,
duration = 0.8,
yOffset = 20,
blurAmount = 10
}: BlurRevealProps) => {
const ref = useRef(null);
const isInView = useInView(ref, { once: true, margin: "-50px" });
return (
<motion.div
ref={ref}
initial={{ opacity: 0, y: yOffset, filter: `blur(${blurAmount}px)` }}
animate={isInView ? { opacity: 1, y: 0, filter: "blur(0px)" } : { opacity: 0, y: yOffset, filter: `blur(${blurAmount}px)` }}
transition={{ duration, delay, ease: "easeOut" }}
className={cn(className)}
>
{children}
</motion.div>
);
};

View File

@@ -0,0 +1,127 @@
"use client";
import { motion, AnimatePresence } from "framer-motion";
import { X, ZoomIn, ZoomOut, RotateCcw } from "lucide-react";
import Image from "next/image";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch";
interface ImageModalProps {
isOpen: boolean;
onClose: () => void;
src: string;
alt: string;
}
export function ImageModal({ isOpen, onClose, src, alt }: ImageModalProps) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
// Prevent scrolling when modal is open
useEffect(() => {
if (isOpen) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "unset";
}
return () => {
document.body.style.overflow = "unset";
};
}, [isOpen]);
// Handle escape key
useEffect(() => {
const handleEsc = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handleEsc);
return () => window.removeEventListener("keydown", handleEsc);
}, [onClose]);
if (!mounted) return null;
const modalContent = (
<AnimatePresence>
{isOpen && (
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-background/95 backdrop-blur-xl overflow-hidden">
{/* Controls & Close */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="absolute top-0 left-0 right-0 z-50 flex items-center justify-between p-6 pointer-events-none"
>
<span className="text-muted-foreground text-sm font-medium tracking-wider uppercase bg-secondary/80 backdrop-blur-md px-4 py-2 rounded-full border border-border">
{alt}
</span>
<button
onClick={onClose}
className="pointer-events-auto p-3 text-muted-foreground hover:text-foreground bg-secondary/50 hover:bg-secondary rounded-full transition-all border border-border hover:border-border/80"
aria-label="Close modal"
>
<X className="w-6 h-6" />
</button>
</motion.div>
{/* Image Area */}
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.4 }}
className="w-full h-full flex items-center justify-center p-4 md:p-10"
>
<TransformWrapper
initialScale={1}
minScale={0.5}
maxScale={4}
centerOnInit
>
{({ zoomIn, zoomOut, resetTransform }) => (
<>
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 z-50 flex items-center gap-2 pointer-events-auto">
<button onClick={() => zoomOut()} className="p-3 bg-secondary/50 hover:bg-secondary rounded-full text-foreground backdrop-blur-md border border-border">
<ZoomOut className="w-5 h-5" />
</button>
<button onClick={() => resetTransform()} className="p-3 bg-secondary/50 hover:bg-secondary rounded-full text-foreground backdrop-blur-md border border-border">
<RotateCcw className="w-5 h-5" />
</button>
<button onClick={() => zoomIn()} className="p-3 bg-secondary/50 hover:bg-secondary rounded-full text-foreground backdrop-blur-md border border-border">
<ZoomIn className="w-5 h-5" />
</button>
</div>
<TransformComponent
wrapperClass="flex items-center justify-center cursor-move"
contentClass="flex items-center justify-center"
wrapperStyle={{ width: "100%", height: "100%" }}
contentStyle={{ width: "100%", height: "100%" }}
>
<div className="relative w-full h-[80vh] md:h-[90vh] flex items-center justify-center">
<Image
src={src}
alt={alt}
fill
className="object-contain"
quality={100}
priority
sizes="100vw"
/>
</div>
</TransformComponent>
</>
)}
</TransformWrapper>
</motion.div>
</div>
)}
</AnimatePresence>
);
return createPortal(modalContent, document.body);
}

View File

@@ -0,0 +1,120 @@
"use client";
import Link from "next/link";
import Image from "next/image";
import { Project } from "@/types";
import { motion } from "framer-motion";
import { useState } from "react";
import { ArrowUpRight } from "lucide-react";
import { Locale } from "@/i18n-config";
interface ProjectItemProps {
project: Project;
dict?: any;
lang: Locale;
}
import { STRAPI_URL } from "@/lib/config";
export const ProjectItem = ({ project, dict, lang }: ProjectItemProps) => {
const [isHovered, setIsHovered] = useState(false);
const imgUrl = project.clientBackground?.url ? `${STRAPI_URL}${project.clientBackground.url}` : null;
const logoUrl = project.clientLogo?.url ? `${STRAPI_URL}${project.clientLogo.url}` : null;
const accentColor = project.mainColor || "#ffffff";
return (
<Link
href={`/${lang}/work/${project.slug}`}
className="group block relative w-full"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<motion.div
initial={{ opacity: 0, y: 20, filter: "blur(10px)" }}
whileInView={{ opacity: 1, y: 0, filter: "blur(0px)" }}
viewport={{ once: true, margin: "-100px" }}
transition={{ duration: 0.5, ease: "easeOut" }}
className="relative aspect-[4/3] rounded-3xl overflow-hidden bg-card border border-border"
style={{
borderColor: isHovered ? `${accentColor}40` : 'var(--border)',
willChange: "transform, opacity, filter",
transform: "translateZ(0)",
WebkitBackfaceVisibility: "hidden",
backfaceVisibility: "hidden"
}}
>
{/* Background Image */}
{imgUrl ? (
<Image
src={imgUrl}
alt={project.title}
fill
className="object-cover transition-transform duration-700 group-hover:scale-110"
sizes="(max-width: 768px) 100vw, 50vw"
/>
) : (
<div className="w-full h-full bg-card" />
)}
{/* Dark Overlay - Initial and Hover States */}
<div className="absolute inset-0 bg-black/20 group-hover:bg-black/60 transition-colors duration-500" />
{/* Content Overlay */}
<div className="absolute inset-0 p-8 flex flex-col justify-between">
{/* Top: Logo Reveal & Featured Tag */}
<div className="flex justify-between items-start">
<div className="flex flex-col gap-4">
<div className={`transition-all duration-500 transform ${isHovered ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-4'}`}>
{logoUrl && (
<div className="relative w-24 h-12">
<Image
src={logoUrl}
alt={`${project.title} logo`}
fill
className="object-contain object-left"
/>
</div>
)}
</div>
{project.isFeatured && (
<div className="flex items-center gap-2 px-3 py-1 bg-secondary/80 border border-border/50 rounded-full w-fit backdrop-blur-xl">
<div className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse shadow-[0_0_8px_rgba(225,29,72,0.8)]" />
<span className="text-[11px] font-bold uppercase tracking-[0.2em] text-muted-foreground">{dict?.featured || "featured"}</span>
</div>
)}
</div>
{/* Arrow Icon */}
<div
className="w-10 h-10 rounded-full bg-secondary/80 backdrop-blur-md flex items-center justify-center text-foreground transition-all duration-300 group-hover:bg-foreground group-hover:text-background"
style={{ backgroundColor: isHovered ? accentColor : undefined }}
>
<ArrowUpRight className="w-5 h-5 group-hover:rotate-45 transition-transform duration-300" />
</div>
</div>
{/* Bottom: Title and Tags */}
<div>
<h3 className="text-3xl font-bold text-white mb-3 translate-y-4 group-hover:translate-y-0 transition-transform duration-500">
{project.title}
</h3>
<div className="flex flex-wrap gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-500 delay-100">
{project.stacks?.slice(0, 3).map((stack, i) => (
<span
key={i}
className="text-sm font-medium px-2 py-1 rounded bg-white/20 text-white backdrop-blur-sm"
>
{stack.name}
</span>
))}
</div>
</div>
</div>
</motion.div>
</Link>
);
};

View File

@@ -0,0 +1,97 @@
"use client";
import { cn } from "@/lib/utils";
import React from "react";
interface RichTextRendererProps {
content: any[];
className?: string;
}
export function RichTextRenderer({ content, className }: RichTextRendererProps) {
if (!content || !Array.isArray(content)) return null;
return (
<div className={cn("prose prose-lg dark:prose-invert max-w-none", className)}>
{content.map((block, index) => {
switch (block.type) {
case "heading":
const HeadingTag = `h${block.level}` as React.ElementType;
return (
<HeadingTag key={index} className="font-bold text-foreground mt-8 mb-4">
{renderChildren(block.children)}
</HeadingTag>
);
case "paragraph":
return (
<p key={index} className="text-muted-foreground leading-relaxed mb-6">
{renderChildren(block.children)}
</p>
);
case "list":
const ListTag = block.format === "ordered" ? "ol" : "ul";
return (
<ListTag key={index} className="list-outside ml-6 mb-6 marker:text-primary">
{block.children.map((item: any, i: number) => (
<li key={i} className="pl-2 mb-2 text-muted-foreground">
{renderChildren(item.children)}
</li>
))}
</ListTag>
);
case "quote":
return (
<blockquote key={index} className="border-l-4 border-primary pl-6 italic text-foreground my-8 bg-secondary p-6 rounded-r-lg">
{renderChildren(block.children)}
</blockquote>
);
case "image":
// Strapi standard image handling in rich text if needed, though usually handled by separate blocks
return null;
default:
return null;
}
})}
</div>
);
}
function renderChildren(children: any[]) {
return children.map((child, index) => {
let text = <span key={index}>{child.text}</span>;
if (child.bold) {
text = <strong key={index} className="font-bold text-foreground">{child.text}</strong>;
}
if (child.italic) {
text = <em key={index} className="italic">{child.text}</em>;
}
if (child.underline) {
text = <u key={index}>{child.text}</u>;
}
if (child.strikethrough) {
text = <s key={index}>{child.text}</s>;
}
if (child.code) {
text = <code key={index} className="bg-secondary/50 px-1.5 py-0.5 rounded text-primary font-mono text-sm">{child.text}</code>;
}
// Check for links - Strapi rich text structure usually wraps text in link type but sometimes it's a property
// Adapting to common Strapi structure:
if (child.type === 'link') {
return (
<a key={index} href={child.url} className="text-primary hover:underline" target="_blank" rel="noopener noreferrer">
{renderChildren(child.children)}
</a>
);
}
return text;
});
}

View File

@@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted/10", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -0,0 +1,42 @@
"use client"
import * as React from "react"
import { Moon, Sun } from "lucide-react"
import { useTheme } from "next-themes"
import { cn } from "@/lib/utils"
export function ThemeToggle() {
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = React.useState(false)
React.useEffect(() => {
setMounted(true)
}, [])
if (!mounted) {
return <div className="w-9 h-9" /> // Placeholder to avoid mismatched hydration
}
return (
<button
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className={cn(
"relative inline-flex items-center justify-center p-2 rounded-full transition-colors",
"bg-foreground/10 border border-foreground/5 hover:bg-foreground/20 text-foreground/60 hover:text-foreground"
// In light mode these classes might look weird if strictly using white/black.
// Let's use semantic classes for the container button
)}
// Override className for semantic support
>
<div className={cn(
"flex items-center justify-center w-9 h-9 rounded-full transition-all border",
"bg-secondary text-secondary-foreground border-border hover:bg-muted"
)}>
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</div>
</button>
)
}

View File

@@ -0,0 +1,33 @@
"use client";
import { ExternalLink } from "lucide-react";
interface VisitProjectButtonProps {
url: string;
accentColor: string;
label?: string;
}
export default function VisitProjectButton({ url, accentColor, label }: VisitProjectButtonProps) {
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-8 py-4 bg-white/10 border border-white/20 rounded-full text-white font-bold transition-all group shadow-lg shadow-black/20"
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = accentColor;
e.currentTarget.style.borderColor = accentColor;
e.currentTarget.style.boxShadow = `0 10px 30px -10px ${accentColor}`;
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.1)';
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.2)';
e.currentTarget.style.boxShadow = '0 10px 15px -3px rgba(0,0,0,0.2)';
}}
>
{label || "Visit Project"}
<ExternalLink className="w-5 h-5 group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-transform" />
</a>
);
}

View File

@@ -0,0 +1,6 @@
export const XodoLogo = ({ className = "h-8 w-auto fill-[#f4f4f5] group-hover:fill-[#FF1F4A] transition-colors" }: { className?: string }) => (
<svg viewBox="0 0 264 164" className={className} role="img" aria-label="XODO Studio Logo">
<path d="M0 162.56L2.34 153.55L58.74 82.8297L0.76 8.09972L0.64 1.94972L46.92 1.27972L87.09 52.7297C98.91 36.0797 109.42 17.5897 127.74 8.29972C130.44 6.92972 147.52 0.429719 149.23 0.429719H178.78L114.32 83.6797L143.93 120.9C153.75 129.84 181.61 128.94 194.75 127.78C207.58 126.65 216.29 118.48 218.04 105.62C219.37 95.8197 219.37 67.1997 218.04 57.3997C216.61 46.8997 205.71 33.7797 194.69 33.7797H166.66C173.08 28.2797 189.58 1.77972 196.24 0.479719C212.31 -2.64028 238.31 10.0097 248.45 22.4497C267.77 46.1497 268.06 116.13 249.28 139.89C226.05 169.28 148.65 169.38 119.2 151.7C104.68 142.98 99.07 126.74 85.8 117.24L47.71 162.58H0V162.56Z"></path>
</svg>
);

View File

@@ -0,0 +1,70 @@
"use client";
import { useState } from "react";
import Image, { ImageProps } from "next/image";
import Link from "next/link";
import { ImageModal } from "./ImageModal";
import { Maximize2 } from "lucide-react";
import { twMerge } from "tailwind-merge";
interface ZoomableImageProps extends ImageProps {
containerClassName?: string;
showZoomIcon?: boolean;
zoomEnabled?: boolean;
children?: React.ReactNode;
}
export function ZoomableImage({
containerClassName = "",
showZoomIcon = true,
zoomEnabled = true,
alt,
src,
children,
...props
}: ZoomableImageProps) {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<div
className={twMerge(
"group relative overflow-hidden",
props.fill ? 'h-full w-full' : '',
zoomEnabled ? 'cursor-zoom-in' : '',
containerClassName
)}
onClick={() => zoomEnabled && setIsOpen(true)}
>
<Image
src={src}
alt={alt}
{...props}
className={twMerge(
"transition-transform duration-700",
zoomEnabled ? 'group-hover:scale-105' : '',
props.className
)}
sizes={props.sizes || "(max-width: 768px) 100vw, 80vw"}
/>
{children}
{showZoomIcon && zoomEnabled && (
<div className="absolute bottom-4 right-4 z-50 pointer-events-none">
<div className="bg-white/20 backdrop-blur-md p-3 rounded-full border border-white/30 opacity-0 group-hover:opacity-100 transition-opacity duration-300 transform translate-y-2 group-hover:translate-y-0">
<Maximize2 className="w-5 h-5 text-white" />
</div>
</div>
)}
</div>
<ImageModal
isOpen={isOpen}
onClose={() => setIsOpen(false)}
src={typeof src === "string" ? src : ""}
alt={alt || "Project Image"}
/>
</>
);
}

117
src/dictionaries/en.json Normal file
View File

@@ -0,0 +1,117 @@
{
"visit_project": "Visit Project",
"contact": "Contact",
"contact_us": "Contact Us",
"view_all_projects": "View All Projects",
"all_rights_reserved": "All rights reserved.",
"navigation": "Navigation",
"services": "Services",
"work": "Work",
"about": "About",
"home": "Home",
"socials": "Socials",
"footer_description": "Refining digital experiences for ambitious brands. We build high-performance websites and applications.",
"footer_credits": "Designed & Developed by XODO.",
"services_title_accent": "Services",
"work_title_accent": "Work",
"our_services_title_1": "Our",
"our_services_title_2": "Services",
"our_work_title_1": "Selected",
"our_work_title_2": "Work",
"selected_projects_filter": "Projects",
"clear_filter": "Clear Filter",
"no_projects_found": "No projects found with this technology.",
"seo_home_title": "XODO | Digital Product Studio",
"seo_home_description": "We build digital products for ambitious brands. Bucharest-based design & development studio.",
"legal": "Legal",
"privacy_policy": "Privacy Policy",
"terms_of_service": "Terms of Service",
"cookie_policy": "Cookie Policy",
"hero_title_1": "Transform your",
"hero_title_2": "online presence",
"hero_title_3": "into a client magnet.",
"hero_description": "We build fast, secure, and conversion-optimized websites that help you stand out and grow your business.",
"start_project": "Start a project",
"selected_work": "Selected Work",
"sections_subtitle": "We partner with ambitious brands to create digital experiences that drive growth and innovation.",
"our_expertise": "Our Expertise",
"services_title": "Digital services to \nhelp your brand grow.",
"technologies_title": "Technologies we work with",
"client_stories": "Client Stories",
"testimonials_subtitle": "Don't just take our word for it.",
"services_page_title": "Our Services",
"services_page_subtitle": "Comprehensive digital services to help ambitious brands grow.",
"projects_page_title": "Our Projects",
"projects_page_subtitle": "A selection of our best work. Digital products, websites, and applications.",
"about_page_title": "About",
"about_page_subtitle": "XODO is a digital design and development agency built on real experience.",
"contact_page_title": "Let's <br /> <span class=\"text-dark-accent\">talk.</span>",
"contact_page_subtitle": "Have a project in mind? We'd love to hear about it.",
"project_details": "Project Details",
"sending": "Sending...",
"message_sent": "Message Sent!",
"capabilities": "Capabilities",
"recent_work_in_area": "Recent Work in this area",
"view_case_study": "View Case Study",
"featured": "Featured",
"view_project": "View Project",
"back_to_work": "Back to Work",
"services_provided": "Services Provided",
"technology_stack": "Technology Stack",
"case_study_for": "Case study for",
"work_suffix": "| XODO Work",
"our_services": "Our Services",
"digital_architects_since": "Digital Architects since 2015",
"about_title": "About",
"roots_title": "Roots in",
"roots_subtitle": "performance.",
"roots_desc_1": "We didn't happen overnight. Our journey was shaped by massive technical challenges and a constant desire to deliver quality.",
"roots_desc_2": "That period laid the groundwork for an essential understanding: a digital product must be solid at its core to grow healthily.",
"origin_year": "The Origin (2015)",
"origin_desc": "Developing complex themes and solutions for large online platforms where stability was critical.",
"gaming_scalability": "Gaming & Scalability",
"collab_large_communities": "Collaborations with large communities",
"gaming_desc": "Managing massive traffic and high security requirements for the biggest gaming communities in Romania.",
"quote_part_1": "A successful digital platform is not just about design or code, but",
"quote_highlight": "performance, structure, scalability",
"quote_part_3": ", and right technical decisions.",
"evolution_desc": "Over time, an ambitious web design project called Drawwwn naturally evolved into what XODO is today: a full-service agency combining strategy, design, and infrastructure.",
"what_xodo_does": "What XODO Does",
"more_than_agency": "More than just a web design agency.",
"more_than_agency_desc_1": "XODO does not operate like a classic \"execution-only\" agency. Every project is treated as a digital product that needs to grow and perform.",
"more_than_agency_desc_2": "Our goal isn't just delivering a website, but building a solid digital platform backed by strategy and product logic.",
"partner_quote": "If you need a partner who understands architecture, infrastructure, and business logic, XODO is built for that.",
"lets_evolve": "Let's evolve.",
"lets_evolve_desc": "Ready to build something solid? Let's discuss your project.",
"intro_talk": "talk.",
"view_portfolio": "View portfolio",
"start_project_title": "Start a Project",
"name": "Name",
"email": "Email",
"project_details_label": "Project Details",
"send_message": "Send Message",
"error_occured": "Error. Try Again.",
"step_identity": "Who are you?",
"step_services": "What do you need?",
"step_details": "Project Details",
"next_step": "Next Step",
"prev_step": "Back",
"label_name": "Your Name",
"label_email": "Your Email",
"label_company": "Company (Optional)",
"label_budget": "Budget Range",
"label_description": "Tell us about your project",
"service_branding": "Branding",
"service_web": "Web Design",
"service_app": "App Development",
"service_marketing": "Marketing",
"budget_low": "< $5k",
"budget_medium": "$5k - $15k",
"budget_high": "$15k - $50k",
"budget_enterprise": "> $50k",
"success_title": "Message Sent!",
"success_desc": "We'll be in touch shortly.",
"get_in_touch": "Get in Touch",
"cta_title": "Ready to start?",
"cta_description": "Let's build something exceptional together. Reach out and tell us about your project."
}

112
src/dictionaries/ro.json Normal file
View File

@@ -0,0 +1,112 @@
{
"visit_project": "Vizitează Proiectul",
"contact": "Contact",
"contact_us": "Contactează-ne",
"view_all_projects": "Vezi Toate Proiectele",
"all_rights_reserved": "Toate drepturile rezervate.",
"navigation": "Navigare",
"services": "Servicii",
"work": "Proiecte",
"about": "Despre",
"socials": "Social media",
"legal": "Legal",
"privacy_policy": "Politica de Confidențialitate",
"terms_of_service": "Termeni și Condiții",
"cookie_policy": "Politica Cookie",
"hero_title_1": "Transformă-ți",
"hero_title_2": "prezența online",
"hero_title_3": "într-un magnet de clienți.",
"hero_description": "Construim site-uri web rapide, sigure și optimizate pentru conversii care te ajută să ieși în evidență și să îți crești afacerea.",
"start_project": "Începe un proiect",
"selected_work": "Proiecte Selectate",
"selected_projects_filter": "Proiecte",
"clear_filter": "Șterge Filtrul",
"no_projects_found": "Nu am găsit proiecte cu această tehnologie.",
"sections_subtitle": "Colaborăm cu branduri ambițioase pentru a crea experiențe digitale care generează creștere și inovație.",
"seo_home_title": "XODO | Studio de Produse Digitale",
"seo_home_description": "Construim produse digitale pentru branduri ambițioase. Studio de design și dezvoltare din București.",
"our_expertise": "Expertiza Noastră",
"services_title": "Servicii digitale pentru \na-ți crește brandul.",
"technologies_title": "Tehnologii cu care lucrăm",
"client_stories": "Povestea Clienților",
"testimonials_subtitle": "Nu ne crede doar pe cuvânt.",
"services_page_title": "Serviciile Noastre",
"services_page_subtitle": "Servicii digitale complete pentru a ajuta brandurile ambițioase să crească.",
"our_services_title_1": "Serviciile",
"our_services_title_2": "Noastre",
"projects_page_title": "Proiectele Noastre",
"our_work_title_1": "Proiectele",
"our_work_title_2": "Noastre",
"projects_page_subtitle": "O selecție a celor mai bune proiecte. Produse digitale, site-uri web și aplicații.",
"about_page_title": "Despre XODO",
"about_page_subtitle": "XODO este o agenție de web design și dezvoltare digitală construită pe experiență reală.",
"contact_page_title": "Hai să <br /> <span class=\"text-dark-accent\">discutăm.</span>",
"contact_page_subtitle": "Ai un proiect în minte? Ne-ar plăcea să auzim despre el.",
"project_details": "Detalii Proiect",
"sending": "Se trimite...",
"message_sent": "Mesaj Trimis!",
"capabilities": "Abilități",
"recent_work_in_area": "Proiecte Recente în această zonă",
"view_case_study": "Vezi Studiu de Caz",
"featured": "Recomandat",
"view_project": "Vezi Proiect",
"back_to_work": "Înapoi la Proiecte",
"services_provided": "Servicii Oferite",
"technology_stack": "Tehnologii Utilizate",
"case_study_for": "Studiu de caz pentru",
"work_suffix": "| Proiecte XODO",
"our_services": "Serviciile Noastre",
"digital_architects_since": "Arhitecți Digitali din 2015",
"about_title": "Despre",
"roots_title": "Rădăcini în",
"roots_subtitle": "performanță.",
"roots_desc_1": "Nu ne-am născut peste noapte. Parcursul nostru a fost modelat de provocări tehnice masive și o dorință constantă de a livra calitate.",
"roots_desc_2": "Acea perioadă a pus bazele unei înțelegeri esențiale: un produs digital trebuie să fie solid la bază pentru a putea crește sănătos.",
"origin_year": "Originea (2015)",
"origin_desc": "Dezvoltare de teme și soluții complexe pentru platforme online mari, unde stabilitatea era critică.",
"gaming_scalability": "Gaming & Scalabilitate",
"collab_large_communities": "Collaborări cu comunități mari",
"gaming_desc": "Gestionând trafic masiv și cerințe de securitate ridicate pentru cele mai mari comunități de gaming din România.",
"quote_part_1": "O platformă digitală de succes nu înseamnă doar design sau cod, ci",
"quote_highlight": "performanță, structură, scalabilitate",
"quote_part_3": "și decizii tehnice corecte.",
"evolution_desc": "În timp, un proiect ambițios de web design numit Drawwwn a evoluat natural în ceea ce este astăzi XODO: o agenție full-service care combină strategia, designul și infrastructura.",
"what_xodo_does": "Ce face XODO",
"more_than_agency": "Mai mult decât o agenție de web design.",
"more_than_agency_desc_1": "XODO nu funcționează ca o agenție clasică de „execuție la comandă”. Fiecare proiect este tratat ca un produs digital care trebuie să crească și să performeze.",
"more_than_agency_desc_2": "Obiectivul nostru nu este doar livrarea unui site, ci construirea unei platforme digitale solide, susținute de strategie și logica produsului.",
"partner_quote": "Dacă ai nevoie de un partener care înțelege arhitectura, infrastructura și logica de business, XODO este construit pentru asta.",
"lets_evolve": "Let's evolve.",
"lets_evolve_desc": "Ești pregătit să construim ceva solid? Hai să discutăm despre proiectul tău.",
"intro_talk": "Discutăm.",
"view_portfolio": "Vezi portofoliul",
"start_project_title": "Începe un Proiect",
"name": "Nume",
"email": "Email",
"project_details_label": "Detalii Proiect",
"send_message": "Trimite Mesaj",
"error_occured": "Eroare. Încearcă din nou.",
"step_identity": "Cine ești?",
"step_services": "Ce ai nevoie?",
"step_details": "Detalii Proiect",
"next_step": "Pasul Următor",
"prev_step": "Înapoi",
"label_name": "Numele Tău",
"label_email": "Email-ul Tău",
"label_company": "Companie (Opțional)",
"label_budget": "Buget Estimativ",
"label_description": "Povestește-ne despre proiect",
"service_branding": "Branding",
"service_web": "Web Design",
"service_app": "Dezvoltare Aplicații",
"service_marketing": "Marketing",
"budget_low": "< €5k",
"budget_medium": "€5k - €15k",
"budget_high": "€15k - €50k",
"budget_enterprise": "> €50k",
"success_title": "Mesaj Trimis!",
"success_desc": "Te vom contacta în scurt timp.",
"get_in_touch": "Contactează-ne",
"cta_title": "Ești pregătit să începem?",
"cta_description": "Hai să construim ceva excepțional împreună. Contactează-ne și povestește-ne despre proiectul tău."
}

11
src/get-dictionary.ts Normal file
View File

@@ -0,0 +1,11 @@
import "server-only";
import type { Locale } from "./i18n-config";
// We enumerate all dictionaries here for better tree-shaking and type safety
const dictionaries = {
en: () => import("./dictionaries/en.json").then((module) => module.default),
ro: () => import("./dictionaries/ro.json").then((module) => module.default),
};
export const getDictionary = async (locale: Locale) =>
dictionaries[locale]?.() ?? dictionaries.ro();

6
src/i18n-config.ts Normal file
View File

@@ -0,0 +1,6 @@
export const i18n = {
defaultLocale: "ro",
locales: ["en", "ro"],
} as const;
export type Locale = (typeof i18n)["locales"][number];

200
src/lib/api.ts Normal file
View File

@@ -0,0 +1,200 @@
import { Project, Service } from "@/types";
import { FALLBACK_DATA } from "./data";
import { i18n } from "@/i18n-config";
import { slugify } from "./slugify";
import { STRAPI_URL } from "./config";
export async function getProjects(locale: string = i18n.defaultLocale): Promise<Project[]> {
try {
const populate = [
'populate[clientLogo][populate]=*',
'populate[clientBackground][populate]=*',
'populate[stacks][populate]=*',
'populate[services][populate]=*',
'populate[testimonial][populate]=*',
'populate[projectContent][populate]=*',
'sort[0]=isFeatured:desc',
'sort[1]=createdAt:desc',
`locale=${locale}`
].join('&');
const res = await fetch(`${STRAPI_URL}/api/projects?${populate}`, { next: { revalidate: 60 } });
if (!res.ok) throw new Error("Failed to fetch projects");
const json = await res.json();
// If no projects found in requested locale and it's not the default locale, try fallback
if ((!json.data || json.data.length === 0) && locale !== i18n.defaultLocale) {
return getProjects(i18n.defaultLocale);
}
if (!json.data) return FALLBACK_DATA.projects;
// Normalize Data for list view
return json.data.map((p: any) => ({
...p,
projectUrl: p.projectUrl || p.projectURL,
services: (p.services || []).map((s: any) => ({
...s,
slug: s.slug || slugify(s.title)
})),
tags: p.tags?.length ? p.tags : (p.services?.map((s: any) => s.title) || []),
testimonials: p.testimonial ? [p.testimonial] : (p.testimonials || []),
stacks: p.stacks?.map((s: any) => ({ name: s.title || s.name, ...s }))
}));
} catch (error) {
console.error("Error fetching projects:", error);
return FALLBACK_DATA.projects;
}
}
export async function getProjectBySlug(slug: string, locale: string = i18n.defaultLocale): Promise<Project | null> {
try {
const populate = [
'populate[clientLogo][populate]=*',
'populate[clientBackground][populate]=*',
'populate[stacks][populate]=*',
'populate[services][populate]=*',
'populate[testimonial][populate]=*',
'populate[projectContent][on][blocks.comp-highlight][populate]=*',
'populate[projectContent][on][blocks.comp-full-bleed-image][populate]=*',
'populate[projectContent][on][blocks.comp-full-bleed-video][populate]=*',
`locale=${locale}`
].join('&');
const res = await fetch(
`${STRAPI_URL}/api/projects?filters[slug][$eq]=${slug}&${populate}`,
{ next: { revalidate: 60 } }
);
if (!res.ok) return null;
const json = await res.json();
// FALLBACK LOGIC: If no project found in requested locale, try default locale
if ((!json.data || json.data.length === 0) && locale !== i18n.defaultLocale) {
return getProjectBySlug(slug, i18n.defaultLocale);
}
if (!json.data || json.data.length === 0) return null;
const rawProject = json.data[0];
// Normalize Data
return {
...rawProject,
projectUrl: rawProject.projectUrl || rawProject.projectURL,
services: (rawProject.services || []).map((s: any) => ({
...s,
slug: s.slug || slugify(s.title)
})),
// Map services to tags if tags is empty
tags: rawProject.tags?.length ? rawProject.tags : (rawProject.services?.map((s: any) => s.title) || []),
// Map singular testimonial to array
testimonials: rawProject.testimonial ? [rawProject.testimonial] : (rawProject.testimonials || []),
// Map title/name for stacks if needed
stacks: rawProject.stacks?.map((s: any) => ({ name: s.title || s.name, ...s }))
} as Project;
} catch (error) {
console.error("Error fetching project:", error);
return null;
}
}
export async function getServices(locale: string = i18n.defaultLocale): Promise<Service[]> {
try {
const res = await fetch(`${STRAPI_URL}/api/services?populate=*&locale=${locale}`, { next: { revalidate: 60 } });
if (!res.ok) throw new Error("Failed to fetch services");
const json = await res.json();
return json.data.map((s: any) => ({
...s,
projects: s.projects || []
}));
} catch (error) {
console.error("Error fetching services:", error);
return FALLBACK_DATA.services as Service[];
}
}
export async function getServiceBySlug(slug: string, locale: string = i18n.defaultLocale): Promise<Service | null> {
try {
const populate = [
'populate[icon][populate]=*',
'populate[capabilities][populate]=*',
'populate[projects][populate][clientLogo][populate]=*',
'populate[projects][populate][clientBackground][populate]=*',
'populate[projects][populate][stacks][populate]=*',
'populate[projects][populate][services][populate]=*',
`locale=${locale}`
].join('&');
// First try exact match
const res = await fetch(
`${STRAPI_URL}/api/services?filters[slug][$eq]=${slug}&${populate}`,
{ next: { revalidate: 60 } }
);
if (!res.ok) return null;
let json = await res.json();
// If no match by slug field, try matching by sanitized title
if (!json.data || json.data.length === 0) {
const allServicesRes = await fetch(`${STRAPI_URL}/api/services?populate=*&locale=${locale}`, { next: { revalidate: 60 } });
if (allServicesRes.ok) {
const allServicesJson = await allServicesRes.json();
const matchedService = allServicesJson.data.find((s: any) => {
return slugify(s.title) === slug || s.slug === slug;
});
if (matchedService) {
// Re-fetch the matched service by DocumentID to get full population (Strapi v5)
const serviceRes = await fetch(`${STRAPI_URL}/api/services/${matchedService.documentId}?${populate}`, { next: { revalidate: 60 } });
if (serviceRes.ok) {
json = await serviceRes.json();
}
}
}
}
if (!json.data || (Array.isArray(json.data) && json.data.length === 0)) return null;
const rawService = Array.isArray(json.data) ? json.data[0] : json.data;
// Ensure slug is present
if (!rawService.slug) {
rawService.slug = slugify(rawService.title);
}
// Normalize Data for projects inside service
const normalizedProjects = (rawService.projects || []).map((p: any) => ({
...p,
projectUrl: p.projectUrl || p.projectURL,
services: p.services || [],
tags: p.tags?.length ? p.tags : (p.services?.map((s: any) => s.title) || []),
stacks: p.stacks?.map((s: any) => ({ name: s.title || s.name, ...s }))
}));
return {
...rawService,
projects: normalizedProjects
} as Service;
} catch (error) {
console.error("Error fetching service by slug:", error);
return null;
}
}
export async function getGlobal(locale: string = i18n.defaultLocale): Promise<any> {
try {
const res = await fetch(`${STRAPI_URL}/api/global?populate[defaultSeo][populate]=*&populate[favicon]=*&locale=${locale}`, { next: { revalidate: 60 } });
if (!res.ok) {
// Silence 404s to avoid console noise per user feedback
return null;
}
const json = await res.json();
return json.data;
} catch (error) {
// console.error("Error fetching global:", error);
return null;
}
}

1
src/lib/config.ts Normal file
View File

@@ -0,0 +1 @@
export const STRAPI_URL = "https://strapi.xodo.ro";

62
src/lib/data.ts Normal file
View File

@@ -0,0 +1,62 @@
export const FALLBACK_DATA = {
projects: [
{
id: 25,
title: "OLD-GAMING",
slug: "old-gaming",
description: [
{ type: "paragraph", children: [{ text: "O platformă completă de gaming cu integrare blockchain și comunitate activă." }] }
],
tags: ["Dev", "Strategy"],
clientBackground: { url: null },
year: "2026",
clientLogo: { url: null },
mainColor: "#FF1F4A",
stacks: [{ name: "Next.js" }, { name: "Solidity" }],
testimonials: [{ id: 3, clientName: "Stefan Z.", clientRole: "Founder", content: "Amazing work." }],
completionDate: "February 2026",
projectUrl: "https://old.gaming"
},
{
id: 26,
title: "ECHO SYSTEMS",
slug: "echo-systems",
description: [
{ type: "paragraph", children: [{ text: "Arhitectură de brand și dezvoltare web pentru un startup fintech." }] }
],
tags: ["Design", "Frontend"],
clientBackground: { url: null },
year: "2025",
clientLogo: { url: null },
mainColor: "#3B82F6",
stacks: [{ name: "React" }, { name: "Tailwind" }],
testimonials: [],
completionDate: "January 2025",
projectUrl: "https://echo.systems"
}
],
services: [
{
id: 6,
title: "Strategie și Consultanță",
shortDescription: "Auditul brandului și direcția vizuală.",
icon: { url: "/uploads/Frame_1_ca3c2afd47.svg", name: "strategy.svg", ext: ".svg" },
capabilities: [{ id: 1, title: "Market Research" }, { id: 2, title: "Positioning" }]
},
{
id: 8,
title: "Dezvoltare",
shortDescription: "Next.js, React & Strapi architecture.",
icon: { url: "/uploads/Frame_1_ca3c2afd47.svg", name: "dev.svg", ext: ".svg" },
capabilities: [{ id: 3, title: "Custom Software" }, { id: 4, title: "API Integration" }]
}
],
testimonials: [
{
id: 3,
clientName: "Stefan Z.",
clientRole: "Founder @ OldGaming",
content: "Everything is so clean and turned out perfectly based on my needs. Pure execution.",
}
]
};

43
src/lib/email/send.ts Normal file
View File

@@ -0,0 +1,43 @@
import nodemailer from 'nodemailer';
const transporter = nodemailer.createTransport({
host: "mail.xodo.ro",
port: 465,
secure: true,
auth: {
user: "hello@xodo.ro",
pass: "Panamera333.",
},
});
export async function sendEmail(data: { name: string; email: string; message: string }) {
const mailOptions = {
from: '"XODO Website" <hello@xodo.ro>',
to: "hello@xodo.ro",
replyTo: data.email,
subject: `New Project Inquiry from ${data.name}`,
text: `
Name: ${data.name}
Email: ${data.email}
Message:
${data.message}
`,
html: `
<h3>New Project Inquiry</h3>
<p><strong>Name:</strong> ${data.name}</p>
<p><strong>Email:</strong> ${data.email}</p>
<br/>
<p><strong>Message:</strong></p>
<p>${data.message.replace(/\n/g, '<br>')}</p>
`,
};
try {
await transporter.sendMail(mailOptions);
return { success: true };
} catch (error) {
console.error("Email send error:", error);
return { success: false, error };
}
}

15
src/lib/slugify.ts Normal file
View File

@@ -0,0 +1,15 @@
/**
* Centralized slugification utility to ensure consistency across the app.
* Handles special characters like &, slashes, and multiple spaces.
*/
export function slugify(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[&\\#,+()$~%.'":*?<>{}]/g, '') // Remove most special characters
.replace(/\//g, '-') // Replace / with -
.replace(/\s+/g, '-') // Replace spaces with -
.replace(/-+/g, '-') // Replace multiple - with single -
.replace(/^-+/, '') // Trim - from beginning
.replace(/-+$/, ''); // Trim - from end
}

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

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

63
src/middleware.ts Normal file
View File

@@ -0,0 +1,63 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { i18n } from "./i18n-config";
import { match as matchLocale } from "@formatjs/intl-localematcher";
import Negotiator from "negotiator";
function getLocale(request: NextRequest): string | undefined {
// Negotiator expects plain object so we need to transform headers
const negotiatorHeaders: Record<string, string> = {};
request.headers.forEach((value, key) => (negotiatorHeaders[key] = value));
// @ts-ignore locales are readonly
const locales: string[] = i18n.locales;
// Use negotiator and intl-localematcher to get best locale
let languages = new Negotiator({ headers: negotiatorHeaders }).languages(
locales
);
const locale = matchLocale(languages, locales, i18n.defaultLocale);
return locale;
}
export function middleware(request: NextRequest) {
const pathname = request.nextUrl.pathname;
// // `/_next/` and `/api/` are ignored by the watcher, but we need to ignore files in `public` manually.
// // If you have one
if (
[
'/manifest.json',
'/favicon.ico',
'/logo.svg',
// Your other files in `public`
].includes(pathname)
)
return;
// Check if there is any supported locale in the pathname
const pathnameIsMissingLocale = i18n.locales.every(
(locale) => !pathname.startsWith(`/${locale}/`) && pathname !== `/${locale}`
);
// Redirect if there is no locale
if (pathnameIsMissingLocale) {
const locale = getLocale(request);
// e.g. incoming request is /products
// The new URL is now /en/products
return NextResponse.redirect(
new URL(
`/${locale}${pathname.startsWith("/") ? "" : "/"}${pathname}`,
request.url
)
);
}
}
export const config = {
// Matcher ignoring `/_next/`, `api`, and static assets
matcher: ["/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)"],
};

85
src/types/index.ts Normal file
View File

@@ -0,0 +1,85 @@
export interface Testimonial {
id: number;
clientName: string;
clientRole: string;
content: string;
clientAvatar?: { url: string };
}
export interface ComponentHighlight {
id: number;
__component: "blocks.comp-highlight";
title: string;
description: { type: string; children: { text: string }[] }[];
layout: "media-left" | "media-right";
image?: { url: string };
}
export interface ComponentFullBleedImage {
id: number;
__component: "blocks.comp-full-bleed-image";
images?: { url: string }[]; // It was block.images?.[0] in page.tsx
text?: string;
}
export interface ComponentFullBleedVideo {
id: number;
__component: "blocks.comp-full-bleed-video";
video?: { url: string };
poster?: { url: string };
text?: string;
}
export type ProjectContentBlock = ComponentHighlight | ComponentFullBleedImage | ComponentFullBleedVideo;
export interface Project {
id: number;
title: string;
slug: string;
description: { type: string; children: { text: string }[] }[];
// clientSide 'tags' is mapped from 'services' or 'tags'
tags: string[];
services?: Service[];
clientBackground: { url: string | null };
year: string;
clientLogo?: { url: string | null };
mainColor?: string;
stacks?: { name: string; icon?: { url: string } }[];
// Allow both single object (Strapi response) and array (internal use)
testimonials?: { data: Testimonial[] } | Testimonial[] | Testimonial;
completionDate?: string;
projectUrl?: string;
isFeatured?: boolean;
projectContent?: ProjectContentBlock[];
}
export interface Seo {
metaTitle: string;
metaDescription: string;
shareImage?: { url: string };
}
export interface Global {
siteName: string;
favicon?: { url: string };
defaultSeo?: Seo;
}
export interface Service {
id: number;
title: string;
slug?: string;
shortDescription: string;
icon?: {
url: string;
name: string;
ext: string;
width?: number;
height?: number;
};
capabilities?: {
id: number;
title: string;
}[];
projects?: Project[];
}

34
tsconfig.json Normal file
View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}