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

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,51 @@
'use client'
import { useState, useEffect } from 'react'
import { usePathname } from 'next/navigation'
import { LoadingScreen } from '@/components/loading-screen'
import { useScrollEffect } from '@/hooks/use-scroll-effect'
interface AppWrapperProps {
children: React.ReactNode
}
/** @description Client component wrapper that handles loading screen and scroll effects */
export function AppWrapper({ children }: AppWrapperProps) {
const [isLoading, setIsLoading] = useState(false)
const [hasLoadedOnce, setHasLoadedOnce] = useState(false)
const pathname = usePathname()
const isScrolled = useScrollEffect()
useEffect(() => {
// Only show loading screen on first visit to home page
if (pathname === '/' && !hasLoadedOnce) {
setIsLoading(true)
setHasLoadedOnce(true)
}
}, [pathname, hasLoadedOnce])
const handleLoadingComplete = () => {
setIsLoading(false)
}
useEffect(() => {
// Apply scroll class to header
const header = document.querySelector('.site-header')
if (header) {
if (isScrolled) {
header.classList.add('scrolled')
} else {
header.classList.remove('scrolled')
}
}
}, [isScrolled])
return (
<>
{isLoading && <LoadingScreen onComplete={handleLoadingComplete} />}
<div style={{ opacity: isLoading ? 0 : 1, transition: 'opacity 0.5s ease' }}>
{children}
</div>
</>
)
}

View File

@@ -0,0 +1,28 @@
'use client'
import { useEffect } from 'react'
import { usePathname, useSearchParams } from 'next/navigation'
import formbricks from '@formbricks/js'
const FORMBRICKS_ENVIRONMENT_ID = process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID || ''
const FORMBRICKS_API_HOST = process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST || 'https://app.formbricks.com'
export function FormbricksProvider() {
const pathname = usePathname()
const searchParams = useSearchParams()
useEffect(() => {
if (typeof window !== 'undefined' && FORMBRICKS_ENVIRONMENT_ID) {
formbricks.init({
environmentId: FORMBRICKS_ENVIRONMENT_ID,
apiHost: FORMBRICKS_API_HOST
})
}
}, [])
useEffect(() => {
formbricks?.registerRouteChange()
}, [pathname, searchParams])
return null
}

View File

