Restaurare XODO Frontend - Next.js
72
.bash_history
Normal 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
@@ -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
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
.next/
|
||||
out/
|
||||
build/
|
||||
dist/
|
||||
.env
|
||||
.env.local
|
||||
22
.profile
Executable 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
@@ -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
@@ -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
@@ -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
@@ -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
43
package.json
Normal 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
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
public/file.svg
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 |
34
src/app/[lang]/about/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
241
src/app/[lang]/about/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
src/app/[lang]/contact/ContactContent.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
12
src/app/[lang]/contact/layout.tsx
Normal 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}</>;
|
||||
}
|
||||
27
src/app/[lang]/contact/page.tsx
Normal 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} />;
|
||||
}
|
||||
25
src/app/[lang]/cookies/page.tsx
Normal 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 să 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
@@ -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>
|
||||
);
|
||||
}
|
||||
3
src/app/[lang]/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Loading() {
|
||||
return null;
|
||||
}
|
||||
13
src/app/[lang]/not-found.tsx
Normal 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
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
33
src/app/[lang]/privacy/page.tsx
Normal 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 să 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 să 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>
|
||||
);
|
||||
}
|
||||
98
src/app/[lang]/services/[slug]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
src/app/[lang]/services/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
src/app/[lang]/services/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
src/app/[lang]/template.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
src/app/[lang]/terms/page.tsx
Normal 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 să 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>
|
||||
);
|
||||
}
|
||||
349
src/app/[lang]/work/[slug]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
src/app/[lang]/work/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
108
src/app/[lang]/work/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
src/app/api/contact/route.ts
Normal 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
|
After Width: | Height: | Size: 25 KiB |
319
src/app/globals.css
Normal 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
@@ -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
@@ -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,
|
||||
},
|
||||
];
|
||||
}
|
||||
359
src/components/features/ContactForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
89
src/components/layout/Footer.tsx
Normal 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>
|
||||
);
|
||||
7
src/components/layout/GridBackground.tsx
Normal 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>
|
||||
);
|
||||
287
src/components/layout/Navbar.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
11
src/components/providers/ThemeProvider.tsx
Normal 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>
|
||||
}
|
||||
48
src/components/sections/CTASection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
140
src/components/sections/Hero.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
60
src/components/sections/ProjectMarquee.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
src/components/sections/Projects.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
37
src/components/sections/Services.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
136
src/components/sections/ServicesList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
src/components/sections/ServicesSection.tsx
Normal 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 >
|
||||
);
|
||||
}
|
||||
45
src/components/sections/StackSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
src/components/sections/TechMarquee.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
165
src/components/sections/TestimonialSlider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
src/components/sections/TestimonialsSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
83
src/components/ui/AnimatedText.tsx
Normal 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' }}> </span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
38
src/components/ui/BlurReveal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
127
src/components/ui/ImageModal.tsx
Normal 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);
|
||||
}
|
||||
120
src/components/ui/ProjectItem.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
97
src/components/ui/RichTextRenderer.tsx
Normal 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;
|
||||
});
|
||||
}
|
||||
15
src/components/ui/Skeleton.tsx
Normal 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 }
|
||||
42
src/components/ui/ThemeToggle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
33
src/components/ui/VisitProjectButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
6
src/components/ui/XodoLogo.tsx
Normal 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>
|
||||
);
|
||||
70
src/components/ui/ZoomableImage.tsx
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1 @@
|
||||
export const STRAPI_URL = "https://strapi.xodo.ro";
|
||||
62
src/lib/data.ts
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"]
|
||||
}
|
||||