diff --git a/backend/src/lib/emailTemplates.ts b/backend/src/lib/emailTemplates.ts index 2468477..17ccf90 100644 --- a/backend/src/lib/emailTemplates.ts +++ b/backend/src/lib/emailTemplates.ts @@ -88,13 +88,9 @@ export const baseEmailWrapper = ` padding: 24px; text-align: center; } - .header h1 { - margin: 0; - color: #fff; - font-size: 24px; - } - .header h1 span { - color: #f4d03f; + .header img { + max-height: 40px; + width: auto; } .content { padding: 32px 24px; @@ -191,7 +187,7 @@ export const baseEmailWrapper = `
-

Spanglish

+ Spanglish
{{content}} diff --git a/frontend/.env.example b/frontend/.env.example index bcc17eb..ef2402d 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -2,7 +2,7 @@ PORT=3002 # Site URL (for SEO canonical URLs, sitemap, etc.) -NEXT_PUBLIC_SITE_URL=https://spanglish.com.py +NEXT_PUBLIC_SITE_URL=https://spanglishcommunity.com # API URL (leave empty for same-origin proxy) NEXT_PUBLIC_API_URL= diff --git a/frontend/public/images/logo-spanglish.png b/frontend/public/images/logo-spanglish.png new file mode 100644 index 0000000..e2865c0 Binary files /dev/null and b/frontend/public/images/logo-spanglish.png differ diff --git a/frontend/src/app/(public)/components/AboutSection.tsx b/frontend/src/app/(public)/components/AboutSection.tsx new file mode 100644 index 0000000..465e78f --- /dev/null +++ b/frontend/src/app/(public)/components/AboutSection.tsx @@ -0,0 +1,64 @@ +'use client'; + +import { useLanguage } from '@/context/LanguageContext'; +import Card from '@/components/ui/Card'; +import { + CalendarIcon, + ChatBubbleLeftRightIcon, + AcademicCapIcon +} from '@heroicons/react/24/outline'; + +export default function AboutSection() { + const { t } = useLanguage(); + + return ( +
+
+
+

{t('home.about.title')}

+

+ {t('home.about.description')} +

+
+ +
+ +
+ +
+

+ {t('home.about.feature1')} +

+

+ {t('home.about.feature1Desc')} +

+
+ + +
+ +
+

+ {t('home.about.feature2')} +

+

+ {t('home.about.feature2Desc')} +

+
+ + +
+ +
+

+ {t('home.about.feature3')} +

+

+ {t('home.about.feature3Desc')} +

