Add full SEO optimization for Spanglish social and language events
- Add comprehensive metadata to root layout with Open Graph, Twitter cards - Create dynamic sitemap.ts for all pages and events - Create robots.ts with proper allow/disallow rules - Add JSON-LD Event structured data to event detail pages - Add page-specific metadata to events, community, contact, FAQ pages - Add FAQ structured data schema - Update footer with local SEO text for Asunción, Paraguay - Add web manifest for mobile SEO - Create 404 page with proper noindex - Optimize image alt text and add lazy loading - Add NEXT_PUBLIC_SITE_URL env variable - Add about/ folder to gitignore
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
google?: {
|
||||
accounts: {
|
||||
id: {
|
||||
initialize: (config: GoogleInitConfig) => void;
|
||||
renderButton: (element: HTMLElement | null, options: GoogleButtonOptions) => void;
|
||||
prompt: () => void;
|
||||
cancel: () => void;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface GoogleInitConfig {
|
||||
client_id: string;
|
||||
callback: (response: GoogleCredentialResponse) => void;
|
||||
auto_select?: boolean;
|
||||
cancel_on_tap_outside?: boolean;
|
||||
}
|
||||
|
||||
interface GoogleButtonOptions {
|
||||
type?: 'standard' | 'icon';
|
||||
theme?: 'outline' | 'filled_blue' | 'filled_black';
|
||||
size?: 'large' | 'medium' | 'small';
|
||||
text?: 'signin_with' | 'signup_with' | 'continue_with' | 'signin';
|
||||
shape?: 'rectangular' | 'pill' | 'circle' | 'square';
|
||||
logo_alignment?: 'left' | 'center';
|
||||
width?: string | number;
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
interface GoogleCredentialResponse {
|
||||
credential: string;
|
||||
select_by?: string;
|
||||
}
|
||||
|
||||
interface GoogleSignInButtonProps {
|
||||
onSuccess?: () => void;
|
||||
onError?: (error: string) => void;
|
||||
redirectTo?: string;
|
||||
text?: 'signin_with' | 'signup_with' | 'continue_with' | 'signin';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function GoogleSignInButton({
|
||||
onSuccess,
|
||||
onError,
|
||||
redirectTo = '/dashboard',
|
||||
text = 'continue_with',
|
||||
className = '',
|
||||
}: GoogleSignInButtonProps) {
|
||||
const buttonRef = useRef<HTMLDivElement>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [scriptLoaded, setScriptLoaded] = useState(false);
|
||||
const [scriptError, setScriptError] = useState(false);
|
||||
const { loginWithGoogle } = useAuth();
|
||||
const { locale } = useLanguage();
|
||||
|
||||
const clientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID;
|
||||
|
||||
const handleGoogleCallback = useCallback(
|
||||
async (response: GoogleCredentialResponse) => {
|
||||
if (!response.credential) {
|
||||
const errorMsg = locale === 'es' ? 'No se recibio credencial de Google' : 'No credential received from Google';
|
||||
onError?.(errorMsg);
|
||||
toast.error(errorMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await loginWithGoogle(response.credential);
|
||||
toast.success(locale === 'es' ? 'Bienvenido!' : 'Welcome!');
|
||||
onSuccess?.();
|
||||
|
||||
// Use window.location for navigation to ensure clean state
|
||||
if (redirectTo) {
|
||||
window.location.href = redirectTo;
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Google login failed';
|
||||
const displayError = locale === 'es' ? 'Error al iniciar sesion con Google' : errorMessage;
|
||||
onError?.(displayError);
|
||||
toast.error(displayError);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[loginWithGoogle, locale, onSuccess, onError, redirectTo]
|
||||
);
|
||||
|
||||
const initializeGoogleSignIn = useCallback(() => {
|
||||
if (!clientId) {
|
||||
console.warn('Google Client ID not configured');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.google?.accounts?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.google.accounts.id.initialize({
|
||||
client_id: clientId,
|
||||
callback: handleGoogleCallback,
|
||||
auto_select: false,
|
||||
cancel_on_tap_outside: true,
|
||||
});
|
||||
|
||||
if (buttonRef.current) {
|
||||
// Clear any existing button
|
||||
buttonRef.current.innerHTML = '';
|
||||
|
||||
window.google.accounts.id.renderButton(buttonRef.current, {
|
||||
type: 'standard',
|
||||
theme: 'outline',
|
||||
size: 'large',
|
||||
text: text,
|
||||
shape: 'rectangular',
|
||||
logo_alignment: 'left',
|
||||
width: 280,
|
||||
locale: locale === 'es' ? 'es' : 'en',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error initializing Google Sign-In:', error);
|
||||
setScriptError(true);
|
||||
}
|
||||
}, [clientId, handleGoogleCallback, text, locale]);
|
||||
|
||||
// Load Google Sign-In script
|
||||
useEffect(() => {
|
||||
if (!clientId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if script is already loaded
|
||||
if (window.google?.accounts?.id) {
|
||||
setScriptLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if script tag already exists
|
||||
const existingScript = document.querySelector('script[src="https://accounts.google.com/gsi/client"]');
|
||||
if (existingScript) {
|
||||
// Script exists but may not be loaded yet
|
||||
existingScript.addEventListener('load', () => setScriptLoaded(true));
|
||||
existingScript.addEventListener('error', () => setScriptError(true));
|
||||
return;
|
||||
}
|
||||
|
||||
// Load the script
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://accounts.google.com/gsi/client';
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.onload = () => setScriptLoaded(true);
|
||||
script.onerror = () => setScriptError(true);
|
||||
document.head.appendChild(script);
|
||||
|
||||
return () => {
|
||||
// Cleanup is handled by checking for existing script
|
||||
};
|
||||
}, [clientId]);
|
||||
|
||||
// Initialize when script is loaded
|
||||
useEffect(() => {
|
||||
if (scriptLoaded) {
|
||||
// Small delay to ensure Google object is fully available
|
||||
const timer = setTimeout(initializeGoogleSignIn, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [scriptLoaded, initializeGoogleSignIn]);
|
||||
|
||||
// Don't render if no client ID configured
|
||||
if (!clientId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (scriptError) {
|
||||
return (
|
||||
<div className={`text-center text-sm text-gray-500 py-2 ${className}`}>
|
||||
{locale === 'es'
|
||||
? 'Google Sign-In no disponible'
|
||||
: 'Google Sign-In unavailable'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
{/* Google Sign-In Button Container */}
|
||||
<div
|
||||
ref={buttonRef}
|
||||
className="flex justify-center min-h-[44px]"
|
||||
aria-label={locale === 'es' ? 'Iniciar sesion con Google' : 'Sign in with Google'}
|
||||
/>
|
||||
|
||||
{/* Loading overlay */}
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 bg-white/80 flex items-center justify-center rounded">
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-secondary-blue" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { mediaApi, Media } from '@/lib/api';
|
||||
import Button from '@/components/ui/Button';
|
||||
import {
|
||||
PhotoIcon,
|
||||
ArrowUpTrayIcon,
|
||||
XMarkIcon,
|
||||
CheckIcon,
|
||||
FolderOpenIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface MediaPickerProps {
|
||||
value?: string;
|
||||
onChange: (url: string) => void;
|
||||
relatedId?: string;
|
||||
relatedType?: string;
|
||||
}
|
||||
|
||||
export default function MediaPicker({ value, onChange, relatedId, relatedType = 'event' }: MediaPickerProps) {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'upload' | 'library'>('upload');
|
||||
const [media, setMedia] = useState<Media[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [selectedMedia, setSelectedMedia] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (showModal && activeTab === 'library') {
|
||||
loadMedia();
|
||||
}
|
||||
}, [showModal, activeTab]);
|
||||
|
||||
const loadMedia = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { media } = await mediaApi.getAll();
|
||||
setMedia(media);
|
||||
} catch (error) {
|
||||
toast.error('Failed to load media library');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const result = await mediaApi.upload(file, relatedId, relatedType);
|
||||
onChange(result.url);
|
||||
toast.success('Image uploaded successfully');
|
||||
setShowModal(false);
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to upload image');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectFromLibrary = () => {
|
||||
if (selectedMedia) {
|
||||
onChange(selectedMedia);
|
||||
setShowModal(false);
|
||||
setSelectedMedia(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = () => {
|
||||
onChange('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Event Banner Image</label>
|
||||
<div className="mt-2">
|
||||
{value ? (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={value}
|
||||
alt="Event banner"
|
||||
className="w-full h-40 object-cover rounded-btn"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemove}
|
||||
className="absolute top-2 right-2 bg-red-500 text-white p-1 rounded-full hover:bg-red-600"
|
||||
>
|
||||
<XMarkIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex-1 border-2 border-dashed border-secondary-light-gray rounded-btn p-6 text-center cursor-pointer hover:border-primary-yellow transition-colors"
|
||||
>
|
||||
{uploading ? (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
||||
<p className="mt-2 text-sm text-gray-500">Uploading...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center">
|
||||
<ArrowUpTrayIcon className="w-10 h-10 text-gray-400" />
|
||||
<p className="mt-2 text-sm text-gray-600">Upload New</p>
|
||||
<p className="text-xs text-gray-400">JPEG, PNG, GIF, WebP</p>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setShowModal(true); setActiveTab('library'); }}
|
||||
className="flex-1 border-2 border-dashed border-secondary-light-gray rounded-btn p-6 text-center cursor-pointer hover:border-primary-yellow transition-colors"
|
||||
>
|
||||
<div className="flex flex-col items-center">
|
||||
<FolderOpenIcon className="w-10 h-10 text-gray-400" />
|
||||
<p className="mt-2 text-sm text-gray-600">Choose from Library</p>
|
||||
<p className="text-xs text-gray-400">Select existing media</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/gif,image/webp,image/avif"
|
||||
onChange={handleUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Media Library Modal */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 bg-black/50 z-[60] flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-btn w-full max-w-4xl max-h-[80vh] flex flex-col">
|
||||
{/* Modal Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<h3 className="text-lg font-semibold">Select Image</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setShowModal(false); setSelectedMedia(null); }}
|
||||
className="p-1 hover:bg-gray-100 rounded"
|
||||
>
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('upload')}
|
||||
className={`px-6 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'upload'
|
||||
? 'border-primary-yellow text-primary-dark'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<ArrowUpTrayIcon className="w-4 h-4 inline-block mr-2" />
|
||||
Upload New
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('library')}
|
||||
className={`px-6 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'library'
|
||||
? 'border-primary-yellow text-primary-dark'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<FolderOpenIcon className="w-4 h-4 inline-block mr-2" />
|
||||
Media Library
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{activeTab === 'upload' && (
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="border-2 border-dashed border-secondary-light-gray rounded-btn p-12 text-center cursor-pointer hover:border-primary-yellow transition-colors"
|
||||
>
|
||||
{uploading ? (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="animate-spin w-12 h-12 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
||||
<p className="mt-4 text-gray-500">Uploading...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center">
|
||||
<PhotoIcon className="w-16 h-16 text-gray-400" />
|
||||
<p className="mt-4 text-lg text-gray-600">Click to upload an image</p>
|
||||
<p className="text-sm text-gray-400 mt-1">JPEG, PNG, GIF, WebP (max 10MB)</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'library' && (
|
||||
<>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
||||
</div>
|
||||
) : media.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<PhotoIcon className="w-12 h-12 mx-auto text-gray-400" />
|
||||
<p className="mt-2">No images in library</p>
|
||||
<p className="text-sm">Upload an image to get started</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-3">
|
||||
{media.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedMedia(item.fileUrl)}
|
||||
className={`relative aspect-square rounded-btn overflow-hidden border-2 transition-all ${
|
||||
selectedMedia === item.fileUrl
|
||||
? 'border-primary-yellow ring-2 ring-primary-yellow/30'
|
||||
: 'border-transparent hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={item.fileUrl}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
{selectedMedia === item.fileUrl && (
|
||||
<div className="absolute inset-0 bg-primary-yellow/20 flex items-center justify-center">
|
||||
<div className="bg-primary-yellow rounded-full p-1">
|
||||
<CheckIcon className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
{activeTab === 'library' && (
|
||||
<div className="flex justify-end gap-3 p-4 border-t">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => { setShowModal(false); setSelectedMedia(null); }}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSelectFromLibrary}
|
||||
disabled={!selectedMedia}
|
||||
>
|
||||
Select Image
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import Script from 'next/script';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
export default function PlausibleAnalytics() {
|
||||
const pathname = usePathname();
|
||||
|
||||
// Get Plausible configuration from environment variables
|
||||
const plausibleUrl = process.env.NEXT_PUBLIC_PLAUSIBLE_URL;
|
||||
const plausibleDomain = process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN;
|
||||
|
||||
// Don't render on admin pages or if configuration is missing
|
||||
const isAdminPage = pathname?.startsWith('/admin');
|
||||
|
||||
if (isAdminPage || !plausibleUrl || !plausibleDomain) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Script
|
||||
defer
|
||||
data-domain={plausibleDomain}
|
||||
src={`${plausibleUrl}/js/script.js`}
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -29,6 +29,10 @@ export default function Footer() {
|
||||
<p className="mt-3 text-gray-600 max-w-md">
|
||||
{t('footer.tagline')}
|
||||
</p>
|
||||
{/* Local SEO text */}
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
Language Exchange Events in Asunción, Paraguay
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
|
||||
Reference in New Issue
Block a user