mirror of
https://github.com/marcogll/AnchorOS.git
synced 2026-03-15 11:24:26 +00:00
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:
77
components/animated-logo.tsx
Normal file
77
components/animated-logo.tsx
Normal file
File diff suppressed because one or more lines are too long
51
components/app-wrapper.tsx
Normal file
51
components/app-wrapper.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
28
components/formbricks-provider.tsx
Normal file
28
components/formbricks-provider.tsx
Normal 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
|
||||
}
|
||||
196
components/loading-screen.tsx
Normal file
196
components/loading-screen.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
68
components/pattern-overlay.tsx
Normal file
68
components/pattern-overlay.tsx
Normal 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
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
70
components/responsive-nav.tsx
Normal file
70
components/responsive-nav.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
95
components/rolling-phrases.tsx
Normal file
95
components/rolling-phrases.tsx
Normal 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
171
components/webhook-form.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user