+
+
+
+
+ ); +} diff --git a/frontend/src/app/(public)/components/HeroSection.tsx b/frontend/src/app/(public)/components/HeroSection.tsx new file mode 100644 index 0000000..362c4b6 --- /dev/null +++ b/frontend/src/app/(public)/components/HeroSection.tsx @@ -0,0 +1,101 @@ +'use client'; + +import Link from 'next/link'; +import Image from 'next/image'; +import { useLanguage } from '@/context/LanguageContext'; +import Button from '@/components/ui/Button'; +import { + ChatBubbleLeftRightIcon, + UserGroupIcon +} from '@heroicons/react/24/outline'; + +export default function HeroSection() { + const { t } = useLanguage(); + + return ( +
+
+
+
+

+ {t('home.hero.title')} +

+

+ {t('home.hero.subtitle')} +

+
+ + + + + + +
+
+ + {/* Hero Image Grid */} +
+
+
+
+ Spanglish language exchange social event in Asunción +
+ +
+
+ English and Spanish language practice session in Asunción +
+
+
+
+ Spanglish community meetup in Paraguay +
+
+ Language exchange group practicing English and Spanish +
+ +
+
+
+ {/* Decorative elements */} +
+
+
+
+
+
+ ); +} diff --git a/frontend/src/app/(public)/components/NewsletterForm.tsx b/frontend/src/app/(public)/components/NewsletterForm.tsx new file mode 100644 index 0000000..6262b6a --- /dev/null +++ b/frontend/src/app/(public)/components/NewsletterForm.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { useState } from 'react'; +import { useLanguage } from '@/context/LanguageContext'; +import { contactsApi } from '@/lib/api'; +import Button from '@/components/ui/Button'; +import Input from '@/components/ui/Input'; +import toast from 'react-hot-toast'; + +export default function NewsletterForm() { + const { t } = useLanguage(); + const [email, setEmail] = useState(''); + const [subscribing, setSubscribing] = useState(false); + + const handleSubscribe = async (e: React.FormEvent) => { + e.preventDefault(); + if (!email) return; + + setSubscribing(true); + try { + await contactsApi.subscribe(email); + toast.success(t('home.newsletter.success')); + setEmail(''); + } catch (error) { + toast.error(t('home.newsletter.error')); + } finally { + setSubscribing(false); + } + }; + + return ( +
+ setEmail(e.target.value)} + className="flex-1 bg-white/10 border-white/20 text-white placeholder:text-gray-400" + required + /> + +
+ ); +} diff --git a/frontend/src/app/(public)/components/NewsletterSection.tsx b/frontend/src/app/(public)/components/NewsletterSection.tsx new file mode 100644 index 0000000..e56fe7a --- /dev/null +++ b/frontend/src/app/(public)/components/NewsletterSection.tsx @@ -0,0 +1,26 @@ +'use client'; + +import { useLanguage } from '@/context/LanguageContext'; +import { SparklesIcon } from '@heroicons/react/24/outline'; +import NewsletterForm from './NewsletterForm'; + +export default function NewsletterSection() { + const { t } = useLanguage(); + + return ( +
+
+
+ +

+ {t('home.newsletter.title')} +

+

+ {t('home.newsletter.description')} +

+ +
+
+
+ ); +} diff --git a/frontend/src/app/(public)/components/NextEventSection.tsx b/frontend/src/app/(public)/components/NextEventSection.tsx new file mode 100644 index 0000000..b0999bb --- /dev/null +++ b/frontend/src/app/(public)/components/NextEventSection.tsx @@ -0,0 +1,108 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import Link from 'next/link'; +import { useLanguage } from '@/context/LanguageContext'; +import { eventsApi, Event } from '@/lib/api'; +import Button from '@/components/ui/Button'; +import Card from '@/components/ui/Card'; +import { CalendarIcon, MapPinIcon } from '@heroicons/react/24/outline'; + +export default function NextEventSection() { + const { t, locale } = useLanguage(); + const [nextEvent, setNextEvent] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + eventsApi.getNextUpcoming() + .then(({ event }) => setNextEvent(event)) + .catch(console.error) + .finally(() => setLoading(false)); + }, []); + + const formatDate = (dateStr: string) => { + return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + }); + }; + + const formatTime = (dateStr: string) => { + return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', { + hour: '2-digit', + minute: '2-digit', + }); + }; + + if (loading) { + return ( +
+
+
+ ); + } + + if (!nextEvent) { + return ( +
+ +

{t('home.nextEvent.noEvents')}

+

{t('home.nextEvent.stayTuned')}

+
+ ); + } + + return ( + + +
+
+

+ {locale === 'es' && nextEvent.titleEs ? nextEvent.titleEs : nextEvent.title} +

+

+ {locale === 'es' && nextEvent.descriptionEs + ? nextEvent.descriptionEs + : nextEvent.description} +

+ +
+
+ + {formatDate(nextEvent.startDatetime)} +
+
+ + ⏰ + + {formatTime(nextEvent.startDatetime)} +
+
+ + {nextEvent.location} +
+
+
+ +
+
+ + {nextEvent.price === 0 + ? t('events.details.free') + : `${nextEvent.price.toLocaleString()} ${nextEvent.currency}`} + +

+ {nextEvent.availableSeats} {t('events.details.spotsLeft')} +

+
+ +
+
+
+ + ); +} diff --git a/frontend/src/app/(public)/components/NextEventSectionWrapper.tsx b/frontend/src/app/(public)/components/NextEventSectionWrapper.tsx new file mode 100644 index 0000000..a2525ef --- /dev/null +++ b/frontend/src/app/(public)/components/NextEventSectionWrapper.tsx @@ -0,0 +1,21 @@ +'use client'; + +import { useLanguage } from '@/context/LanguageContext'; +import NextEventSection from './NextEventSection'; + +export default function NextEventSectionWrapper() { + const { t } = useLanguage(); + + return ( +
+
+

+ {t('home.nextEvent.title')} +

+
+ +
+
+
+ ); +} diff --git a/frontend/src/app/(public)/contact/page.tsx b/frontend/src/app/(public)/contact/page.tsx index ea9de2f..a722ef5 100644 --- a/frontend/src/app/(public)/contact/page.tsx +++ b/frontend/src/app/(public)/contact/page.tsx @@ -104,7 +104,7 @@ export default function ContactPage() {

{t('contact.info.email')}

{emailLink.handle} @@ -129,7 +129,7 @@ export default function ContactPage() { href={link.url} target="_blank" rel="noopener noreferrer" - className="flex items-center gap-3 text-secondary-blue hover:text-primary-dark transition-colors group" + className="flex items-center gap-3 text-brand-navy hover:text-primary-dark transition-colors group" > {socialIcons[link.type]} diff --git a/frontend/src/app/(public)/events/[id]/EventDetailClient.tsx b/frontend/src/app/(public)/events/[id]/EventDetailClient.tsx index 00ade58..8bb41a8 100644 --- a/frontend/src/app/(public)/events/[id]/EventDetailClient.tsx +++ b/frontend/src/app/(public)/events/[id]/EventDetailClient.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'; import Link from 'next/link'; +import Image from 'next/image'; import { useLanguage } from '@/context/LanguageContext'; import { eventsApi, Event } from '@/lib/api'; import Card from '@/components/ui/Card'; @@ -22,6 +23,12 @@ interface EventDetailClientProps { export default function EventDetailClient({ eventId, initialEvent }: EventDetailClientProps) { const { t, locale } = useLanguage(); const [event, setEvent] = useState(initialEvent); + const [mounted, setMounted] = useState(false); + + // Ensure consistent hydration by only rendering dynamic content after mount + useEffect(() => { + setMounted(true); + }, []); // Refresh event data on client for real-time availability useEffect(() => { @@ -48,7 +55,8 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail const isSoldOut = event.availableSeats === 0; const isCancelled = event.status === 'cancelled'; - const isPastEvent = new Date(event.startDatetime) < new Date(); + // Only calculate isPastEvent after mount to avoid hydration mismatch + const isPastEvent = mounted ? new Date(event.startDatetime) < new Date() : false; const canBook = !isSoldOut && !isCancelled && !isPastEvent && event.status === 'published'; return ( @@ -66,14 +74,20 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail {/* Event Details */}
- {/* Banner */} + {/* Banner - LCP element, loaded with high priority */} + {/* Using unoptimized for backend-served images via /uploads/ rewrite */} {event.bannerUrl ? ( - {`${event.title} +
+ {`${event.title} +
) : (
@@ -82,7 +96,7 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
-

+

{locale === 'es' && event.titleEs ? event.titleEs : event.title}

{isCancelled && ( @@ -98,7 +112,7 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail

{t('events.details.date')}

-

{formatDate(event.startDatetime)}

+

{formatDate(event.startDatetime)}

@@ -106,7 +120,7 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail

{t('events.details.time')}

-

{formatTime(event.startDatetime)}

+

{formatTime(event.startDatetime)}

@@ -141,7 +155,7 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail

About this event

-

+

{locale === 'es' && event.descriptionEs ? event.descriptionEs : event.description} @@ -149,7 +163,7 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail

{/* Social Sharing */} -
+
155 + ? event.description.slice(0, 152).trim() + '...' + : event.description; - const title = `${event.title} – English & Spanish Meetup in Asunción`; - const description = `Join Spanglish on ${eventDate} in Asunción. Practice English and Spanish in a relaxed social setting. Limited spots available.`; + // Convert relative banner URL to absolute URL for SEO + const imageUrl = event.bannerUrl + ? (event.bannerUrl.startsWith('http') ? event.bannerUrl : `${siteUrl}${event.bannerUrl}`) + : `${siteUrl}/images/og-image.jpg`; return { title, @@ -64,15 +65,13 @@ export async function generateMetadata({ params }: { params: { id: string } }): description, type: 'website', url: `${siteUrl}/events/${event.id}`, - images: event.bannerUrl - ? [{ url: event.bannerUrl, width: 1200, height: 630, alt: event.title }] - : [{ url: `${siteUrl}/images/og-image.jpg`, width: 1200, height: 630, alt: 'Spanglish Language Exchange Event' }], + images: [{ url: imageUrl, width: 1200, height: 630, alt: event.title }], }, twitter: { card: 'summary_large_image', title, description, - images: event.bannerUrl ? [event.bannerUrl] : [`${siteUrl}/images/og-image.jpg`], + images: [imageUrl], }, alternates: { canonical: `${siteUrl}/events/${event.id}`, diff --git a/frontend/src/app/(public)/page.tsx b/frontend/src/app/(public)/page.tsx index 6d117c1..805eb35 100644 --- a/frontend/src/app/(public)/page.tsx +++ b/frontend/src/app/(public)/page.tsx @@ -1,308 +1,15 @@ -'use client'; - -import { useState, useEffect } from 'react'; -import Link from 'next/link'; -import Image from 'next/image'; -import { useLanguage } from '@/context/LanguageContext'; -import { eventsApi, contactsApi, Event } from '@/lib/api'; -import Button from '@/components/ui/Button'; -import Card from '@/components/ui/Card'; -import Input from '@/components/ui/Input'; -import { - CalendarIcon, - MapPinIcon, - UserGroupIcon, - ChatBubbleLeftRightIcon, - AcademicCapIcon, - SparklesIcon -} from '@heroicons/react/24/outline'; -import toast from 'react-hot-toast'; +import HeroSection from './components/HeroSection'; +import NextEventSectionWrapper from './components/NextEventSectionWrapper'; +import AboutSection from './components/AboutSection'; +import NewsletterSection from './components/NewsletterSection'; export default function HomePage() { - const { t, locale } = useLanguage(); - const [nextEvent, setNextEvent] = useState(null); - const [loading, setLoading] = useState(true); - const [email, setEmail] = useState(''); - const [subscribing, setSubscribing] = useState(false); - - useEffect(() => { - eventsApi.getNextUpcoming() - .then(({ event }) => setNextEvent(event)) - .catch(console.error) - .finally(() => setLoading(false)); - }, []); - - const handleSubscribe = async (e: React.FormEvent) => { - e.preventDefault(); - if (!email) return; - - setSubscribing(true); - try { - await contactsApi.subscribe(email); - toast.success(t('home.newsletter.success')); - setEmail(''); - } catch (error) { - toast.error(t('home.newsletter.error')); - } finally { - setSubscribing(false); - } - }; - - const formatDate = (dateStr: string) => { - return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { - weekday: 'long', - year: 'numeric', - month: 'long', - day: 'numeric', - }); - }; - - const formatTime = (dateStr: string) => { - return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', { - hour: '2-digit', - minute: '2-digit', - }); - }; - return ( <> - {/* Hero Section */} -
-
-
-
-

- {t('home.hero.title')} -

-

- {t('home.hero.subtitle')} -

-
- - - - - - -
-
- - {/* Hero Image Grid */} -
-
-
-
- Spanglish language exchange social event in Asunción -
- -
-
- English and Spanish language practice session in Asunción -
-
-
-
- Spanglish community meetup in Paraguay -
-
- Language exchange group practicing English and Spanish -
- -
-
-
- {/* Decorative elements */} -
-
-
-
-
-
- - {/* Next Event Section */} -
-
-

- {t('home.nextEvent.title')} -

- -
- {loading ? ( -
-
-
- ) : nextEvent ? ( - - -
-
-

- {locale === 'es' && nextEvent.titleEs ? nextEvent.titleEs : nextEvent.title} -

-

- {locale === 'es' && nextEvent.descriptionEs - ? nextEvent.descriptionEs - : nextEvent.description} -

- -
-
- - {formatDate(nextEvent.startDatetime)} -
-
- - ⏰ - - {formatTime(nextEvent.startDatetime)} -
-
- - {nextEvent.location} -
-
-
- -
-
- - {nextEvent.price === 0 - ? t('events.details.free') - : `${nextEvent.price.toLocaleString()} ${nextEvent.currency}`} - -

- {nextEvent.availableSeats} {t('events.details.spotsLeft')} -

-
- -
-
-
- - ) : ( -
- -

{t('home.nextEvent.noEvents')}

-

{t('home.nextEvent.stayTuned')}

-
- )} -
-
-
- - {/* About Section */} -
-
-
-

{t('home.about.title')}

-

- {t('home.about.description')} -

-
- -
- -
- -
-

- {t('home.about.feature1')} -

-

- {t('home.about.feature1Desc')} -

-
- - -
- -
-

- {t('home.about.feature2')} -

-

- {t('home.about.feature2Desc')} -

-
- - -
- -
-

- {t('home.about.feature3')} -

-

- {t('home.about.feature3Desc')} -

-
-
-
-
- - {/* Newsletter Section */} -
-
-
- -

- {t('home.newsletter.title')} -

-

- {t('home.newsletter.description')} -

- -
- setEmail(e.target.value)} - className="flex-1 bg-white/10 border-white/20 text-white placeholder:text-gray-400" - required - /> - -
-
-
-
+ + + + ); } diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 15ff730..2804f4e 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -1,5 +1,3 @@ -@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Poppins:wght@500;600;700&display=swap'); - @tailwind base; @tailwind components; @tailwind utilities; diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 0b79ab3..5ce2019 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,10 +1,26 @@ import type { Metadata, Viewport } from 'next'; +import { Inter, Poppins } from 'next/font/google'; import { Toaster } from 'react-hot-toast'; import { LanguageProvider } from '@/context/LanguageContext'; import { AuthProvider } from '@/context/AuthContext'; import PlausibleAnalytics from '@/components/PlausibleAnalytics'; import './globals.css'; +// Self-hosted fonts via next/font - eliminates render-blocking external requests +const inter = Inter({ + subsets: ['latin'], + display: 'swap', + variable: '--font-inter', + weight: ['400', '500', '600', '700'], +}); + +const poppins = Poppins({ + subsets: ['latin'], + display: 'swap', + variable: '--font-poppins', + weight: ['500', '600', '700'], +}); + const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py'; export const metadata: Metadata = { @@ -94,8 +110,8 @@ export default function RootLayout({ children: React.ReactNode; }) { return ( - - + + diff --git a/frontend/src/components/layout/Footer.tsx b/frontend/src/components/layout/Footer.tsx index d744e00..db38e15 100644 --- a/frontend/src/components/layout/Footer.tsx +++ b/frontend/src/components/layout/Footer.tsx @@ -1,6 +1,7 @@ 'use client'; import Link from 'next/link'; +import Image from 'next/image'; import { useLanguage } from '@/context/LanguageContext'; import { getSocialLinks, socialIcons } from '@/lib/socialLinks'; @@ -22,9 +23,13 @@ export default function Footer() { {/* Brand */}
- - Spanglish - + Spanglish

{t('footer.tagline')} diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx index 5c92516..d550916 100644 --- a/frontend/src/components/layout/Header.tsx +++ b/frontend/src/components/layout/Header.tsx @@ -1,6 +1,7 @@ 'use client'; import Link from 'next/link'; +import Image from 'next/image'; import { useState } from 'react'; import { useLanguage } from '@/context/LanguageContext'; import { useAuth } from '@/context/AuthContext'; @@ -26,10 +27,15 @@ export default function Header() {