first commit

This commit is contained in:
gestions-UO
2026-09-03 15:39:26 +02:00
commit 13502fea39
66 changed files with 7297 additions and 0 deletions

9
AGENTS.md Normal file
View File

@@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->

1
CLAUDE.md Normal file
View File

@@ -0,0 +1 @@
@AGENTS.md

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

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

9
next.config.mjs Normal file
View File

@@ -0,0 +1,9 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
images: {
unoptimized: true,
},
};
export default nextConfig;

2369
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "barman-concept-web",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "module",
"dependencies": {
"@types/canvas-confetti": "^1.9.0",
"canvas-confetti": "^1.9.4",
"framer-motion": "^13.2.0",
"lucide-react": "^1.39.0",
"next": "^16.3.4",
"react": "^19.2.8",
"react-dom": "^19.2.8"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.3.3",
"@types/node": "^26.4.1",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.5",
"autoprefixer": "^10.5.4",
"postcss": "^8.5.26",
"tailwindcss": "^4.3.3",
"typescript": "^7.0.2"
}
}

5
postcss.config.mjs Normal file
View File

@@ -0,0 +1,5 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
},
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -0,0 +1,32 @@
[
{
"slug": "c3_01",
"alt": "A woman enjoying a candlelit dinner with pumpkins and wine glasses, creating a warm, cozy atmosphere.",
"by": "cottonbro studio",
"url": "https://www.pexels.com/photo/woman-sitting-in-front-of-the-dining-table-6554681/"
},
{
"slug": "c3_02",
"alt": "Crop friends gathering at table under burning garland in garden and toasting with glasses",
"by": "Askar Abayev",
"url": "https://www.pexels.com/photo/faceless-people-cheering-at-dinner-in-glowing-backyard-5638750/"
},
{
"slug": "c3_03",
"alt": "Stunning New York City skyline featuring the iconic Empire State Building at dusk.",
"by": "Yura Forrat",
"url": "https://www.pexels.com/photo/new-york-city-skyline-with-empire-state-building-38103463/"
},
{
"slug": "c3_04",
"alt": "A beautifully arranged rustic wedding reception table in a barn setting with floral decorations.",
"by": "Alexander Mass",
"url": "https://www.pexels.com/photo/elegant-rustic-wedding-reception-table-setup-38893465/"
},
{
"slug": "c3_05",
"alt": "Close-up of bartender pouring a cocktail through a strainer into glasses at a bar.",
"by": "Daniel Duarte",
"url": "https://www.pexels.com/photo/bartender-pouring-cocktail-into-glasses-28717625/"
}
]

BIN
public/images/c3_01.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

BIN
public/images/c3_02.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

BIN
public/images/c3_03.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

BIN
public/images/c3_04.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

BIN
public/images/c3_05.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

266
public/images/manifest.json Normal file
View File

