feat: Add Formbricks integration, update forms with webhooks, enhance navigation

- Integrate @formbricks/js for future surveys (FormbricksProvider)
- Add WebhookForm component for unified form submission (contact/franchise/membership)
- Update contact form with reason dropdown field
- Update franchise form with new fields: estado, ciudad, socios checkbox
- Update franchise benefits: manuals, training platform, RH system, investment $100k
- Add Contacto link to desktop/mobile nav and footer
- Update membership form to use WebhookForm with membership_id select
- Update hero buttons to use #3E352E color consistently
- Refactor contact/franchise pages to use new hero layout and components
- Add webhook utility (lib/webhook.ts) for parallel submission to test+prod
- Add email receipt hooks to booking endpoints
- Update globals.css with new color variables and navigation styles
- Docker configuration for deployment
This commit is contained in:
Marco Gallegos
2026-01-17 22:54:20 -06:00
parent b7d6e51d67
commit 66e20d25a7
60 changed files with 4534 additions and 791 deletions

View File

@@ -1,66 +1,244 @@
/** @description Static services page component displaying available salon services and categories. */
import { AnimatedLogo } from '@/components/animated-logo'
import { RollingPhrases } from '@/components/rolling-phrases'
/** @description Services page with home page style structure */
'use client'
import { AnimatedLogo } from '@/components/animated-logo'
import { RollingPhrases } from '@/components/rolling-phrases'
/** @description Services page with home page style structure */
import { useState, useEffect } from 'react'
interface Service {
id: string
name: string
description: string
duration_minutes: number
base_price: number
category: string
requires_dual_artist: boolean
is_active: boolean
}
export default function ServiciosPage() {
const services = [
{
category: 'Spa de Alta Gama',
description: 'Sauna y spa excepcionales, diseñados para el rejuvenecimiento y el equilibrio.',
items: ['Tratamientos Faciales', 'Masajes Terapéuticos', 'Hidroterapia']
},
{
category: 'Arte y Manicure de Precisión',
description: 'Estilización y técnica donde el detalle define el resultado.',
items: ['Manicure de Precisión', 'Pedicure Spa', 'Arte en Uñas']
},
{
category: 'Peinado y Maquillaje de Lujo',
description: 'Transformaciones discretas y sofisticadas para ocasiones selectas.',
items: ['Corte y Estilismo', 'Color Premium', 'Maquillaje Profesional']
},
{
category: 'Cuidado Corporal',
description: 'Ritual de bienestar integral.',
items: ['Exfoliación Profunda', 'Envolturas Corporales', 'Tratamientos Reductores']
},
{
category: 'Membresías Exclusivas',
description: 'Acceso prioritario y experiencias personalizadas.',
items: ['Gold Tier', 'Black Tier', 'VIP Tier']
const [services, setServices] = useState<Service[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchServices()
}, [])
const fetchServices = async () => {
try {
const response = await fetch('/api/services')
const data = await response.json()
if (data.success) {
setServices(data.services.filter((s: Service) => s.is_active))
}
} catch (error) {
console.error('Error fetching services:', error)
} finally {
setLoading(false)
}
]
}
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN'
}).format(amount)
}
const formatDuration = (minutes: number) => {
const hours = Math.floor(minutes / 60)
const mins = minutes % 60
if (hours > 0) {
return mins > 0 ? `${hours}h ${mins}min` : `${hours}h`
}
return `${mins} min`
}
const getCategoryTitle = (category: string) => {
const titles: Record<string, string> = {
core: 'CORE EXPERIENCES - El corazón de Anchor 23',
nails: 'NAIL COUTURE - Técnica invisible. Resultado impecable.',
hair: 'HAIR FINISHING RITUALS',
lashes: 'LASH & BROW RITUALS - Mirada definida con sutileza.',
brows: 'LASH & BROW RITUALS - Mirada definida con sutileza.',
events: 'EVENT EXPERIENCES - Agenda especial',
permanent: 'PERMANENT RITUALS - Agenda limitada · Especialista certificada'
}
return titles[category] || category
}
const getCategoryDescription = (category: string) => {
const descriptions: Record<string, string> = {
core: 'Rituales conscientes donde el tiempo se desacelera. Cada experiencia está diseñada para mujeres que valoran el silencio, la atención absoluta y los resultados impecables.',
nails: 'En Anchor 23 no eliges técnicas. Cada decisión se toma internamente para lograr un resultado elegante, duradero y natural. No ofrecemos servicios de mantenimiento ni correcciones.',
hair: 'Disponibles únicamente para clientas con experiencia Anchor el mismo día.',
lashes: '',
brows: '',
events: 'Agenda especial para ocasiones selectas.',
permanent: ''
}
return descriptions[category] || ''
}
const groupedServices = services.reduce((acc, service) => {
if (!acc[service.category]) {
acc[service.category] = []
}
acc[service.category].push(service)
return acc
}, {} as Record<string, Service[]>)
const categoryOrder = ['core', 'nails', 'hair', 'lashes', 'brows', 'events', 'permanent']
if (loading) {
return (
<div className="section">
<div className="section-header">
<h1 className="section-title">Nuestros Servicios</h1>
<p className="section-subtitle">Cargando servicios...</p>
</div>
</div>
)
}
return (
<div className="section">
<div className="section-header">
<h1 className="section-title">Nuestros Servicios</h1>
<p className="section-subtitle">
Experiencias diseñadas con precisión y elegancia para clientes que valoran la exclusividad.
</p>
</div>
<div className="max-w-7xl mx-auto px-6">
<div className="grid md:grid-cols-2 gap-8">
{services.map((service, index) => (
<article key={index} className="p-8 bg-white rounded-2xl shadow-lg hover:shadow-xl transition-shadow border border-gray-100">
<h2 className="text-2xl font-semibold text-gray-900 mb-3">{service.category}</h2>
<p className="text-gray-600 mb-4">{service.description}</p>
<ul className="space-y-2">
{service.items.map((item, idx) => (
<li key={idx} className="flex items-center text-gray-700">
<span className="w-1.5 h-1.5 bg-gray-900 rounded-full mr-2" />
{item}
</li>
))}
</ul>
</article>
))}
<>
<section className="hero">
<div className="hero-content">
<AnimatedLogo />
<h1>Servicios</h1>
<h2>Anchor:23</h2>
<RollingPhrases />
<div className="hero-actions">
<a href="/booking/servicios" className="btn-primary">
Reservar Cita
</a>
</div>
</div>
<div className="mt-12 text-center">
<a href="https://booking.anchor23.mx" className="btn-primary">
Reservar Cita
</a>
<div className="hero-image">
<div className="w-full h-96 flex items-center justify-center bg-gradient-to-br from-amber-50 to-gray-50">
<span className="text-gray-500 text-lg">Imagen Servicios</span>
</div>
</div>
</div>
</div>
</section>
<section className="foundation">
<article>
<h3>Experiencias</h3>
<h4>Criterio antes que cantidad</h4>
<p>
Anchor 23 es un espacio privado donde el tiempo se desacelera. Aquí, cada experiencia está diseñada para mujeres que valoran el silencio, la atención absoluta y los resultados impecables.
</p>
<p>
No trabajamos con volumen. Trabajamos con intención.
</p>
</article>
<aside className="foundation-image">
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-amber-50 to-gray-50">
<span className="text-gray-500 text-lg">Imagen Experiencias</span>
</div>
</aside>
</section>
<section className="services-preview">
<h3>Nuestros Servicios</h3>
<div className="max-w-7xl mx-auto px-6">
{categoryOrder.map(category => {
const categoryServices = groupedServices[category]
if (!categoryServices || categoryServices.length === 0) return null
return (
<div key={category} className="service-cards mb-24">
<div className="mb-8">
<h4 className="text-3xl font-bold text-gray-900 mb-4">
{getCategoryTitle(category)}
</h4>
{getCategoryDescription(category) && (
<p className="text-gray-600 text-lg leading-relaxed">
{getCategoryDescription(category)}
</p>
)}
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{categoryServices.map((service) => (
<article
key={service.id}
className="service-card"
>
<div className="mb-4">
<h5 className="text-xl font-semibold text-gray-900 mb-2">
{service.name}
</h5>
{service.description && (
<p className="text-gray-600 text-sm leading-relaxed">
{service.description}
</p>
)}
</div>
<div className="flex items-center justify-between mb-4">
<span className="text-gray-500 text-sm">
{formatDuration(service.duration_minutes)}
</span>
{service.requires_dual_artist && (
<span className="text-xs bg-gray-100 px-2 py-1 rounded-full">Dual Artist</span>
)}
</div>
<div className="flex items-center justify-between">
<span className="text-2xl font-bold text-gray-900">
{formatCurrency(service.base_price)}
</span>
<a href="/booking/servicios" className="btn-primary">
Reservar
</a>
</div>
</article>
))}
</div>
</div>
)
})}
<section className="testimonials">
<h3>Lo que Define Anchor 23</h3>
<div className="max-w-4xl mx-auto text-center">
<div className="grid md:grid-cols-2 gap-6 text-left">
<div className="space-y-3">
<div className="flex items-start gap-3">
<span className="text-red-500 text-xl"></span>
<span className="text-gray-700">No ofrecemos retoques ni servicios aislados</span>
</div>
<div className="flex items-start gap-3">
<span className="text-red-500 text-xl"></span>
<span className="text-gray-700">No trabajamos con prisas</span>
</div>
<div className="flex items-start gap-3">
<span className="text-red-500 text-xl"></span>
<span className="text-gray-700">No explicamos de más</span>
</div>
</div>
<div className="space-y-3">
<div className="flex items-start gap-3">
<span className="text-red-500 text-xl"></span>
<span className="text-gray-700">No negociamos estándares</span>
</div>
<div className="flex items-start gap-3">
<span className="text-red-500 text-xl"></span>
<span className="text-gray-700">Cada experiencia está pensada para durar, sentirse y recordarse</span>
</div>
</div>
</div>
</div>
</section>
</div>
</section>
</>
)
}