@@ -0,0 +1,196 @@
'use client'
import React, { useState, useEffect } from 'react'
import { AnimatedLogo } from './animated-logo'
/** @description Elegant loading screen with Anchor 23 branding */
export function LoadingScreen({ onComplete }: { onComplete: () => void }) {
const [progress, setProgress] = useState(0)
const [showLogo, setShowLogo] = useState(false)
const [isFadingOut, setIsFadingOut] = useState(false)
useEffect(() => {
// Simulate loading progress
const progressInterval = setInterval(() => {
setProgress(prev => {
if (prev >= 100) {
clearInterval(progressInterval)
// Start fade out from top
setIsFadingOut(true)
// Complete after fade out animation
setTimeout(() => onComplete(), 800)
return 100
}
return prev + Math.random() * 12 + 8 // Faster progress
})
}, 120) // Faster interval
// Show logo immediately (fast fade in)
setShowLogo(true)
return () => {
clearInterval(progressInterval)
}
}, [onComplete])
return (
<div className={`loading-screen ${isFadingOut ? 'fade-out' : ''}`}>
<div className="loading-content">
{showLogo && (
<div className="logo-wrapper">
<svg
viewBox="0 0 160 110"
className="loading-logo"
>
<g className="loading-anchor-group">
<path
d="m 243.91061,490.07237 c -14.90708,-20.76527 -40.32932,-38.4875 -72.46962,-50.51961 -6.28037,-2.35113 -18.82672,-6.82739 -27.88083,-9.94725 -26.58857,-9.1619 -41.30507,-16.6129 -58.331488,-29.53333 C 61.948377,382.40597 45.952738,359.43239 36.175195,329.61973 31.523123,315.43512 27.748747,295.05759 28.346836,287.35515 l 0.358542,-4.61742 8.564133,5.67181 c 17.36555,11.50076 46.202142,24.17699 72.956399,32.07091 6.95761,2.05286 12.50649,4.24632 12.33087,4.87435 -0.17562,0.62804 -2.82456,2.39475 -5.88665,3.92604 -10.99498,5.49858 -27.443714,4.43247 -46.080425,-2.98665 -3.96919,-1.58011 -7.405462,-2.6842 -7.636123,-2.45354 -0.733091,0.7331 8.423453,18.11108 13.820007,26.22861 6.692697,10.0673 20.30956,24.52092 29.977331,31.81955 13.28709,10.03091 31.4128,18.34633 64.69007,29.67743 32.46139,11.05328 49.71037,18.63784 59.69045,26.24654 l 6.02195,4.59101 -0.31253,-19.52332 -0.31242,-19.52333 -7.99319,-2.55382 c -8.69798,-2.77901 -17.71738,-7.05988 -17.66851,-8.38597 0.0171,-0.45828 3.48344,-2.37476 7.70338,-4.25887 9.02318,-4.02858 14.84235,-8.8019 16.98658,-13.93357 1.02073,-2.44313 1.54554,-8.63027 1.55114,-18.288 l 0.0114,-14.59572 5.22252,-6.56584 c 2.87241,-3.6112 5.60849,-6.56584 6.08008,-6.56584 0.47171,0 2.99928,2.89079 5.61694,6.42397 l 4.75983,6.42395 v 13.4163 c 0,7.37896 0.34337,15.13294 0.76301,17.23107 1.21074,6.0538 9.83699,13.83192 18.97482,17.10906 4.21709,1.51242 7.66741,3.13118 7.66741,3.59724 0,1.40969 -10.95798,6.50426 -17.85291,8.30017 -3.55972,0.92721 -6.66393,1.87743 -6.89813,2.1116 -0.2342,0.23416 -0.28479,9.22125 -0.11305,19.97131 l 0.31311,19.54557 7.42225,-5.20492 c 14.2352,-9.98251 28.50487,-15.97591 69.08404,-29.01591 32.15697,-10.33354 51.17096,-21.00285 68.8865,-38.65433 5.44702,-5.42731 12.3286,-13.51773 15.29236,-17.97873 6.31188,-9.50047 15.28048,-26.39644 14.45147,-27.22542 -0.31619,-0.31622 -4.13888,0.91353 -8.49471,2.7328 -16.38628,6.84381 -33.37216,7.63073 -45.31663,2.0994 -3.6112,-1.6723 -6.56584,-3.47968 -6.56584,-4.01639"
fill="#E9E1D8"
transform="scale(0.25) translate(-200,-150)"
/>
</g>
</svg>
<h2 className="loading-text">ANCHOR:23</h2>
</div>
)}
<div className="loading-bar">
<div
className="loading-progress"
style={{ width: `${progress}%` }}
></div>
</div>
</div>
<style jsx>{`
.loading-screen {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: #3F362E;
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
animation: screenFadeIn 0.3s ease-out;
}
.loading-screen.fade-out {
animation: screenFadeOutUp 0.8s ease-in forwards;
}
.loading-content {
text-align: center;
color: white;
}
.logo-wrapper {
margin-bottom: 3rem;
animation: logoFadeIn 1s ease-out 0.3s both;
}
.loading-logo {
width: 160px;
height: 110px;
margin: 0 auto 1rem;
filter: drop-shadow(0 0 15px rgba(255, 255, 255, 0.4));
}
.loading-anchor-group {
opacity: 1;
}
.loading-text {
font-size: 2rem;
font-weight: 300;
letter-spacing: 2px;
margin: 0;
animation: textGlow 2s ease-in-out infinite alternate;
}
.loading-bar {
width: 200px;
height: 3px;
background: rgba(255, 255, 255, 0.2);
border-radius: 2px;
overflow: hidden;
margin: 2rem auto 0;
}
.loading-progress {
height: 100%;
background: #E9E1D8;
border-radius: 2px;
transition: width 0.2s ease;
box-shadow: 0 0 8px rgba(233, 225, 216, 0.3);
}
@keyframes screenFadeIn {
0% { opacity: 0; }
100% { opacity: 1; }
}
@keyframes screenFadeOutUp {
0% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 0;
transform: translateY(-100px);
}
}
@keyframes logoFadeIn {
0% {
opacity: 0;
transform: scale(0.8) translateY(-20px);
}
100% {
opacity: 1;
transform: scale(1) translateY(0);
}
}
@keyframes logoPulse {
0%, 100% {
opacity: 0.8;
transform: scale(1);
}
50% {
opacity: 1;
transform: scale(1.05);
}
}
@keyframes textGlow {
0% {
text-shadow: 0 0 5px rgba(255, 255, 255, 0.5);
}
100% {
text-shadow: 0 0 20px rgba(255, 255, 255, 0.8), 0 0 30px rgba(255, 255, 255, 0.6);
}
}
@media (min-width: 768px) {
.loading-logo {
width: 160px;
height: 160px;
}
.loading-text {
font-size: 2.5rem;
}
.loading-bar {
width: 300px;
}
}
`}</style>
</div>
)
}

