Some checks failed
Build & Push / build-and-push (push) Failing after 52s
Google Consent Mode v2: region-specific defaults (granted globally, denied for EEA/UK), update all 4 consent types on accept/decline, add url_passthrough and ads_data_redaction for better measurement. Visual: unified 48px grid texture across all light sections, animated constellation SVG in Process section, radial glow on Philosophy, removed broken SVG connector lines and unused imports. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
390 lines
13 KiB
TypeScript
390 lines
13 KiB
TypeScript
'use client';
|
|
|
|
import { motion } from 'framer-motion';
|
|
import { useTranslations } from 'next-intl';
|
|
import { cn } from '@/lib/utils';
|
|
import {
|
|
staggerContainerWide,
|
|
slideLeftVariants,
|
|
fadeVariants,
|
|
viewportOnce,
|
|
} from '@/lib/animations';
|
|
import Image from 'next/image';
|
|
import { Link } from '@/i18n/navigation';
|
|
import { Lock, Clock, ArrowRight } from 'lucide-react';
|
|
|
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
|
|
interface Project {
|
|
/** i18n key under "work.projects" (e.g. "monaco") */
|
|
i18nKey: string;
|
|
slug: string;
|
|
/** number of tags to resolve from the translation array */
|
|
tagCount: number;
|
|
featured?: boolean;
|
|
image: string;
|
|
}
|
|
|
|
interface ComingSoonItem {
|
|
/** i18n key under "work.comingSoonProjects" (e.g. "riviera") */
|
|
i18nKey: string;
|
|
confidential?: boolean;
|
|
}
|
|
|
|
// ─── Data ──────────────────────────────────────────────────────────────────────
|
|
|
|
const PROJECTS: Project[] = [
|
|
{
|
|
i18nKey: 'monaco',
|
|
slug: 'monaco-ocean',
|
|
tagCount: 2,
|
|
featured: true,
|
|
image: '/images/monaco_high_res.jpg',
|
|
},
|
|
{
|
|
i18nKey: 'portNimara',
|
|
slug: 'port-nimara',
|
|
tagCount: 2,
|
|
image: '/images/anguilla.png',
|
|
},
|
|
{
|
|
i18nKey: 'portAmador',
|
|
slug: 'port-amador',
|
|
tagCount: 2,
|
|
image: '/images/panama.png',
|
|
},
|
|
];
|
|
|
|
const COMING_SOON: ComingSoonItem[] = [
|
|
{ i18nKey: 'riviera', confidential: true },
|
|
{ i18nKey: 'sophia' },
|
|
];
|
|
|
|
// ─── Animation Variants ───────────────────────────────────────────────────────
|
|
|
|
const ease = [0.16, 1, 0.3, 1] as const;
|
|
|
|
const featuredCardVariants = {
|
|
hidden: { opacity: 0, y: 48 },
|
|
visible: {
|
|
opacity: 1,
|
|
y: 0,
|
|
transition: { duration: 0.7, ease },
|
|
},
|
|
};
|
|
|
|
const smallCardVariants = {
|
|
hidden: { opacity: 0, y: 32 },
|
|
visible: {
|
|
opacity: 1,
|
|
y: 0,
|
|
transition: { duration: 0.6, ease },
|
|
},
|
|
};
|
|
|
|
const comingSoonVariants = {
|
|
hidden: { opacity: 0, y: 24 },
|
|
visible: {
|
|
opacity: 1,
|
|
y: 0,
|
|
transition: { duration: 0.55, ease },
|
|
},
|
|
};
|
|
|
|
// ─── Tag Chip ─────────────────────────────────────────────────────────────────
|
|
|
|
function TagChip({ label, showDot = false }: { label: string; showDot?: boolean }) {
|
|
return (
|
|
<span className="inline-flex items-center gap-1.5 bg-primary/10 text-primary-dark text-[0.75rem] font-semibold px-3 py-1 rounded-full leading-none tracking-wide">
|
|
{showDot && (
|
|
<span className="w-1 h-1 rounded-full bg-primary/50 shrink-0" aria-hidden="true" />
|
|
)}
|
|
{label}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// ─── Featured Card ────────────────────────────────────────────────────────────
|
|
|
|
function FeaturedCard({ project, readLabel, t }: { project: Project; readLabel: string; t: (key: string) => string }) {
|
|
const tags = Array.from({ length: project.tagCount }, (_, i) =>
|
|
t(`work.projects.${project.i18nKey}.tags.${i}`),
|
|
);
|
|
|
|
return (
|
|
<motion.article
|
|
variants={featuredCardVariants}
|
|
className={cn(
|
|
'group relative flex flex-col bg-surface-high rounded-2xl overflow-hidden',
|
|
'shadow-subtle',
|
|
'transition-shadow duration-300 hover:shadow-[0_24px_48px_rgba(25,28,29,0.10)]',
|
|
)}
|
|
>
|
|
<div className="relative w-full aspect-[16/9] md:aspect-[2/1] overflow-hidden">
|
|
<Image
|
|
src={project.image}
|
|
alt={t(`work.projects.${project.i18nKey}.title`)}
|
|
fill
|
|
className="object-cover transition-transform duration-500 group-hover:scale-[1.03]"
|
|
sizes="(max-width: 768px) 100vw, 66vw"
|
|
/>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="flex flex-col flex-1 p-7 gap-4">
|
|
{/* Tags */}
|
|
<div className="flex flex-wrap gap-2">
|
|
{tags.map((tag, i) => (
|
|
<TagChip key={tag} label={tag} showDot={i > 0} />
|
|
))}
|
|
</div>
|
|
|
|
{/* Title */}
|
|
<h3 className="font-serif text-2xl font-semibold text-on-surface leading-snug">
|
|
{t(`work.projects.${project.i18nKey}.title`)}
|
|
</h3>
|
|
|
|
{/* Description */}
|
|
<p className="text-sm text-outline leading-relaxed flex-1">
|
|
{t(`work.projects.${project.i18nKey}.description`)}
|
|
</p>
|
|
|
|
{/* CTA */}
|
|
<Link
|
|
href={`/work/${project.slug}` as any}
|
|
className={cn(
|
|
'inline-flex items-center gap-2 text-sm font-medium text-primary-dark',
|
|
'mt-1 group/link',
|
|
)}
|
|
>
|
|
<span className="relative after:absolute after:bottom-0 after:left-0 after:h-px after:w-full after:bg-primary-dark after:origin-left after:scale-x-100 after:transition-transform after:duration-200">
|
|
{readLabel}
|
|
</span>
|
|
<ArrowRight
|
|
size={15}
|
|
className="transition-transform duration-200 group-hover/link:translate-x-1.5"
|
|
/>
|
|
</Link>
|
|
</div>
|
|
</motion.article>
|
|
);
|
|
}
|
|
|
|
// ─── Small Card ───────────────────────────────────────────────────────────────
|
|
|
|
function SmallCard({ project, readLabel, t }: { project: Project; readLabel: string; t: (key: string) => string }) {
|
|
const tags = Array.from({ length: project.tagCount }, (_, i) =>
|
|
t(`work.projects.${project.i18nKey}.tags.${i}`),
|
|
);
|
|
|
|
return (
|
|
<motion.article
|
|
variants={smallCardVariants}
|
|
className={cn(
|
|
'group relative flex flex-col bg-surface-high rounded-xl overflow-hidden',
|
|
'shadow-card',
|
|
'transition-all duration-300',
|
|
'hover:shadow-subtle',
|
|
)}
|
|
>
|
|
<div className="relative overflow-hidden">
|
|
<div className="relative w-full aspect-[16/7]">
|
|
<Image
|
|
src={project.image}
|
|
alt={t(`work.projects.${project.i18nKey}.title`)}
|
|
fill
|
|
className="object-cover transition-transform duration-500 group-hover:scale-[1.03]"
|
|
sizes="(max-width: 768px) 100vw, 33vw"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="flex flex-col flex-1 p-5 gap-3">
|
|
{/* Tags */}
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{tags.map((tag, i) => (
|
|
<TagChip key={tag} label={tag} showDot={i > 0} />
|
|
))}
|
|
</div>
|
|
|
|
{/* Title */}
|
|
<h3 className="font-serif text-lg font-semibold text-on-surface leading-snug">
|
|
{t(`work.projects.${project.i18nKey}.title`)}
|
|
</h3>
|
|
|
|
{/* Description */}
|
|
<p className="text-xs text-outline leading-relaxed flex-1">
|
|
{t(`work.projects.${project.i18nKey}.description`)}
|
|
</p>
|
|
|
|
{/* CTA */}
|
|
<Link
|
|
href={`/work/${project.slug}` as any}
|
|
className="inline-flex items-center gap-1.5 text-xs font-medium text-primary-dark group/link"
|
|
>
|
|
<span className="relative after:absolute after:bottom-0 after:left-0 after:h-px after:w-full after:bg-primary-dark after:origin-left after:scale-x-100 after:transition-transform after:duration-200">
|
|
{readLabel}
|
|
</span>
|
|
<ArrowRight
|
|
size={13}
|
|
className="transition-transform duration-200 group-hover/link:translate-x-1"
|
|
/>
|
|
</Link>
|
|
</div>
|
|
</motion.article>
|
|
);
|
|
}
|
|
|
|
// ─── Coming Soon Card ─────────────────────────────────────────────────────────
|
|
|
|
function ComingSoonCard({ item, t }: { item: ComingSoonItem; t: (key: string) => string }) {
|
|
const Icon = item.confidential ? Lock : Clock;
|
|
|
|
return (
|
|
<motion.div
|
|
variants={comingSoonVariants}
|
|
className={cn(
|
|
'relative flex flex-col items-center justify-center gap-4',
|
|
'rounded-xl p-8 min-h-[160px]',
|
|
'border border-dashed border-outline-variant/50',
|
|
'coming-soon-card',
|
|
'overflow-hidden',
|
|
)}
|
|
>
|
|
{/* Subtle animated pulse overlay */}
|
|
<div
|
|
className="absolute inset-0 rounded-xl opacity-0 coming-soon-pulse"
|
|
aria-hidden="true"
|
|
/>
|
|
|
|
<div className="relative z-10 flex flex-col items-center gap-3 text-center">
|
|
<Icon
|
|
size={20}
|
|
className="text-outline/60"
|
|
strokeWidth={1.5}
|
|
/>
|
|
<div>
|
|
<p className="font-serif text-base font-medium text-on-surface/60 leading-snug">
|
|
{t(`work.comingSoonProjects.${item.i18nKey}.title`)}
|
|
</p>
|
|
<p className="label-md text-outline/70 mt-1">
|
|
{t(`work.comingSoonProjects.${item.i18nKey}.subtitle`)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
}
|
|
|
|
// ─── Main Component ───────────────────────────────────────────────────────────
|
|
|
|
export default function SelectedWorks() {
|
|
const t = useTranslations();
|
|
|
|
const featuredProject = PROJECTS.find((p) => p.featured)!;
|
|
const secondaryProjects = PROJECTS.filter((p) => !p.featured);
|
|
|
|
return (
|
|
<section
|
|
id="work"
|
|
className="bg-surface-low py-20"
|
|
style={{
|
|
backgroundImage: [
|
|
'repeating-linear-gradient(0deg, rgba(25,28,29,0.02) 0px, rgba(25,28,29,0.02) 1px, transparent 1px, transparent 48px)',
|
|
'repeating-linear-gradient(90deg, rgba(25,28,29,0.02) 0px, rgba(25,28,29,0.02) 1px, transparent 1px, transparent 48px)',
|
|
].join(', '),
|
|
}}
|
|
>
|
|
<style>{`
|
|
@keyframes dashed-drift {
|
|
0% { background-position: 0 0, 100% 0, 100% 100%, 0 100%; }
|
|
100% { background-position: 100% 0, 100% 100%, 0 100%, 0 0; }
|
|
}
|
|
@keyframes coming-soon-bg-pulse {
|
|
0%, 100% { background-color: var(--color-surface-low); }
|
|
50% { background-color: var(--color-surface); }
|
|
}
|
|
@keyframes coming-soon-fade {
|
|
0%, 100% { opacity: 0; }
|
|
50% { opacity: 1; }
|
|
}
|
|
.coming-soon-card {
|
|
animation: coming-soon-bg-pulse 4s ease-in-out infinite;
|
|
}
|
|
.coming-soon-pulse {
|
|
background: radial-gradient(
|
|
ellipse at center,
|
|
rgba(91, 164, 217, 0.06) 0%,
|
|
transparent 70%
|
|
);
|
|
animation: coming-soon-fade 4s ease-in-out infinite;
|
|
}
|
|
`}</style>
|
|
|
|
<div className="container mx-auto px-6">
|
|
|
|
{/* ── Section Header ── */}
|
|
<motion.div
|
|
variants={slideLeftVariants}
|
|
initial="hidden"
|
|
whileInView="visible"
|
|
viewport={viewportOnce}
|
|
className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-14"
|
|
>
|
|
<div>
|
|
<p className="label-md text-primary mb-2">{t('work.eyebrow')}</p>
|
|
<h2 className="font-serif text-4xl md:text-5xl font-semibold text-on-surface leading-[1.1] tracking-[-0.02em]">
|
|
{t('work.title')}
|
|
</h2>
|
|
</div>
|
|
</motion.div>
|
|
|
|
{/* ── Primary Grid: Featured + 2 Small ── */}
|
|
<motion.div
|
|
variants={staggerContainerWide}
|
|
initial="hidden"
|
|
whileInView="visible"
|
|
viewport={viewportOnce}
|
|
className="grid grid-cols-1 md:grid-cols-12 gap-5 mb-5"
|
|
>
|
|
{/* Featured card — 8 cols */}
|
|
<div className="md:col-span-8">
|
|
<FeaturedCard
|
|
project={featuredProject}
|
|
readLabel={t('work.readCaseStudy')}
|
|
t={t}
|
|
/>
|
|
</div>
|
|
|
|
{/* Secondary column — 4 cols, 2 stacked */}
|
|
<div className="md:col-span-4 flex flex-col gap-5">
|
|
{secondaryProjects.map((project) => (
|
|
<SmallCard
|
|
key={project.slug}
|
|
project={project}
|
|
readLabel={t('work.readCaseStudy')}
|
|
t={t}
|
|
/>
|
|
))}
|
|
</div>
|
|
</motion.div>
|
|
|
|
{/* ── Coming Soon Row ── */}
|
|
<motion.div
|
|
variants={staggerContainerWide}
|
|
initial="hidden"
|
|
whileInView="visible"
|
|
viewport={viewportOnce}
|
|
className="grid grid-cols-1 sm:grid-cols-2 gap-5"
|
|
>
|
|
{COMING_SOON.map((item) => (
|
|
<ComingSoonCard key={item.i18nKey} item={item} t={t} />
|
|
))}
|
|
</motion.div>
|
|
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|