@@ -0,0 +1,266 @@
{
"hero_bartender": [
{
"id": 30403324,
"file": "/images/hero_bartender_1.jpg",
"alt": "Bartender with tattoos preparing a cocktail behind a well-stocked bar.",
"photographer": "Radik 2707",
"avg_color": "#322F2A"
},
{
"id": 3811834,
"file": "/images/hero_bartender_2.jpg",
"alt": "A professional bartender preparing cocktails at an upscale bar with a wide variety of spirits.",
"photographer": "Andrea Piacquadio",
"avg_color": "#A89E8C"
},
{
"id": 4667023,
"file": "/images/hero_bartender_3.jpg",
"alt": "Skilled bartender in action mixing a cocktail with precision at a bar counter.",
"photographer": "cottonbro studio",
"avg_color": "#484338"
},
{
"id": 19674009,
"file": "/images/hero_bartender_4.jpg",
"alt": "A skilled bartender wearing a hat mixes drinks behind the bar in a dimly lit club setting.",
"photographer": "Alexandru Cojanu",
"avg_color": "#484631"
}
],
"speakeasy": [
{
"id": 7253705,
"file": "/images/speakeasy_1.jpg",
"alt": "A close-up of a whiskey bottle, highlighting amber hues against a dark background.",
"photographer": "Eva Bronzini",
"avg_color": "#1E1912"
},
{
"id": 16374125,
"file": "/images/speakeasy_2.jpg",
"alt": "Close-up of a woman's hand holding a glass of whiskey on a marble table, with warm lighting.",
"photographer": "The OurWhisky Foundation 🥃",
"avg_color": "#5A3F2A"
},
{
"id": 36322544,
"file": "/images/speakeasy_3.jpg",
"alt": "Crystal glasses filled with whiskey on a dark wooden surface, highlighting texture and color.",
"photographer": "Konstantin Mishchenko",
"avg_color": "#3F241B"
},
{
"id": 28180061,
"file": "/images/speakeasy_4.jpg",
"alt": "A classic whisky glass with intricate design filled with whisky, garnished with a lemon slice and surrounded by ice cubes.",
"photographer": "Nano Erdozain",
"avg_color": "#594129"
},
{
"id": 7254863,
"file": "/images/speakeasy_5.jpg",
"alt": "Close-up of whiskey glasses with reflections in dim lighting, creating a moody ambiance.",
"photographer": "Eva Bronzini",
"avg_color": "#13120F"
},
{
"id": 37332353,
"file": "/images/speakeasy_6.jpg",
"alt": "Classic whiskey cocktail with ice and cinnamon stick on a moody background.",
"photographer": "Efe Burak Baydar",
"avg_color": "#1B1509"
}
],
"alchemist": [
{
"id": 38446239,
"file": "/images/alchemist_1.jpg",
"alt": "Cocktail mixing setup with ingredients and equipment at a workshop.",
"photographer": "Anat Landa",
"avg_color": "#786658"
},
{
"id": 10610378,
"file": "/images/alchemist_2.jpg",
"alt": "Artistic cocktail presentation with berries and smoke on gray background.",
"photographer": "Vivita Malite",
"avg_color": "#9C9A9B"
},
{
"id": 10610374,
"file": "/images/alchemist_3.jpg",
"alt": "A vibrant blue cocktail in a glass bowl with blueberries and smoke on a wooden surface.",
"photographer": "Vivita Malite",
"avg_color": "#929395"
},
{
"id": 31579448,
"file": "/images/alchemist_4.jpg",
"alt": "A vibrant red cocktail with ice, surrounded by dramatic fog, and tropical plants in the background.",
"photographer": "Sóc Năng Động",
"avg_color": "#86837E"
},
{
"id": 10610351,
"file": "/images/alchemist_5.jpg",
"alt": "A vibrant blueberry cocktail garnished with berries, featuring a stunning smoke effect against a gray background.",
"photographer": "Vivita Malite",
"avg_color": "#979296"
},
{
"id": 5546958,
"file": "/images/alchemist_6.jpg",
"alt": "Close-up of a hand holding a smoking red cocktail in a glass, capturing elegance and intrigue.",
"photographer": "cottonbro studio",
"avg_color": "#9F868D"
}
],
"editorial": [
{
"id": 7827814,
"file": "/images/editorial_1.jpg",
"alt": "Stylized martini glass with olives, vase, and pyramid on a marble tray.",
"photographer": "Mikhail Nilov",
"avg_color": "#A1A572"
},
{
"id": 4051371,
"file": "/images/editorial_2.jpg",
"alt": "A cool margarita with ice cubes and lime, evoking a refreshing summer vibe.",
"photographer": "alleksana",
"avg_color": "#BFBDB8"
},
{
"id": 36189463,
"file": "/images/editorial_3.jpg",
"alt": "A beautifully lit cocktail on a smooth wooden surface, evoking a sophisticated nighttime atmosphere.",
"photographer": "Szymon Shields",
"avg_color": "#966E4B"
},
{
"id": 8944369,
"file": "/images/editorial_4.jpg",
"alt": "Stylish cocktail glasses with ice cubes and orange garnish, set in a minimalist scene with a small staircase prop.",
"photographer": "cottonbro studio",
"avg_color": "#4E4341"
},
{
"id": 7282880,
"file": "/images/editorial_5.jpg",
"alt": "Stylish arrangement of pink, green, and clear cocktails in glasses with lime and olive garnishes.",
"photographer": "Polina ⠀",
"avg_color": "#D2CECC"
},
{
"id": 17941233,
"file": "/images/editorial_6.jpg",
"alt": "A black and white photo of a passion fruit cocktail held by a hand, top view.",
"photographer": "Nadin Sh",
"avg_color": "#2B2B2B"
}
],
"tropical": [
{
"id": 5856353,
"file": "/images/tropical_1.jpg",
"alt": "Refreshing tropical cocktail with pineapple slice and orchid garnish in a glass.",
"photographer": "John Webb",
"avg_color": "#7F8975"
},
{
"id": 2531184,
"file": "/images/tropical_2.jpg",
"alt": "Colorful cocktails with garnishes at a sunset beach bar, perfect for tropical vacations.",
"photographer": "Engin Akyurt",
"avg_color": "#BAAB91"
},
{
"id": 5856352,
"file": "/images/tropical_3.jpg",
"alt": "Vibrant tropical cocktails adorned with pineapple slices and orchids, perfect for a summer gathering.",
"photographer": "John Webb",
"avg_color": "#AB8B6F"
},
{
"id": 5863518,
"file": "/images/tropical_4.jpg",
"alt": "Exotic tropical cocktails in tiki-themed mugs on a dimly lit bar counter.",
"photographer": "Rachel Claire",
"avg_color": "#1A140E"
},
{
"id": 5863510,
"file": "/images/tropical_5.jpg",
"alt": "A close-up of a colorful cocktail with a cherry garnish in a dimly lit bar, enhancing a lively atmosphere.",
"photographer": "Rachel Claire",
"avg_color": "#33281E"
},
{
"id": 8375034,
"file": "/images/tropical_6.jpg",
"alt": "Close-up of mint mojito and passionfruit cocktails on coasters, perfect for a refreshing summer drink.",
"photographer": "Denys Gromov",
"avg_color": "#DAD4C4"
}
],
"instagram": [
{
"id": 17192065,
"file": "/images/instagram_1.jpg",
"alt": "A bartender skillfully pouring cocktails into glasses with a cocktail shaker in a bar setting.",
"photographer": "Dmitriy Tarasenko",
"avg_color": "#4E4945"
},
{
"id": 6284889,
"file": "/images/instagram_2.jpg",
"alt": "Bartender preparing a cocktail using a jigger and mixing glass at an elegant bar counter.",
"photographer": "Gustavo Fring",
"avg_color": "#6D5E50"
},
{
"id": 5097033,
"file": "/images/instagram_3.jpg",
"alt": "A bartender pours a drink into a jigger at a stylish bar with bottles in the background.",
"photographer": "James Dollin",
"avg_color": "#4D413C"
},
{
"id": 20104020,
"file": "/images/instagram_4.jpg",
"alt": "Bartender pouring whiskey into glasses at a bar with cocktail setups in view.",
"photographer": "Airam Dato-on",
"avg_color": "#554136"
},
{
"id": 17850173,
"file": "/images/instagram_5.jpg",
"alt": "Close-up of hands mixing a cocktail with fresh ingredients at a bar in CDMX.",
"photographer": "Isaac Pérez",
"avg_color": "#857D68"
},
{
"id": 15750727,
"file": "/images/instagram_6.jpg",
"alt": "Bartender pouring cocktails in a dimly lit bar, emphasizing mixology and nightlife ambiance.",
"photographer": "Ayberk Mirza",
"avg_color": "#393720"
},
{
"id": 6284902,
"file": "/images/instagram_7.jpg",
"alt": "A bartender expertly pours a cocktail into a crystal glass at a bar, showcasing a refined cocktail-making process.",
"photographer": "Gustavo Fring",
"avg_color": "#6D5948"
},
{
"id": 4485353,
"file": "/images/instagram_8.jpg",
"alt": "Bartender skillfully measures and pours red wine in a bar setting.",
"photographer": "Antoni Shkraba",
"avg_color": "#2E2723"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -0,0 +1,93 @@
import fs from 'fs';
import path from 'path';
import https from 'https';
const API_KEY = "z8LbaRbolg9A7BKNmGbitUY5JsUuSTogwFLTR6dMu4cY53zstDjWNmbR";
const TARGET_DIR = path.resolve("./public/images");
if (!fs.existsSync(TARGET_DIR)) {
fs.mkdirSync(TARGET_DIR, { recursive: true });
}
const queries = [
{ category: 'hero_bartender', query: 'charismatic male bartender mixing drink bar', count: 4 },
{ category: 'speakeasy', query: 'dark whiskey cocktail bar luxury amber', count: 6 },
{ category: 'alchemist', query: 'molecular mixology smoke cocktail dry ice beaker', count: 6 },
{ category: 'editorial', query: 'minimalist cocktail aesthetic elegant glass garnish', count: 6 },
{ category: 'tropical', query: 'tropical exotic cocktail colorful passionfruit tiki summer', count: 6 },
{ category: 'instagram', query: 'craft cocktail bartender pouring aesthetic bar party', count: 8 },
];
function fetchJson(url) {
return new Promise((resolve, reject) => {
https.get(url, { headers: { Authorization: API_KEY } }, (res) => {
let data = '';
res.on('data', (chunk) => data += chunk);
res.on('end', () => {
try {
resolve(JSON.parse(data));
} catch (e) {
reject(e);
}
});
}).on('error', reject);
});
}
function downloadFile(url, dest) {
return new Promise((resolve, reject) => {
const file = fs.createWriteStream(dest);
https.get(url, (response) => {
if (response.statusCode === 301 || response.statusCode === 302) {
return downloadFile(response.headers.location, dest).then(resolve).catch(reject);
}
response.pipe(file);
file.on('finish', () => {
file.close(resolve);
});
}).on('error', (err) => {
fs.unlink(dest, () => reject(err));
});
});
}
async function run() {
console.log("Starting image downloads from Pexels...");
const manifest = {};
for (const q of queries) {
console.log(`Searching for: ${q.query}...`);
try {
const data = await fetchJson(`https://api.pexels.com/v1/search?query=${encodeURIComponent(q.query)}&per_page=${q.count}&orientation=landscape`);
manifest[q.category] = [];
if (data.photos && data.photos.length > 0) {
for (let i = 0; i < data.photos.length; i++) {
const photo = data.photos[i];
const filename = `${q.category}_${i + 1}.jpg`;
const filePath = path.join(TARGET_DIR, filename);
const imgUrl = photo.src.large || photo.src.large2x || photo.src.original;
console.log(` Downloading ${filename} (${photo.photographer})...`);
await downloadFile(imgUrl, filePath);
manifest[q.category].push({
id: photo.id,
file: `/images/${filename}`,
alt: photo.alt || `${q.category} cocktail image`,
photographer: photo.photographer,
avg_color: photo.avg_color
});
}
}
} catch (err) {
console.error(`Error processing query ${q.query}:`, err.message);
}
}
// Save manifest
fs.writeFileSync(path.join(TARGET_DIR, "manifest.json"), JSON.stringify(manifest, null, 2));
console.log("Image downloads complete! Manifest created at public/images/manifest.json");
}
run();

167
skills-lock.json Normal file
View File

@@ -0,0 +1,167 @@
{
"version": 1,
"skills": {
"12-principles-of-animation": {
"source": "raphaelsalaja/skill",
"sourceType": "github",
"skillPath": "skills/12-principles-of-animation/SKILL.md",
"computedHash": "fd8f83df6be0cebd89ac8c5e5345f7b5f4028a5e32f98f1699d52fa5008c7082"
},
"animate": {
"source": "emilkowalski/skill",
"sourceType": "github",
"skillPath": "skills/animate/SKILL.md",
"computedHash": "609e30351bf518b21a8cfe87cd1111554855527075d6563605bafe61963252f7"
},
"animate-expo": {
"source": "emilkowalski/skill",
"sourceType": "github",
"skillPath": "skills/animate-expo/SKILL.md",
"computedHash": "c9eaf6603b97ca61b05c7561e1045b2f58b6d84cdf8d67565bff23a9ff94ce8a"
},
"animation-vocabulary": {
"source": "emilkowalski/skill",
"sourceType": "github",
"skillPath": "skills/animation-vocabulary/SKILL.md",
"computedHash": "39319fc9a33c15be08666b3685f58666f042ff36bb902b7814c0834a5ba99df4"
},
"apple-design": {
"source": "emilkowalski/skill",
"sourceType": "github",
"skillPath": "skills/apple-design/SKILL.md",
"computedHash": "8b94db67cb9edaad5f1501010804caa610131f1d2dfda0a27664b2e858deade3"
},
"ask-sonner": {
"source": "emilkowalski/skill",
"sourceType": "github",
"skillPath": "skills/ask-sonner/SKILL.md",
"computedHash": "23f724e97247b2003ccc479f3a9b3af8e4191a0e133b94ee6b73f8405349687f"
},
"bencium-innovative-ux-designer": {
"source": "bencium/bencium-marketplace",
"sourceType": "github",
"skillPath": "bencium-innovative-ux-designer/skills/bencium-innovative-ux-designer/SKILL.md",
"computedHash": "e39e56d3960a6caefb28d66e47ad9eb83bd5927ba6e97423a4af150e0f0b82c2"
},
"better-ui": {
"source": "jakubkrehel/skills",
"sourceType": "github",
"skillPath": "skills/better-ui/SKILL.md",
"computedHash": "d05cf568f197e47b15e71e21b19be1897eba2bf187ef7b0a341bc5ffaf3ffd4d"
},
"design-lab": {
"source": "0xdesign/design-plugin",
"sourceType": "github",
"skillPath": "design-and-refine/skills/design-lab/SKILL.md",
"computedHash": "2f91bff63e6e749642f966510755b8912d5d53e56de3f0fb4a86bc8c604da192"
},
"emil-design-eng": {
"source": "emilkowalski/skill",
"sourceType": "github",
"skillPath": "skills/emil-design-eng/SKILL.md",
"computedHash": "41b0a4dc1a27164fe297845a6c6850a39e9242c42c9be999967fcee9df2c5974"
},
"find-animation-opportunities": {
"source": "emilkowalski/skill",
"sourceType": "github",
"skillPath": "skills/find-animation-opportunities/SKILL.md",
"computedHash": "8fb8492eb8fbed1313cb430b5831d925d9b8964ca24f3c41a6244cbd1fd98732"
},
"frontend-design": {
"source": "anthropics/skills",
"sourceType": "github",
"skillPath": "skills/frontend-design/SKILL.md",
"computedHash": "4eabc66183767153e404b39d1b839b1c37f2d82d86f0a0d7e880a579d8d62336"
},
"frontend-ui-engineering": {
"source": "addyosmani/agent-skills",
"sourceType": "github",
"skillPath": "skills/frontend-ui-engineering/SKILL.md",
"computedHash": "cb4e981ecb5f701b451fc4e937a38049b49fd4523f213e06cffbd00b007060dc"
},
"gpt-taste": {
"source": "Leonxlnx/taste-skill",
"sourceType": "github",
"skillPath": "skills/gpt-tasteskill/SKILL.md",
"computedHash": "cc8f0c601d8240a124e1d11634351a2be7b8a72fd807c75e5b6bf7afcd5ddee0"
},
"improve": {
"source": "shadcn/improve",
"sourceType": "github",
"skillPath": "skills/improve/SKILL.md",
"computedHash": "39a9358732dcff385e9e4d3e60b4547856f834e54a58b35e455aa9bd49928c13"
},
"improve-animations": {
"source": "emilkowalski/skill",
"sourceType": "github",
"skillPath": "skills/improve-animations/SKILL.md",
"computedHash": "eeb219a407e325b687af88db25771cc3018e3148245604112f5ac6990b6fd79c"
},
"improve-react": {
"source": "millionco/react-doctor",
"sourceType": "github",
"skillPath": "skills/improve-react/SKILL.md",
"computedHash": "ee6095448a511e2507528caae7ba91c5dc462464b603196c5b1931d4924a371a"
},
"improve-ui": {
"source": "ibelick/ui-skills",
"sourceType": "github",
"skillPath": "skills/improve-ui/SKILL.md",
"computedHash": "5bd7689c5508c6bd8166f796b3d1bd32255077447595b14b60fac9847dce63f1"
},
"interaction-design": {
"source": "wshobson/agents",
"sourceType": "github",
"skillPath": "plugins/ui-design/skills/interaction-design/SKILL.md",
"computedHash": "1fac9a5aff8463e98b935a0a1b48452d71afcfa50a3bc4b7af9510bfdd8a1731"
},
"interface-design": {
"source": "Dammyjay93/interface-design",
"sourceType": "github",
"skillPath": ".claude/skills/interface-design/SKILL.md",
"computedHash": "340cc79a1558f09371aa3eb689952a8b777c1d7362c24226e99693606a80828e"
},
"pick-ui-library": {
"source": "emilkowalski/skill",
"sourceType": "github",
"skillPath": "skills/pick-ui-library/SKILL.md",
"computedHash": "f8d4d2cf4677bf54b14f35f62a741add93541bfcb67863315a40164365161b49"
},
"prototype": {
"source": "emilkowalski/skill",
"sourceType": "github",
"skillPath": "skills/prototype/SKILL.md",
"computedHash": "f246d1f47566481d9207e1aa574bdee5454257d862919a6591283e9af2f57eab"
},
"rams": {
"source": "brianlovin/agent-config",
"sourceType": "github",
"skillPath": "skills/rams/SKILL.md",
"computedHash": "caf6767c23716926511bc367f21afeea310be1f7cd22084f667d68761e669b4c"
},
"review-animations": {
"source": "emilkowalski/skill",
"sourceType": "github",
"skillPath": "skills/review-animations/SKILL.md",
"computedHash": "b9f669af5ae280c19a592a94611520335a81de25c88f225bf60ae4b2d66c8c7c"
},
"ui-ux-pro-max": {
"source": "nextlevelbuilder/ui-ux-pro-max-skill",
"sourceType": "github",
"skillPath": ".claude/skills/ui-ux-pro-max/SKILL.md",
"computedHash": "cf5f99f7bcb0c03d8a51fe715cff5635a2b2fac59598be6220b59e572d5bd492"
},
"web-design-guidelines": {
"source": "antfu/skills",
"sourceType": "github",
"skillPath": "skills/web-design-guidelines/SKILL.md",
"computedHash": "65a2e7d85753383ae0f88df15475d58b9e39723e9c4bb6891421d6144a85f79c"
},
"write-swift": {
"source": "emilkowalski/skill",
"sourceType": "github",
"skillPath": "skills/write-swift/SKILL.md",
"computedHash": "ccbb74cac81765fcf2912ceba8d188fddbd75393ecb808aa1a4f6f58d9c2c828"
}
}
}

729
src/app/concept-1/page.tsx Normal file
View File

@@ -0,0 +1,729 @@
"use client";
import { useState, useEffect, useRef } from "react";
import Image from "next/image";
import {
AnimatePresence,
motion,
useInView,
useMotionTemplate,
useReducedMotion,
useScroll,
useTransform,
} from "framer-motion";
import { BARTENDER_PROFILE } from "@/data/portfolio-data";
import { BookingForm } from "@/components/BookingForm";
import { HubButton } from "@/components/HubButton";
import { WhatsAppFloatingButton } from "@/components/WhatsAppFloatingButton";
const EASE = [0.22, 1, 0.36, 1] as const;
/* Crosshair marker sitting on a grid intersection. */
function Cross({ className = "" }: { className?: string }) {
return <span aria-hidden className={`crosshair absolute block ${className}`} />;
}
/* Headline reveal: each line rides up from behind its own mask. */
function RevealLines({
lines,
className = "",
delay = 0,
onLoad = false,
}: {
lines: React.ReactNode[];
className?: string;
delay?: number;
onLoad?: boolean;
}) {
const reduce = useReducedMotion();
const motionProps = onLoad
? { initial: "hidden" as const, animate: "show" as const }
: { initial: "hidden" as const, whileInView: "show" as const, viewport: { once: true, margin: "-15%" } };
return (
<motion.span
{...motionProps}
variants={{
hidden: {},
show: { transition: { staggerChildren: reduce ? 0 : 0.08, delayChildren: reduce ? 0 : delay } },
}}
className={`block ${className}`}
>
{lines.map((line, i) => (
<span key={i} className="block overflow-hidden pb-[0.08em]">
{/* Full transform string, not the `y` shorthand: the shorthand runs on
the main thread and drops frames while the page is still loading. */}
<motion.span
variants={
reduce
? { hidden: { opacity: 0 }, show: { opacity: 1, transition: { duration: 0.2 } } }
: {
hidden: { transform: "translateY(112%)" },
show: { transform: "translateY(0%)", transition: { duration: 0.7, ease: EASE } },
}
}
className="block"
>
{line}
</motion.span>
</span>
))}
</motion.span>
);
}
/* Body copy and small blocks: a quieter rise, once, on entry. */
function Rise({
children,
className = "",
delay = 0,
onLoad = false,
}: {
children: React.ReactNode;
className?: string;
delay?: number;
onLoad?: boolean;
}) {
const reduce = useReducedMotion();
const motionProps = onLoad
? { initial: "hidden" as const, animate: "show" as const }
: { initial: "hidden" as const, whileInView: "show" as const, viewport: { once: true, margin: "-12%" } };
return (
<motion.div
{...motionProps}
variants={
reduce
? { hidden: { opacity: 0 }, show: { opacity: 1, transition: { duration: 0.2 } } }
: {
hidden: { opacity: 0, transform: "translateY(22px)" },
show: {
opacity: 1,
transform: "translateY(0px)",
transition: { duration: 0.6, ease: EASE, delay },
},
}
}
className={className}
>
{children}
</motion.div>
);
}
/* Full-bleed photograph that drifts slightly slower than the page. */
function PhotoBand({
src,
alt,
children,
height = "min-h-[70svh]",
}: {
src: string;
alt: string;
children?: React.ReactNode;
height?: string;
}) {
const reduce = useReducedMotion();
const ref = useRef<HTMLDivElement | null>(null);
const { scrollYProgress } = useScroll({ target: ref, offset: ["start end", "end start"] });
const shift = useTransform(scrollYProgress, [0, 1], reduce ? ["0%", "0%"] : ["-8%", "8%"]);
const transform = useMotionTemplate`translateY(${shift})`;
return (
<div ref={ref} className={`relative flex items-end overflow-hidden ${height}`}>
<motion.div style={{ transform }} className="absolute inset-x-0 -inset-y-[9%]">
<Image src={src} alt={alt} fill sizes="100vw" className="object-cover" />
</motion.div>
<div aria-hidden className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/25 to-black/40" />
{children}
</div>
);
}
function Marker({ n, children, dark = false }: { n: string; children: React.ReactNode; dark?: boolean }) {
return (
<div className={`flex items-baseline gap-3 text-[13px] ${dark ? "text-white/55" : "text-[var(--dim)]"}`}>
<span className={`figures ${dark ? "text-[var(--chartreuse)]" : "text-[var(--bottle)]"}`}>{n}</span>
<span className={`h-px w-8 ${dark ? "bg-white/25" : "bg-[var(--rule)]"}`} />
<span>{children}</span>
</div>
);
}
const FORMATS = [
{
name: "Barra speakeasy",
from: 1200,
guests: "Hasta 60 invitados",
blurb:
"Barra de maderas nobles, cristalería de corte japonés y campana de humo de roble encendida delante del invitado.",
includes: ["Barra móvil montada y desmontada", "Cristalería completa", "Hielo tallado a mano", "Dos bartenders"],
},
{
name: "Barra de clásicos",
from: 2400,
guests: "Hasta 80 invitados",
blurb:
"El canon bien hecho. Seis clásicos elegidos contigo, cristalería propia para cada uno y el vermut comprado el mismo día.",
includes: ["Carta de seis clásicos", "Cristalería por cóctel", "Hielo tallado a mano", "Tres bartenders"],
},
{
name: "Boda de gala",
from: 3800,
guests: "De 80 a 250 invitados",
blurb:
"Servicio completo de boda: dos estaciones simultáneas, cartas impresas con vuestros nombres y equipo uniformado.",
includes: ["Dos barras simultáneas", "Cartas impresas", "Prueba de cóctel previa", "Equipo de cinco"],
},
{
name: "Ritual de hielo",
from: 600,
guests: "Complemento",
blurb:
"Tallado en directo de bloques de agua pura con cuchilla de diamante, para los tragos cortos de la noche.",
includes: ["Bloques de hielo transparente", "Tallado durante el servicio", "Cuatro formatos de hielo"],
},
{
name: "Cata privada",
from: 950,
guests: "De 8 a 20 personas",
blurb:
"Sesión guiada de cata y formulación botánica. Cada asistente se lleva su receta escrita a mano.",
includes: ["Dos horas guiadas", "Seis destilados", "Recetario impreso"],
},
];
const MENU = [
{
name: "Old Fashioned ahumado",
place: "Loft en SoHo, Manhattan",
note: "Cena privada, 24 invitados",
image: "/images/speakeasy_1.jpg",
build: [
["Bourbon single barrel, 12 años", "60 ml"],
["Sirope de vainilla tostada", "10 ml"],
["Bitter de cacao artesanal", "2 golpes"],
["Humo de roble americano", "en mesa"],
],
spec: [
["Método", "Removido, 20 s"],
["Copa", "Lowball japonés"],
["Hielo", "Bloque tallado, 5 cm"],
["Alcohol", "32 %"],
],
},
{
name: "Manhattan",
place: "Brownstone en Brooklyn Heights",
note: "Pedida de mano, 70 invitados",
image: "/images/speakeasy_2.jpg",
build: [
["Whisky de centeno", "60 ml"],
["Vermut rojo di Torino", "25 ml"],
["Angostura", "2 golpes"],
],
spec: [
["Método", "Removido, 30 s"],
["Copa", "Coupé helada"],
["Hielo", "Ninguno en la copa"],
["Alcohol", "28 %"],
],
},
{
name: "Daiquiri",
place: "Casa en Amagansett, los Hamptons",
note: "Fiesta de atardecer, 140 invitados",
image: "/images/hero_bartender_4.jpg",
build: [
["Ron blanco", "60 ml"],
["Zumo de lima", "25 ml"],
["Jarabe de azúcar", "15 ml"],
],
spec: [
["Método", "Agitado, 12 s"],
["Copa", "Coupé"],
["Hielo", "Ninguno en la copa"],
["Alcohol", "22 %"],
],
},
];
const RUNSHEET = [
["16:00", "Montaje", "Llega la barra, se monta la estación y se enfría la cristalería."],
["19:30", "Prueba", "Se calibra cada cóctel con el agua y el hielo del sitio. Nunca sabe igual en dos fincas."],
["20:00", "Apertura", "Primer trago en la mano del invitado antes de que pregunte dónde está la barra."],
["23:00", "Segundo acto", "Cambia la carta. Tragos más cortos, más alcohol, menos azúcar."],
["02:00", "Cierre", "Se recoge todo. A la mañana siguiente no queda rastro de que hubo una barra."],
];
const FAQS = [
{
q: "¿Cómo se calcula el presupuesto?",
a: "Con tres datos: cuántos invitados, cuántas horas de servicio y qué formato de barra. Todos los formatos ya incluyen barra, cristalería, hielo, destilados e ingredientes frescos, así que el precio que ves es el precio que pagas.",
},
{
q: "¿Con cuánta antelación hay que reservar?",
a: "De mayo a octubre, y las semanas de fiestas de diciembre, de tres a seis meses. Fuera de esas fechas suele haber hueco con dos semanas.",
},
{
q: "¿Hasta dónde os desplazáis?",
a: "La base está en Nueva York. Los cinco condados entran sin recargo; al valle del Hudson, los Hamptons, Connecticut y Nueva Jersey subimos con gastos de desplazamiento aparte.",
},
{
q: "¿Hay cócteles sin alcohol?",
a: "Sí, y no son un refresco con una rodaja. Se construyen con destilados botánicos desalcoholizados, fermentados y jarabes propios, con el mismo número de pasos que la carta con alcohol.",
},
{
q: "¿Quién pone la barra y la cristalería?",
a: "Nosotros, entera. Barra modular retroiluminada, estaciones con hielo seco, cristal fino para cada cóctel y utillaje japonés. Vosotros no alquiláis nada.",
},
];
export default function ConceptOnePage() {
const reduce = useReducedMotion();
const [clock, setClock] = useState<string | null>(null);
useEffect(() => {
const tick = () =>
setClock(
new Date().toLocaleTimeString("es-ES", { hour: "2-digit", minute: "2-digit", second: "2-digit" })
);
tick();
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, []);
const [openFormat, setOpenFormat] = useState<number | null>(0);
const [openFaq, setOpenFaq] = useState<number | null>(null);
const menuRef = useRef<HTMLElement | null>(null);
const menuInView = useInView(menuRef, { once: true, margin: "-120px" });
const draw = reduce || menuInView ? 1 : 0;
// Hero photograph drifts and darkens as the page leaves it.
const heroRef = useRef<HTMLElement | null>(null);
const { scrollYProgress: heroProgress } = useScroll({ target: heroRef, offset: ["start start", "end start"] });
const heroShift = useTransform(heroProgress, [0, 1], reduce ? ["0%", "0%"] : ["0%", "14%"]);
const heroTransform = useMotionTemplate`translateY(${heroShift})`;
const heroScrim = useTransform(heroProgress, [0, 1], [0.4, 0.8]);
return (
<div className="c1 min-h-screen pb-28 sm:pb-32">
<HubButton currentConceptName="01. Barra privada" theme="light" />
<WhatsAppFloatingButton conceptName="Barra privada John Caiza" theme="editorial" />
<header className="fixed inset-x-0 top-0 z-40 border-b border-white/15 bg-black/25 backdrop-blur-md">
<div className="mx-auto flex h-16 max-w-[1240px] items-center justify-between px-5 sm:px-8">
<a href="#top" className="display text-lg tracking-[-0.04em] text-white">
John Caiza
</a>
<nav className="flex items-center gap-6 text-[13px] text-white/70">
<a href="#formatos" className="hidden sm:inline hover:text-white">Formatos</a>
<a href="#carta" className="hidden sm:inline hover:text-white">Carta</a>
<a href="#servicio" className="hidden md:inline hover:text-white">Servicio</a>
<a
href="#reservar"
className="press bg-white px-4 py-2 text-[13px] text-[var(--ink)] transition-colors hover:bg-[var(--chartreuse)]"
>
Pedir presupuesto
</a>
</nav>
</div>
</header>
{/* ── Hero: full-bleed photograph, type laid over it. ───────────── */}
<section id="top" ref={heroRef} className="relative flex min-h-[100svh] flex-col justify-between overflow-hidden text-white">
<motion.div style={{ transform: heroTransform }} className="absolute inset-x-0 -top-[2%] h-[116%]">
<Image
src="/images/hero_bartender_1.jpg"
alt="John Caiza terminando un cóctel en la estación de barra"
fill
priority
sizes="100vw"
className="object-cover"
/>
</motion.div>
<motion.div aria-hidden style={{ opacity: heroScrim }} className="absolute inset-0 bg-black" />
<div aria-hidden className="absolute inset-0 bg-gradient-to-t from-black/85 via-transparent to-black/50" />
{/* Dashed field drawn over the photograph on load. */}
<div aria-hidden className="pointer-events-none absolute inset-x-5 bottom-0 top-24 sm:inset-x-8">
{[0, 1].map((row) => (
<motion.div
key={row}
initial={{ scaleX: reduce ? 1 : 0 }}
animate={{ scaleX: 1 }}
transition={{ duration: 1, ease: EASE, delay: 0.25 + row * 0.12 }}
className="grid-field grid-field--light absolute inset-x-0 h-px origin-left"
style={{ top: row === 0 ? 0 : "auto", bottom: row === 1 ? 0 : "auto" }}
>
<Cross className="crosshair--light left-0 top-0" />
<Cross className="crosshair--light left-1/2 top-0" />
<Cross className="crosshair--light right-0 top-0" />
</motion.div>
))}
</div>
{/* Top meta row */}
<Rise onLoad delay={0.5} className="relative z-10 mx-auto w-full max-w-[1240px] px-5 pt-28 sm:px-8">
<div className="flex flex-wrap items-baseline justify-between gap-3 text-[13px] text-white/75">
<span>{BARTENDER_PROFILE.location.split(" & ")[0]}</span>
<span className="flex items-center gap-2">
<span className="h-1.5 w-1.5 rounded-full bg-[var(--chartreuse)]" />
Barra abierta
<time className="figures text-white" suppressHydrationWarning>
{clock ?? "--:--:--"}
</time>
</span>
</div>
</Rise>
{/* Headline block */}
<div className="relative z-10 mx-auto w-full max-w-[1240px] px-5 pb-24 sm:px-8 sm:pb-28">
<h1 className="display text-[clamp(2.3rem,7.2vw,4.6rem)]">
<RevealLines
onLoad
delay={0.35}
lines={["Doce años detrás de la barra.", "Una noche delante de tus invitados."]}
/>
</h1>
<Rise onLoad delay={0.75} className="mt-8 max-w-[46ch]">
<p className="text-[17px] leading-[1.65] text-white/80">
Barra privada y coctelería de autor para bodas, villas y cenas de empresa.
Montamos, servimos y recogemos. Vosotros solo tenéis que llegar con sed.
</p>
</Rise>
<Rise onLoad delay={0.85} className="mt-7 flex flex-wrap items-center gap-3">
<a
href="#reservar"
className="press bg-white px-6 py-3.5 text-[15px] text-[var(--ink)] transition-colors hover:bg-[var(--chartreuse)]"
>
Pedir presupuesto
</a>
<a
href="#carta"
className="press border border-white/50 px-6 py-3.5 text-[15px] text-white transition-colors hover:bg-white hover:text-[var(--ink)]"
>
Ver la carta
</a>
</Rise>
</div>
</section>
{/* ── Cifras ────────────────────────────────────────────────────── */}
<section className="border-b border-[var(--rule)]">
<Rise className="mx-auto max-w-[1240px] px-5 py-10 sm:px-8">
<dl className="grid grid-cols-2 gap-8 sm:grid-cols-4">
{BARTENDER_PROFILE.stats.map((s) => (
<div key={s.label}>
<dt className="figures text-[32px] leading-none">{s.value}</dt>
<dd className="mt-2 text-[13px] leading-snug text-[var(--dim)]">{s.label}</dd>
</div>
))}
</dl>
</Rise>
</section>
{/* ── Formatos ──────────────────────────────────────────────────── */}
<section id="formatos">
<div className="mx-auto max-w-[1240px] px-5 py-16 sm:px-8 sm:py-24">
<Rise><Marker n="01">Formatos de barra</Marker></Rise>
<h2 className="display mt-5 max-w-[16ch] text-[9vw] sm:text-[4rem]">
<RevealLines lines={["Cinco maneras de", "montar una barra"]} />
</h2>
<ul className="mt-12 border-t border-[var(--rule)]">
{FORMATS.map((f, i) => {
const open = openFormat === i;
return (
<li key={f.name} className="border-b border-[var(--rule)]">
<button
onClick={() => setOpenFormat(open ? null : i)}
aria-expanded={open}
aria-controls={`fmt-${i}`}
className="press group flex w-full items-baseline gap-4 py-6 text-left sm:gap-8"
>
<span className="figures w-8 shrink-0 text-[13px] text-[var(--dim)]">
{String(i + 1).padStart(2, "0")}
</span>
<span className="display flex-1 text-[22px] tracking-[-0.02em] transition-colors group-hover:text-[var(--bottle)] sm:text-[30px]">
{f.name}
</span>
<span className="hidden shrink-0 text-[13px] text-[var(--dim)] sm:block">
{f.guests}
</span>
<span className="figures shrink-0 text-[18px] sm:text-[22px]">
desde ${f.from.toLocaleString("en-US")}
</span>
<span
aria-hidden
className="shrink-0 text-[22px] leading-none text-[var(--bottle)] transition-transform duration-200"
style={{ transform: open ? "rotate(45deg)" : "none" }}
>
+
</span>
</button>
<AnimatePresence initial={false}>
{open && (
<motion.div
id={`fmt-${i}`}
key="body"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{
height: { duration: reduce ? 0 : 0.28, ease: EASE },
opacity: { duration: reduce ? 0 : 0.2, ease: EASE },
}}
className="overflow-hidden"
>
<div className="grid gap-6 pb-8 sm:grid-cols-12 sm:gap-8 sm:pl-16">
<p className="max-w-[52ch] text-[16px] leading-[1.7] text-[var(--dim)] sm:col-span-7">
{f.blurb}
</p>
<ul className="space-y-2 text-[14px] sm:col-span-5">
{f.includes.map((inc) => (
<li key={inc} className="flex gap-2.5">
<span aria-hidden className="text-[var(--bottle)]">·</span>
{inc}
</li>
))}
</ul>
</div>
</motion.div>
)}
</AnimatePresence>
</li>
);
})}
</ul>
</div>
</section>
{/* ── Banda fotográfica ─────────────────────────────────────────── */}
<PhotoBand src="/images/speakeasy_5.jpg" alt="Servicio de barra durante una cena privada">
<div className="relative z-10 mx-auto w-full max-w-[1240px] px-5 pb-14 sm:px-8 sm:pb-20">
<p className="display max-w-[20ch] text-[clamp(1.8rem,4.4vw,3.2rem)] text-white">
<RevealLines lines={["El bar no es el mueble.", "Es quién está detrás."]} />
</p>
</div>
</PhotoBand>
{/* ── La carta ──────────────────────────────────────────────────── */}
<section id="carta" ref={menuRef} className="bg-[var(--paper)]">
<div className="mx-auto max-w-[1240px] px-5 py-16 sm:px-8 sm:py-24">
<Rise><Marker n="02">Tres noches, tres cartas</Marker></Rise>
<h2 className="display mt-5 max-w-[18ch] text-[9vw] sm:text-[4rem]">
<RevealLines lines={["La carta, escrita", "como se escribe"]} />
</h2>
<Rise delay={0.15}>
<p className="mt-6 max-w-[54ch] text-[17px] leading-[1.65] text-[var(--dim)]">
Ninguna carta se repite. Estas tres se sirvieron una sola vez, y así quedaron
anotadas en la ficha de servicio.
</p>
</Rise>
<div className="mt-14 space-y-16 sm:space-y-24">
{MENU.map((m, i) => (
<article key={m.name} className="grid gap-8 sm:grid-cols-12 sm:gap-12">
<Rise className={`sm:col-span-5 ${i % 2 ? "sm:order-2" : ""}`}>
<figure className="zoom relative aspect-[4/5] overflow-hidden bg-[var(--ground)]">
<Image
src={m.image}
alt={`${m.name}, servido en ${m.place}`}
fill
sizes="(max-width: 640px) 100vw, 40vw"
className="object-cover"
/>
</figure>
</Rise>
<div className="sm:col-span-7">
<h3 className="display text-[30px] tracking-[-0.02em] sm:text-[40px]">
<RevealLines lines={[m.name]} />
</h3>
<Rise delay={0.1}>
<p className="mt-2 text-[14px] text-[var(--dim)]">
{m.place} — {m.note}
</p>
</Rise>
<div className="mt-8 border-t border-[var(--rule)] pt-6">
<ul className="space-y-3 text-[16px]">
{m.build.map(([item, qty], j) => (
<li
key={item}
className="leader"
style={{
["--draw" as string]: draw,
transitionDelay: `${j * 90}ms`,
}}
>
<span>{item}</span>
<i />
<b className="figures">{qty}</b>
</li>
))}
</ul>
</div>
<Rise delay={0.15}>
<dl className="mt-8 grid grid-cols-2 gap-x-8 gap-y-4 border-t border-[var(--rule)] pt-6 sm:grid-cols-4">
{m.spec.map(([k, v]) => (
<div key={k}>
<dt className="text-[13px] text-[var(--dim)]">{k}</dt>
<dd className="figures mt-1 text-[15px]">{v}</dd>
</div>
))}
</dl>
</Rise>
</div>
</article>
))}
</div>
</div>
</section>
{/* ── Servicio: escaleta sobre fotografía. ──────────────────────── */}
<section id="servicio" className="relative overflow-hidden text-[var(--paper)]">
<div className="absolute inset-0">
<Image
src="/images/speakeasy_6.jpg"
alt=""
aria-hidden
fill
sizes="100vw"
className="object-cover"
/>
<div className="absolute inset-0 bg-[var(--ink)]/92" />
</div>
<div className="relative z-10 mx-auto max-w-[1240px] px-5 py-16 sm:px-8 sm:py-24">
<Rise><Marker n="03" dark>Cómo va la noche</Marker></Rise>
<h2 className="display mt-5 max-w-[16ch] text-[9vw] sm:text-[4rem]">
<RevealLines lines={["De las cuatro de la", "tarde a las dos"]} />
</h2>
<ol className="mt-14 border-t border-white/15">
{RUNSHEET.map(([time, title, text], i) => (
<Rise key={time} delay={i * 0.06}>
<li className="grid gap-2 border-b border-white/15 py-7 sm:grid-cols-12 sm:gap-8">
<span className="figures text-[22px] text-[var(--chartreuse)] sm:col-span-2">
{time}
</span>
<span className="display text-[20px] tracking-[-0.02em] sm:col-span-3">{title}</span>
<p className="max-w-[60ch] text-[16px] leading-[1.65] text-white/65 sm:col-span-7">
{text}
</p>
</li>
</Rise>
))}
</ol>
</div>
</section>
{/* ── Preguntas ─────────────────────────────────────────────────── */}
<section>
<div className="mx-auto max-w-[1240px] px-5 py-16 sm:px-8 sm:py-24">
<Rise><Marker n="04">Antes de escribir</Marker></Rise>
<h2 className="display mt-5 max-w-[14ch] text-[9vw] sm:text-[4rem]">
<RevealLines lines={["Lo que suelen preguntar"]} />
</h2>
<ul className="mt-12 max-w-[80ch] border-t border-[var(--rule)]">
{FAQS.map((f, i) => {
const open = openFaq === i;
return (
<li key={f.q} className="border-b border-[var(--rule)]">
<button
onClick={() => setOpenFaq(open ? null : i)}
aria-expanded={open}
aria-controls={`faq-${i}`}
className="press flex w-full items-baseline justify-between gap-6 py-6 text-left"
>
<span className="display text-[19px] tracking-[-0.015em] sm:text-[23px]">{f.q}</span>
<span
aria-hidden
className="shrink-0 text-[22px] leading-none text-[var(--bottle)] transition-transform duration-200"
style={{ transform: open ? "rotate(45deg)" : "none" }}
>
+
</span>
</button>
<AnimatePresence initial={false}>
{open && (
<motion.div
id={`faq-${i}`}
key="body"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{
height: { duration: reduce ? 0 : 0.28, ease: EASE },
opacity: { duration: reduce ? 0 : 0.2, ease: EASE },
}}
className="overflow-hidden"
>
<p className="max-w-[62ch] pb-7 text-[16px] leading-[1.7] text-[var(--dim)]">
{f.a}
</p>
</motion.div>
)}
</AnimatePresence>
</li>
);
})}
</ul>
</div>
</section>
{/* ── Reservar ──────────────────────────────────────────────────── */}
<section id="reservar" className="bg-[var(--paper)]">
<div className="mx-auto max-w-[1240px] px-5 py-16 sm:px-8 sm:py-24">
<Rise><Marker n="05">Reservar fecha</Marker></Rise>
<h2 className="display mt-5 max-w-[15ch] text-[9vw] sm:text-[4rem]">
<RevealLines lines={["Contadnos qué noche es"]} />
</h2>
<Rise delay={0.12}>
<p className="mt-6 max-w-[52ch] text-[17px] leading-[1.65] text-[var(--dim)]">
Con la fecha, el sitio y cuántos sois ya se puede dar un precio cerrado.
Respondemos el mismo día.
</p>
</Rise>
<Rise delay={0.2} className="mt-12">
<BookingForm theme="editorial" />
</Rise>
</div>
</section>
{/* The floating desktop buttons are hidden under sm, so mobile gets its own bar. */}
<div className="fixed inset-x-0 bottom-0 z-50 flex border-t border-[var(--rule)] bg-[var(--ground)]/95 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 py-4 text-center text-[15px] text-[var(--bottle)]"
>
WhatsApp
</a>
<a
href="#reservar"
className="press flex-1 bg-[var(--ink)] py-4 text-center text-[15px] text-[var(--paper)]"
>
Pedir presupuesto
</a>
</div>
<footer className="border-t border-[var(--rule)]">
<div className="mx-auto flex max-w-[1240px] flex-col gap-3 px-5 py-10 text-[13px] text-[var(--dim)] sm:flex-row sm:items-center sm:justify-between sm:px-8">
<span>John Caiza · Barra privada y coctelería de autor</span>
<span>{BARTENDER_PROFILE.location}</span>
</div>
</footer>
</div>
);
}

906
src/app/concept-2/page.tsx Normal file
View File

@@ -0,0 +1,906 @@
"use client";
import { useEffect, useRef, 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;
/* A drop of the liquid itself, sized to whatever type it sits in. */
function Drop({ color }: { color: string }) {
return <span aria-hidden className="drop" style={{ ["--dot" as string]: color }} />;
}
/* Photograph that wipes up from its bottom edge as it enters. */
function Wipe({
src,
alt,
ratio,
className = "",
}: {
src: string;
alt: string;
ratio: string;
className?: string;
}) {
const ref = useRef<HTMLDivElement | null>(null);
// Safe default is *visible*. We only hide it if it starts below the fold, so a
// missed observer can never leave the photograph permanently clipped away.
const [revealed, setRevealed] = useState(true);
useEffect(() => {
const el = ref.current;
if (!el) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
if (el.getBoundingClientRect().top < window.innerHeight) return;
setRevealed(false);
const io = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setRevealed(true);
io.disconnect();
}
},
{ rootMargin: "0px 0px -80px 0px" }
);
io.observe(el);
return () => io.disconnect();
}, []);
return (
<div
ref={ref}
className={`wipe relative overflow-hidden rounded-[20px] bg-white ${ratio} ${className}`}
style={{ ["--wipe" as string]: revealed ? "0%" : "100%" }}
>
<Image src={src} alt={alt} fill sizes="(max-width: 768px) 100vw, 55vw" className="object-cover" />
</div>
);
}
/* Quiet entrance for text blocks. Reduced motion keeps the fade, drops the move. */
function Rise({
children,
className = "",
delay = 0,
onLoad = false,
}: {
children: React.ReactNode;
className?: string;
delay?: number;
onLoad?: boolean;
}) {
const reduce = useReducedMotion();
const props = onLoad
? { initial: "hidden" as const, animate: "show" as const }
: { initial: "hidden" as const, whileInView: "show" as const, viewport: { once: true, margin: "-10%" } };
return (
<motion.div
{...props}
variants={
reduce
? { hidden: { opacity: 0 }, show: { opacity: 1, transition: { duration: 0.2 } } }
: {
hidden: { opacity: 0, transform: "translateY(18px)" },
show: {
opacity: 1,
transform: "translateY(0px)",
transition: { duration: 0.6, ease: EASE, delay },
},
}
}
className={className}
>
{children}
</motion.div>
);
}
/* The page's own booking form. There is no backend, so the button does the one
thing that actually works and says so: it opens WhatsApp with the message
already written. No fake "request registered" state. */
function LabForm() {
const [f, setF] = useState({
name: "",
phone: "",
date: "",
guests: "60",
place: "",
notes: "",
});
const field =
"mt-2 w-full rounded-[14px] border border-[var(--rule)] bg-[var(--card)] px-4 py-3 text-[15px] outline-none transition-colors placeholder:text-[var(--dim)]/60 focus:border-[var(--ink)]";
const send = () => {
const msg =
`Hola John, quería preguntarte por una barra.\n\n` +
`Nombre: ${f.name || "—"}\n` +
`Teléfono: ${f.phone || "—"}\n` +
`Fecha: ${f.date || "por concretar"}\n` +
`Invitados: ${f.guests}\n` +
`Sitio: ${f.place || "por concretar"}\n` +
(f.notes ? `\n${f.notes}\n` : "");
window.open(
`https://wa.me/${BARTENDER_PROFILE.whatsapp.replace(/[^0-9]/g, "")}?text=${encodeURIComponent(msg)}`,
"_blank"
);
};
return (
<form
onSubmit={(e) => {
e.preventDefault();
send();
}}
className="rounded-[24px] bg-[var(--card)] p-7 sm:p-10"
>
<div className="grid gap-5 sm:grid-cols-2">
<label className="block text-[13px] text-[var(--dim)] sm:col-span-1">
Cómo te llamas
<input
required
value={f.name}
onChange={(e) => setF({ ...f, name: e.target.value })}
placeholder="Sofía Benítez"
className={field}
/>
</label>
<label className="block text-[13px] text-[var(--dim)]">
Teléfono
<input
required
type="tel"
value={f.phone}
onChange={(e) => setF({ ...f, phone: e.target.value })}
placeholder="+1 (212) 555-0143"
className={field}
/>
</label>
<label className="block text-[13px] text-[var(--dim)]">
Qué día
<input
type="date"
value={f.date}
onChange={(e) => setF({ ...f, date: e.target.value })}
className={field}
/>
</label>
<label className="block text-[13px] text-[var(--dim)]">
<span className="flex items-baseline justify-between">
<span>Cuántos sois</span>
<span className="figures text-[18px] text-[var(--ink)]">{f.guests}</span>
</span>
<input
type="range"
min={10}
max={300}
step={5}
value={f.guests}
onChange={(e) => setF({ ...f, guests: e.target.value })}
className="mt-4 w-full accent-[var(--liq-campari)]"
/>
</label>
<label className="block text-[13px] text-[var(--dim)] sm:col-span-2">
Dónde
<input
value={f.place}
onChange={(e) => setF({ ...f, place: e.target.value })}
placeholder="Loft, casa, azotea o barrio"
className={field}
/>
</label>
<label className="block text-[13px] text-[var(--dim)] sm:col-span-2">
Algo más que deba saber
<textarea
rows={3}
value={f.notes}
onChange={(e) => setF({ ...f, notes: e.target.value })}
placeholder="Destilados que os gustan, alguien sin alcohol, si hay que montar en la azotea…"
className={`${field} resize-none`}
/>
</label>
</div>
<div className="mt-7 flex flex-col gap-3 sm:flex-row sm:items-center">
<button
type="submit"
className="press rounded-full 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 el mensaje escrito. Lo lees antes de mandarlo.
</span>
</div>
</form>
);
}
const BACKBAR = [
{ name: "London Dry Gin", role: "Base", figure: "45 %", color: "var(--liq-gin)" },
{ name: "Vermut rojo di Torino", role: "Aromatizado", figure: "16 %", color: "var(--liq-vermut)" },
{ name: "Campari", role: "Amargo", figure: "25 %", color: "var(--liq-campari)" },
{ name: "Bourbon de centeno alto", role: "Base", figure: "50 %", color: "var(--liq-bourbon)" },
{ name: "Lima exprimida al momento", role: "Cítrico", figure: "el mismo día", color: "var(--liq-lima)" },
{ name: "Angostura", role: "Bitter", figure: "2 golpes", color: "var(--liq-vermut)" },
];
type Classic = {
id: string;
name: string;
year: string;
color: string;
image?: string;
signature?: boolean;
base: "Ginebra" | "Whisky" | "Ron" | "Agave" | "Coñac" | "Vermut";
method: "Removido" | "Agitado";
bitter: number; // 0-10
sweet: number; // 0-10
blurb: string;
build: [string, string][];
glass: string;
garnish: string;
time: string;
};
/* The canon the selector scores against. Proportions are the standard ones;
the notes are John's. Three are flagged `signature` and also head the page. */
const CANON: Classic[] = [
{
id: "negroni",
name: "Negroni",
year: "Florencia, 1919",
color: "var(--liq-campari)",
image: "/images/editorial_3.jpg",
signature: true,
base: "Ginebra",
method: "Removido",
bitter: 9,
sweet: 4,
blurb:
"Tres partes iguales y nada que esconder. Si el vermut lleva un mes abierto se nota, por eso lo compro pequeño y lo gasto.",
build: [["Ginebra", "30 ml"], ["Campari", "30 ml"], ["Vermut rojo", "30 ml"]],
glass: "Vaso bajo",
garnish: "Piel de naranja",
time: "Removido, 25 s",
},
{
id: "boulevardier",
name: "Boulevardier",
year: "París, 1927",
color: "var(--liq-vermut)",
base: "Whisky",
method: "Removido",
bitter: 8,
sweet: 5,
blurb: "El Negroni de quien prefiere el whisky. Más redondo, algo más largo de boca.",
build: [["Bourbon", "45 ml"], ["Campari", "30 ml"], ["Vermut rojo", "30 ml"]],
glass: "Vaso bajo",
garnish: "Piel de naranja",
time: "Removido, 25 s",
},
{
id: "americano",
name: "Americano",
year: "Milán, 1860",
color: "var(--liq-campari)",
base: "Vermut",
method: "Removido",
bitter: 7,
sweet: 5,
blurb: "Amargo pero largo y con burbuja. El que sirvo cuando queda tarde por delante.",
build: [["Campari", "30 ml"], ["Vermut rojo", "30 ml"], ["Soda", "al gusto"]],
glass: "Vaso alto con hielo",
garnish: "Media rodaja de naranja",
time: "Directo en vaso",
},
{
id: "manhattan",
name: "Manhattan",
year: "Nueva York, 1880",
color: "var(--liq-vermut)",
base: "Whisky",
method: "Removido",
bitter: 4,
sweet: 6,
blurb: "Vermut bueno y frío. Es un trago de conversación, no de brindis.",
build: [["Bourbon o centeno", "60 ml"], ["Vermut rojo", "25 ml"], ["Angostura", "2 golpes"]],
glass: "Copa helada",
garnish: "Cereza",
time: "Removido, 30 s",
},
{
id: "martini",
name: "Dry Martini",
year: "Nueva York, 1900",
color: "var(--liq-gin)",
image: "/images/editorial_1.jpg",
signature: true,
base: "Ginebra",
method: "Removido",
bitter: 2,
sweet: 1,
blurb:
"El más fácil de servir y el más difícil de servir bien. Todo depende de la temperatura y de cuánta agua entra al remover.",
build: [["Ginebra", "60 ml"], ["Vermut seco", "10 ml"]],
glass: "Copa helada",
garnish: "Limón o aceituna",
time: "Removido, 30 s",
},
{
id: "oldfashioned",
name: "Old Fashioned",
year: "Kentucky, 1880",
color: "var(--liq-bourbon)",
base: "Whisky",
method: "Removido",
bitter: 4,
sweet: 4,
blurb: "Azúcar, bitter y hielo grande. Cuanto menos se toca, mejor sale.",
build: [["Bourbon", "60 ml"], ["Azúcar", "1 terrón"], ["Angostura", "2 golpes"]],
glass: "Vaso bajo",
garnish: "Piel de naranja",
time: "Removido, 30 s",
},
{
id: "daiquiri",
name: "Daiquiri",
year: "Cuba, 1898",
color: "var(--liq-lima)",
image: "/images/editorial_2.jpg",
signature: true,
base: "Ron",
method: "Agitado",
bitter: 1,
sweet: 6,
blurb:
"Tres ingredientes, y el único sitio donde esconderse es la lima. Exprimida hace dos horas ya sabe a otra cosa, así que se exprime delante.",
build: [["Ron blanco", "60 ml"], ["Zumo de lima", "25 ml"], ["Jarabe de azúcar", "15 ml"]],
glass: "Coupé, bien fría",
garnish: "Nada",
time: "Agitado, 12 s",
},
{
id: "gimlet",
name: "Gimlet",
year: "Londres, 1920",
color: "var(--liq-lima)",
base: "Ginebra",
method: "Agitado",
bitter: 2,
sweet: 5,
blurb: "El Daiquiri de los que beben ginebra. Seco, corto y muy frío.",
build: [["Ginebra", "60 ml"], ["Zumo de lima", "20 ml"], ["Jarabe de azúcar", "15 ml"]],
glass: "Coupé",
garnish: "Rodaja de lima",
time: "Agitado, 12 s",
},
{
id: "sour",
name: "Whisky Sour",
year: "Estados Unidos, 1870",
color: "var(--liq-bourbon)",
base: "Whisky",
method: "Agitado",
bitter: 3,
sweet: 6,
blurb: "El equilibrio más fácil de entender y el más fácil de romper.",
build: [["Bourbon", "60 ml"], ["Zumo de limón", "25 ml"], ["Jarabe de azúcar", "15 ml"]],
glass: "Vaso bajo",
garnish: "Angostura por encima",
time: "Agitado, 12 s",
},
{
id: "margarita",
name: "Margarita",
year: "México, 1948",
color: "var(--liq-lima)",
base: "Agave",
method: "Agitado",
bitter: 2,
sweet: 5,
blurb: "Con tequila 100 % agave y sal sólo en medio borde, para que se pueda elegir.",
build: [["Tequila blanco", "50 ml"], ["Triple seco", "20 ml"], ["Zumo de lima", "20 ml"]],
glass: "Coupé",
garnish: "Medio borde de sal",
time: "Agitado, 12 s",
},
{
id: "sidecar",
name: "Sidecar",
year: "París, 1922",
color: "var(--liq-bourbon)",
base: "Coñac",
method: "Agitado",
bitter: 2,
sweet: 5,
blurb: "Coñac y limón. El más elegante de los agitados y el que menos se pide.",
build: [["Coñac", "50 ml"], ["Triple seco", "20 ml"], ["Zumo de limón", "20 ml"]],
glass: "Coupé",
garnish: "Piel de limón",
time: "Agitado, 12 s",
},
{
id: "paperplane",
name: "Paper Plane",
year: "Nueva York, 2008",
color: "var(--liq-bourbon)",
base: "Whisky",
method: "Agitado",
bitter: 7,
sweet: 5,
blurb: "Cuatro partes iguales con amaro. Amargo y cítrico a la vez, para el segundo de la noche.",
build: [["Bourbon", "22 ml"], ["Aperol", "22 ml"], ["Amaro", "22 ml"], ["Zumo de limón", "22 ml"]],
glass: "Coupé",
garnish: "Nada",
time: "Agitado, 12 s",
},
];
const BASES = ["La que sea", "Ginebra", "Whisky", "Ron", "Agave", "Coñac"] as const;
const METHODS = ["Me da igual", "Removido", "Agitado"] as const;
/* Closest match wins: distance on the two taste axes, plus a penalty for
ignoring an explicit method or base preference. Always returns something. */
function rank(
method: (typeof METHODS)[number],
base: (typeof BASES)[number],
bitter: number,
sweet: number
) {
return [...CANON]
.map((c) => {
let d = Math.abs(c.bitter - bitter) * 1.2 + Math.abs(c.sweet - sweet);
if (method !== "Me da igual" && c.method !== method) d += 9;
if (base !== "La que sea" && c.base !== base) d += 7;
return { c, d };
})
.sort((a, b) => a.d - b.d)
.map((x) => x.c);
}
const DOTS = [
"var(--liq-campari)",
"var(--liq-bourbon)",
"var(--liq-lima)",
];
export default function ConceptTwoPage() {
const [method, setMethod] = useState<(typeof METHODS)[number]>("Me da igual");
const [base, setBase] = useState<(typeof BASES)[number]>("La que sea");
const [bitter, setBitter] = useState(6);
const [sweet, setSweet] = useState(4);
// Lets the visitor step into a runner-up without losing their settings.
const [chosen, setChosen] = useState<string | null>(null);
const ranked = rank(method, base, bitter, sweet);
const pick = ranked.find((c) => c.id === chosen) ?? ranked[0];
const alternates = ranked.filter((c) => c.id !== pick.id).slice(0, 3);
const sendPick = () => {
const recipe = pick.build.map(([i, q]) => `· ${i} — ${q}`).join("\n");
const msg =
`Hola John, he estado mirando la carta.\n\n` +
`Me sale un ${pick.name}:\n${recipe}\n${pick.time}. ${pick.glass}.\n\n` +
`¿Lo tendrías en la barra para mi evento?`;
window.open(
`https://wa.me/${BARTENDER_PROFILE.whatsapp.replace(/[^0-9]/g, "")}?text=${encodeURIComponent(msg)}`,
"_blank"
);
};
return (
<div className="c2 min-h-screen pb-28 sm:pb-20">
<HubButton currentConceptName="02. Clásicos" theme="light" />
<WhatsAppFloatingButton conceptName="Coctelería clásica (John Caiza)" theme="editorial" />
<header className="sticky top-0 z-40 bg-[var(--ground)]/85 backdrop-blur-md">
<div className="mx-auto flex max-w-[1180px] items-center justify-between px-6 py-6 sm:px-10">
<a href="#top" className="flex items-center gap-2.5">
<span
aria-hidden
className="h-7 w-7 rounded-full"
style={{
background:
"radial-gradient(circle at 34% 30%, #fff 5%, var(--liq-bourbon) 45%, var(--liq-campari) 100%)",
}}
/>
<span className="display text-[19px]">John Caiza</span>
</a>
<a
href="#reservar"
className="press rounded-full bg-[var(--ink)] px-5 py-2.5 text-[14px] text-white"
>
Escríbeme
</a>
</div>
</header>
{/* ── Hero: one sentence, punctuated with the liquids themselves. ── */}
<section id="top" className="mx-auto max-w-[1180px] px-6 pb-20 pt-10 sm:px-10 sm:pb-28 sm:pt-16">
<Rise onLoad>
<h1 className="display max-w-[19ch] text-[clamp(2.2rem,5.4vw,4rem)]">
{/* Drop and full stop stay glued to the word they punctuate. */}
No hace falta inventar <span className="whitespace-nowrap">nada<Drop color={DOTS[0]} />.</span>{" "}
Hace falta{" "}
<span className="whitespace-nowrap">
<span className="marker">hacerlo bien</span>
<Drop color={DOTS[1]} />
</span>
</h1>
</Rise>
<Rise onLoad delay={0.14}>
<p className="mt-9 max-w-[52ch] text-[16px] leading-[1.7] text-[var(--dim)]">
Coctelería clásica. Ginebra, vermut, cítrico exprimido en el momento y hielo como es
debido. Estas recetas llevan cien años funcionando: mi trabajo no es reinventarlas,
es no estropearlas.
</p>
</Rise>
<Rise onLoad delay={0.22}>
<div className="mt-9 flex flex-wrap gap-3">
<a
href="#formula"
className="press rounded-full bg-[var(--ink)] px-6 py-3 text-[15px] text-white"
>
Dime cómo lo tomas
</a>
<a
href="#carta"
className="press rounded-full border border-[var(--rule)] px-6 py-3 text-[15px]"
>
Ver la carta
</a>
</div>
</Rise>
</section>
{/* ── Two photographs, unequal. ─────────────────────────────────── */}
<section className="mx-auto max-w-[1180px] px-6 sm:px-10">
<div className="grid gap-4 sm:grid-cols-12">
<Wipe
src="/images/editorial_4.jpg"
alt="Negroni servido en vaso bajo con piel de naranja"
ratio="aspect-[4/3]"
className="sm:col-span-5"
/>
<Wipe
src="/images/speakeasy_3.jpg"
alt="Estación de barra con las botellas de la casa"
ratio="aspect-[16/10]"
className="sm:col-span-7"
/>
</div>
</section>
{/* ── Tres clásicos ─────────────────────────────────────────────── */}
<section id="carta" className="mx-auto max-w-[1180px] px-6 py-20 sm:px-10 sm:py-28">
<Rise>
<h2 className="display max-w-[15ch] text-[clamp(1.9rem,4.4vw,3rem)]">
Tres que tienen que salir perfectos
</h2>
</Rise>
<div className="mt-12 grid gap-4 md:grid-cols-3">
{CANON.filter((c) => c.signature).map((c, i) => (
<Rise key={c.id} delay={i * 0.08}>
<article className="flex h-full flex-col rounded-[20px] bg-[var(--card)] p-6">
<div className="relative aspect-[5/4] overflow-hidden rounded-[14px] bg-[var(--ground)]">
<Image
src={c.image!}
alt={`${c.name} servido en barra`}
fill
sizes="(max-width: 768px) 100vw, 33vw"
className="object-cover"
/>
</div>
<div className="mt-5 flex items-center gap-2 text-[13px] text-[var(--dim)]">
<Drop color={c.color} />
<span>{c.year}</span>
</div>
<h3 className="display mt-2 text-[26px] leading-[1.12]">{c.name}</h3>
<p className="mt-3 text-[15px] leading-[1.65] text-[var(--dim)]">{c.blurb}</p>
<ul className="mt-5 flex-1 space-y-2 border-t border-[var(--rule)] pt-4 text-[15px]">
{c.build.map(([item, qty]) => (
<li key={item} className="flex items-baseline justify-between gap-3">
<span>{item}</span>
<span className="figures text-[var(--dim)]">{qty}</span>
</li>
))}
</ul>
<dl className="mt-5 grid grid-cols-2 gap-x-4 gap-y-3 border-t border-[var(--rule)] pt-4 text-[13px]">
<div>
<dt className="text-[var(--dim)]">Método</dt>
<dd className="figures mt-0.5">{c.time}</dd>
</div>
<div>
<dt className="text-[var(--dim)]">Copa</dt>
<dd className="mt-0.5">{c.glass}</dd>
</div>
<div className="col-span-2">
<dt className="text-[var(--dim)]">Decoración</dt>
<dd className="mt-0.5">{c.garnish}</dd>
</div>
</dl>
</article>
</Rise>
))}
</div>
</section>
{/* ── La estantería ─────────────────────────────────────────────── */}
<section className="mx-auto max-w-[1180px] px-6 pb-20 sm:px-10 sm:pb-28">
<Rise>
<h2 className="display max-w-[16ch] text-[clamp(1.9rem,4.4vw,3rem)]">
Lo que hay en la estantería
</h2>
</Rise>
<ul className="mt-12">
{BACKBAR.map((r, i) => (
<Rise key={r.name} delay={i * 0.05}>
<li className="grid grid-cols-12 items-baseline gap-4 border-t border-[var(--rule)] py-5 last:border-b">
<span className="col-span-12 flex items-center gap-2.5 sm:col-span-5">
<Drop color={r.color} />
{r.name}
</span>
<span className="col-span-7 text-[var(--dim)] sm:col-span-4">{r.role}</span>
<span className="figures col-span-5 text-right sm:col-span-3">{r.figure}</span>
</li>
</Rise>
))}
</ul>
</section>
{/* ── Dime cómo lo tomas ──────────────────────────────────────────── */}
<section id="formula" className="mx-auto flex min-h-[100svh] max-w-[1180px] flex-col justify-start px-6 pb-20 pt-6 sm:px-10 sm:pb-10">
<div className="rounded-[24px] bg-[var(--card)] p-3.5 sm:p-7">
<Rise>
<h2 className="display max-w-[20ch] text-[clamp(1.25rem,2.6vw,1.9rem)]">
Dime cómo te gusta<span className="hidden sm:inline"> y te digo cuál pedir</span>
</h2>
<p data-dense className="mt-2.5 hidden max-w-[46ch] text-[15px] leading-[1.6] text-[var(--dim)] sm:block">
Doce clásicos en la barra. Ajusta lo que te apetezca y sale el que más se acerca.
</p>
</Rise>
<div className="mt-4 grid gap-5 sm:mt-6 lg:grid-cols-12 lg:gap-12 [&>*]:min-w-0">
<div className="min-w-0 space-y-3 sm:space-y-5 lg:col-span-6">
<fieldset className="min-w-0">
<legend className="text-[13px] text-[var(--dim)]">Cómo lo prefieres</legend>
<div className="pillrow mt-2 flex gap-1.5 overflow-x-auto pb-0.5 [scrollbar-width:none] sm:flex-wrap sm:overflow-visible">
{METHODS.map((m) => (
<button
key={m}
type="button"
onClick={() => {
setMethod(m);
setChosen(null);
}}
aria-pressed={method === m}
className={`press shrink-0 rounded-full px-3.5 py-1.5 text-[13px] transition-colors sm:text-[14px] ${
method === m
? "bg-[var(--ink)] text-white"
: "border border-[var(--rule)] hover:border-[var(--ink)]"
}`}
>
{m}
</button>
))}
</div>
<p data-dense className="mt-2 hidden text-[13px] leading-snug text-[var(--dim)] sm:block">
Removido si sólo lleva alcohol. Agitado si lleva cítrico.
</p>
</fieldset>
<fieldset className="min-w-0">
<legend className="text-[13px] text-[var(--dim)]">Con qué base</legend>
<div className="pillrow mt-2 flex gap-1.5 overflow-x-auto pb-0.5 [scrollbar-width:none] sm:flex-wrap sm:overflow-visible">
{BASES.map((b) => (
<button
key={b}
type="button"
onClick={() => {
setBase(b);
setChosen(null);
}}
aria-pressed={base === b}
className={`press shrink-0 rounded-full px-3.5 py-1.5 text-[13px] transition-colors sm:text-[14px] ${
base === b
? "bg-[var(--ink)] text-white"
: "border border-[var(--rule)] hover:border-[var(--ink)]"
}`}
>
{b}
</button>
))}
</div>
</fieldset>
<div>
<label htmlFor="bitter" className="flex items-baseline justify-between text-[13px] text-[var(--dim)]">
<span>Cuánto amargor aguantas</span>
<span className="figures text-[20px] text-[var(--ink)]">{bitter}</span>
</label>
<input
id="bitter"
type="range"
min={0}
max={10}
value={bitter}
onChange={(e) => {
setBitter(Number(e.target.value));
setChosen(null);
}}
className="mt-2 w-full accent-[var(--liq-campari)]"
/>
</div>
<div>
<label htmlFor="sweet" className="flex items-baseline justify-between text-[13px] text-[var(--dim)]">
<span>Cuánto dulzor</span>
<span className="figures text-[20px] text-[var(--ink)]">{sweet}</span>
</label>
<input
id="sweet"
type="range"
min={0}
max={10}
value={sweet}
onChange={(e) => {
setSweet(Number(e.target.value));
setChosen(null);
}}
className="mt-2 w-full accent-[var(--liq-bourbon)]"
/>
</div>
</div>
{/* The answer, with the whole recipe rather than just a name. */}
<div className="min-w-0 lg:col-span-6">
<div className="rounded-[18px] bg-[var(--ground)] p-4 sm:p-6">
<span data-dense className="hidden text-[13px] text-[var(--dim)] sm:block">Lo tuyo es un</span>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={pick.id}
initial={{ opacity: 0, transform: "translateY(8px)" }}
animate={{ opacity: 1, transform: "translateY(0px)" }}
exit={{ opacity: 0, transform: "translateY(-8px)" }}
transition={{ duration: 0.22, ease: EASE }}
>
<h3 className="display mt-0.5 flex items-center gap-2.5 text-[23px] leading-[1.1] sm:text-[30px]">
{pick.name}
<Drop color={pick.color} />
</h3>
<p data-dense className="mt-1 hidden text-[13px] text-[var(--dim)] sm:block">{pick.year}</p>
<p data-dense className="mt-2.5 hidden text-[15px] leading-[1.6] text-[var(--dim)] sm:block">{pick.blurb}</p>
<ul className="mt-3 space-y-1 border-t border-[var(--rule)] pt-3 text-[14px] sm:mt-3.5 sm:space-y-1.5 sm:pt-3.5 sm:text-[15px]">
{pick.build.map(([item, qty]) => (
<li key={item} className="flex items-baseline justify-between gap-3">
<span>{item}</span>
<span className="figures text-[var(--dim)]">{qty}</span>
</li>
))}
</ul>
<dl data-dense className="mt-3.5 hidden grid-cols-2 gap-x-4 gap-y-2.5 border-t border-[var(--rule)] pt-3.5 text-[13px] sm:grid">
<div>
<dt className="text-[var(--dim)]">Método</dt>
<dd className="figures mt-0.5">{pick.time}</dd>
</div>
<div>
<dt className="text-[var(--dim)]">Copa</dt>
<dd className="mt-0.5">{pick.glass}</dd>
</div>
<div className="col-span-2">
<dt className="text-[var(--dim)]">Decoración</dt>
<dd className="mt-0.5">{pick.garnish}</dd>
</div>
</dl>
</motion.div>
</AnimatePresence>
<div className="mt-3 border-t border-[var(--rule)] pt-3 sm:mt-3.5 sm:pt-3.5">
<span data-dense className="hidden text-[13px] text-[var(--dim)] sm:block">Por ahí cerca</span>
<div className="pillrow mt-2 flex gap-1.5 overflow-x-auto pb-0.5 [scrollbar-width:none] sm:flex-wrap sm:overflow-visible">
{alternates.map((a) => (
<button
key={a.id}
type="button"
onClick={() => setChosen(a.id)}
className="press flex shrink-0 items-center gap-2 rounded-full border border-[var(--rule)] px-3 py-1.5 text-[13px] transition-colors hover:border-[var(--ink)]"
>
<Drop color={a.color} />
{a.name}
</button>
))}
</div>
</div>
<button
type="button"
onClick={sendPick}
className="press mt-3.5 w-full rounded-full bg-[var(--ink)] px-6 py-2.5 text-[15px] text-white sm:mt-4"
>
Pedírselo a John
</button>
</div>
</div>
</div>
</div>
</section>
{/* ── Una foto grande ───────────────────────────────────────────── */}
<section className="mx-auto max-w-[1180px] px-6 sm:px-10">
<Wipe
src="/images/speakeasy_4.jpg"
alt="Servicio de barra durante un evento privado"
ratio="aspect-[16/9]"
/>
</section>
{/* ── Reservar ──────────────────────────────────────────────────── */}
<section id="reservar" className="mx-auto max-w-[1180px] px-6 py-20 sm:px-10 sm:py-28">
<Rise>
<h2 className="display max-w-[16ch] text-[clamp(1.9rem,4.4vw,3rem)]">
Cuéntame qué evento es y montamos la carta
</h2>
</Rise>
<Rise delay={0.12}>
<p className="mt-5 max-w-[50ch] text-[16px] leading-[1.7] text-[var(--dim)]">
Con la fecha, el sitio y cuántos sois te digo qué carta aguanta ese servicio
y cuánto hielo hace falta. Respondo el mismo día.
</p>
</Rise>
<Rise delay={0.18} className="mt-10">
<LabForm />
</Rise>
</section>
<div className="fixed inset-x-0 bottom-0 z-50 flex gap-2 bg-[var(--ground)]/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 rounded-full border border-[var(--rule)] py-3 text-center text-[15px]"
>
WhatsApp
</a>
<a
href="#reservar"
className="press flex-1 rounded-full bg-[var(--ink)] py-3 text-center text-[15px] text-white"
>
Escríbeme
</a>
</div>
<footer className="mx-auto max-w-[1180px] px-6 pb-12 text-[13px] text-[var(--dim)] sm:px-10">
<div className="flex flex-col gap-2 border-t border-[var(--rule)] pt-8 sm:flex-row sm:justify-between">
<span>John Caiza · Coctelería clásica y barra de autor</span>
<span>{BARTENDER_PROFILE.location}</span>
</div>
</footer>
</div>
);
}