View File

@@ -0,0 +1,68 @@
import React from 'react'
interface PatternOverlayProps {
variant?: 'diagonal' | 'circles' | 'waves' | 'hexagons'
opacity?: number
className?: string
}
/** @description Elegant pattern overlay component */
export function PatternOverlay({
variant = 'diagonal',
opacity = 0.1,
className = ''
}: PatternOverlayProps) {
const getPatternStyle = () => {
switch (variant) {
case 'diagonal':
return {
backgroundImage: `
linear-gradient(45deg, currentColor 1px, transparent 1px),
linear-gradient(-45deg, currentColor 1px, transparent 1px)
`,
backgroundSize: '20px 20px'
}
case 'circles':
return {
backgroundImage: 'radial-gradient(circle, currentColor 1px, transparent 1px)',
backgroundSize: '30px 30px'
}
case 'waves':
return {
backgroundImage: `
radial-gradient(ellipse 60% 40%, currentColor 1px, transparent 1px),
radial-gradient(ellipse 40% 60%, currentColor 1px, transparent 1px)
`,
backgroundSize: '40px 40px'
}
case 'hexagons':
return {
backgroundImage: `
linear-gradient(60deg, currentColor 1px, transparent 1px),
linear-gradient(-60deg, currentColor 1px, transparent 1px),
linear-gradient(120deg, currentColor 1px, transparent 1px)
`,
backgroundSize: '25px 43px'
}
default:
return {}
}
}
return (
<div
className={`pattern-overlay ${className}`}
style={{
...getPatternStyle(),
opacity,
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
pointerEvents: 'none',
zIndex: 0
}}
/>
)
}

View File

