first commit
9
AGENTS.md
Normal 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 -->
|
||||
7
next-env.d.ts
vendored
Normal 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
@@ -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
34
package.json
Normal 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
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
BIN
public/images/alchemist_1.jpg
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
public/images/alchemist_2.jpg
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
public/images/alchemist_3.jpg
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
public/images/alchemist_4.jpg
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
public/images/alchemist_5.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
public/images/alchemist_6.jpg
Normal file
|
After Width: | Height: | Size: 36 KiB |
32
public/images/c3-credits.json
Normal 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
|
After Width: | Height: | Size: 132 KiB |
BIN
public/images/c3_02.jpg
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
public/images/c3_03.jpg
Normal file
|
After Width: | Height: | Size: 179 KiB |
BIN
public/images/c3_04.jpg
Normal file
|
After Width: | Height: | Size: 127 KiB |
BIN
public/images/c3_05.jpg
Normal file
|
After Width: | Height: | Size: 140 KiB |
BIN
public/images/editorial_1.jpg
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
public/images/editorial_2.jpg
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
public/images/editorial_3.jpg
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
public/images/editorial_4.jpg
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
public/images/editorial_5.jpg
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
public/images/editorial_6.jpg
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
public/images/hero_bartender_1.jpg
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
public/images/hero_bartender_2.jpg
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
public/images/hero_bartender_3.jpg
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
public/images/hero_bartender_4.jpg
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
public/images/instagram_1.jpg
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
public/images/instagram_2.jpg
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
public/images/instagram_3.jpg
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
public/images/instagram_4.jpg
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
public/images/instagram_5.jpg
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
public/images/instagram_6.jpg
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
public/images/instagram_7.jpg
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
public/images/instagram_8.jpg
Normal file
|
After Width: | Height: | Size: 78 KiB |
266
public/images/manifest.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
public/images/speakeasy_1.jpg
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
public/images/speakeasy_2.jpg
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
public/images/speakeasy_3.jpg
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
public/images/speakeasy_4.jpg
Normal file
|
After Width: | Height: | Size: 122 KiB |
BIN
public/images/speakeasy_5.jpg
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
public/images/speakeasy_6.jpg
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
public/images/tropical_1.jpg
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
public/images/tropical_2.jpg
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
public/images/tropical_3.jpg
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
public/images/tropical_4.jpg
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
public/images/tropical_5.jpg
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
public/images/tropical_6.jpg
Normal file
|
After Width: | Height: | Size: 52 KiB |
93
scripts/download-images.mjs
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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">
|
||||
> 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" />
|
||||
> Mismo negocio y mismo contenido en las cuatro; cambian la tipografía, la paleta,
|
||||
la estructura y el movimiento. <br className="hidden sm:block" />
|
||||
> 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 & 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>
|
||||
);
|
||||
}
|
||||
342
src/components/BookingForm.tsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
39
src/components/HubButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
229
src/components/InstagramFeed.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
src/components/WhatsAppFloatingButton.tsx
Normal 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
@@ -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
@@ -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"
|
||||
]
|
||||
}
|
||||