395
src/app/concept-3/page.tsx Normal file
View File

@@ -0,0 +1,395 @@
"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>
);
}

447
src/app/concept-4/page.tsx Normal file
View File

@@ -0,0 +1,447 @@
"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;
const SLIDE_MS = 6000;
const SLIDES = [
{
id: "barra",
lines: ["Barra", "privada"],
image: "/images/hero_bartender_2.jpg",
wash: "linear-gradient(180deg, rgba(198,92,26,0.34) 0%, rgba(120,26,20,0.58) 52%, rgba(13,12,12,0.92) 100%)",
caption: "Monto la barra en tu casa y me la llevo por la mañana.",
},
{
id: "clasicos",
lines: ["Clásicos", "de siempre"],
image: "/images/hero_bartender_3.jpg",
wash: "linear-gradient(180deg, rgba(150,26,44,0.34) 0%, rgba(70,18,32,0.58) 52%, rgba(13,12,12,0.92) 100%)",
caption: "Negroni, Daiquiri, Dry Martini. Nada que no lleve cien años funcionando.",
},
{
id: "nyc",
lines: ["Nueva", "York"],
image: "/images/c3_03.jpg",
wash: "linear-gradient(180deg, rgba(30,58,110,0.32) 0%, rgba(20,34,66,0.56) 52%, rgba(13,12,12,0.92) 100%)",
caption: "Los cinco condados, el valle del Hudson y los Hamptons.",
},
];
const MOMENTS = [
{ title: "Cena en un loft", place: "SoHo, Manhattan", year: "2025", image: "/images/instagram_1.jpg" },
{ title: "Pedida de mano", place: "Brooklyn Heights", year: "2025", image: "/images/editorial_5.jpg" },
{ title: "Fiesta de azotea", place: "Dumbo, Brooklyn", year: "2024", image: "/images/instagram_3.jpg" },
{ title: "Boda de fin de semana", place: "Hudson Valley", year: "2024", image: "/images/editorial_6.jpg" },
{ title: "Cata en mesa", place: "Upper East Side", year: "2024", image: "/images/instagram_5.jpg" },
];
/* Section content that rises once as it enters. */
function Rise({ children, className = "", delay = 0 }: { children: React.ReactNode; className?: string; delay?: number }) {
const reduce = useReducedMotion();
return (
<motion.div
initial="hidden"
whileInView="show"
viewport={{ once: true, margin: "-12%" }}
variants={
reduce
? { hidden: { opacity: 0 }, show: { opacity: 1, transition: { duration: 0.2 } } }
: {
hidden: { opacity: 0, transform: "translateY(24px)" },
show: { opacity: 1, transform: "translateY(0px)", transition: { duration: 0.7, ease: EASE, delay } },
}
}
className={className}
>
{children}
</motion.div>
);
}
/* The page is a carousel, so the form is one too: a question at a time, with
the same indicators as the hero. Different from the grid of fields, the
pill sliders and the fill-in-the-blanks sentence the other concepts use. */
const STEPS = [
{ key: "name", q: "¿Cómo te llamas?", type: "text", ph: "Tu nombre", hint: "" },
{ key: "date", q: "¿Qué día?", type: "date", ph: "", hint: "Si aún no hay fecha, sáltalo." },
{ key: "place", q: "¿Dónde?", type: "text", ph: "Loft, casa, azotea o barrio", hint: "" },
{ key: "guests", q: "¿Cuántos sois?", type: "number", ph: "40", hint: "" },
{ key: "phone", q: "¿A qué número te escribo?", type: "tel", ph: "+1 (212) 555-0143", hint: "" },
] as const;
type Answers = Record<(typeof STEPS)[number]["key"], string>;
function NightForm() {
const reduce = useReducedMotion();
const [step, setStep] = useState(0);
const [a, setA] = useState<Answers>({ name: "", date: "", place: "", guests: "", phone: "" });
const last = step === STEPS.length;
const cur = STEPS[Math.min(step, STEPS.length - 1)];
const send = () => {
const msg =
`Hola John.\n\n` +
`Me llamo ${a.name || "—"}.\n` +
`Fecha: ${a.date || "por concretar"}\n` +
`Sitio: ${a.place || "por concretar"}\n` +
`Invitados: ${a.guests || "—"}\n` +
`Teléfono: ${a.phone || "—"}`;
window.open(
`https://wa.me/${BARTENDER_PROFILE.whatsapp.replace(/[^0-9]/g, "")}?text=${encodeURIComponent(msg)}`,
"_blank"
);
};
return (
<div className="mt-12 max-w-2xl">
{/* Progress, same shape as the hero indicators */}
<div className="flex items-center gap-2">
{STEPS.map((s, k) => (
<button
key={s.key}
type="button"
onClick={() => setStep(k)}
aria-label={`Ir a: ${s.q}`}
aria-current={k === step}
className={`h-1 w-10 ${k <= step ? "bg-[var(--paper)]" : "bg-white/25"}`}
/>
))}
<span className="micro ml-3">
{Math.min(step + 1, STEPS.length)} / {STEPS.length}
</span>
</div>
<div aria-live="polite" className="mt-8 min-h-[190px]">
<AnimatePresence mode="wait" initial={false}>
{!last ? (
<motion.form
key={cur.key}
initial={{ opacity: 0, transform: reduce ? "none" : "translateY(14px)" }}
animate={{ opacity: 1, transform: "translateY(0px)" }}
exit={{ opacity: 0, transform: reduce ? "none" : "translateY(-10px)" }}
transition={{ duration: reduce ? 0 : 0.35, ease: EASE }}
onSubmit={(e) => {
e.preventDefault();
setStep((p) => p + 1);
}}
>
<label htmlFor={`s-${cur.key}`} className="brand block text-[clamp(1.6rem,4vw,2.8rem)]">
{cur.q}
</label>
<input
id={`s-${cur.key}`}
type={cur.type}
placeholder={cur.ph}
value={a[cur.key]}
autoComplete="off"
onChange={(e) => setA((p) => ({ ...p, [cur.key]: e.target.value }))}
className="mt-5 w-full border-b border-[var(--rule)] bg-transparent pb-3 text-[clamp(1.1rem,2.2vw,1.5rem)] outline-none placeholder:text-white/35 focus:border-[var(--paper)]"
/>
{cur.hint && <p className="micro mt-3">{cur.hint}</p>}
<div className="mt-7 flex items-center gap-4">
<button type="submit" className="press caps bg-[var(--paper)] px-7 py-3.5 text-[14px] text-[var(--ink)]">
{step === STEPS.length - 1 ? "Ver el resumen" : "Siguiente"}
</button>
{step > 0 && (
<button
type="button"
onClick={() => setStep((p) => p - 1)}
className="press micro text-[var(--paper)]"
>
Atrás
</button>
)}
<span className="micro hidden sm:inline">o pulsa Intro</span>
</div>
</motion.form>
) : (
<motion.div
key="resumen"
initial={{ opacity: 0, transform: reduce ? "none" : "translateY(14px)" }}
animate={{ opacity: 1, transform: "translateY(0px)" }}
transition={{ duration: reduce ? 0 : 0.35, ease: EASE }}
>
<p className="brand text-[clamp(1.6rem,4vw,2.8rem)]">Esto es lo que te mando</p>
<dl className="mt-6 space-y-2 text-[15px]">
{STEPS.map((s) => (
<div key={s.key} className="flex gap-5 border-b border-[var(--rule)] pb-2">
<dt className="w-32 shrink-0 text-[var(--dim)]">{s.q.replace(/[¿?]/g, "")}</dt>
<dd>{a[s.key] || "—"}</dd>
</div>
))}
</dl>
<div className="mt-8 flex flex-wrap items-center gap-4">
<button
type="button"
onClick={send}
className="press caps bg-[var(--paper)] px-7 py-3.5 text-[14px] text-[var(--ink)]"
>
Enviar por WhatsApp
</button>
<button type="button" onClick={() => setStep(0)} className="press micro text-[var(--paper)]">
Corregir
</button>
</div>
<p className="micro mt-4">Se abre WhatsApp con el mensaje escrito. Lo lees antes de mandarlo.</p>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
);
}
export default function ConceptFourPage() {
const reduce = useReducedMotion();
const [i, setI] = useState(0);
const [paused, setPaused] = useState(false);
const n = SLIDES.length;
const slide = SLIDES[i];
const go = useCallback((d: number) => setI((p) => (p + d + n) % n), [n]);
// Autoplay is opt-out, and never starts when the system asks for less motion.
useEffect(() => {
if (reduce || paused) return;
const t = setTimeout(() => go(1), SLIDE_MS);
return () => clearTimeout(t);
}, [i, paused, reduce, go]);
const wa = `https://wa.me/${BARTENDER_PROFILE.whatsapp.replace(/[^0-9]/g, "")}`;
return (
<div className="c4 min-h-screen pb-24 sm:pb-0">
<HubButton currentConceptName="04. Carrusel" theme="dark" />
<WhatsAppFloatingButton conceptName="John Caiza (Nueva York)" theme="kalkbrenner" />
{/* ── Hero carousel ─────────────────────────────────────────────── */}
<section className="relative h-[100svh] overflow-hidden">
<AnimatePresence initial={false}>
<motion.div
key={slide.id}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: reduce ? 0 : 1.1, ease: EASE }}
className="absolute inset-0"
>
<motion.div
initial={{ transform: reduce ? "scale(1)" : "scale(1.08)" }}
animate={{ transform: "scale(1)" }}
transition={{ duration: reduce ? 0 : 7, ease: "linear" }}
className="absolute inset-0"
>
<Image
src={slide.image}
alt=""
aria-hidden
fill
priority={i === 0}
sizes="100vw"
className="object-cover"
/>
</motion.div>
<div aria-hidden className="absolute inset-0" style={{ background: slide.wash }} />
{/* The type sits on the left, where the photographs are brightest. */}
<div
aria-hidden
className="absolute inset-0"
style={{ background: "linear-gradient(100deg, rgba(13,12,12,0.62) 0%, rgba(13,12,12,0.28) 42%, transparent 68%)" }}
/>
</motion.div>
</AnimatePresence>
<div className="relative z-10 flex h-full flex-col justify-between px-5 pb-24 pt-5 sm:px-8 sm:pb-28 sm:pt-7">
<nav className="caps flex items-center justify-between text-[13px]">
<span className="brand text-[20px]">John Caiza</span>
<div className="flex items-center gap-5 sm:gap-7">
<a href="#trabajo" className="hidden transition-opacity hover:opacity-60 sm:inline">Trabajo</a>
<a href="#oficio" className="hidden transition-opacity hover:opacity-60 sm:inline">Oficio</a>
<a href="#contacto" className="transition-opacity hover:opacity-60">Hablemos ↗</a>
</div>
</nav>
{/* Headline: each line rides up from behind its own mask on change. */}
<h1 className="brand text-[clamp(3.2rem,14vw,11rem)]" aria-live="polite">
<AnimatePresence mode="wait" initial={false}>
<motion.span key={slide.id} className="block">
{slide.lines.map((line, k) => (
<span key={line} className="-mt-[0.14em] block overflow-hidden pb-[0.04em] pt-[0.14em]">
<motion.span
className="block"
initial={{ transform: reduce ? "none" : "translateY(105%)" }}
animate={{ transform: "translateY(0%)" }}
exit={{ opacity: 0 }}
transition={{ duration: reduce ? 0 : 0.75, ease: EASE, delay: reduce ? 0 : k * 0.08 }}
>
{line}
</motion.span>
</span>
))}
</motion.span>
</AnimatePresence>
</h1>
<div className="flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between">
{/* Indicators double as the autoplay progress readout. */}
<div className="flex items-center gap-3">
{SLIDES.map((s, k) => (
<button
key={s.id}
type="button"
onClick={() => setI(k)}
aria-label={`Ir a ${s.lines.join(" ")}`}
aria-current={k === i}
className="h-1 w-12 overflow-hidden bg-white/30"
>
{k === i && (
<span
key={`${s.id}-${paused}`}
className={paused ? "block h-full w-full bg-white" : "fill block h-full w-full bg-white"}
style={{ ["--slide" as string]: `${SLIDE_MS}ms` }}
/>
)}
</button>
))}
<button
type="button"
onClick={() => setPaused((p) => !p)}
className="press micro ml-2 text-[var(--paper)]"
aria-pressed={paused}
>
{paused ? "Reanudar" : "Pausar"}
</button>
</div>
<motion.p
key={slide.id}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: reduce ? 0 : 0.6, ease: EASE, delay: reduce ? 0 : 0.25 }}
className="max-w-[38ch] text-[15px] leading-[1.6] text-[var(--dim)]"
>
<span aria-hidden className="mb-2 block text-[var(--paper)]">↘</span>
{slide.caption}
</motion.p>
</div>
</div>
</section>
{/* ── Momentos ──────────────────────────────────────────────────── */}
<section id="trabajo" className="px-5 py-16 sm:px-8 sm:py-24">
<Rise>
<p className="micro">Trabajo</p>
<h2 className="brand mt-4 max-w-[14ch] text-[clamp(2rem,6vw,4.6rem)]">Cinco noches de este año</h2>
</Rise>
<div className="mt-14 grid gap-x-6 gap-y-12 sm:grid-cols-12">
{MOMENTS.map((m, k) => (
<Rise
key={m.title}
delay={(k % 3) * 0.08}
className={
k === 0 ? "sm:col-span-7" : k === 1 ? "sm:col-span-5 sm:mt-20" : k === 3 ? "sm:col-span-8" : "sm:col-span-4"
}
>
<figure>
<div className={`relative overflow-hidden bg-white/5 ${k === 3 ? "aspect-[16/10]" : "aspect-[4/5]"}`}>
<Image
src={m.image}
alt={`${m.title}, ${m.place}`}
fill
sizes="(max-width: 640px) 100vw, 45vw"
className="object-cover"
/>
</div>
<figcaption className="mt-3 flex items-baseline justify-between border-t border-[var(--rule)] pt-3">
<span className="caps text-[13px]">{m.title}</span>
<span className="micro">{m.year}</span>
</figcaption>
<p className="micro mt-1">{m.place}</p>
</figure>
</Rise>
))}
</div>
</section>
{/* ── Oficio ────────────────────────────────────────────────────── */}
<section id="oficio" className="border-t border-[var(--rule)] px-5 py-16 sm:px-8 sm:py-24">
<div className="grid gap-10 sm:grid-cols-12">
<Rise className="sm:col-span-3">
<p className="micro">El oficio</p>
</Rise>
<Rise className="sm:col-span-9" delay={0.1}>
<p className="brand max-w-[16ch] text-[clamp(2rem,5.4vw,4.2rem)]">
Doce años, una barra y ningún atajo
</p>
<p className="mt-8 max-w-[54ch] text-[15px] leading-[1.7] text-[var(--dim)]">
Sólo eventos privados en Nueva York: casas, lofts, azoteas y fincas. Coctelería
clásica, proporciones conocidas, cítrico exprimido en el momento y hielo cuidado.
Monto, sirvo y recojo.
</p>
<dl className="mt-12 grid max-w-2xl grid-cols-2 gap-x-8 gap-y-6 border-t border-[var(--rule)] pt-8 sm:grid-cols-4">
{BARTENDER_PROFILE.stats.map((s) => (
<div key={s.label}>
<dt className="brand text-[34px] leading-none">{s.value}</dt>
<dd className="micro mt-2">{s.label}</dd>
</div>
))}
</dl>
</Rise>
</div>
</section>
{/* ── Contacto ──────────────────────────────────────────────────── */}
<section id="contacto" className="border-t border-[var(--rule)] px-5 py-16 sm:px-8 sm:py-28">
<Rise>
<p className="brand max-w-[12ch] text-[clamp(2.4rem,9vw,7rem)]">¿Qué noche es?</p>
<p className="mt-8 max-w-[46ch] text-[15px] leading-[1.7] text-[var(--dim)]">
Dime la fecha, la dirección y cuántos sois. Los cinco condados sin recargo; al
Hudson y a los Hamptons, con desplazamiento aparte.
</p>
<NightForm />
<p className="mt-12 border-t border-[var(--rule)] pt-6 text-[13px] text-[var(--dim)]">
O escríbeme directamente por{" "}
<a href={wa} target="_blank" rel="noopener noreferrer" className="text-[var(--paper)] underline underline-offset-4">
WhatsApp
</a>{" "}
o en{" "}
<a
href={BARTENDER_PROFILE.instagramUrl}
target="_blank"
rel="noopener noreferrer"
className="text-[var(--paper)] underline underline-offset-4"
>
{BARTENDER_PROFILE.instagram}
</a>
</p>
</Rise>
</section>
<div className="fixed inset-x-0 bottom-0 z-50 flex border-t border-[var(--rule)] bg-[var(--ink)]/95 backdrop-blur-md sm:hidden">
<a href={wa} target="_blank" rel="noopener noreferrer" className="press caps flex-1 py-4 text-center text-[13px]">
WhatsApp
</a>
<a href="#contacto" className="press caps flex-1 bg-[var(--paper)] py-4 text-center text-[13px] text-[var(--ink)]">
Hablemos
</a>
</div>
<footer className="micro flex items-center justify-between border-t border-[var(--rule)] px-5 py-6 sm:px-8">
<span>®2026</span>
<span>Eventos privados</span>
<span>{BARTENDER_PROFILE.location}</span>
</footer>
</div>
);
}

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

