first commit

This commit is contained in:
Michaël
2026-01-29 14:13:11 -03:00
commit 2302748c87
105 changed files with 93301 additions and 0 deletions
@@ -0,0 +1,94 @@
'use client';
import { useState } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { Locale, localeNames, localeFlags } from '@/i18n';
import { ChevronDownIcon, GlobeAltIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
interface LanguageToggleProps {
variant?: 'dropdown' | 'buttons';
showFlags?: boolean;
}
export default function LanguageToggle({
variant = 'dropdown',
showFlags = true
}: LanguageToggleProps) {
const { locale, setLocale } = useLanguage();
const [isOpen, setIsOpen] = useState(false);
const availableLocales = Object.keys(localeNames) as Locale[];
if (variant === 'buttons') {
return (
<div className="flex items-center gap-1 bg-secondary-gray rounded-btn p-1">
{availableLocales.map((loc) => (
<button
key={loc}
onClick={() => setLocale(loc)}
className={clsx(
'px-3 py-1.5 rounded-btn text-sm font-medium transition-colors',
{
'bg-white shadow-sm text-primary-dark': locale === loc,
'text-gray-600 hover:text-primary-dark': locale !== loc,
}
)}
>
{showFlags && <span className="mr-1">{localeFlags[loc]}</span>}
{loc.toUpperCase()}
</button>
))}
</div>
);
}
return (
<div className="relative">
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 px-3 py-2 rounded-btn hover:bg-secondary-gray transition-colors"
>
<GlobeAltIcon className="w-5 h-5 text-gray-600" />
{showFlags && <span>{localeFlags[locale]}</span>}
<span className="text-sm font-medium">{localeNames[locale]}</span>
<ChevronDownIcon
className={clsx(
'w-4 h-4 text-gray-500 transition-transform',
{ 'rotate-180': isOpen }
)}
/>
</button>
{isOpen && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setIsOpen(false)}
/>
<div className="absolute right-0 mt-2 w-40 bg-white rounded-card shadow-card-hover border border-secondary-light-gray z-20 overflow-hidden">
{availableLocales.map((loc) => (
<button
key={loc}
onClick={() => {
setLocale(loc);
setIsOpen(false);
}}
className={clsx(
'w-full flex items-center gap-2 px-4 py-2.5 text-left transition-colors',
{
'bg-secondary-gray': locale === loc,
'hover:bg-gray-50': locale !== loc,
}
)}
>
{showFlags && <span>{localeFlags[loc]}</span>}
<span className="text-sm font-medium">{localeNames[loc]}</span>
</button>
))}
</div>
</>
)}
</div>
);
}
+148
View File
@@ -0,0 +1,148 @@
'use client';
import { useState } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import {
ShareIcon,
LinkIcon,
CheckIcon,
} from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
interface ShareButtonsProps {
title: string;
url?: string;
description?: string;
}
export default function ShareButtons({ title, url, description }: ShareButtonsProps) {
const { locale } = useLanguage();
const [copied, setCopied] = useState(false);
// Use provided URL or current page URL
const shareUrl = url || (typeof window !== 'undefined' ? window.location.href : '');
const shareText = description || title;
const handleCopyLink = async () => {
try {
await navigator.clipboard.writeText(shareUrl);
setCopied(true);
toast.success(locale === 'es' ? 'Enlace copiado' : 'Link copied');
setTimeout(() => setCopied(false), 2000);
} catch (err) {
toast.error(locale === 'es' ? 'Error al copiar' : 'Failed to copy');
}
};
const shareToWhatsApp = () => {
const text = encodeURIComponent(`${shareText}\n\n${shareUrl}`);
window.open(`https://wa.me/?text=${text}`, '_blank');
};
const shareToFacebook = () => {
const encodedUrl = encodeURIComponent(shareUrl);
window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`, '_blank', 'width=600,height=400');
};
const shareToTwitter = () => {
const text = encodeURIComponent(shareText);
const encodedUrl = encodeURIComponent(shareUrl);
window.open(`https://twitter.com/intent/tweet?text=${text}&url=${encodedUrl}`, '_blank', 'width=600,height=400');
};
const shareToLinkedIn = () => {
const encodedUrl = encodeURIComponent(shareUrl);
window.open(`https://www.linkedin.com/sharing/share-offsite/?url=${encodedUrl}`, '_blank', 'width=600,height=400');
};
const handleNativeShare = async () => {
if (navigator.share) {
try {
await navigator.share({
title,
text: shareText,
url: shareUrl,
});
} catch (err) {
// User cancelled or error
}
}
};
return (
<div className="flex flex-col gap-3">
<p className="text-sm font-medium text-gray-600">
{locale === 'es' ? 'Compartir evento' : 'Share event'}
</p>
<div className="flex items-center gap-2 flex-wrap">
{/* WhatsApp */}
<button
onClick={shareToWhatsApp}
className="w-10 h-10 flex items-center justify-center rounded-full bg-[#25D366] text-white hover:opacity-90 transition-opacity"
title="WhatsApp"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/>
</svg>
</button>
{/* Facebook */}
<button
onClick={shareToFacebook}
className="w-10 h-10 flex items-center justify-center rounded-full bg-[#1877F2] text-white hover:opacity-90 transition-opacity"
title="Facebook"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
</svg>
</button>
{/* Twitter/X */}
<button
onClick={shareToTwitter}
className="w-10 h-10 flex items-center justify-center rounded-full bg-black text-white hover:opacity-90 transition-opacity"
title="X (Twitter)"
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
</svg>
</button>
{/* LinkedIn */}
<button
onClick={shareToLinkedIn}
className="w-10 h-10 flex items-center justify-center rounded-full bg-[#0A66C2] text-white hover:opacity-90 transition-opacity"
title="LinkedIn"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
</svg>
</button>
{/* Copy Link */}
<button
onClick={handleCopyLink}
className="w-10 h-10 flex items-center justify-center rounded-full bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors"
title={locale === 'es' ? 'Copiar enlace' : 'Copy link'}
>
{copied ? (
<CheckIcon className="w-5 h-5 text-green-600" />
) : (
<LinkIcon className="w-5 h-5" />
)}
</button>
{/* Native Share (mobile) */}
{typeof navigator !== 'undefined' && typeof navigator.share === 'function' && (
<button
onClick={handleNativeShare}
className="w-10 h-10 flex items-center justify-center rounded-full bg-primary-yellow text-primary-dark hover:bg-primary-yellow/90 transition-colors"
title={locale === 'es' ? 'Más opciones' : 'More options'}
>
<ShareIcon className="w-5 h-5" />
</button>
)}
</div>
</div>
);
}
+119
View File
@@ -0,0 +1,119 @@
'use client';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
import { getSocialLinks, socialIcons } from '@/lib/socialLinks';
const legalLinks = [
{ slug: 'terms-policy', en: 'Terms & Conditions', es: 'Términos y Condiciones' },
{ slug: 'privacy-policy', en: 'Privacy Policy', es: 'Política de Privacidad' },
{ slug: 'refund-cancelation-policy', en: 'Refund Policy', es: 'Política de Reembolso' },
];
export default function Footer() {
const { t, locale } = useLanguage();
const currentYear = new Date().getFullYear();
const socialLinks = getSocialLinks();
return (
<footer className="bg-secondary-gray border-t border-secondary-light-gray">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
{/* Brand */}
<div className="col-span-1 md:col-span-2">
<Link href="/" className="inline-block">
<span className="text-2xl font-bold font-heading text-primary-dark">
Span<span className="text-primary-yellow">glish</span>
</span>
</Link>
<p className="mt-3 text-gray-600 max-w-md">
{t('footer.tagline')}
</p>
</div>
{/* Quick Links */}
<div>
<h3 className="font-semibold text-primary-dark mb-4">
{t('footer.links')}
</h3>
<ul className="space-y-2">
<li>
<Link
href="/events"
className="text-gray-600 hover:text-primary-dark transition-colors"
>
{t('nav.events')}
</Link>
</li>
<li>
<Link
href="/community"
className="text-gray-600 hover:text-primary-dark transition-colors"
>
{t('nav.community')}
</Link>
</li>
<li>
<Link
href="/contact"
className="text-gray-600 hover:text-primary-dark transition-colors"
>
{t('nav.contact')}
</Link>
</li>
<li>
<Link
href="/faq"
className="text-gray-600 hover:text-primary-dark transition-colors"
>
{t('nav.faq')}
</Link>
</li>
</ul>
</div>
{/* Social */}
{socialLinks.length > 0 && (
<div>
<h3 className="font-semibold text-primary-dark mb-4">
{t('footer.social')}
</h3>
<div className="flex flex-wrap gap-3">
{socialLinks.map((link) => (
<a
key={link.type}
href={link.url}
target={link.type === 'email' ? undefined : '_blank'}
rel={link.type === 'email' ? undefined : 'noopener noreferrer'}
className="w-10 h-10 flex items-center justify-center rounded-full bg-white shadow-sm hover:shadow-md hover:bg-primary-yellow/10 transition-all"
title={link.label}
>
{socialIcons[link.type]}
</a>
))}
</div>
</div>
)}
</div>
{/* Legal Links */}
<div className="border-t border-secondary-light-gray mt-10 pt-8">
<div className="flex flex-wrap justify-center gap-4 md:gap-6 mb-4">
{legalLinks.map((link) => (
<Link
key={link.slug}
href={`/legal/${link.slug}`}
className="text-gray-500 hover:text-primary-dark transition-colors text-sm"
>
{locale === 'es' ? link.es : link.en}
</Link>
))}
</div>
<div className="text-center text-gray-500 text-sm">
{t('footer.copyright', { year: currentYear })}
</div>
</div>
</div>
</footer>
);
}
+165
View File
@@ -0,0 +1,165 @@
'use client';
import Link from 'next/link';
import { useState } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext';
import LanguageToggle from '@/components/LanguageToggle';
import Button from '@/components/ui/Button';
import { Bars3Icon, XMarkIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
export default function Header() {
const { t } = useLanguage();
const { user, isAdmin, logout } = useAuth();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const navLinks = [
{ href: '/', label: t('nav.home') },
{ href: '/events', label: t('nav.events') },
{ href: '/community', label: t('nav.community') },
{ href: '/contact', label: t('nav.contact') },
];
return (
<header className="sticky top-0 z-50 bg-white shadow-sm">
<nav className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
{/* Logo */}
<Link href="/" className="flex items-center gap-2">
<span className="text-2xl font-bold font-heading text-primary-dark">
Span<span className="text-primary-yellow">glish</span>
</span>
</Link>
{/* Desktop Navigation */}
<div className="hidden md:flex items-center gap-6">
{navLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className="text-gray-700 hover:text-primary-dark font-medium transition-colors"
>
{link.label}
</Link>
))}
</div>
{/* Right side actions */}
<div className="hidden md:flex items-center gap-4">
<LanguageToggle />
{user ? (
<div className="flex items-center gap-3">
<Link href="/dashboard">
<Button variant="ghost" size="sm">
{t('nav.dashboard')}
</Button>
</Link>
{isAdmin && (
<Link href="/admin">
<Button variant="ghost" size="sm">
{t('nav.admin')}
</Button>
</Link>
)}
<span className="text-sm text-gray-600">{user.name}</span>
<Button variant="outline" size="sm" onClick={logout}>
{t('nav.logout')}
</Button>
</div>
) : (
<div className="flex items-center gap-2">
<Link href="/login">
<Button variant="ghost" size="sm">
{t('nav.login')}
</Button>
</Link>
<Link href="/events">
<Button size="sm">
{t('nav.joinEvent')}
</Button>
</Link>
</div>
)}
</div>
{/* Mobile menu button */}
<button
className="md:hidden p-2 rounded-lg hover:bg-gray-100"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
>
{mobileMenuOpen ? (
<XMarkIcon className="w-6 h-6" />
) : (
<Bars3Icon className="w-6 h-6" />
)}
</button>
</div>
{/* Mobile Navigation */}
<div
className={clsx(
'md:hidden overflow-hidden transition-all duration-300',
{
'max-h-0': !mobileMenuOpen,
'max-h-96 pb-4': mobileMenuOpen,
}
)}
>
<div className="flex flex-col gap-2 pt-4">
{navLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className="px-4 py-2 text-gray-700 hover:bg-gray-50 rounded-lg font-medium"
onClick={() => setMobileMenuOpen(false)}
>
{link.label}
</Link>
))}
<div className="border-t border-gray-100 mt-2 pt-4 px-4">
<LanguageToggle variant="buttons" />
</div>
<div className="px-4 pt-2 flex flex-col gap-2">
{user ? (
<>
<Link href="/dashboard" onClick={() => setMobileMenuOpen(false)}>
<Button variant="outline" className="w-full">
{t('nav.dashboard')}
</Button>
</Link>
{isAdmin && (
<Link href="/admin" onClick={() => setMobileMenuOpen(false)}>
<Button variant="outline" className="w-full">
{t('nav.admin')}
</Button>
</Link>
)}
<Button variant="secondary" onClick={logout} className="w-full">
{t('nav.logout')}
</Button>
</>
) : (
<>
<Link href="/login" onClick={() => setMobileMenuOpen(false)}>
<Button variant="outline" className="w-full">
{t('nav.login')}
</Button>
</Link>
<Link href="/events" onClick={() => setMobileMenuOpen(false)}>
<Button className="w-full">
{t('nav.joinEvent')}
</Button>
</Link>
</>
)}
</div>
</div>
</div>
</nav>
</header>
);
}
@@ -0,0 +1,191 @@
'use client';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import Link from 'next/link';
import { ArrowLeftIcon } from '@heroicons/react/24/outline';
interface LegalPageLayoutProps {
title: string;
content: string;
lastUpdated?: string;
}
export default function LegalPageLayout({ title, content, lastUpdated }: LegalPageLayoutProps) {
return (
<div className="section-padding">
<div className="container-page max-w-4xl">
{/* Back link */}
<Link
href="/"
className="inline-flex items-center text-gray-600 hover:text-primary-dark transition-colors mb-8"
>
<ArrowLeftIcon className="w-4 h-4 mr-2" />
Back to Home
</Link>
{/* Title */}
<div className="mb-8 pb-6 border-b border-gray-200">
<h1 className="text-3xl md:text-4xl font-bold text-primary-dark mb-2">
{title}
</h1>
{lastUpdated && lastUpdated !== '[Insert Date]' && (
<p className="text-sm text-gray-500">
Last updated: {lastUpdated}
</p>
)}
</div>
{/* Markdown content */}
<article className="prose prose-gray max-w-none legal-content">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
// Style headings
h1: ({ children }) => (
<h1 className="text-3xl font-bold text-primary-dark mt-8 mb-4 first:mt-0">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="text-2xl font-semibold text-primary-dark mt-8 mb-4 pb-2 border-b border-gray-200">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="text-xl font-semibold text-primary-dark mt-6 mb-3">
{children}
</h3>
),
h4: ({ children }) => (
<h4 className="text-lg font-semibold text-primary-dark mt-4 mb-2">
{children}
</h4>
),
// Style paragraphs
p: ({ children }) => (
<p className="text-gray-700 leading-relaxed mb-4">
{children}
</p>
),
// Style lists
ul: ({ children }) => (
<ul className="list-disc list-inside space-y-2 mb-4 text-gray-700 ml-4">
{children}
</ul>
),
ol: ({ children }) => (
<ol className="list-decimal list-inside space-y-2 mb-4 text-gray-700 ml-4">
{children}
</ol>
),
li: ({ children }) => (
<li className="text-gray-700">
{children}
</li>
),
// Style links
a: ({ href, children }) => (
<a
href={href}
className="text-primary-dark underline hover:text-primary-yellow transition-colors"
target={href?.startsWith('http') ? '_blank' : undefined}
rel={href?.startsWith('http') ? 'noopener noreferrer' : undefined}
>
{children}
</a>
),
// Style horizontal rules
hr: () => (
<hr className="my-8 border-gray-200" />
),
// Style blockquotes
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-primary-yellow pl-4 my-4 italic text-gray-600">
{children}
</blockquote>
),
// Style tables
table: ({ children }) => (
<div className="overflow-x-auto my-6">
<table className="min-w-full divide-y divide-gray-200 border border-gray-200 rounded-lg">
{children}
</table>
</div>
),
thead: ({ children }) => (
<thead className="bg-gray-50">
{children}
</thead>
),
tbody: ({ children }) => (
<tbody className="bg-white divide-y divide-gray-200">
{children}
</tbody>
),
tr: ({ children }) => (
<tr>
{children}
</tr>
),
th: ({ children }) => (
<th className="px-4 py-3 text-left text-sm font-semibold text-primary-dark">
{children}
</th>
),
td: ({ children }) => (
<td className="px-4 py-3 text-sm text-gray-700">
{children}
</td>
),
// Style code blocks
code: ({ className, children }) => {
const isInline = !className;
if (isInline) {
return (
<code className="bg-gray-100 px-1.5 py-0.5 rounded text-sm text-gray-800">
{children}
</code>
);
}
return (
<code className={className}>
{children}
</code>
);
},
pre: ({ children }) => (
<pre className="bg-gray-100 rounded-lg p-4 overflow-x-auto my-4">
{children}
</pre>
),
// Style strong and emphasis
strong: ({ children }) => (
<strong className="font-semibold text-primary-dark">
{children}
</strong>
),
em: ({ children }) => (
<em className="italic">
{children}
</em>
),
}}
>
{content}
</ReactMarkdown>
</article>
{/* Back to top link */}
<div className="mt-12 pt-6 border-t border-gray-200">
<button
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
className="text-gray-500 hover:text-primary-dark transition-colors text-sm"
>
Back to top
</button>
</div>
</div>
</div>
);
}
+77
View File
@@ -0,0 +1,77 @@
'use client';
import { ButtonHTMLAttributes, forwardRef } from 'react';
import clsx from 'clsx';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger';
size?: 'sm' | 'md' | 'lg';
isLoading?: boolean;
}
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = 'primary', size = 'md', isLoading, children, disabled, ...props }, ref) => {
return (
<button
ref={ref}
disabled={disabled || isLoading}
className={clsx(
'inline-flex items-center justify-center font-medium transition-all duration-200 rounded-btn focus:outline-none focus:ring-2 focus:ring-offset-2',
{
// Variants
'bg-primary-yellow text-primary-dark hover:bg-yellow-400 focus:ring-yellow-400':
variant === 'primary',
'bg-primary-dark text-white hover:bg-gray-800 focus:ring-gray-800':
variant === 'secondary',
'border-2 border-primary-dark text-primary-dark bg-transparent hover:bg-gray-50 focus:ring-gray-400':
variant === 'outline',
'text-primary-dark hover:bg-gray-100 focus:ring-gray-300':
variant === 'ghost',
'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500':
variant === 'danger',
// Sizes
'text-sm px-3 py-1.5': size === 'sm',
'text-base px-5 py-2.5': size === 'md',
'text-lg px-7 py-3': size === 'lg',
// States
'opacity-50 cursor-not-allowed': disabled || isLoading,
},
className
)}
{...props}
>
{isLoading ? (
<>
<svg
className="animate-spin -ml-1 mr-2 h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Loading...
</>
) : (
children
)}
</button>
);
}
);
Button.displayName = 'Button';
export default Button;
+35
View File
@@ -0,0 +1,35 @@
'use client';
import { HTMLAttributes, forwardRef } from 'react';
import clsx from 'clsx';
interface CardProps extends HTMLAttributes<HTMLDivElement> {
variant?: 'default' | 'elevated' | 'bordered';
}
const Card = forwardRef<HTMLDivElement, CardProps>(
({ className, variant = 'default', children, ...props }, ref) => {
return (
<div
ref={ref}
className={clsx(
'bg-white rounded-card overflow-hidden',
{
'shadow-card': variant === 'default',
'shadow-card-hover hover:shadow-lg transition-shadow duration-200':
variant === 'elevated',
'border border-secondary-light-gray': variant === 'bordered',
},
className
)}
{...props}
>
{children}
</div>
);
}
);
Card.displayName = 'Card';
export default Card;
+48
View File
@@ -0,0 +1,48 @@
'use client';
import { InputHTMLAttributes, forwardRef } from 'react';
import clsx from 'clsx';
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
}
const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, label, error, id, ...props }, ref) => {
return (
<div className="w-full">
{label && (
<label
htmlFor={id}
className="block text-sm font-medium text-primary-dark mb-1.5"
>
{label}
</label>
)}
<input
ref={ref}
id={id}
className={clsx(
'w-full px-4 py-3 rounded-btn border transition-colors duration-200',
'focus:outline-none focus:ring-2 focus:ring-primary-yellow focus:border-transparent',
'placeholder:text-gray-400',
{
'border-secondary-light-gray': !error,
'border-red-500 focus:ring-red-500': error,
},
className
)}
{...props}
/>
{error && (
<p className="mt-1.5 text-sm text-red-600">{error}</p>
)}
</div>
);
}
);
Input.displayName = 'Input';
export default Input;