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:
@@ -1,13 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { eventsApi, mediaApi, Event } from '@/lib/api';
|
||||
import { eventsApi, Event } from '@/lib/api';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import { PlusIcon, PencilIcon, TrashIcon, EyeIcon, PhotoIcon, ArrowUpTrayIcon, DocumentDuplicateIcon, ArchiveBoxIcon } from '@heroicons/react/24/outline';
|
||||
import MediaPicker from '@/components/MediaPicker';
|
||||
import { PlusIcon, PencilIcon, TrashIcon, EyeIcon, PhotoIcon, DocumentDuplicateIcon, ArchiveBoxIcon } from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
import clsx from 'clsx';
|
||||
|
||||
@@ -18,8 +19,6 @@ export default function AdminEventsPage() {
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingEvent, setEditingEvent] = useState<Event | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [formData, setFormData] = useState<{
|
||||
title: string;
|
||||
@@ -166,25 +165,6 @@ export default function AdminEventsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const result = await mediaApi.upload(file, editingEvent?.id, 'event');
|
||||
// Use proxied path so it works through Next.js rewrites
|
||||
setFormData({ ...formData, bannerUrl: result.url });
|
||||
toast.success('Image uploaded successfully');
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to upload image');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
@@ -360,53 +340,13 @@ export default function AdminEventsPage() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Image Upload */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Event Banner Image</label>
|
||||
<div className="mt-2">
|
||||
{formData.bannerUrl ? (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={formData.bannerUrl}
|
||||
alt="Event banner"
|
||||
className="w-full h-40 object-cover rounded-btn"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, bannerUrl: '' })}
|
||||
className="absolute top-2 right-2 bg-red-500 text-white p-1 rounded-full hover:bg-red-600"
|
||||
>
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="border-2 border-dashed border-secondary-light-gray rounded-btn p-8 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">
|
||||
<PhotoIcon className="w-12 h-12 text-gray-400" />
|
||||
<p className="mt-2 text-sm text-gray-600">Click to upload event image</p>
|
||||
<p className="text-xs text-gray-400">JPEG, PNG, GIF, WebP (max 5MB)</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/gif,image/webp,image/avif"
|
||||
onChange={handleImageUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Image Upload / Media Picker */}
|
||||
<MediaPicker
|
||||
value={formData.bannerUrl}
|
||||
onChange={(url) => setFormData({ ...formData, bannerUrl: url })}
|
||||
relatedId={editingEvent?.id}
|
||||
relatedType="event"
|
||||
/>
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button type="submit" isLoading={saving}>
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
CheckCircleIcon,
|
||||
XCircleIcon,
|
||||
ArrowPathIcon,
|
||||
TicketIcon,
|
||||
Cog6ToothIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
@@ -38,6 +40,7 @@ export default function PaymentOptionsPage() {
|
||||
cashEnabled: true,
|
||||
cashInstructions: null,
|
||||
cashInstructionsEs: null,
|
||||
allowDuplicateBookings: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -399,6 +402,66 @@ export default function PaymentOptionsPage() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Booking Settings */}
|
||||
<Card className="mb-6">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<Cog6ToothIcon className="w-5 h-5 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">
|
||||
{locale === 'es' ? 'Configuración de Reservas' : 'Booking Settings'}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{locale === 'es' ? 'Opciones adicionales para el proceso de reserva' : 'Additional options for the booking process'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pt-4 border-t">
|
||||
{/* Allow Duplicate Bookings */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<TicketIcon className="w-5 h-5 text-gray-400" />
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">
|
||||
{locale === 'es' ? 'Permitir Reservas Múltiples' : 'Allow Multiple Bookings'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{locale === 'es'
|
||||
? 'Permitir que un usuario reserve varios tickets para el mismo evento con el mismo email'
|
||||
: 'Allow a user to book multiple tickets for the same event with the same email'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => updateOption('allowDuplicateBookings', !options.allowDuplicateBookings)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
options.allowDuplicateBookings ? 'bg-primary-yellow' : 'bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
options.allowDuplicateBookings ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{options.allowDuplicateBookings && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 ml-8">
|
||||
<p className="text-sm text-yellow-800">
|
||||
{locale === 'es'
|
||||
? '⚠️ Cuando está habilitado, los usuarios pueden crear múltiples reservas para el mismo evento. Esto es útil para reservar en nombre de amigos o familiares.'
|
||||
: '⚠️ When enabled, users can create multiple bookings for the same event. This is useful for booking on behalf of friends or family.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Summary */}
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
|
||||
Reference in New Issue
Block a user