@@ -0,0 +1,459 @@
@import "tailwindcss";
:root {
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
}
@layer base {
html {
scroll-behavior: smooth;
}
}
/* Emil Kowalski precision motion utilities */
.ease-emil {
transition-timing-function: var(--ease-out);
}
.transition-emil {
transition-property: transform, opacity, background-color, border-color, color;
transition-duration: 200ms;
transition-timing-function: var(--ease-out);
}
.btn-press {
transition: transform 140ms var(--ease-out);
}
.btn-press:active {
transform: scale(0.97);
}
@keyframes fadeUpIn {
from {
opacity: 0;
transform: translateY(12px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.animate-fade-up {
animation: fadeUpIn 280ms var(--ease-out) forwards;
}
@keyframes pulseGlow {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.6;
transform: scale(1.15);
}
}
.animate-pulse-glow {
animation: pulseGlow 2s var(--ease-in-out) infinite;
}
@media (prefers-reduced-motion: reduce) {
*, ::before, ::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* Custom subtle scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(150, 150, 150, 0.3);
border-radius: 9999px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(150, 150, 150, 0.5);
}
/* ── Concept 01 · Barra privada John Caiza ────────────────────────────
Scoped to .c1 so the other three concepts keep their current styling. */
.c1 {
--ground: #dcdfdb;
--paper: #eff0ed;
--ink: #14171a;
--bottle: #2f4a3c;
--chartreuse: #c3ce2b;
--dim: #6e736d;
--rule: rgba(47, 74, 60, 0.22);
background: var(--ground);
color: var(--ink);
font-family: var(--font-text), Georgia, serif;
}
.c1 .display {
font-family: var(--font-display), "Helvetica Neue", sans-serif;
font-weight: 800;
letter-spacing: -0.032em;
line-height: 0.92;
}
/* Tabular figures replace the usual monospace label tic */
.c1 .figures {
font-family: var(--font-display), sans-serif;
font-variant-numeric: tabular-nums;
font-feature-settings: "tnum" 1;
}
/* Dashed architectural grid with crosshairs at the intersections */
.c1 .grid-field {
background-image:
repeating-linear-gradient(to right, var(--rule) 0 1px, transparent 1px 8px),
repeating-linear-gradient(to bottom, var(--rule) 0 1px, transparent 1px 8px);
background-size: 100% 1px, 1px 100%;
background-repeat: no-repeat;
}
.c1 .crosshair::before,
.c1 .crosshair::after {
content: "";
position: absolute;
background: var(--bottle);
opacity: 0.55;
}
.c1 .crosshair::before {
width: 9px;
height: 1px;
left: -4px;
top: 0;
}
.c1 .crosshair::after {
width: 1px;
height: 9px;
left: 0;
top: -4px;
}
/* Menu-style dotted leader: ingredient ······ 60 ml */
.c1 .leader {
display: flex;
align-items: baseline;
gap: 0.5rem;
}
.c1 .leader > span:first-child { flex: 0 1 auto; }
.c1 .leader > i {
flex: 1 1 auto;
border-bottom: 1px dotted var(--rule);
transform: scaleX(var(--draw, 1));
transform-origin: left;
transition: transform 620ms cubic-bezier(0.23, 1, 0.32, 1);
}
.c1 .leader > b { flex: 0 0 auto; font-weight: 500; }
.c1 :is(a, button, input, select, textarea):focus-visible {
outline: 2px solid var(--chartreuse);
outline-offset: 3px;
}
@media (prefers-reduced-motion: reduce) {
.c1 .leader > i { transition: none; transform: none; }
}
/* Grid + crosshairs laid over a photograph */
.c1 .grid-field--light {
background-image:
repeating-linear-gradient(to right, rgba(255, 255, 255, 0.45) 0 1px, transparent 1px 8px),
repeating-linear-gradient(to bottom, rgba(255, 255, 255, 0.45) 0 1px, transparent 1px 8px);
}
.c1 .crosshair--light::before,
.c1 .crosshair--light::after {
background: #fff;
opacity: 0.75;
}
/* Press feedback: any pressable element acknowledges the press. */
.c1 .press {
transition: transform 160ms var(--ease-out);
}
.c1 .press:active {
transform: scale(0.97);
}
/* Hover motion is gated: on touch, a tap fires a false hover. */
.c1 .zoom img {
transition: transform 300ms var(--ease-out);
}
@media (hover: hover) and (pointer: fine) {
.c1 .zoom:hover img {
transform: scale(1.04);
}
}
@media (prefers-reduced-motion: reduce) {
.c1 .press,
.c1 .zoom img {
transition: none;
}
.c1 .press:active,
.c1 .zoom:hover img {
transform: none;
}
}
/* ── Concept 02 · Clásicos ─────────────────────────────────────────────
Classic cocktails: bottles, ice and citrus. Scoped to .c2. */
.c2 {
--ground: #f1f2ee;
--card: #ffffff;
--ink: #15181b;
--dim: #6b6f6c;
--rule: rgba(21, 24, 27, 0.14);
/* Liquid swatches: the actual colour of what goes in the glass. */
--liq-gin: #dfe3dc;
--liq-campari: #c0321d;
--liq-vermut: #7c2f22;
--liq-bourbon: #b4711e;
--liq-lima: #7c8c33;
background: var(--ground);
color: var(--ink);
font-family: var(--font-display), "Helvetica Neue", sans-serif;
}
.c2 .display {
font-family: var(--font-lab), "Helvetica Neue", sans-serif;
font-weight: 600;
letter-spacing: -0.028em;
line-height: 1.04;
}
/* A grotesque has no true italic; emphasis comes from the weight axis instead. */
.c2 .thin {
font-weight: 300;
letter-spacing: -0.02em;
}
.c2 .figures {
font-variant-numeric: tabular-nums;
font-feature-settings: "tnum" 1;
}
/* Inline liquid dot, sized to the type it sits in */
.c2 .drop {
display: inline-block;
width: 0.26em;
height: 0.26em;
border-radius: 9999px;
vertical-align: 0.06em;
margin-inline: 0.12em;
background: var(--dot, var(--liq-campari));
}
/* Marker highlight behind a phrase */
/* Band sized in em against the font, not as a share of the line box: a
percentage stop lands under the glyphs when line-height is tight. */
.c2 .marker {
background-image: linear-gradient(var(--liq-gin), var(--liq-gin));
background-repeat: no-repeat;
background-size: 100% 0.36em;
background-position: 0 0.60em;
padding-inline: 0.08em;
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
}
/* Emil's image reveal: wipe up from the bottom edge, once, on entry. */
.c2 .wipe {
clip-path: inset(0 0 var(--wipe, 100%) 0);
transition: clip-path 900ms var(--ease-out);
}
.c2 .press {
transition: transform 160ms var(--ease-out);
}
.c2 .press:active {
transform: scale(0.97);
}
.c2 :is(a, button, input, select, textarea):focus-visible {
outline: 2px solid var(--liq-campari);
outline-offset: 3px;
}
@media (prefers-reduced-motion: reduce) {
.c2 .wipe { transition: none; clip-path: none; }
.c2 .press { transition: none; }
.c2 .press:active { transform: none; }
}
/* Sticky header would otherwise clip the heading of any anchor target. */
.c2 section[id] {
scroll-margin-top: 5.5rem;
}
/* Browsers give <fieldset> min-inline-size: min-content, which stops it
shrinking and lets scrollable rows push the page sideways. */
.c2 fieldset {
min-inline-size: 0;
}
/* Short laptop screens get the same trim phones get, so the selector still
fits on one screen instead of clipping its call to action. */
@media (min-width: 640px) and (max-height: 820px) {
.c2 [data-dense] {
display: none !important;
}
}
/* On phones the pill rows scroll sideways; fade the trailing edge so it reads
as "there is more" rather than as clipped content. */
@media (max-width: 639px) {
.c2 .pillrow {
-webkit-mask-image: linear-gradient(to right, #000 86%, transparent 100%);
mask-image: linear-gradient(to right, #000 86%, transparent 100%);
}
.c2 .pillrow::-webkit-scrollbar {
display: none;
}
}
/* ── Concept 03 · Atelier ──────────────────────────────────────────────
Rams: as little design as possible. No accent colour at all — the
photographs carry every bit of colour on the page. Scoped to .c3. */
.c3 {
--paper: #ffffff;
--ink: #0b0b0b;
--dim: #767676;
--rule: rgba(11, 11, 11, 0.14);
background: var(--paper);
color: var(--ink);
font-family: var(--font-atelier), "Helvetica Neue", sans-serif;
font-feature-settings: "tnum" 1;
}
.c3 .tight {
letter-spacing: -0.035em;
line-height: 0.94;
font-weight: 500;
}
.c3 .micro {
font-size: 12px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--dim);
}
.c3 .press {
transition: transform 160ms var(--ease-out);
}
.c3 .press:active {
transform: scale(0.97);
}
.c3 :is(a, button):focus-visible {
outline: 2px solid var(--ink);
outline-offset: 3px;
}
@media (prefers-reduced-motion: reduce) {
.c3 .press { transition: none; }
.c3 .press:active { transform: none; }
}
/* ── Concept 04 · Carrusel ─────────────────────────────────────────────
A colour wash over photography, heavy condensed type over the top, and a
carousel that advances on its own. Scoped to .c4. */
.c4 {
--ink: #0d0c0c;
--paper: #f6f4f0;
--dim: rgba(246, 244, 240, 0.72);
--rule: rgba(246, 244, 240, 0.22);
background: var(--ink);
color: var(--paper);
font-family: var(--font-display), "Helvetica Neue", sans-serif;
}
.c4 .brand {
font-family: var(--font-brand), "Arial Narrow", sans-serif;
font-weight: 400;
/* 0.84 clipped the tildes off á/é/ó, which in Spanish misspells the word. */
line-height: 0.9;
letter-spacing: -0.005em;
text-transform: uppercase;
}
.c4 .caps {
text-transform: uppercase;
letter-spacing: 0.02em;
font-weight: 500;
}
.c4 .micro {
font-size: 12px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--dim);
}
/* The indicator fills over the life of a slide. */
.c4 .fill {
transform-origin: left;
animation: c4-fill var(--slide, 6s) linear forwards;
}
@keyframes c4-fill {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
.c4 .press { transition: transform 160ms var(--ease-out); }
.c4 .press:active { transform: scale(0.97); }
.c4 :is(a, button):focus-visible {
outline: 2px solid var(--paper);
outline-offset: 3px;
}
@media (prefers-reduced-motion: reduce) {
.c4 .fill { animation: none; transform: scaleX(1); }
.c4 .press { transition: none; }
.c4 .press:active { transform: none; }
}
/* Ticker. Constant motion, so linear timing and no easing. */
.c4 .ticker {
display: flex;
width: max-content;
animation: c4-scroll 38s linear infinite;
}
@keyframes c4-scroll {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
.c4 .press { transition: transform 160ms var(--ease-out); }
.c4 .press:active { transform: scale(0.97); }
.c4 :is(a, button, input, textarea):focus-visible {
outline: 2px solid var(--blood);
outline-offset: 3px;
}
@media (prefers-reduced-motion: reduce) {
.c4 .ticker { animation: none; }
.c4 .press { transition: none; }
.c4 .press:active { transform: none; }
}

58
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,58 @@
import type { Metadata } from "next";
import { Anton, Archivo, Bricolage_Grotesque, Newsreader, Schibsted_Grotesk } from "next/font/google";
import "./globals.css";
const archivo = Archivo({
subsets: ["latin"],
display: "swap",
variable: "--font-display",
});
const bricolage = Bricolage_Grotesque({
subsets: ["latin"],
display: "swap",
variable: "--font-lab",
});
const anton = Anton({
subsets: ["latin"],
weight: "400",
display: "swap",
variable: "--font-brand",
});
const schibsted = Schibsted_Grotesk({
subsets: ["latin"],
display: "swap",
variable: "--font-atelier",
});
const newsreader = Newsreader({
subsets: ["latin"],
display: "swap",
style: ["normal", "italic"],
variable: "--font-text",
});
export const metadata: Metadata = {
title: "John Caiza — Mixología de Autor & Barra Privada",
description: "Suite de 4 conceptos vanguardistas de portafolio y contratación de servicios de coctelería de autor por John Caiza.",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="es"
data-scroll-behavior="smooth"
className={`${archivo.variable} ${bricolage.variable} ${schibsted.variable} ${anton.variable} ${newsreader.variable} h-full antialiased scroll-smooth selection:bg-neutral-900 selection:text-white`}
>
<body className="min-h-full flex flex-col bg-[#f8f9fa] text-[#0a0a0a] font-sans">
{children}
</body>
</html>
);
}

242
src/app/page.tsx Normal file
View File

@@ -0,0 +1,242 @@
import Link from "next/link";
import { ArrowRight, Flame, Wine, Beaker, Sparkles, Sun, Code2, Compass, Smartphone, ShieldCheck, Zap } from "lucide-react";
export default function HubPage() {
const concepts = [
{
id: "concept-1",
slug: "/concept-1",
title: "Barra Privada",
subtitle: "Editorial Cinematográfico & Ficha de Barra",
tags: ["Foto a sangre", "Parallax", "Ficha de barra"],
accentColor: "border-neutral-900",
accentBg: "bg-neutral-900 text-white",
icon: Flame,
summary:
"Portada fotográfica a pantalla completa con parallax y rejilla punteada con crucetas. Los formatos de barra se leen como una lista de precios en dólares, y la carta está escrita como se escribe de verdad: ingrediente, líder punteado y medida. Cierra con la noche hora a hora, de las 16:00 a las 02:00.",
params: [
{ label: "Paleta & Tipografía", desc: "Gris vidrio esmerilado, verde botella, y chartreuse sólo en el estado en vivo. Archivo y Newsreader, con cifras tabulares en lugar de monoespaciada." },
{ label: "Recurso Propio", desc: "El líder punteado de una carta de bar, que une cada ingrediente con su medida." },
{ label: "Movimiento", desc: "Una sola secuencia orquestada al cargar y un parallax ligado al scroll. El resto sólo responde a tus clics." },
{ label: "Pendiente", desc: "Es la única de las cuatro que aún usa el formulario compartido, el del acuse de recibo falso." },
],
},
{
id: "concept-2",
slug: "/concept-2",
title: "Clásicos de Siempre",
subtitle: "Coctelería Clásica & Selector de Perfil",
tags: ["Clásicos", "Porcelana", "Selector"],
accentColor: "border-neutral-900",
accentBg: "bg-neutral-900 text-white",
icon: Wine,
summary:
"Doce clásicos con receta completa —Negroni, Daiquiri, Dry Martini, Boulevardier, Sidecar, Paper Plane y seis más— y un selector que pregunta cómo te gusta el trago para recomendarte el que te toca, con sus tres finalistas.",
params: [
{ label: "Paleta & Tipografía", desc: "Porcelana cálida y Bricolage Grotesque. Cada ingrediente tiene su color y se usa como puntuación dentro del texto." },
{ label: "Herramienta Dinámica", desc: "Removido o agitado, con qué base, cuánto amargor y cuánto dulzor. Puntúa por cercanía sobre el canon entero, nunca se queda sin respuesta." },
{ label: "Formulario", desc: "Campos redondeados y etiquetas en frase normal. Compone el mensaje y abre WhatsApp, sin falsos acuses de recibo." },
{ label: "Cabe en Pantalla", desc: "El selector entra entero en una pantalla, sin scroll ni recortes, también en móvil y en portátiles de 720 px de alto." },
],
},
{
id: "concept-3",
slug: "/concept-3",
title: "Atelier",
subtitle: "Índice de Noches & Partición 50/50",
tags: ["Rams", "Índice", "Sin acento"],
accentColor: "border-neutral-900",
accentBg: "bg-neutral-900 text-white",
icon: Sparkles,
summary:
"Cinco noches reales de Nueva York —Tribeca, West Village, Dumbo, Hudson Valley y Upper East Side— una cada vez, en partición 50/50 a sangre: tipografía a la izquierda, fotografía a la derecha.",
params: [
{ label: "Paleta & Tipografía", desc: "Blanco puro, negro y un gris. Sin color de acento: las fotografías son lo único que aporta color. Schibsted Grotesk." },
{ label: "Formulario", desc: "Una frase con huecos que rellenas: sin etiquetas visibles ni cajas, sólo subrayados que engordan al enfocar." },
{ label: "Decisión de Diseño", desc: "La referencia secuestra la rueda del ratón. Aquí la navegación es explícita —flechas del teclado, botones y marcadores— para no romper el teclado ni los lectores de pantalla." },
{ label: "Fotografía", desc: "Cinco imágenes propias descargadas de Pexels, una por noche, elegidas para que cada una diga lo que dice su texto." },
],
},
{
id: "concept-4",
slug: "/concept-4",
title: "Carrusel",
subtitle: "Portada Automática & Formulario por Pasos",
tags: ["Carrusel", "Lavado de color", "Anton"],
accentColor: "border-neutral-900",
accentBg: "bg-neutral-900 text-white",
icon: Zap,
summary:
"Portada a pantalla completa con tres diapositivas que se avanzan solas cada seis segundos —barra privada, clásicos de siempre y Nueva York— cada una con su fotografía y su lavado de color propio.",
params: [
{ label: "Paleta & Tipografía", desc: "Fondo casi negro con lavados ámbar, granate y azul noche. Anton condensada a pantalla completa." },
{ label: "Movimiento", desc: "Tres capas a distinta duración: fundido de 1,1 s, escala continua de 7 s en lineal y titular escalonado cada 80 ms. Los indicadores hacen de reloj de la diapositiva." },
{ label: "Accesibilidad", desc: "El carrusel se puede pausar, y con movimiento reducido no arranca: ni autoavance, ni escala, ni máscaras." },
{ label: "Formulario", desc: "Una pregunta cada vez, con los mismos indicadores que la portada y un resumen antes de enviar." },
],
},
];
return (
<main
className="min-h-screen flex flex-col bg-[#f8f9fa] text-[#0a0a0a] font-sans relative selection:bg-[#0a0a0a] selection:text-white"
style={{
backgroundImage:
"linear-gradient(#e5e5e5 1px, transparent 1px), linear-gradient(90deg, #e5e5e5 1px, transparent 1px)",
backgroundSize: "40px 40px",
}}
>
{/* Top Header */}
<header className="w-full py-4 px-4 sm:px-6 lg:px-12 border-b border-[#e5e5e5] bg-white flex flex-col sm:flex-row justify-between items-center gap-3 sm:gap-4 sticky top-0 z-50 shadow-sm">
<div className="flex items-center gap-2 text-xl sm:text-2xl font-black tracking-tight uppercase text-[#0a0a0a] text-center sm:text-left w-full sm:w-auto">
<span>JOHN CAIZA</span>
<span className="text-stone-400 font-mono text-xs sm:text-sm font-normal tracking-widest pl-1">
[BARRA PRIVADA]
</span>
</div>
<div className="text-[11px] sm:text-xs text-[#0a0a0a] font-mono uppercase tracking-widest px-3 sm:px-4 py-2 bg-[#f8f9fa] border border-[#e5e5e5] flex flex-wrap justify-center items-center gap-2 sm:gap-3 w-full sm:w-auto">
<span>4 conceptos</span>
<span className="text-gray-400 hidden sm:inline">|</span>
<span>Nueva York</span>
<span className="text-gray-400 hidden sm:inline">|</span>
<span>Coctelería clásica</span>
</div>
</header>
{/* Main Container */}
<div className="w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-12 py-12 sm:py-16 md:py-20 relative z-10 flex-grow">
{/* Hero Section */}
<div className="mb-12 sm:mb-16 md:mb-20 border-l-4 border-[#0a0a0a] pl-4 sm:pl-6">
<div className="flex items-center gap-2 font-mono text-xs uppercase tracking-widest text-neutral-600 mb-2">
<Compass className="w-4 h-4 animate-spin" style={{ animationDuration: "12s" }} />
<span>Exploración de Arquitectura de Marca</span>
</div>
<h1 className="text-4xl sm:text-5xl md:text-6xl lg:text-[4.5rem] font-black tracking-tighter mb-4 leading-none text-[#0a0a0a] uppercase break-words">
Conceptos <br className="hidden sm:block" /> Visuales
</h1>
<p className="text-xs sm:text-sm md:text-base text-gray-600 font-mono tracking-wide max-w-3xl uppercase leading-relaxed">
&gt; 4 direcciones visuales para la barra privada de{" "}
<strong className="text-[#0a0a0a]">John Caiza</strong>, eventos privados en Nueva York. <br className="hidden sm:block" />
&gt; Mismo negocio y mismo contenido en las cuatro; cambian la tipografía, la paleta,
la estructura y el movimiento. <br className="hidden sm:block" />
&gt; Cada una lleva su propio <strong className="text-[#0a0a0a]">formulario</strong>, y todos
abren <strong className="text-[#0a0a0a]">WhatsApp</strong> con el mensaje ya escrito.
</p>
</div>
{/* 2x2 Concept Grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 lg:gap-8">
{concepts.map((concept, idx) => {
const Icon = concept.icon;
return (
<div
key={concept.id}
className="group relative bg-white border border-[#e5e5e5] p-5 sm:p-6 md:p-8 hover:border-[#0a0a0a] transition-all duration-300 flex flex-col h-full shadow-sm hover:shadow-xl"
>
{/* Corner Accent Badge */}
<div className="absolute top-0 right-0 w-0 h-0 border-t-[14px] sm:border-t-[18px] border-r-[14px] sm:border-r-[18px] border-t-transparent border-r-[#0a0a0a] hidden group-hover:block transition-all"></div>
<div className="relative z-10 flex flex-col h-full">
{/* Card Header */}
<div className="flex flex-col sm:flex-row justify-between items-start gap-3 sm:gap-0 mb-4">
<div>
<div className="flex items-center gap-2 mb-1">
<span className="text-[10px] font-mono font-bold text-gray-400">
[0{idx + 1}]
</span>
<Icon className="w-4 h-4 text-[#0a0a0a]" />
<span className="text-[11px] font-mono uppercase tracking-widest text-gray-500">
{concept.subtitle}
</span>
</div>
<h2 className="text-2xl sm:text-3xl font-black text-[#0a0a0a] uppercase tracking-tight">
{concept.title}
</h2>
</div>
{/* Tags */}
<div className="flex gap-1.5 flex-wrap justify-start sm:justify-end">
{concept.tags.map((tag, i) => (
<span
key={i}
className="bg-[#f8f9fa] border border-[#e5e5e5] text-[#0a0a0a] text-[9px] sm:text-[10px] font-mono font-bold px-2 py-1 uppercase tracking-widest shrink-0"
>
{tag}
</span>
))}
</div>
</div>
{/* Summary */}
<p className="text-gray-600 leading-relaxed mb-6 text-xs sm:text-sm">
{concept.summary}
</p>
{/* Stack Params Box */}
<div className="mb-6 sm:mb-8 bg-[#f8f9fa] border border-[#e5e5e5] p-4 sm:p-5 flex-grow">
<div className="flex items-center gap-2 mb-3 border-b border-[#e5e5e5] pb-2">
<Code2 className="w-3.5 h-3.5 text-[#0a0a0a]" />
<h3 className="text-[10px] sm:text-[11px] font-mono font-bold text-[#0a0a0a] uppercase tracking-widest">
Stack_Params &amp; Arquitectura
</h3>
</div>
<ul className="space-y-2.5 font-mono">
{concept.params.map((p, i) => (
<li key={i} className="text-[10px] sm:text-xs leading-relaxed flex flex-col sm:flex-row gap-1 sm:gap-2 items-start">
<span className="inline-block px-1.5 py-0.5 bg-white border border-[#e5e5e5] text-neutral-800 text-[8px] sm:text-[9px] uppercase tracking-widest shrink-0 mt-0.5 w-max">
[{p.label}]
</span>
<div className="flex-1 text-gray-600">
{p.desc}
</div>
</li>
))}
</ul>
</div>
{/* Execution CTA Button */}
<Link
href={concept.slug}
className="inline-flex items-center justify-center w-full bg-[#0a0a0a] text-white font-bold text-xs sm:text-sm px-4 sm:px-6 py-3.5 sm:py-4 uppercase tracking-wider border border-[#0a0a0a] hover:bg-white hover:text-[#0a0a0a] active:scale-[0.98] transition-all duration-200 group/btn mt-auto"
>
<span>Ejecutar Previsualización</span>
<ArrowRight className="w-4 h-4 sm:w-5 sm:h-5 ml-2 sm:ml-3 group-hover/btn:translate-x-1.5 transition-transform shrink-0" />
</Link>
</div>
</div>
);
})}
</div>
{/* Technical Features Footer Note */}
<div className="mt-12 p-6 bg-white border border-[#e5e5e5] flex flex-col md:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="p-2.5 bg-neutral-100 border border-neutral-200">
<Smartphone className="w-5 h-5 text-[#0a0a0a]" />
</div>
<div>
<div className="text-xs font-mono font-bold uppercase tracking-wider">
Cuatro formularios distintos, un mismo canal
</div>
<div className="text-xs text-gray-500">
Rejilla de campos, píldoras con deslizadores, frase con huecos y preguntas de una en una. Ninguno inventa un acuse de recibo: todos abren WhatsApp con el mensaje escrito.
</div>
</div>
</div>
<div className="flex items-center gap-3 text-xs font-mono text-gray-600">
<span>Fotografía vía Pexels</span>
<span className="text-gray-300">|</span>
<span>4 diseños independientes</span>
</div>
</div>
</div>
{/* Footer */}
<footer className="w-full py-6 px-4 text-center border-t border-[#e5e5e5] bg-white relative z-10 mt-auto">
<p className="text-[#0a0a0a] text-[9px] sm:text-[11px] font-mono font-bold tracking-widest uppercase">
JOHN CAIZA © 2026 // Barra privada y coctelería clásica // Nueva York
</p>
</footer>
</main>
);
}