@@ -0,0 +1,70 @@
'use client'
import { useState } from 'react'
import { Menu, X } from 'lucide-react'
/** @description Responsive navigation component with hamburger menu for mobile */
export function ResponsiveNav() {
const [isOpen, setIsOpen] = useState(false)
return (
<header className="site-header">
<nav className="nav-primary">
<div className="logo">
<a href="/">ANCHOR:23</a>
</div>
{/* Desktop nav */}
<ul className="nav-links hidden md:flex items-center space-x-8">
<li><a href="/">Inicio</a></li>
<li><a href="/historia">Nosotros</a></li>
<li><a href="/servicios">Servicios</a></li>
<li><a href="/contacto">Contacto</a></li>
</ul>
{/* Desktop actions */}
<div className="nav-actions hidden md:flex items-center gap-4">
<a href="/booking/servicios" className="btn-secondary">
Book Now
</a>
<a href="/membresias" className="btn-primary">
Memberships
</a>
</div>
{/* Mobile elegant vertical dots menu */}
<button
className="md:hidden p-1 ml-auto"
onClick={() => setIsOpen(!isOpen)}
aria-label="Toggle menu"
>
<div className="w-5 h-5 flex flex-col justify-center items-center space-y-0.25">
<span className="w-1.5 h-1.5 bg-current rounded-full opacity-80 hover:opacity-100 transition-opacity"></span>
<span className="w-1.5 h-1.5 bg-current rounded-full opacity-80 hover:opacity-100 transition-opacity"></span>
<span className="w-1.5 h-1.5 bg-current rounded-full opacity-80 hover:opacity-100 transition-opacity"></span>
</div>
</button>
</nav>
{/* Mobile menu */}
{isOpen && (
<div className="md:hidden bg-white/95 backdrop-blur-sm border-t border-gray-200 px-8 py-6">
<ul className="space-y-4 text-center">
<li><a href="/" className="block py-2 text-lg" onClick={() => setIsOpen(false)}>Inicio</a></li>
<li><a href="/historia" className="block py-2 text-lg" onClick={() => setIsOpen(false)}>Nosotros</a></li>
<li><a href="/servicios" className="block py-2 text-lg" onClick={() => setIsOpen(false)}>Servicios</a></li>
<li><a href="/contacto" className="block py-2 text-lg" onClick={() => setIsOpen(false)}>Contacto</a></li>
</ul>
<div className="flex flex-col items-center space-y-4 mt-6 pt-6 border-t border-gray-200">
<a href="/booking/servicios" className="btn-secondary w-full max-w-xs animate-pulse-subtle">
Book Now
</a>
<a href="/membresias" className="bg-[#3E352E] text-white hover:bg-[#3E352E]/90 w-full max-w-xs px-6 py-3 rounded-lg font-semibold transition-all duration-300">
Memberships
</a>
</div>
</div>
)}
</header>
)
}

View File

@@ -0,0 +1,95 @@
'use client'
import React, { useState, useEffect } from 'react'
/** @description Rolling phrases component that cycles through Anchor 23 standards */
export function RollingPhrases() {
const phrases = [
"Manifiesto la belleza que merezco",
"Atraigo experiencias extraordinarias",
"Mi confianza irradia elegancia",
"Soy el estándar de sofisticación",
"Mi presencia transforma espacios",
"Vivo con propósito y distinción"
]
const [currentPhrase, setCurrentPhrase] = useState(0)
const [isAnimating, setIsAnimating] = useState(false)
useEffect(() => {
const interval = setInterval(() => {
setIsAnimating(true)
setTimeout(() => {
setCurrentPhrase((prev) => (prev + 1) % phrases.length)
setIsAnimating(false)
}, 300)
}, 4000) // Cambiar cada 4 segundos
return () => clearInterval(interval)
}, [phrases.length])
return (
<div className="rolling-phrases">
<div className={`phrase-container ${isAnimating ? 'animating' : ''}`}>
<p className="phrase">
{phrases[currentPhrase]}
</p>
<div className="phrase-underline"></div>
</div>
<style jsx>{`
.rolling-phrases {
position: relative;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.phrase-container {
position: relative;
text-align: center;
}
.phrase {
font-size: 1.125rem;
font-weight: 300;
color: #6f5e4f;
margin: 0;
letter-spacing: 0.5px;
font-style: italic;
transition: all 0.3s ease;
}
.phrase-underline {
height: 2px;
background: linear-gradient(90deg, #8B4513, #DAA520, #8B4513);
width: 0;
margin: 8px auto 0;
border-radius: 1px;
transition: width 0.6s ease;
}
.phrase-container:not(.animating) .phrase-underline {
width: 80px;
}
.animating .phrase {
opacity: 0;
transform: translateY(-10px);
}
@media (min-width: 768px) {
.rolling-phrases {
height: 80px;
}
.phrase {
font-size: 1.5rem;
}
}
`}</style>
</div>
)
}

171
components/webhook-form.tsx Normal file
View File

