Files
gestions-john-concept-web/src/app/concept-3/page.tsx
gestions-UO 13502fea39 first commit
2026-09-03 15:39:26 +02:00

396 lines
15 KiB
TypeScript

"use client";
import { useCallback, useEffect, useState } from "react";
import Image from "next/image";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { BARTENDER_PROFILE } from "@/data/portfolio-data";
import { HubButton } from "@/components/HubButton";
import { WhatsAppFloatingButton } from "@/components/WhatsAppFloatingButton";
const EASE = [0.22, 1, 0.36, 1] as const;
type Night = {
name: string;
place: string;
year: string;
guests: string;
hours: string;
bar: string[];
note: string;
image: string;
};
const NIGHTS: Night[] = [
{
name: "Tribeca",
place: "Loft privado, Manhattan",
year: "2025",
guests: "24",
hours: "4 h",
bar: ["Dry Martini", "Manhattan"],
note: "Cena en casa. Barra montada en la cocina, sin camareros de paso: sólo yo y veinticuatro copas.",
image: "/images/c3_01.jpg",
},
{
name: "West Village",
place: "Townhouse, Manhattan",
year: "2025",
guests: "70",
hours: "5 h",
bar: ["Negroni", "Daiquiri", "Vermut de la casa"],
note: "Pedida de mano. Tres plantas y una sola barra en el jardín trasero, con carta corta para que nadie subiera a esperar.",
image: "/images/c3_02.jpg",
},
{
name: "Dumbo",
place: "Azotea privada, Brooklyn",
year: "2024",
guests: "140",
hours: "6 h",
bar: ["Americano", "Gimlet", "Whisky Sour"],
note: "Fiesta de verano con el puente enfrente. Tragos largos y hielo de sobra: en julio, en una azotea, es la mitad del trabajo.",
image: "/images/c3_03.jpg",
},
{
name: "Hudson Valley",
place: "Granero privado, Nueva York",
year: "2024",
guests: "180",
hours: "7 h",
bar: ["Old Fashioned", "Whisky Sour", "Ponche de la casa"],
note: "Boda de fin de semana a dos horas de la ciudad. Todo subido en furgoneta, incluida el agua para el hielo.",
image: "/images/c3_04.jpg",
},
{
name: "Upper East Side",
place: "Penthouse, Manhattan",
year: "2024",
guests: "12",
hours: "2 h",
bar: ["Boulevardier", "Sidecar"],
note: "Cata privada en mesa. Doce personas, dos clásicos y cada receta escrita a mano para llevarse.",
image: "/images/c3_05.jpg",
},
];
/* A form written as a sentence. The other concepts use a grid of labelled
fields and a row of pills; this one has neither. */
function Blank({
id,
label,
value,
onChange,
placeholder,
type = "text",
min,
}: {
id: string;
label: string;
value: string;
onChange: (v: string) => void;
placeholder: string;
type?: string;
min?: string;
}) {
// A native date field renders dd/mm/aaaa plus a picker icon, and a number
// field a spinner: neither fits the width of its placeholder text.
const w =
type === "date" ? 14 : type === "number" ? 5 : Math.max(placeholder.length, value.length) + 1;
return (
<>
<label htmlFor={id} className="sr-only">
{label}
</label>
<input
id={id}
type={type}
min={min}
value={value}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
style={{ width: `${w}ch` }}
className="max-w-full border-b border-[var(--ink)] bg-transparent px-0.5 pb-0.5 text-[var(--ink)] outline-none placeholder:text-[var(--dim)] focus:border-b-2"
/>
</>
);
}
function SentenceForm() {
const [f, setF] = useState({ name: "", date: "", place: "", guests: "", phone: "" });
const set = (k: keyof typeof f) => (v: string) => setF((p) => ({ ...p, [k]: v }));
const send = () => {
const msg =
`Hola John, me llamo ${f.name || "—"}. ` +
`El ${f.date || "—"} doy una fiesta en ${f.place || "—"} para ${f.guests || "—"} personas. ` +
`Mi teléfono es ${f.phone || "—"}.`;
window.open(
`https://wa.me/${BARTENDER_PROFILE.whatsapp.replace(/[^0-9]/g, "")}?text=${encodeURIComponent(msg)}`,
"_blank"
);
};
return (
<form
onSubmit={(e) => {
e.preventDefault();
send();
}}
>
<p className="max-w-[26ch] text-[clamp(1.5rem,3.4vw,2.4rem)] leading-[1.7] sm:max-w-[34ch]">
Me llamo{" "}
<Blank id="f-name" label="Tu nombre" value={f.name} onChange={set("name")} placeholder="nombre" />. El{" "}
<Blank id="f-date" label="Fecha del evento" type="date" value={f.date} onChange={set("date")} placeholder="fecha" /> doy una
fiesta en{" "}
<Blank id="f-place" label="Dónde es" value={f.place} onChange={set("place")} placeholder="dónde" /> para{" "}
<Blank id="f-guests" label="Cuántos invitados" type="number" min="1" value={f.guests} onChange={set("guests")} placeholder="00" />{" "}
personas. Escríbeme al{" "}
<Blank id="f-phone" label="Tu teléfono" type="tel" value={f.phone} onChange={set("phone")} placeholder="teléfono" />.
</p>
<div className="mt-10 flex flex-wrap items-center gap-4">
<button type="submit" className="press bg-[var(--ink)] px-7 py-3.5 text-[15px] text-white">
Enviar por WhatsApp
</button>
<span className="text-[13px] text-[var(--dim)]">
Se abre WhatsApp con la frase escrita. La lees antes de mandarla.
</span>
</div>
</form>
);
}
export default function ConceptThreePage() {
const reduce = useReducedMotion();
const [i, setI] = useState(0);
const n = NIGHTS.length;
const night = NIGHTS[i];
const go = useCallback((d: number) => setI((p) => (p + d + n) % n), [n]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "ArrowRight") go(1);
if (e.key === "ArrowLeft") go(-1);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [go]);
return (
<div className="c3 min-h-screen">
<HubButton currentConceptName="03. Atelier" theme="light" />
<WhatsAppFloatingButton conceptName="Atelier John Caiza" theme="editorial" />
{/* ── The index. One night at a time, on exactly one screen. ────── */}
<section className="relative grid h-[100svh] grid-rows-[auto_1fr_auto] overflow-hidden md:grid-cols-2 md:grid-rows-1">
{/* Type side */}
<div className="relative z-10 flex flex-col justify-between px-6 pb-6 pt-5 md:row-span-1 md:h-[100svh] md:px-10 md:pb-28 md:pt-7">
<div className="flex items-baseline justify-between">
<span className="micro">John Caiza — Atelier</span>
<span className="micro hidden md:inline">Manhattan / Brooklyn / Hudson Valley</span>
</div>
{/* Detail, centred in the empty middle */}
<div className="hidden md:block">
<AnimatePresence mode="wait" initial={false}>
<motion.dl
key={night.name}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: reduce ? 0 : 0.3, ease: EASE }}
className="max-w-[34ch] space-y-3 text-[14px]"
>
<div className="flex gap-6">
<dt className="w-24 shrink-0 text-[var(--dim)]">Invitados</dt>
<dd>{night.guests}</dd>
</div>
<div className="flex gap-6">
<dt className="w-24 shrink-0 text-[var(--dim)]">Servicio</dt>
<dd>{night.hours}</dd>
</div>
<div className="flex gap-6">
<dt className="w-24 shrink-0 text-[var(--dim)]">En la barra</dt>
<dd>{night.bar.join(", ")}</dd>
</div>
<div className="flex gap-6 pt-2">
<dt className="w-24 shrink-0 text-[var(--dim)]">Nota</dt>
<dd className="leading-[1.6] text-[var(--dim)]">{night.note}</dd>
</div>
</motion.dl>
</AnimatePresence>
</div>
{/* Name + index */}
<div>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={night.name}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: reduce ? 0 : 0.3, ease: EASE }}
>
<p className="micro mb-2">
{night.place} · {night.year}
</p>
<h1 className="tight text-[clamp(2rem,6.4vw,4.2rem)] uppercase">{night.name}</h1>
{/* The detail block is desktop-only; phones get it condensed. */}
<p className="mt-2.5 text-[13px] leading-[1.5] text-[var(--dim)] md:hidden">
{night.guests} invitados · {night.hours} · {night.bar.join(", ")}
</p>
</motion.div>
</AnimatePresence>
<div className="mt-5 flex items-center justify-between border-t border-[var(--rule)] pt-4">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => go(-1)}
aria-label="Noche anterior"
className="press flex h-9 w-9 items-center justify-center border border-[var(--rule)] text-[15px] hover:border-[var(--ink)]"
>
←
</button>
<button
type="button"
onClick={() => go(1)}
aria-label="Noche siguiente"
className="press flex h-9 w-9 items-center justify-center border border-[var(--rule)] text-[15px] hover:border-[var(--ink)]"
>
→
</button>
<ol className="ml-3 flex gap-1.5" aria-label="Índice de noches">
{NIGHTS.map((x, k) => (
<li key={x.name}>
<button
type="button"
onClick={() => setI(k)}
aria-current={k === i}
aria-label={`${k + 1}. ${x.name}`}
className={`h-1.5 w-6 transition-colors ${
k === i ? "bg-[var(--ink)]" : "bg-[var(--rule)] hover:bg-[var(--dim)]"
}`}
/>
</li>
))}
</ol>
</div>
<span className="micro">
{String(i + 1).padStart(2, "0")} / {String(n).padStart(2, "0")}
</span>
</div>
</div>
</div>
{/* Photograph side */}
<div className="relative row-start-2 md:row-start-auto md:h-[100svh]">
<AnimatePresence initial={false}>
<motion.div
key={night.image}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: reduce ? 0 : 0.45, ease: EASE }}
className="absolute inset-0"
>
<Image
src={night.image}
alt={`${night.name}, ${night.place}, ${night.year}`}
fill
priority={i === 0}
sizes="(max-width: 768px) 100vw, 50vw"
className="object-cover"
/>
</motion.div>
</AnimatePresence>
</div>
</section>
{/* ── El atelier ────────────────────────────────────────────────── */}
<section className="border-t border-[var(--rule)] px-6 py-20 md:px-10 md:py-28">
<div className="grid gap-10 md:grid-cols-12">
<p className="micro md:col-span-3">El atelier</p>
<div className="md:col-span-9">
<p className="max-w-[42ch] text-[clamp(1.4rem,2.6vw,2rem)] leading-[1.25]">
Una barra, las botellas justas y nadie esperando. Todo lo que no ayuda a eso
sobra, y sale de la mesa.
</p>
<p className="mt-8 max-w-[54ch] text-[15px] leading-[1.7] text-[var(--dim)]">
Sólo hago eventos privados: casas, lofts, azoteas y fincas de Nueva York.
Coctelería clásica, proporciones conocidas, cítrico exprimido en el momento y
hielo cuidado. Monto, sirvo y recojo. A la mañana siguiente no queda rastro de
que hubo una barra.
</p>
<dl className="mt-12 grid max-w-2xl grid-cols-2 gap-x-10 gap-y-6 border-t border-[var(--rule)] pt-8 text-[14px] sm:grid-cols-4">
{BARTENDER_PROFILE.stats.map((s) => (
<div key={s.label}>
<dt className="text-[26px] leading-none">{s.value}</dt>
<dd className="mt-2 text-[13px] leading-snug text-[var(--dim)]">{s.label}</dd>
</div>
))}
</dl>
</div>
</div>
</section>
{/* ── Contacto ──────────────────────────────────────────────────── */}
<section id="reservar" className="border-t border-[var(--rule)] px-6 py-20 md:px-10 md:py-28">
<div className="grid gap-10 md:grid-cols-12">
<p className="micro md:col-span-3">Reservar</p>
<div className="md:col-span-9">
<h2 className="tight max-w-[16ch] text-[clamp(1.9rem,5vw,3.4rem)] uppercase">
Cuéntame qué noche es
</h2>
<p className="mt-6 max-w-[48ch] text-[15px] leading-[1.7] text-[var(--dim)]">
Dime la fecha, la dirección y cuántos sois. Trabajo en los cinco condados y subo
al valle del Hudson y a los Hamptons. Respondo el mismo día.
</p>
<div className="mt-10">
<SentenceForm />
</div>
<p className="mt-10 border-t border-[var(--rule)] pt-6 text-[13px] text-[var(--dim)]">
También en{" "}
<a
href={BARTENDER_PROFILE.instagramUrl}
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-4 hover:text-[var(--ink)]"
>
{BARTENDER_PROFILE.instagram}
</a>
</p>
</div>
</div>
</section>
<div className="fixed inset-x-0 bottom-0 z-50 flex gap-2 border-t border-[var(--rule)] bg-[var(--paper)]/95 px-4 py-3 backdrop-blur-md sm:hidden">
<a
href={`https://wa.me/${BARTENDER_PROFILE.whatsapp.replace(/[^0-9]/g, "")}`}
target="_blank"
rel="noopener noreferrer"
className="press flex-1 border border-[var(--rule)] py-3 text-center text-[15px]"
>
WhatsApp
</a>
<a
href="#reservar"
className="press flex-1 bg-[var(--ink)] py-3 text-center text-[15px] text-white"
>
Reservar
</a>
</div>
<footer className="border-t border-[var(--rule)] px-6 py-8 pb-24 sm:pb-8 md:px-10">
<div className="flex flex-col gap-2 sm:flex-row sm:justify-between">
<span className="micro">John Caiza — Atelier</span>
<span className="micro">Nueva York / Eventos privados</span>
</div>
</footer>
</div>
);
}