View File

@@ -0,0 +1,342 @@
"use client";
import { useState } from "react";
import { Send, MessageSquare, CheckCircle2, Calendar, Users, MapPin, Sparkles } from "lucide-react";
import confetti from "canvas-confetti";
import { BARTENDER_PROFILE, PACKAGES } from "@/data/portfolio-data";
interface BookingFormProps {
theme?: "kalkbrenner" | "speakeasy" | "alchemist" | "editorial" | "tropical";
defaultPackage?: string;
defaultDate?: string;
}
export function BookingForm({ theme = "kalkbrenner", defaultPackage, defaultDate }: BookingFormProps) {
const [formData, setFormData] = useState({
name: "",
phone: "",
email: "",
eventType: "Boda / Banquete de Lujo",
eventDate: defaultDate || "",
guestCount: "45",
location: "",
selectedPackage: defaultPackage || PACKAGES[0].title,
customNotes: "",
});
const [isSubmitted, setIsSubmitted] = useState(false);
// Harmonized theme palettes: zero generic clashing buttons
const themeStyles = {
kalkbrenner: {
card: "bg-[#0a0a0a] border border-white/15 text-[#ededed] shadow-2xl rounded-none",
input: "bg-[#121212] border border-white/15 text-white font-mono text-base sm:text-xs focus:border-white focus:ring-0 rounded-none placeholder:text-neutral-600 transition-colors py-3 sm:py-3.5",
label: "text-neutral-400 font-mono tracking-[0.25em] text-[10px] uppercase font-bold",
heading: "font-black tracking-tight text-white uppercase text-2xl sm:text-3xl",
subheading: "font-mono text-xs text-neutral-400 uppercase tracking-widest",
buttonPrimary: "bg-white hover:bg-neutral-200 text-black font-mono font-bold tracking-[0.2em] uppercase text-xs rounded-none active:scale-[0.98] transition-all border border-white",
buttonWa: "bg-transparent hover:bg-neutral-900 text-white font-mono font-bold tracking-[0.2em] uppercase text-xs rounded-none active:scale-[0.98] transition-all border border-white/30 hover:border-white",
badge: "border-white/20 bg-white/5 text-white font-mono",
packageSelected: "border-white bg-white text-black font-bold",
packageUnselected: "border-white/15 text-neutral-400 hover:border-white/40 hover:text-white bg-[#121212]",
accent: "text-white",
rangeAccent: "accent-white",
},
speakeasy: {
card: "bg-[#0d0905]/95 border border-[#f59e0b]/20 text-[#f3f4f6] shadow-[0_20px_60px_rgba(0,0,0,0.8)] rounded-xl",
input: "bg-[#060402] border border-[#f59e0b]/20 text-[#fef3c7] focus:border-[#f59e0b] focus:ring-0 rounded-xl placeholder:text-stone-600",
label: "text-[#d97706] font-mono tracking-widest text-[10px] uppercase font-bold",
heading: "font-serif text-amber-100 tracking-tight text-2xl sm:text-3xl font-bold",
subheading: "font-mono text-xs text-amber-500/70 uppercase tracking-widest",
buttonPrimary: "bg-[#f59e0b] hover:bg-[#d97706] text-[#0f0a04] font-serif font-bold tracking-wider active:scale-[0.98] transition-all rounded-xl",
buttonWa: "bg-[#f59e0b]/15 hover:bg-[#f59e0b] text-[#f59e0b] hover:text-black border border-[#f59e0b]/40 font-serif font-bold active:scale-[0.98] transition-all rounded-xl",
badge: "border-amber-500/20 bg-amber-500/10 text-amber-400 font-mono",
packageSelected: "border-amber-400 bg-amber-500/20 text-white font-bold",
packageUnselected: "border-amber-950/40 text-stone-400 hover:border-amber-700/50 bg-[#140e09]/50",
accent: "text-[#f59e0b]",
rangeAccent: "accent-amber-500",
},
alchemist: {
card: "bg-[#040813]/95 border border-[#00f0ff]/30 text-cyan-50 font-mono shadow-[0_0_50px_rgba(0,240,255,0.08)] rounded-2xl",
input: "bg-[#01040a] border border-[#00f0ff]/30 text-[#00f0ff] focus:border-[#a3e635] focus:ring-0 font-mono rounded-xl placeholder:text-cyan-900",
label: "text-[#00f0ff] uppercase tracking-widest text-[10px] font-mono font-bold",
heading: "font-mono uppercase tracking-tight text-white font-bold text-2xl sm:text-3xl",
subheading: "font-mono text-xs text-cyan-400/70 uppercase tracking-widest",
buttonPrimary: "bg-[#00f0ff] hover:bg-[#00c8d6] text-black font-mono font-bold tracking-widest uppercase active:scale-[0.98] transition-all rounded-xl shadow-[0_0_25px_rgba(0,240,255,0.3)]",
buttonWa: "bg-[#a3e635] hover:bg-[#8ec92a] text-black font-mono font-bold tracking-widest uppercase active:scale-[0.98] transition-all rounded-xl shadow-[0_0_25px_rgba(163,230,53,0.3)]",
badge: "border-cyan-500/20 bg-cyan-500/10 text-cyan-400 font-mono",
packageSelected: "border-[#00f0ff] bg-cyan-500/20 text-white font-bold",
packageUnselected: "border-cyan-500/20 text-cyan-300/70 hover:border-cyan-500/50 bg-[#050b18]",
accent: "text-[#00f0ff]",
rangeAccent: "accent-cyan-400",
},
editorial: {
card: "bg-[#ffffff] border border-[#e5e5e5] text-[#0a0a0a] shadow-[0_10px_30px_rgba(0,0,0,0.04)] rounded-none",
input: "bg-[#faf9f6] border border-[#0a0a0a]/15 text-[#0a0a0a] focus:border-[#0a0a0a] focus:ring-0 rounded-none placeholder:text-neutral-400",
label: "text-[#0a0a0a] uppercase tracking-widest text-[10px] font-mono font-bold",
heading: "font-serif italic font-normal text-black tracking-tight text-2xl sm:text-3xl",
subheading: "font-mono text-xs text-[#777777] uppercase tracking-widest",
buttonPrimary: "bg-[#0a0a0a] hover:bg-neutral-800 text-white font-sans uppercase tracking-widest text-[11px] font-bold rounded-none active:scale-[0.98] transition-all",
buttonWa: "bg-transparent hover:bg-[#0a0a0a] text-black hover:text-white border border-black font-sans uppercase tracking-widest text-[11px] font-bold rounded-none active:scale-[0.98] transition-all",
badge: "border-neutral-300 bg-neutral-100 text-neutral-700 font-mono",
packageSelected: "border-black bg-black text-white font-bold",
packageUnselected: "border-[#e5e5e5] text-neutral-500 hover:border-black bg-[#faf9f6]",
accent: "text-black",
rangeAccent: "accent-neutral-900",
},
tropical: {
card: "bg-[#120624]/95 border border-pink-500/30 text-white shadow-[0_20px_60px_rgba(255,0,122,0.2)] rounded-3xl",
input: "bg-[#090214] border border-pink-500/30 text-pink-100 focus:border-[#00f2fe] focus:ring-0 rounded-2xl placeholder:text-pink-900",
label: "text-pink-400 uppercase tracking-widest text-[10px] font-mono font-bold",
heading: "font-sans uppercase font-black text-transparent bg-clip-text bg-gradient-to-r from-[#ff007a] via-[#ff6a00] to-[#00f2fe] text-2xl sm:text-3xl",
subheading: "font-mono text-xs text-pink-300/70 uppercase tracking-widest",
buttonPrimary: "bg-gradient-to-r from-[#ff007a] to-[#ff5e3a] hover:opacity-90 text-white font-bold uppercase tracking-wider rounded-xl active:scale-[0.98] transition-all shadow-[0_0_30px_rgba(255,0,122,0.3)]",
buttonWa: "bg-black/60 hover:bg-white/10 text-[#00f2fe] border border-[#00f2fe]/40 font-bold uppercase tracking-wider rounded-xl active:scale-[0.98] transition-all",
badge: "border-pink-500/30 bg-pink-500/10 text-pink-300 font-mono",
packageSelected: "border-[#ff007a] bg-pink-500/25 text-white font-bold",
packageUnselected: "border-white/10 text-stone-300 hover:border-white/30 bg-[#170a2d]",
accent: "text-[#00f2fe]",
rangeAccent: "accent-pink-500",
},
}[theme];
const buildWhatsAppMessage = () => {
return encodeURIComponent(
`🍸 *SOLICITUD DE COCTELERÍA - JOHN CAIZA*\n` +
`------------------------------------\n` +
`👤 *Cliente:* ${formData.name || "No especificado"}\n` +
`📱 *Teléfono:* ${formData.phone || "No especificado"}\n` +
`🎉 *Tipo de Evento:* ${formData.eventType}\n` +
`📅 *Fecha Prevista:* ${formData.eventDate || "A convenir"}\n` +
`👥 *Invitados:* ${formData.guestCount} personas\n` +
`📍 *Ubicación:* ${formData.location || "Por definir"}\n` +
`📦 *Formato:* ${formData.selectedPackage}\n` +
`📝 *Notas:* ${formData.customNotes || "Sin notas adicionales"}\n` +
`------------------------------------\n` +
`_Enviado desde el portafolio oficial de John Caiza (${theme.toUpperCase()})_`
);
};
const handleSendWhatsApp = (e: React.FormEvent) => {
e.preventDefault();
confetti({
particleCount: 50,
spread: 60,
origin: { y: 0.7 },
colors: ["#ffffff", "#000000", "#ededed"],
});
const url = `https://wa.me/${BARTENDER_PROFILE.whatsapp.replace(/[^0-9]/g, "")}?text=${buildWhatsAppMessage()}`;
window.open(url, "_blank");
};
const handleFormSubmit = (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitted(true);
confetti({
particleCount: 80,
spread: 80,
origin: { y: 0.6 },
});
};
return (
<div className={`p-6 sm:p-10 md:p-12 transition-all duration-300 ${themeStyles.card}`}>
{isSubmitted ? (
<div className="text-center py-12 space-y-4 font-mono">
<div className={`w-14 h-14 mx-auto rounded-none border border-current/20 flex items-center justify-center ${themeStyles.accent}`}>
<CheckCircle2 className="w-8 h-8" />
</div>
<h3 className={`text-2xl sm:text-3xl font-bold ${themeStyles.heading}`}>
Petición Registrada // John Caiza
</h3>
<p className="text-xs sm:text-sm opacity-80 max-w-md mx-auto leading-relaxed">
Estimado/a <strong className={themeStyles.accent}>{formData.name}</strong>: Tu solicitud para el evento de {formData.guestCount} invitados ha ingresado en la agenda de John Caiza.
</p>
<div className="pt-4 flex flex-col sm:flex-row justify-center gap-3">
<button
type="button"
onClick={handleSendWhatsApp}
className={`px-6 py-3.5 flex items-center justify-center gap-2 text-xs font-bold ${themeStyles.buttonWa}`}
>
<MessageSquare className="w-4 h-4" />
Despachar a WhatsApp
</button>
<button
type="button"
onClick={() => setIsSubmitted(false)}
className="px-5 py-3 border border-current/20 text-xs font-mono uppercase tracking-widest hover:bg-current/10"
>
Editar Ficha
</button>
</div>
</div>
) : (
<form onSubmit={handleFormSubmit} className="space-y-6">
<div className="border-b border-white/10 pb-5 mb-6 flex flex-col sm:flex-row sm:items-end justify-between gap-4">
<div>
<div className="flex items-center gap-2 text-[10px] font-mono uppercase tracking-[0.25em] text-neutral-400 mb-1.5">
<Sparkles className="w-3.5 h-3.5" />
Protocolo de Contratación // Temporada 2026
</div>
<h3 className={themeStyles.heading}>
Diseña tu Experiencia
</h3>
</div>
<div className={`text-[11px] font-mono px-3 py-1 border flex items-center gap-2 w-fit ${themeStyles.badge}`}>
<span className="w-1.5 h-1.5 bg-emerald-400 rounded-full" />
<span>Agenda Abierta</span>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6">
<div>
<label className={`block mb-1.5 ${themeStyles.label}`}>Nombre y Apellidos *</label>
<input
type="text"
required
placeholder="Ej. Sofía Benítez"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className={`w-full px-4 py-3.5 border outline-none ${themeStyles.input}`}
/>
</div>
<div>
<label className={`block mb-1.5 ${themeStyles.label}`}>Teléfono / WhatsApp *</label>
<input
type="tel"
required
placeholder="+34 600 000 000"
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
className={`w-full px-4 py-3.5 border outline-none ${themeStyles.input}`}
/>
</div>
<div>
<label className={`block mb-1.5 ${themeStyles.label}`}>Correo Electrónico</label>
<input
type="email"
placeholder="sofia@ejemplo.com"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className={`w-full px-4 py-3.5 border outline-none ${themeStyles.input}`}
/>
</div>
<div>
<label className={`block mb-1.5 ${themeStyles.label}`}>Tipo de Evento</label>
<select
value={formData.eventType}
onChange={(e) => setFormData({ ...formData, eventType: e.target.value })}
className={`w-full px-4 py-3.5 border outline-none ${themeStyles.input}`}
>
<option value="Boda / Banquete de Lujo">Boda / Banquete de Lujo</option>
<option value="Fiesta Privada en Villa">Fiesta Privada en Villa</option>
<option value="Celebración Exclusiva / Cumpleaños">Celebración Exclusiva / Cumpleaños</option>
<option value="Evento Corporativo / Presentación">Evento Corporativo / Presentación</option>
<option value="Masterclass Sensorial Privada">Masterclass Sensorial Privada</option>
</select>
</div>
<div>
<label className={`block mb-1.5 flex items-center gap-1.5 ${themeStyles.label}`}>
<Calendar className="w-3.5 h-3.5" /> Fecha Prevista
</label>
<input
type="date"
value={formData.eventDate}
onChange={(e) => setFormData({ ...formData, eventDate: e.target.value })}
className={`w-full px-4 py-3.5 border outline-none ${themeStyles.input}`}
/>
</div>
<div>
<label className={`block mb-1.5 flex items-center justify-between ${themeStyles.label}`}>
<span className="flex items-center gap-1.5">
<Users className="w-3.5 h-3.5" /> Invitados
</span>
<span className="font-mono text-xs text-white font-bold">{formData.guestCount} PERSONAS</span>
</label>
<div className="pt-2">
<input
type="range"
min="10"
max="300"
step="5"
value={formData.guestCount}
onChange={(e) => setFormData({ ...formData, guestCount: e.target.value })}
className={`w-full cursor-pointer ${themeStyles.rangeAccent}`}
/>
</div>
</div>
<div className="sm:col-span-2">
<label className={`block mb-1.5 flex items-center gap-1.5 ${themeStyles.label}`}>
<MapPin className="w-3.5 h-3.5" /> Ubicación del Evento (Finca, Villa, Ciudad)
</label>
<input
type="text"
placeholder="Ej. Villa Can Trull, Ibiza / Masía Egara, Barcelona / La Finca, Madrid"
value={formData.location}
onChange={(e) => setFormData({ ...formData, location: e.target.value })}
className={`w-full px-4 py-3.5 border outline-none ${themeStyles.input}`}
/>
</div>
<div className="sm:col-span-2">
<label className={`block mb-2 ${themeStyles.label}`}>Seleccionar Formato de Barra</label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{PACKAGES.slice(0, 3).map((pkg) => (
<button
key={pkg.id}
type="button"
onClick={() => setFormData({ ...formData, selectedPackage: pkg.title })}
className={`p-3.5 text-left border transition-all active:scale-[0.98] ${
formData.selectedPackage === pkg.title
? themeStyles.packageSelected
: themeStyles.packageUnselected
}`}
>
<div className="text-xs">{pkg.title}</div>
<div className="opacity-70 text-[10px] mt-1 font-mono">{pkg.guests}</div>
</button>
))}
</div>
</div>
<div className="sm:col-span-2">
<label className={`block mb-1.5 ${themeStyles.label}`}>Notas &amp; Requerimientos Especiales</label>
<textarea
rows={3}
placeholder="Destilados preferidos, temática musical, cócteles de autor sin alcohol, requerimientos de cristalería..."
value={formData.customNotes}
onChange={(e) => setFormData({ ...formData, customNotes: e.target.value })}
className={`w-full px-4 py-3 border outline-none ${themeStyles.input}`}
></textarea>
</div>
</div>
<div className="pt-4 border-t border-white/10 flex flex-col sm:flex-row gap-3">
<button
type="submit"
className={`flex-1 py-4 px-6 flex items-center justify-center gap-2 transition-all ${themeStyles.buttonPrimary}`}
>
<Send className="w-4 h-4" />
<span>Registrar Petición de Servicio</span>
</button>
<button
type="button"
onClick={handleSendWhatsApp}
className={`py-4 px-6 flex items-center justify-center gap-2.5 transition-all ${themeStyles.buttonWa}`}
title="Abre WhatsApp con la ficha prellenada"
>
<MessageSquare className="w-4 h-4" />
<span>Cotizar Directo por WhatsApp</span>
</button>
</div>
</form>
)}
</div>
);
}