@@ -0,0 +1,171 @@
'use client'
import { useState } from 'react'
import { CheckCircle } from 'lucide-react'
import { getDeviceType, sendWebhookPayload } from '@/lib/webhook'
interface WebhookFormProps {
formType: 'contact' | 'franchise' | 'membership'
title: string
successMessage?: string
successSubtext?: string
submitButtonText?: string
fields: {
name: string
label: string
type: 'text' | 'email' | 'tel' | 'textarea' | 'select'
required?: boolean
placeholder?: string
options?: { value: string; label: string }[]
rows?: number
}[]
additionalData?: Record<string, string>
}
export function WebhookForm({
formType,
title,
successMessage = 'Mensaje Enviado',
successSubtext = 'Gracias por contactarnos. Te responderemos lo antes posible.',
submitButtonText = 'Enviar',
fields,
additionalData
}: WebhookFormProps) {
const [isSubmitting, setIsSubmitting] = useState(false)
const [submitted, setSubmitted] = useState(false)
const [showThankYou, setShowThankYou] = useState(false)
const [submitError, setSubmitError] = useState<string | null>(null)
const formData = fields.reduce(
(acc, field) => ({ ...acc, [field.name]: '' }),
{} as Record<string, string>
)
const [values, setValues] = useState(formData)
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
) => {
setValues((prev) => ({
...prev,
[e.target.name]: e.target.value
}))
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsSubmitting(true)
setSubmitError(null)
const payload = {
form: formType,
...values,
timestamp_utc: new Date().toISOString(),
device_type: getDeviceType(),
...additionalData
}
try {
await sendWebhookPayload(payload)
setSubmitted(true)
setShowThankYou(true)
window.setTimeout(() => setShowThankYou(false), 3500)
setValues(formData)
} catch (error) {
setSubmitError('No pudimos enviar tu solicitud. Intenta de nuevo.')
} finally {
setIsSubmitting(false)
}
}
return (
<>
{showThankYou && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-white rounded-2xl p-8 max-w-md w-full text-center shadow-2xl animate-in fade-in zoom-in duration-300">
<CheckCircle className="w-16 h-16 text-green-600 mx-auto mb-4" />
<h3 className="text-2xl font-bold mb-2">¡Gracias!</h3>
<p className="text-gray-600">{successSubtext}</p>
</div>
</div>
)}
{submitted ? (
<div className="p-8 bg-green-50 border border-green-200 rounded-xl text-center">
<CheckCircle className="w-12 h-12 text-green-900 mb-4 mx-auto" />
<h4 className="text-xl font-semibold text-green-900 mb-2">
{successMessage}
</h4>
<p className="text-green-800">
{successSubtext}
</p>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-6 p-8 bg-white rounded-2xl shadow-lg border border-gray-100">
<div className="grid md:grid-cols-2 gap-6">
{fields.map((field) => (
<div key={field.name} className={field.type === 'textarea' ? 'md:col-span-2' : ''}>
<label htmlFor={field.name} className="block text-sm font-medium text-gray-700 mb-2">
{field.label}
</label>
{field.type === 'textarea' ? (
<textarea
id={field.name}
name={field.name}
value={values[field.name]}
onChange={handleChange}
required={field.required}
rows={field.rows || 4}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-900 focus:border-transparent resize-none"
placeholder={field.placeholder}
/>
) : field.type === 'select' ? (
<select
id={field.name}
name={field.name}
value={values[field.name]}
onChange={handleChange}
required={field.required}
className="w-full px-4 py-3 border border-gray-300 rounded-lg bg-white focus:ring-2 focus:ring-gray-900 focus:border-transparent"
>
<option value="">{field.placeholder || 'Selecciona una opción'}</option>
{field.options?.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
) : (
<input
type={field.type}
id={field.name}
name={field.name}
value={values[field.name]}
onChange={handleChange}
required={field.required}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-900 focus:border-transparent"
placeholder={field.placeholder}
/>
)}
</div>
))}
</div>
{submitError && (
<p className="text-sm text-red-600 text-center">
{submitError}
</p>
)}
<button
type="submit"
className="bg-[#3E352E] text-white hover:bg-[#3E352E]/90 px-8 py-3 rounded-lg font-semibold shadow-md hover:shadow-lg transition-all duration-300 inline-flex items-center justify-center w-full"
disabled={isSubmitting}
>
{isSubmitting ? 'Enviando...' : submitButtonText}
</button>
</form>
)}
</>
)
}