View File

@@ -0,0 +1,39 @@
import Link from "next/link";
import { ArrowLeft, Compass } from "lucide-react";
interface HubButtonProps {
currentConceptName?: string;
theme?: "dark" | "light" | "neon" | "amber";
}
export function HubButton({ currentConceptName, theme = "dark" }: HubButtonProps) {
const styles = {
dark: "bg-[#0a0a0a] text-white hover:bg-neutral-800 border-white/20",
light: "bg-white text-[#0a0a0a] hover:bg-neutral-100 border-[#0a0a0a]/20 shadow-xl",
neon: "bg-black text-[#00f0ff] hover:bg-neutral-900 border-[#00f0ff]/40 shadow-[0_0_20px_rgba(0,240,255,0.2)]",
amber: "bg-[#181109] text-[#f59e0b] hover:bg-[#251b10] border-[#f59e0b]/30 shadow-[0_0_20px_rgba(245,158,11,0.15)]",
}[theme];
return (
<aside aria-label="Navegación de conceptos">
<Link
href="/"
className={`hidden sm:flex fixed bottom-6 left-6 z-[999] items-center gap-2.5 px-4 py-3 shadow-2xl hover:scale-105 active:scale-[0.97] transition-all duration-200 border group ${styles}`}
title="Volver al Menú Principal de Conceptos"
>
<ArrowLeft className="w-4 h-4 group-hover:-translate-x-1 transition-transform" />
<div className="flex flex-col text-left">
<span className="text-[10px] sm:text-xs font-mono uppercase tracking-widest font-bold flex items-center gap-1.5">
<Compass className="w-3 h-3 animate-spin" style={{ animationDuration: '8s' }} />
Hub de Conceptos
</span>
{currentConceptName && (
<span className="text-[9px] opacity-70 font-mono hidden sm:inline">
Viendo: {currentConceptName}
</span>
)}
</div>
</Link>
</aside>
);
}

View File

@@ -0,0 +1,229 @@
"use client";
import { useState } from "react";
import Image from "next/image";
import { Heart, MessageCircle, ExternalLink, X, MapPin, CheckCircle } from "lucide-react";
import { BARTENDER_PROFILE, INSTAGRAM_POSTS, InstagramPost } from "@/data/portfolio-data";
function InstagramIcon({ className = "w-4 h-4" }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect width="20" height="20" x="2" y="2" rx="5" ry="5" />
<path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z" />
<line x1="17.5" x2="17.51" y1="6.5" y2="6.5" />
</svg>
);
}
interface InstagramFeedProps {
theme?: "speakeasy" | "alchemist" | "editorial" | "tropical";
title?: string;
}
export function InstagramFeed({ theme = "speakeasy", title = "Diario Visual & Creaciones en Instagram" }: InstagramFeedProps) {
const [activePost, setActivePost] = useState<InstagramPost | null>(null);
const themeStyles = {
speakeasy: {
card: "border-[#f59e0b]/20 bg-[#120c07]/80 hover:border-[#f59e0b]/50",
accent: "text-[#f59e0b]",
badge: "bg-[#f59e0b]/10 text-[#f59e0b] border-[#f59e0b]/30",
},
alchemist: {
card: "border-[#00f0ff]/20 bg-[#050912]/90 hover:border-[#00f0ff]/60 shadow-[0_0_20px_rgba(0,240,255,0.05)]",
accent: "text-[#00f0ff]",
badge: "bg-[#00f0ff]/10 text-[#00f0ff] border-[#00f0ff]/30 font-mono",
},
editorial: {
card: "border-[#e5e5e5] bg-white hover:border-[#0a0a0a]",
accent: "text-black",
badge: "bg-neutral-100 text-black border-neutral-200",
},
tropical: {
card: "border-[#ff007a]/30 bg-[#170929]/90 hover:border-[#00f2fe]/60 shadow-[0_0_25px_rgba(255,0,122,0.15)]",
accent: "text-[#ff007a]",
badge: "bg-gradient-to-r from-[#ff007a]/20 to-[#00f2fe]/20 text-white border-pink-500/30",
},
}[theme];
return (
<section className="py-12 sm:py-16">
{/* Instagram Header Banner */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6 pb-8 border-b border-white/10 mb-8">
<div className="flex items-center gap-4">
<div className="relative">
<div className="w-14 h-14 sm:w-16 sm:h-16 rounded-full p-[2px] bg-gradient-to-tr from-amber-500 via-rose-500 to-purple-600">
<div className="w-full h-full rounded-full overflow-hidden relative border-2 border-black">
<Image
src="/images/hero_bartender_1.jpg"
alt={BARTENDER_PROFILE.name}
fill
className="object-cover"
/>
</div>
</div>
<span className="absolute bottom-0 right-0 w-4 h-4 bg-emerald-500 border-2 border-black rounded-full" />
</div>
<div>
<div className="flex items-center gap-2">
<h4 className="font-bold text-base sm:text-lg text-white flex items-center gap-1.5">
{BARTENDER_PROFILE.instagram}
<CheckCircle className="w-4 h-4 text-sky-400 fill-sky-400/20" />
</h4>
<span className={`text-[10px] px-2 py-0.5 border rounded-full font-mono ${themeStyles.badge}`}>
Verificado
</span>
</div>
<p className="text-xs text-white/60 mt-0.5">
Coctelería de autor • 18.4K seguidores • Eventos de Lujo
</p>
</div>
</div>
<div className="flex items-center gap-3">
<a
href={BARTENDER_PROFILE.instagramUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-gradient-to-r from-[#833ab4] via-[#fd1d1d] to-[#fcb045] text-white text-xs font-bold shadow-lg hover:opacity-95 transition-all"
>
<InstagramIcon className="w-4 h-4" />
<span>Seguir en Instagram</span>
<ExternalLink className="w-3.5 h-3.5 opacity-80" />
</a>
</div>
</div>
{/* Title */}
<div className="mb-6">
<h3 className="text-xl sm:text-2xl font-bold text-white tracking-tight">{title}</h3>
<p className="text-xs sm:text-sm text-white/60 mt-1">
Haz clic en cualquier publicación para leer la crónica del cóctel y los ingredientes utilizados.
</p>
</div>
{/* Grid of Instagram Posts */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 sm:gap-4">
{INSTAGRAM_POSTS.map((post) => (
<div
key={post.id}
onClick={() => setActivePost(post)}
className={`group relative aspect-square rounded-lg overflow-hidden border cursor-pointer transition-all duration-300 ${themeStyles.card}`}
>
<Image
src={post.image}
alt={post.caption}
fill
className="object-cover group-hover:scale-105 transition-transform duration-500"
/>
{/* Hover overlay with likes and comments */}
<div className="absolute inset-0 bg-black/70 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-4 text-white font-bold text-xs sm:text-sm backdrop-blur-[2px]">
<div className="flex items-center gap-1.5">
<Heart className="w-4 h-4 text-rose-400 fill-rose-400" />
<span>{post.likes}</span>
</div>
<div className="flex items-center gap-1.5">
<MessageCircle className="w-4 h-4 fill-white" />
<span>{post.comments}</span>
</div>
</div>
{/* Corner Instagram icon */}
<div className="absolute top-2 right-2 p-1 rounded-full bg-black/40 text-white/80 opacity-0 group-hover:opacity-100 transition-opacity">
<InstagramIcon className="w-3 h-3" />
</div>
</div>
))}
</div>
{/* Detail Modal */}
{activePost && (
<div
className="fixed inset-0 z-[1000] bg-black/80 backdrop-blur-md flex items-center justify-center p-4"
onClick={() => setActivePost(null)}
>
<div
className="bg-[#121214] border border-white/20 rounded-2xl max-w-2xl w-full overflow-hidden shadow-2xl relative flex flex-col md:flex-row"
onClick={(e) => e.stopPropagation()}
>
{/* Close Button */}
<button
onClick={() => setActivePost(null)}
className="absolute top-3 right-3 z-10 w-8 h-8 rounded-full bg-black/60 text-white flex items-center justify-center hover:bg-black/90 transition-colors"
>
<X className="w-4 h-4" />
</button>
{/* Modal Image */}
<div className="relative aspect-square md:w-1/2 bg-black">
<Image
src={activePost.image}
alt={activePost.caption}
fill
className="object-cover"
/>
</div>
{/* Modal Content */}
<div className="p-6 md:w-1/2 flex flex-col justify-between text-white">
<div>
<div className="flex items-center gap-2.5 pb-3 border-b border-white/10 mb-4">
<div className="w-8 h-8 rounded-full overflow-hidden relative border border-white/20">
<Image
src="/images/hero_bartender_1.jpg"
alt={BARTENDER_PROFILE.name}
fill
className="object-cover"
/>
</div>
<div>
<h5 className="font-bold text-xs flex items-center gap-1">
{BARTENDER_PROFILE.instagram}
<CheckCircle className="w-3 h-3 text-sky-400 fill-sky-400/20" />
</h5>
<p className="text-[10px] text-white/50 flex items-center gap-1">
<MapPin className="w-2.5 h-2.5" />
{activePost.location}
</p>
</div>
</div>
<p className="text-xs sm:text-sm text-white/90 leading-relaxed">
{activePost.caption}
</p>
<div className="flex flex-wrap gap-1.5 mt-3">
{activePost.tags.map((tag, i) => (
<span key={i} className="text-[11px] text-sky-400 font-mono">
{tag}
</span>
))}
</div>
</div>
<div className="pt-4 border-t border-white/10 mt-6">
<div className="flex items-center justify-between text-xs text-white/60 mb-3">
<span className="flex items-center gap-1 font-bold text-white">
<Heart className="w-3.5 h-3.5 text-rose-500 fill-rose-500" />
{activePost.likes.toLocaleString()} Me gusta
</span>
<span className="font-mono text-[11px]">{activePost.date}</span>
</div>
<a
href={BARTENDER_PROFILE.instagramUrl}
target="_blank"
rel="noopener noreferrer"
className="w-full py-2.5 px-4 bg-white/10 hover:bg-white/20 border border-white/10 rounded-lg text-xs font-bold text-center flex items-center justify-center gap-2 transition-all"
>
<InstagramIcon className="w-4 h-4" />
Abrir en Instagram
</a>
</div>
</div>
</div>
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,49 @@
"use client";
import { BARTENDER_PROFILE } from "@/data/portfolio-data";
interface WhatsAppFloatingButtonProps {
conceptName?: string;
theme?: "kalkbrenner" | "speakeasy" | "alchemist" | "editorial" | "tropical";
}
export function WhatsAppFloatingButton({
conceptName = "General",
theme = "kalkbrenner",
}: WhatsAppFloatingButtonProps) {
const message = encodeURIComponent(
`¡Hola John Caiza! Vi tu portafolio (${conceptName}) y me gustaría consultar disponibilidad y cotización para un evento de coctelería.`
);
const waUrl = `https://wa.me/${BARTENDER_PROFILE.whatsapp.replace(/[^0-9]/g, "")}?text=${message}`;
const themeClasses = {
kalkbrenner:
"hidden sm:flex bg-[#0a0a0a]/95 text-white border border-white/20 hover:border-white hover:bg-white hover:text-black font-mono text-[11px] uppercase tracking-[0.2em] shadow-[0_10px_30px_rgba(0,0,0,0.8)] backdrop-blur-md rounded-none px-4 py-3",
speakeasy:
"bg-[#0d0905]/95 text-[#f59e0b] border border-[#f59e0b]/30 hover:border-[#f59e0b] hover:bg-[#f59e0b] hover:text-black font-serif text-xs uppercase tracking-wider shadow-[0_10px_30px_rgba(0,0,0,0.8)] backdrop-blur-md rounded-xl px-4 py-3",
alchemist:
"bg-[#02050e]/95 text-[#00f0ff] border border-[#00f0ff]/30 hover:border-[#a3e635] hover:text-[#a3e635] font-mono text-xs uppercase tracking-widest shadow-[0_0_25px_rgba(0,240,255,0.15)] backdrop-blur-md rounded-lg px-4 py-3",
editorial:
"hidden sm:flex bg-white/95 text-black border border-black hover:bg-black hover:text-white font-mono text-xs shadow-xl backdrop-blur-md rounded-full px-5 py-3 tracking-wider font-bold",
tropical:
"bg-black/80 text-white border border-pink-500/40 hover:border-[#00f2fe] font-mono text-xs uppercase tracking-wider shadow-[0_0_25px_rgba(255,0,122,0.3)] backdrop-blur-md rounded-full px-5 py-3",
}[theme];
return (
<aside aria-label="Contacto por WhatsApp">
<a
href={waUrl}
target="_blank"
rel="noopener noreferrer"
className={`fixed bottom-6 right-6 z-[999] flex items-center gap-2.5 active:scale-[0.97] transition-all duration-200 group ${themeClasses}`}
title="Consultar disponibilidad con John Caiza"
>
<span className="w-1.5 h-1.5 bg-emerald-400 rounded-full shrink-0 group-hover:scale-125 transition-transform" />
<span className="font-bold">WhatsApp Direct // John Caiza</span>
<span className="opacity-40 group-hover:opacity-100 group-hover:translate-x-1 transition-all">
→
</span>
</a>
</aside>
);
}

368
src/data/portfolio-data.ts Normal file
View File

@@ -0,0 +1,368 @@
export interface CocktailItem {
id: string;
name: string;
subtitle: string;
category: string;
description: string;
ingredients: string[];
glassware: string;
flavorProfile: {
smoky?: number;
sweet?: number;
bitter?: number;
herbal?: number;
citrus?: number;
boozy?: number;
};
image: string;
technique?: string;
abv?: string;
}
export interface InstagramPost {
id: string;
image: string;
caption: string;
likes: number;
comments: number;
date: string;
tags: string[];
location: string;
}
export interface ServicePackage {
id: string;
title: string;
tagline: string;
badge: string;
idealFor: string;
guests: string;
duration: string;
includes: string[];
recommendedConcept: string;
}
export const BARTENDER_PROFILE = {
name: "John Caiza",
title: "Bartender · Eventos privados en Nueva York",
tagline: "Barra privada y coctelería clásica para casas, lofts y azoteas de Nueva York.",
whatsapp: "+12125550143", // PLACEHOLDER — sustituir por el número real
whatsappDisplay: "+1 (212) 555-0143",
instagram: "@johncaiza.mixology",
instagramUrl: "https://instagram.com/johncaiza.mixology",
location: "Manhattan / Brooklyn / Hudson Valley",
bio: "Doce años detrás de la barra en Nueva York. John Caiza monta barra privada en casas, lofts y azoteas de la ciudad: coctelería clásica, proporciones conocidas, cítrico exprimido en el momento y hielo cuidado.",
stats: [
{ label: "Años de Trayectoria", value: "12+" },
{ label: "Eventos VIP Ejecutados", value: "450+" },
{ label: "Cócteles de Autor", value: "80+" },
{ label: "Calificación Promedio", value: "5.0 ★" },
],
};
export const PACKAGES: ServicePackage[] = [
{
id: "bespoke-private",
title: "The Private Speakeasy Bar",
tagline: "Una barra clandestina completa en tu loft o tu casa.",
badge: "Más Exclusivo",
idealFor: "Cenas privadas, celebraciones íntimas, aniversarios",
guests: "Hasta 35 invitados",
duration: "4 a 6 horas",
includes: [
"Barra móvil de madera noble o metal cepillado",
"Cristalería de cristal tallado japonés",
"Menú de 4 cócteles de autor + clásicos a demanda",
"Hielos cristalinos tallados a mano con sellado en relieve",
"Destilados ultra-premium y jarabes botánicos caseros",
],
recommendedConcept: "Speakeasy Nocturne",
},
{
id: "classics-bar",
title: "Barra de Clásicos",
tagline: "El canon bien hecho: seis clásicos y nada más.",
badge: "Más pedido",
idealFor: "Cenas privadas, aniversarios, actos de marca",
guests: "25 a 150 invitados",
duration: "4 horas",
includes: [
"Carta de seis clásicos elegida contigo",
"Cristalería propia para cada cóctel",
"Hielo tallado a mano durante el servicio",
"Vermut y cítricos comprados el mismo día",
"Carta impresa para la barra",
],
recommendedConcept: "Clásicos de Siempre",
},
{
id: "grand-wedding",
title: "Luxury Wedding & Gala Service",
tagline: "Flujo continuo de coctelería sin esperas con elegancia sublime.",
badge: "Grandes Eventos",
idealFor: "Bodas de lujo, aniversarios de gran escala, recepciones",
guests: "50 a 300+ invitados",
duration: "Hasta 8 horas de servicio",
includes: [
"Equipo completo de bartenders y barbacks uniformados",
"Barra de bienvenida personalizada con cóctel insignia de los novios",
"Menú de cócteles estacionales de autor y mocktails botánicos",
"Cálculo integral de suministros y maridaje con el catering",
"Servicio de cristalería de diseño acorde a la decoración",
],
recommendedConcept: "Atelier Gastronomie / Solstice Tiki",
},
{
id: "masterclass-vip",
title: "Masterclass Sensorial & Tasting",
tagline: "Aprende el arte de la coctelería con tus propios shakers.",
badge: "Interactiva",
idealFor: "Team buildings ejecutivos, despedidas premium, grupos de amigos",
guests: "8 a 20 personas",
duration: "2.5 horas",
includes: [
"Kit individual de coctelería profesional para cada participante",
"Elaboración de 3 cócteles icónicos guiados paso a paso",
"Dossier digital de recetas y secretos de botánicos y siropes",
"Degustación de destilados de colección",
"Diploma conmemorativo y botella de cóctel añejado de regalo",
],
recommendedConcept: "Cualquier concepto",
},
];
export const COCKTAILS_DATA = {
speakeasy: [
{
id: "sp-1",
name: "Old Fashioned Ahumado en Roble",
subtitle: "Whiskey Rye 10 años, Sirope de Higo y Bitter de Cacao",
category: "Ahumados & Envejecidos",
description: "Infusión fría de centeno con higos negros secos, terminado bajo campana de humo de roble americano y aceites esenciales de naranja amarga.",
ingredients: ["Rye Whiskey Reserva", "Reducción de higo negro", "Bitter de nuez y cacao", "Cáscara de naranja flambeada"],
glassware: "Tumbler de cristal cortado",
flavorProfile: { smoky: 95, sweet: 35, bitter: 60, herbal: 40, boozy: 90 },
image: "/images/speakeasy_1.jpg",
abv: "32% ABV",
},
{
id: "sp-2",
name: "Nocturne Manhattan",
subtitle: "Bourbon reposado, Vermouth Torino & Trufa Negra",
category: "Clásicos Reinventados",
description: "La trufa negra infusionada con vermouth rosso aporta un final terroso y aterciopelado inigualable.",
ingredients: ["Bourbon Single Barrel", "Vermouth di Torino macerado en trufa", "Angostura aromática", "Cereza marrasquino artesanal"],
glassware: "Coupette Nick & Nora",
flavorProfile: { smoky: 40, sweet: 45, bitter: 70, herbal: 65, boozy: 85 },
image: "/images/speakeasy_2.jpg",
abv: "29% ABV",
},
{
id: "sp-3",
name: "Amber Velvet Penicillin",
subtitle: "Scotch Speyside, Miel de Brezo, Jengibre & Islay Peat",
category: "Aromáticos de Autor",
description: "Contraste vibrante entre el jengibre fresco prensado en frío, la cremosidad de la miel y el toque volcánico del whisky de Islay.",
ingredients: ["Blended Scotch 12 años", "Float de Islay Peat", "Zumo de limón Meyer", "Melaza de jengibre y miel de brezo"],
glassware: "Lowball con bloque de hielo diamante",
flavorProfile: { smoky: 85, sweet: 50, bitter: 30, citrus: 75, boozy: 78 },
image: "/images/speakeasy_3.jpg",
abv: "24% ABV",
},
{
id: "sp-4",
name: "Boulevardier Oscuro",
subtitle: "Campari infundido en café geisha & Sweet Vermouth",
category: "Amargos Complejos",
description: "Notas tostadas de café de especialidad geisha que equilibran la astringencia del Campari con un final profundo y seductor.",
ingredients: ["Rye Whiskey", "Campari macerado en café de especialidad", "Punt e Mes", "Twist de pomelo rosado"],
glassware: "Rock glass tallado a mano",
flavorProfile: { smoky: 50, sweet: 30, bitter: 95, herbal: 70, boozy: 88 },
image: "/images/speakeasy_4.jpg",
abv: "30% ABV",
},
],
alchemist: [
{
id: "alc-1",
name: "Formula 07 // Clarified Milk Punch",
subtitle: "Ginebra Botánica, Té Lapsang Souchong & Ácido Láctico",
category: "Técnicas de Clarificación",
description: "Cóctel clarificado mediante lavado de leche orgánica que elimina taninos dejando un licor transparente como el agua pero con textura sedosa de terciopelo.",
ingredients: ["London Dry Gin", "Té negro ahumado Lapsang Souchong", "Leche de cabra filtrada", "Ácido cítrico natural", "Cítricos prensados"],
glassware: "Vaso de laboratorio Kimax de borosilicato",
flavorProfile: { smoky: 60, sweet: 40, bitter: 30, citrus: 65, herbal: 80, boozy: 70 },
image: "/images/alchemist_1.jpg",
technique: "Rotary Evaporator + Milk Washing",
abv: "21.5% ABV",
},
{
id: "alc-2",
name: "Cryo-Botanical Mist",
subtitle: "Destilado de Romero en Vacío, Bergamota & Nitrógeno Líquido",
category: "Criogenia & Aromas",
description: "Vapor aromático a -196°C que se derrama sobre la copa, congelando perlas de bergamota y liberando los aceites del romero fresco.",
ingredients: ["Vodka destilado en rotovap con romero", "Licor de bergamota de Calabria", "Tónica artesanal", "Esfera criogénica"],
glassware: "Matraz cónico con soporte de titanio",
flavorProfile: { smoky: 10, sweet: 35, bitter: 50, citrus: 85, herbal: 95, boozy: 65 },
image: "/images/alchemist_2.jpg",
technique: "Liquid Nitrogen Spherification",
abv: "18.0% ABV",
},
{
id: "alc-3",
name: "Ultraviolet Luminescence",
subtitle: "Tequila Blanco, Mezcal Espadín, Flor de Guisante & Luz UV",
category: "Fotoquímica",
description: "Reacción cromática de pH natural: al añadir la solución cítrica ácida, el líquido muta instantáneamente de azul cobalto a púrpura magenta.",
ingredients: ["Tequila 100% Agave infundido en Clitoria ternatea", "Mezcal ahumado joven", "Ácido málico y tartárico", "Sirope de agave silvestre"],
glassware: "Tubo de ensayo graduado y copa cristalina",
flavorProfile: { smoky: 70, sweet: 45, bitter: 40, citrus: 90, herbal: 75, boozy: 80 },
image: "/images/alchemist_3.jpg",
technique: "pH Color-Shift Transition",
abv: "26.0% ABV",
},
],
editorial: [
{
id: "edt-1",
name: "L'Essence Minimaliste",
subtitle: "Vodka de Trigo Francés, Hidrosol de Jazmín & Aceituna Gordal",
category: "Pureza Estética",
description: "Menos es más. Una reinterpretación escultural del Dirty Martini donde el hidrosol de jazmín destilado al vapor se funde con una salmuera purificada.",
ingredients: ["Vodka francés ecológico", "Destilado botánico de jazmín", "Salmuera clarificada", "Aceite de oliva virgen extra en suspensión"],
glassware: "Copa Martini ultrafina de cristal soplado",
flavorProfile: { smoky: 0, sweet: 10, bitter: 40, herbal: 85, citrus: 20, boozy: 92 },
image: "/images/editorial_1.jpg",
abv: "35% ABV",
},
{
id: "edt-2",
name: "Le Jardin Suspendu",
subtitle: "Calvados Añejo, Cordial de Manzana Ácida & Tomillo Limonero",
category: "Terruño & Poesía",
description: "Un tributo a la campiña normanda con un equilibrio armónico de acidez punzante y dulzura maderosa de barrica francesa.",
ingredients: ["Calvados Pays d'Auge", "Cordial artesanal de Granny Smith", "Extracto de tomillo limonero", "Polvo de manzana deshidratada"],
glassware: "Copa Nick & Nora sin reborde",
flavorProfile: { smoky: 20, sweet: 40, bitter: 25, herbal: 90, citrus: 80, boozy: 75 },
image: "/images/editorial_2.jpg",
abv: "23% ABV",
},
{
id: "edt-3",
name: "Lignum & Rosa",
subtitle: "Cognac VSOP, Reducción de Rosas de Grasse & Champagne Brut",
category: "Haute Parfumerie",
description: "Inspirado en la perfumería clásica francesa. Una burbuja finísima que transporta los aceites esenciales de la rosa de Grasse.",
ingredients: ["Cognac VSOP", "Agua de rosas de mayo", "Champagne Gran Reserva", "Azúcar candi aromatizado"],
glassware: "Flauta vintage de los años 30",
flavorProfile: { smoky: 10, sweet: 60, bitter: 20, herbal: 70, citrus: 40, boozy: 68 },
image: "/images/editorial_3.jpg",
abv: "19% ABV",
},
],
tropical: [
{
id: "trp-1",
name: "Solstice Sunset Zombie",
subtitle: "Ron Añejo Reserva, Maracuyá Silvestre, Falernum & Fuego de Absenta",
category: "Neo-Tiki Extremo",
description: "Explosión exuberante de frutas tropicales caribeñas, especias de Barbados y un toque llameante de absenta que aromatiza la mesa.",
ingredients: ["Blend de 3 Rones del Caribe", "Puré fresco de maracuyá", "Falernum artesanal de clavo y almendra", "Zumo de lima", "Flame de overproof rum"],
glassware: "Vaso Tiki tallado a mano con hielo picado",
flavorProfile: { smoky: 30, sweet: 85, bitter: 30, citrus: 95, herbal: 65, boozy: 95 },
image: "/images/tropical_1.jpg",
abv: "34% ABV",
},
{
id: "trp-2",
name: "Agave & Dragonfruit Neon",
subtitle: "Mezcal Artesanal, Pitahaya Rosa, Chile Poblano & Sal Volcánica",
category: "Vibra Oaxaqueña",
description: "Color fucsia eléctrico natural de la fruta del dragón con un sutil picor ahumado que despierta el paladar en eventos veraniegos.",
ingredients: ["Mezcal Espadín", "Infusión de pitahaya rosa", "Licor Ancho Reyes Verde", "Miel de agave", "Borde de sal de gusano y jamaica"],
glassware: "Vaso de barro negro esmaltado",
flavorProfile: { smoky: 75, sweet: 55, bitter: 20, citrus: 80, herbal: 60, boozy: 82 },
image: "/images/tropical_2.jpg",
abv: "25% ABV",
},
{
id: "trp-3",
name: "Coco Loco Colada Clarificada",
subtitle: "Ron Blanco, Agua de Coco Tostado, Piña Asada & Hojuela de Oro",
category: "Lujo Tropical",
description: "Todos los sabores legendarios de una piña colada pero en un cóctel cristalino, ligero y refrescante, ideal para tardes de piscina o solárium.",
ingredients: ["Ron añejo filtrado", "Infusión de coco tostado", "Reducción de piña caramelizada al carbón", "Lima kaffir"],
glassware: "Copa balón de cristal de Bohemia",
flavorProfile: { smoky: 15, sweet: 70, bitter: 10, citrus: 70, herbal: 30, boozy: 72 },
image: "/images/tropical_3.jpg",
abv: "20% ABV",
},
],
};
export const INSTAGRAM_POSTS: InstagramPost[] = [
{
id: "ig-1",
image: "/images/instagram_1.jpg",
caption: "Precisión en cada copa. Servicio privado para 80 invitados en un loft del SoHo. ✨🍸 #PrivateBar #ClassicCocktails #JohnCaiza #NYC",
likes: 1248,
comments: 84,
date: "Hace 2 días",
tags: ["#Mixology", "#LuxuryEvents", "#CocktailCatering"],
location: "SoHo, Manhattan",
},
{
id: "ig-2",
image: "/images/instagram_2.jpg",
caption: "El poder del fuego y los aromas del roble quemado al momento. Una experiencia que trasciende el sabor. 🪵🔥 #SmokedCocktail #SpeakeasyBar #ArtisanSpirits",
likes: 2190,
comments: 132,
date: "Hace 5 días",
tags: ["#Whiskey", "#SmokedOldFashioned", "#BarArchitecture"],
location: "Brownstone privado, Brooklyn",
},
{
id: "ig-3",
image: "/images/instagram_3.jpg",
caption: "Preparando la barra para el banquete de gala de Anna & Carlos. Cada copa es una obra de arte diseñada a medida para su historia. 💍🥂 #WeddingCocktails #VIPService",
likes: 3410,
comments: 205,
date: "Hace 1 semana",
tags: ["#LuxuryWedding", "#CocktailBar", "#EventDesign"],
location: "Castillo de Santa Florentina, Canet de Mar",
},
{
id: "ig-4",
image: "/images/instagram_4.jpg",
caption: "Hielo tallado a mano antes de abrir. La mitad del trabajo pasa antes de que llegue nadie. ❄️🍸 #ClearIce #ClassicCocktails #JohnCaiza",
likes: 1890,
comments: 97,
date: "Hace 2 semanas",
tags: ["#ClassicCocktails", "#ClearIce", "#PrivateBar"],
location: "Casa privada, los Hamptons",
},
{
id: "ig-5",
image: "/images/instagram_5.jpg",
caption: "Nuestros hielos cristalinos tallados a mano con sello de cera en relieve. Porque el hielo es el 40% de tu cóctel. 🧊✨ #ClearIce #BarCraftsmanship",
likes: 2750,
comments: 168,
date: "Hace 3 semanas",
tags: ["#HandCarvedIce", "#JapaneseTechnique", "#CocktailDetail"],
location: "Estudio, Brooklyn",
},
{
id: "ig-6",
image: "/images/instagram_6.jpg",
caption: "Tarde de cata privada de tequilas y mezcales de pequeños productores oaxaqueños. Pasión por el origen y el terruño. 🇲🇽🌵 #AgaveSpirits #MezcalTasting",
likes: 1640,
comments: 73,
date: "Hace 1 mes",
tags: ["#Masterclass", "#Agave", "#ArtisanalCocktails"],
location: "Upper East Side, Manhattan",
},
];

41
tsconfig.json Normal file
View File

@@ -0,0 +1,41 @@
{
"compilerOptions": {
"target": "ES2022",
"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"
],
"exclude": [
"node_modules"
]
}

1
tsconfig.tsbuildinfo Normal file

File diff suppressed because one or more lines are too long