Compare commits

..

7 Commits

Author SHA1 Message Date
Levente Orban
bfb76aa268 feat: remove hu.json due the mistakes 2025-09-29 10:55:23 +02:00
Levente Orban
f8758d7b47 feat: add i18n translatation check 2025-09-29 08:42:15 +02:00
Levente Orban
f51f89e35f Merge pull request #28 from polaroi8d/fix/location-missing-field
fix: location missing field error
2025-09-25 09:33:56 +02:00
Levente Orban
26824eb3a8 fix: location missing field error 2025-09-25 09:28:44 +02:00
Levente Orban
d2024d31ba Merge pull request #25 from polaroi8d/feat/google-maps-link
feat: add option to link Google Maps to events
2025-09-24 21:41:40 +02:00
Levente Orban
cc3c868f7d feat: add option to link Google Maps to events 2025-09-24 21:15:31 +02:00
Levente Orban
69a760d3f1 Merge pull request #23 from polaroi8d/fix/windows-not-defined
fix: windows not defined in SSR
2025-09-24 20:21:34 +02:00
19 changed files with 911 additions and 118 deletions

View File

@@ -1,4 +1,4 @@
.PHONY: help build up db-only logs db-clean prune
.PHONY: help build up db-only logs db-clean prune i18n lint format
# Default target
help:
@@ -15,6 +15,7 @@ help:
@echo " make logs - Show logs from all services"
@echo " make db-clean - Stop & remove database container"
@echo " make prune - Remove all containers, images, and volumes"
@echo " make i18n - Validate translation files against messages.json"
@echo " make help - Show this help message"
# Build the Docker images
@@ -46,4 +47,21 @@ prune:
@echo "Cleaning up all Docker resources..."
docker compose down -v --rmi all
# Validate translation files
i18n:
@echo "Validating translation files..."
@if [ -n "$(FILE)" ]; then \
./scripts/i18n-check.sh $(FILE); \
else \
./scripts/i18n-check.sh; \
fi
lint:
@echo "Linting the project..."
npm run lint
format:
@echo "Formatting the project..."
npm run format

View File

@@ -55,8 +55,24 @@ Your app will be available at `http://localhost:5173`. You can use the Makefile
Use the `database/seed.sql` if you want to populate your database with dummy data.
### i18n
There is no proper i18n implemented, we have an `/i18n` folder with specific languages. To use an existing translation, just rename the language code JSON file to `messages.json` and you are ready to go. If you would like to add a new translation (which is really appreciated), just create a new `<language_code>.json` file and add the translations from the `messages.json`.
The project includes a translation validation script to ensure all translation files are complete and up-to-date with the source `messages.json` file.
```bash
# Validate all translation files
make i18n
```
```bash
# Validate a specific translation file
make i18n FILE=src/lib/i18n/it.json
```
### License
This project is licensed under the MIT License - see the [LICENSE](./LICENSE) file for details.
This project is licensed under the `AGPL-3.0 License` - see the [LICENSE](./LICENSE) file for details.
**Made with ❤️ by @polaroi8d**

View File

@@ -14,6 +14,8 @@ CREATE TABLE IF NOT EXISTS events (
date DATE NOT NULL,
time TIME NOT NULL,
location VARCHAR(200) NOT NULL,
location_type VARCHAR(20) NOT NULL DEFAULT 'none' CHECK (location_type IN ('none','text','maps')),
location_url VARCHAR(500),
type VARCHAR(20) NOT NULL CHECK (type IN ('limited','unlimited')),
attendee_limit INTEGER CHECK (attendee_limit > 0),
user_id VARCHAR(100) NOT NULL,
@@ -37,6 +39,7 @@ CREATE TABLE IF NOT EXISTS rsvps (
-- =======================================
CREATE INDEX IF NOT EXISTS idx_events_user_id ON events(user_id);
CREATE INDEX IF NOT EXISTS idx_events_date ON events(date);
CREATE INDEX IF NOT EXISTS idx_events_location_type ON events(location_type);
CREATE INDEX IF NOT EXISTS idx_rsvps_event_id ON rsvps(event_id);
CREATE INDEX IF NOT EXISTS idx_rsvps_user_id ON rsvps(user_id);

191
scripts/i18n-check.sh Executable file
View File

@@ -0,0 +1,191 @@
#!/bin/bash
# Translation validation script
# Compares a translation file against the source messages.json to find missing keys
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Default paths
SOURCE_FILE="src/lib/i18n/messages.json"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
# Function to show usage
show_usage() {
echo "Usage: $0 [LANGUAGE_FILE]"
echo ""
echo "Validates a translation file against the source messages.json"
echo ""
echo "Arguments:"
echo " LANGUAGE_FILE Path to the translation file to validate (e.g., src/lib/i18n/it.json)"
echo ""
echo "Examples:"
echo " $0 src/lib/i18n/it.json"
echo " $0 src/lib/i18n/fr.json"
echo ""
echo "If no file is provided, it will check all .json files in src/lib/i18n/ except messages.json"
}
# Function to get all keys from a JSON file recursively
get_keys() {
local file="$1"
local prefix="$2"
# Use jq to extract all keys recursively
jq -r 'paths(scalars) as $p | $p | join(".")' "$file" | while read -r key; do
if [ -n "$prefix" ]; then
echo "${prefix}.${key}"
else
echo "$key"
fi
done
}
# Function to validate a single translation file
validate_file() {
local translation_file="$1"
local source_file="$2"
echo -e "${YELLOW}Validating: $translation_file${NC}"
echo "----------------------------------------"
# Check if files exist
if [ ! -f "$source_file" ]; then
echo -e "${RED}Error: Source file $source_file not found${NC}"
return 1
fi
if [ ! -f "$translation_file" ]; then
echo -e "${RED}Error: Translation file $translation_file not found${NC}"
return 1
fi
# Get all keys from source file
local source_keys
source_keys=$(get_keys "$source_file")
# Get all keys from translation file
local translation_keys
translation_keys=$(get_keys "$translation_file")
# Find missing keys
local missing_keys
missing_keys=$(comm -23 <(echo "$source_keys" | sort) <(echo "$translation_keys" | sort))
# Find extra keys (in translation but not in source)
local extra_keys
extra_keys=$(comm -13 <(echo "$source_keys" | sort) <(echo "$translation_keys" | sort))
# Count missing and extra keys
local missing_count
if [ -z "$missing_keys" ]; then
missing_count=0
else
missing_count=$(echo "$missing_keys" | wc -l | tr -d ' ')
fi
local extra_count
if [ -z "$extra_keys" ]; then
extra_count=0
else
extra_count=$(echo "$extra_keys" | wc -l | tr -d ' ')
fi
# Report results
if [ "$missing_count" -eq 0 ] && [ "$extra_count" -eq 0 ]; then
echo -e "${GREEN} Perfect! All keys match.${NC}"
return 0
fi
if [ "$missing_count" -gt 0 ]; then
echo -e "${RED} Missing $missing_count key(s) in translation:${NC}"
echo "$missing_keys" | while read -r key; do
echo -e " ${RED}$key${NC}"
done
echo ""
fi
if [ "$extra_count" -gt 0 ]; then
echo -e "${YELLOW} Extra $extra_count key(s) in translation (not in source):${NC}"
echo "$extra_keys" | while read -r key; do
echo -e " ${YELLOW}$key${NC}"
done
echo ""
fi
# Return error code if there are missing keys
if [ "$missing_count" -gt 0 ]; then
return 1
fi
return 0
}
# Main function
main() {
local translation_file="$1"
local source_file="$PROJECT_ROOT/$SOURCE_FILE"
local exit_code=0
# Change to project root directory
cd "$PROJECT_ROOT"
# If no file specified, check all translation files
if [ -z "$translation_file" ]; then
echo -e "${YELLOW}No file specified. Checking all translation files...${NC}"
echo ""
# Find all .json files in i18n directory except messages.json
local files
files=$(find src/lib/i18n -name "*.json" -not -name "messages.json" 2>/dev/null || true)
if [ -z "$files" ]; then
echo -e "${YELLOW}No translation files found in src/lib/i18n/${NC}"
return 0
fi
# Validate each file
echo "$files" | while read -r file; do
if [ -n "$file" ]; then
if ! validate_file "$file" "$source_file"; then
exit_code=1
fi
echo ""
fi
done
return $exit_code
fi
# Validate the specified file
if ! validate_file "$translation_file" "$source_file"; then
exit_code=1
fi
return $exit_code
}
# Check if jq is installed
if ! command -v jq &> /dev/null; then
echo -e "${RED}Error: jq is required but not installed.${NC}"
echo "Please install jq:"
echo " macOS: brew install jq"
echo " Ubuntu/Debian: sudo apt-get install jq"
echo " CentOS/RHEL: sudo yum install jq"
exit 1
fi
# Handle help flag
if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then
show_usage
exit 0
fi
# Run main function
main "$1"

View File

@@ -17,6 +17,7 @@ import type { InferInsertModel, InferSelectModel } from 'drizzle-orm';
// --- Enums (matching the SQL CHECK constraints)
export const eventTypeEnum = pgEnum('event_type', ['limited', 'unlimited']);
export const visibilityEnum = pgEnum('visibility', ['public', 'private']);
export const locationTypeEnum = pgEnum('location_type', ['none', 'text', 'maps']);
// --- Events table
export const events = pgTable(
@@ -27,6 +28,8 @@ export const events = pgTable(
date: date('date', { mode: 'string' }).notNull(), // ISO 'YYYY-MM-DD'
time: time('time', { withTimezone: false }).notNull(), // 'HH:MM:SS'
location: varchar('location', { length: 200 }).notNull(),
locationType: locationTypeEnum('location_type').notNull().default('none'),
locationUrl: varchar('location_url', { length: 500 }),
type: eventTypeEnum('type').notNull(),
attendeeLimit: integer('attendee_limit'), // nullable in SQL
userId: varchar('user_id', { length: 100 }).notNull(),

264
src/lib/i18n/it.json Normal file
View File

@@ -0,0 +1,264 @@
{
"common": {
"required": "*",
"cancel": "Annulla",
"create": "Crea",
"edit": "Modifica",
"delete": "Elimina",
"view": "Visualizza",
"home": "Home",
"loading": "Caricamento...",
"error": "Errore",
"success": "Successo",
"name": "Nome",
"date": "Data",
"time": "Ora",
"location": "Luogo",
"locationType": "Tipo di Luogo",
"locationNone": "Nessuno",
"locationText": "Testo",
"locationMaps": "Google Maps",
"locationNoneDescription": "Nessun luogo specificato",
"locationTextDescription": "Inserisci il luogo come testo semplice.",
"locationMapsDescription": "Inserisci il link di Google Maps.",
"googleMapsUrl": "URL di Google Maps",
"googleMapsUrlPlaceholder": "https://maps.google.com/...",
"type": "Tipo",
"visibility": "Visibilità",
"public": "Pubblico",
"private": "Privato",
"limited": "Limitato",
"unlimited": "Illimitato",
"capacity": "Capacità",
"attendees": "Partecipanti",
"attendeeLimit": "Limite di Partecipanti",
"enterLimit": "Inserisci il limite",
"enterEventName": "Inserisci il nome dell'evento",
"enterLocation": "Inserisci il luogo",
"enterYourName": "Inserisci il tuo nome",
"enterNumberOfGuests": "Inserisci il numero di ospiti",
"yourName": "Il tuo nome",
"numberOfGuests": "Numero di Ospiti",
"addGuests": "Aggiungi ospiti",
"joinEvent": "Partecipa all'Evento",
"copyLink": "Copia Link",
"addToCalendar": "Aggiungi al Calendario",
"close": "Chiudi",
"closeModal": "Chiudi finestra",
"removeRSVP": "Rimuovi RSVP",
"updating": "Aggiornamento...",
"creating": "Creazione...",
"adding": "Aggiunta...",
"updateEvent": "Aggiorna Evento",
"createEvent": "Crea Evento",
"createNewEvent": "Crea Nuovo Evento",
"createYourFirstEvent": "Crea il Tuo Primo Evento",
"editEvent": "Modifica Evento",
"deleteEvent": "Elimina Evento",
"myEvents": "I Miei Eventi",
"discover": "Scopri",
"noEventsYet": "Ancora Nessun Evento",
"noPublicEventsYet": "Ancora Nessun Evento Pubblico",
"noAttendeesYet": "Ancora nessun partecipante",
"beFirstToJoin": "Sii il primo a partecipare!",
"eventNotFound": "Evento Non Trovato",
"eventIsFull": "L'Evento è Pieno!",
"maximumCapacityReached": "Raggiunta la capacità massima",
"eventLinkCopied": "Link dell'evento copiato negli appunti!",
"rsvpAddedSuccessfully": "RSVP aggiunto con successo!",
"removedRsvpSuccessfully": "RSVP rimosso con successo.",
"anUnexpectedErrorOccurred": "Si è verificato un errore inaspettato.",
"somethingWentWrong": "Qualcosa è andato storto. Riprova.",
"failedToAddRsvp": "Impossibile aggiungere RSVP",
"failedToRemoveRsvp": "Impossibile rimuovere RSVP",
"failedToDeleteEvent": "Impossibile eliminare l'evento",
"youMayNotHavePermission": "Potresti non avere il permesso di eliminare questo evento.",
"anErrorOccurredWhileDeleting": "Si è verificato un errore durante l'eliminazione dell'evento:",
"databaseUnreachable": "Database non raggiungibile.",
"eventIdNotFound": "EventId non trovato",
"eventNotExists": "Evento non trovato",
"failedToLoadEvent": "Impossibile caricare l'evento",
"nameAndUserIdRequired": "Nome e ID utente sono obbligatori",
"eventCapacityExceeded": "Capacità dell'evento superata. Stai cercando di aggiungere {guests} partecipanti (te compreso/a), ma rimangono solo {remaining} posti.",
"nameAlreadyExists": "Il nome esiste già per questo evento",
"missingOrEmptyFields": "Campi mancanti o vuoti: {fields}",
"dateCannotBeInPast": "La data non può essere nel passato.",
"limitMustBeAtLeast2": "Il limite deve essere almeno 2 per eventi limitati.",
"unauthorized": "Non autorizzato",
"youCanOnlyEditYourOwnEvents": "Puoi modificare solo i tuoi eventi",
"youDoNotHavePermissionToDelete": "Non hai il permesso di eliminare questo evento",
"eventIdAndUserIdRequired": "ID evento e ID utente sono obbligatori",
"guestsWillBeAddedAs": "Gli ospiti verranno aggiunti come \"Ospite #1 di {name}\", \"Ospite #2 di {name}\", ecc.",
"yourNamePlaceholder": "Il tuo nome",
"atTime": "alle"
},
"navigation": {
"home": "Home",
"discover": "Scopri",
"create": "Crea",
"myEvents": "I Miei Eventi"
},
"home": {
"title": "Cactoide - Il sito per gli RSVP",
"description": "Crea e gestisci gli RSVP degli eventi. Nessuna registrazione richiesta, condivisione immediata.",
"mainTitle": "Cactoide(ea)",
"subtitle": "La Piattaforma Definitiva per gli RSVP",
"tagline": "Crea, condividi e gestisci eventi senza intoppi.",
"whyCactoideTitle": "Perché Cactoide(ae)? 🌵",
"whyCactoideDescription": "Come il cactus, i grandi eventi fioriscono in ogni condizione se gestiti con cura. Cactoide(ae) ti aiuta a semplificare gli RSVP, coordinare in modo semplice e mantenere ogni dettaglio efficiente: così i tuoi incontri sono resilienti, vivaci e indimenticabili.",
"createEventNow": "Crea Evento Ora",
"discoverPublicEventsTitle": "Scopri Eventi Pubblici",
"discoverPublicEventsDescription": "Guarda cosa stanno pianificando gli altri e lasciati ispirare",
"browseAllPublicEvents": "Sfoglia Tutti gli Eventi Pubblici",
"whyCactoideFeatureTitle": "Perché Cactoide?",
"instantEventCreationTitle": "Creazione Istantanea di Eventi",
"instantEventCreationDescription": "Crea eventi in pochi secondi con il nostro modulo semplificato. Nessun account, nessuna attesa, solo pura efficienza.",
"oneClickSharingTitle": "Condivisione con un Clic",
"oneClickSharingDescription": "Ogni evento ottiene un URL unico e memorabile. Condividi istantaneamente tramite qualsiasi piattaforma o app di messaggistica.",
"allInOneClarityTitle": "Chiarezza Tutto-in-Uno",
"allInOneClarityDescription": "Niente più scorrimento infinito tra chat e reazioni. Visualizza la disponibilità e le risposte di tutti in un unico posto.",
"noHassleNoSignUpsTitle": "Nessun Problema, Nessuna Registrazione",
"noHassleNoSignUpsDescription": "Salta le registrazioni e i moduli infiniti. A differenza di altre piattaforme di eventi, crei e condividi istantaneamente: nessun account, nessuna barriera.",
"smartLimitsTitle": "Limiti Intelligenti",
"smartLimitsDescription": "Scegli tra RSVP illimitati o imposta una capacità limitata. Perfetto per eventi di qualsiasi dimensione.",
"effortlessSimplicityTitle": "Semplicità Senza Sforzo",
"effortlessSimplicityDescription": "Progettato per essere immediatamente chiaro e facile. Nessuna curva di apprendimento: apri, crea e vai.",
"howItWorksTitle": "Come Funziona",
"step1Title": "Crea Evento",
"step1Description": "Compila un semplice modulo con i dettagli dell'evento. Scegli tra capacità limitata o illimitata.",
"step2Title": "Ottieni URL Unico",
"step2Description": "Ricevi un URL casuale e memorabile per il tuo evento. Perfetto per la condivisione ovunque.",
"step3Title": "Raccogli gli RSVP",
"step3Description": "Le persone visitano il tuo link e partecipano solo con il loro nome. Nessun account necessario.",
"ctaTitle": "Pronto a Creare il Tuo Primo Evento?",
"ctaDescription": "Unisciti a migliaia di organizzatori di eventi che si fidano di Cactoide",
"ctaButton": "Crea"
},
"create": {
"title": "Crea Evento - Cactoide",
"formTitle": "Crea Nuovo Evento",
"eventNameLabel": "Nome",
"eventNamePlaceholder": "Inserisci il nome dell'evento",
"dateLabel": "Data",
"timeLabel": "Ora",
"locationLabel": "Luogo",
"locationPlaceholder": "Inserisci il luogo",
"locationTypeLabel": "Tipo di Luogo",
"locationNoneOption": "Nessuno",
"locationTextOption": "Testo Semplice",
"locationMapsOption": "Google Maps",
"locationNoneDescription": "Nessun luogo specificato.",
"locationTextDescription": "Inserisci il luogo come testo semplice.",
"locationMapsDescription": "Inserisci il link di Google Maps.",
"googleMapsUrlLabel": "URL di Google Maps",
"googleMapsUrlPlaceholder": "https://maps.google.com/...",
"typeLabel": "Tipo",
"unlimitedOption": "Illimitato",
"limitedOption": "Limitato",
"attendeeLimitLabel": "Limite di Partecipanti",
"attendeeLimitPlaceholder": "Inserisci il limite",
"visibilityLabel": "Visibilità",
"publicOption": "🌍 Pubblico",
"privateOption": "🔒 Privato",
"publicDescription": "Gli eventi pubblici sono visibili a tutti e possono essere scoperti da altri.",
"privateDescription": "Gli eventi privati sono visibili solo a te e alle persone con cui condividi il link.",
"creatingEvent": "Creazione Evento...",
"createEventButton": "Crea Evento"
},
"event": {
"title": "{eventName} - Cactoide",
"eventTitle": "Evento - Cactoide",
"editTitle": "Modifica Evento - {eventName} - Cactoide",
"myEventsTitle": "I Miei Eventi - Cactoide",
"eventNotFoundTitle": "Evento Non Trovato",
"eventNotFoundDescription": "L'evento che stai cercando non esiste o è stato rimosso.",
"joinThisEvent": "Partecipa a Questo Evento",
"eventIsFull": "L'Evento è Pieno!",
"maximumCapacityReached": "Raggiunta la capacità massima",
"yourNameLabel": "Il tuo nome",
"yourNamePlaceholder": "Inserisci il tuo nome",
"addGuestsLabel": "Aggiungi ospiti",
"numberOfGuestsLabel": "Numero di Ospiti",
"numberOfGuestsPlaceholder": "Inserisci il numero di ospiti",
"guestsWillBeAddedAs": "Gli ospiti verranno aggiunti come \"Ospite #1 di {name}\", \"Ospite #2 di {name}\", ecc.",
"joinEventButton": "Partecipa all'Evento",
"joinEventWithGuests": "Partecipa all'Evento + {count} Ospite{plural}",
"adding": "Aggiunta...",
"attendeesTitle": "Partecipanti",
"noAttendeesYet": "Ancora nessun partecipante",
"beFirstToJoin": "Sii il primo a partecipare!",
"copyLinkButton": "Copia Link",
"addToCalendarButton": "Aggiungi al Calendario",
"eventLinkCopied": "Link dell'evento copiato negli appunti!",
"rsvpAddedSuccessfully": "RSVP aggiunto con successo!",
"removedRsvpSuccessfully": "RSVP rimosso con successo.",
"failedToAddRsvp": "Impossibile aggiungere RSVP",
"failedToRemoveRsvp": "Impossibile rimuovere RSVP",
"editEventTitle": "Modifica Evento",
"editEventDescription": "Aggiorna i dettagli del tuo evento",
"updatingEvent": "Aggiornamento...",
"updateEventButton": "Aggiorna Evento",
"myEventsDescription": "Gestisci i tuoi eventi creati",
"noEventsYetTitle": "Ancora Nessun Evento",
"noEventsYetDescription": "Non hai ancora creato nessun evento. Inizia creando il tuo primo evento!",
"createYourFirstEventButton": "Crea il Tuo Primo Evento",
"deleteEventTitle": "Elimina Evento",
"deleteEventDescription": "Sei sicuro di voler eliminare \"{eventName}\"? Questa azione non può essere annullata e rimuoverà tutti gli RSVP.",
"deleteButton": "Elimina",
"viewEventAriaLabel": "Visualizza evento",
"editEventAriaLabel": "Modifica evento",
"deleteEventAriaLabel": "Elimina evento",
"removeRsvpAriaLabel": "Rimuovi RSVP"
},
"discover": {
"title": "Scopri Eventi - Cactoide",
"noPublicEventsTitle": "Ancora Nessun Evento Pubblico",
"noPublicEventsDescription": "Al momento non ci sono eventi pubblici disponibili. Sii il primo a crearne uno!",
"createButton": "Crea",
"publicEventsTitle": "Eventi Pubblici ({count})",
"publicEventsDescription": "Scopri eventi creati dalla comunità",
"searchPlaceholder": "Cerca eventi per nome, luogo...",
"searchInputAriaLabel": "Input di ricerca",
"toggleFiltersAriaLabel": "Attiva/Disattiva filtri",
"typeFilterLabel": "Tipo:",
"typeFilterAll": "Tutti",
"typeFilterLimited": "Limitati",
"typeFilterUnlimited": "Illimitati",
"statusFilterLabel": "Stato:",
"statusFilterAll": "Tutti gli eventi",
"statusFilterUpcoming": "Eventi imminenti",
"statusFilterPast": "Eventi passati",
"timeFilterLabel": "Orario:",
"timeFilterAny": "Qualsiasi orario",
"timeFilterNextWeek": "Prossima settimana",
"timeFilterNextMonth": "Prossimo mese",
"sortOrderLabel": "Ordina:",
"sortOrderEarliest": "Prima i più vicini",
"sortOrderLatest": "Prima i più recenti",
"viewButton": "Visualizza",
"noEventsFoundTitle": "Nessun evento trovato",
"noEventsFoundDescription": "Prova a modificare i termini di ricerca o sfoglia tutti gli eventi"
},
"calendar": {
"addToCalendarTitle": "Aggiungi al Calendario",
"googleCalendarTitle": "Google Calendar",
"googleCalendarDescription": "Aggiungi a Google Calendar",
"microsoftOutlookTitle": "Microsoft Outlook",
"microsoftOutlookDescription": "Aggiungi a Outlook Calendar",
"downloadICalTitle": "Scarica File iCal",
"downloadICalDescription": "Scarica file .ics per qualsiasi app di calendario"
},
"errors": {
"title": "Errore - Cactoide",
"errorTitle": "Errore",
"anUnexpectedErrorOccurred": "Si è verificato un errore inaspettato.",
"homeButton": "Home"
},
"layout": {
"defaultTitle": "Cactoide -",
"defaultDescription": "Crea e gestisci gli RSVP degli eventi",
"userIdCookieText": "Il tuo UserID memorizzato come cookie:",
"firstTimeVisiting": "Prima visita. Generazione di un nuovo UserID...",
"copyright": "© 2025 Cactoide"
}
}

View File

@@ -14,6 +14,15 @@
"date": "Date",
"time": "Time",
"location": "Location",
"locationType": "Location Type",
"locationNone": "None",
"locationText": "Text",
"locationMaps": "Google Maps",
"locationNoneDescription": "No location specified",
"locationTextDescription": "Enter location as plain text.",
"locationMapsDescription": "Enter Google Maps link.",
"googleMapsUrl": "Google Maps URL",
"googleMapsUrlPlaceholder": "https://maps.google.com/...",
"type": "Type",
"visibility": "Visibility",
"public": "Public",
@@ -134,6 +143,15 @@
"timeLabel": "Time",
"locationLabel": "Location",
"locationPlaceholder": "Enter location",
"locationTypeLabel": "Location Type",
"locationNoneOption": "None",
"locationTextOption": "Plain Text",
"locationMapsOption": "Google Maps",
"locationNoneDescription": "No location specified.",
"locationTextDescription": "Enter location as plain text.",
"locationMapsDescription": "Enter Google Maps link.",
"googleMapsUrlLabel": "Google Maps URL",
"googleMapsUrlPlaceholder": "https://maps.google.com/...",
"typeLabel": "Type",
"unlimitedOption": "Unlimited",
"limitedOption": "Limited",
@@ -142,8 +160,8 @@
"visibilityLabel": "Visibility",
"publicOption": "🌍 Public",
"privateOption": "🔒 Private",
"publicDescription": "Public events are visible to everyone and can be discovered by others",
"privateDescription": "Private events are only visible to you and people you share the link with",
"publicDescription": "Public events are visible to everyone and can be discovered by others.",
"privateDescription": "Private events are only visible to you and people you share the link with.",
"creatingEvent": "Creating Event...",
"createEventButton": "Create Event"
},

View File

@@ -1,6 +1,7 @@
export type EventType = 'limited' | 'unlimited';
export type EventVisibility = 'public' | 'private';
export type ActionType = 'add' | 'remove';
export type LocationType = 'none' | 'text' | 'maps';
export interface Event {
id: string;
@@ -8,6 +9,8 @@ export interface Event {
date: string;
time: string;
location: string;
location_type: LocationType;
location_url?: string;
type: EventType;
attendee_limit?: number;
visibility: EventVisibility;
@@ -29,6 +32,8 @@ export interface CreateEventData {
date: string;
time: string;
location: string;
location_type: LocationType;
location_url?: string;
type: EventType;
attendee_limit?: number;
visibility: EventVisibility;
@@ -40,6 +45,8 @@ export interface DatabaseEvent {
date: string;
time: string;
location: string;
location_type: LocationType;
location_url?: string;
type: EventType;
attendee_limit?: number;
visibility: EventVisibility;

View File

@@ -25,7 +25,7 @@
<h2 class="mt-6 pt-8 text-xl md:text-2xl">
{t('home.whyCactoideTitle')}<span class="text-violet-400"
><a href="https://en.wikipedia.org/wiki/Cactoideae" target="_blank">*</a></span
>?🌵
>
</h2>
<p class="mt-4 text-lg md:text-xl">
{t('home.whyCactoideDescription')}

View File

@@ -21,6 +21,8 @@ export const actions: Actions = {
const date = formData.get('date') as string;
const time = formData.get('time') as string;
const location = formData.get('location') as string;
const locationType = formData.get('location_type') as 'none' | 'text' | 'maps';
const locationUrl = formData.get('location_url') as string;
const type = formData.get('type') as 'limited' | 'unlimited';
const attendeeLimit = formData.get('attendee_limit') as string;
const visibility = formData.get('visibility') as 'public' | 'private';
@@ -32,7 +34,9 @@ export const actions: Actions = {
if (!name?.trim()) missingFields.push('name');
if (!date) missingFields.push('date');
if (!time) missingFields.push('time');
if (!location?.trim()) missingFields.push('location');
if (!locationType) missingFields.push('location_type');
if (locationType === 'text' && !location?.trim()) missingFields.push('location');
if (locationType === 'maps' && !locationUrl?.trim()) missingFields.push('location_url');
if (!userId) missingFields.push('userId');
if (missingFields.length > 0) {
@@ -43,6 +47,8 @@ export const actions: Actions = {
date,
time,
location,
location_type: locationType,
location_url: locationUrl,
type,
attendee_limit: attendeeLimit,
visibility
@@ -53,14 +59,34 @@ export const actions: Actions = {
if (new Date(date) < new Date()) {
return fail(400, {
error: 'Date cannot be in the past.',
values: { name, date, time, location, type, attendee_limit: attendeeLimit, visibility }
values: {
name,
date,
time,
location,
location_type: locationType,
location_url: locationUrl,
type,
attendee_limit: attendeeLimit,
visibility
}
});
}
if (type === 'limited' && (!attendeeLimit || parseInt(attendeeLimit) < 2)) {
return fail(400, {
error: 'Limit must be at least 2 for limited events.',
values: { name, date, time, location, type, attendee_limit: attendeeLimit, visibility }
values: {
name,
date,
time,
location,
location_type: locationType,
location_url: locationUrl,
type,
attendee_limit: attendeeLimit,
visibility
}
});
}
@@ -73,7 +99,9 @@ export const actions: Actions = {
name: name.trim(),
date: date,
time: time,
location: location.trim(),
location: location?.trim() || '',
locationType: locationType,
locationUrl: locationType === 'maps' ? locationUrl?.trim() : null,
type: type,
attendeeLimit: type === 'limited' ? parseInt(attendeeLimit) : null,
visibility: visibility,

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import type { CreateEventData, EventType } from '$lib/types';
import type { CreateEventData, EventType, LocationType } from '$lib/types';
import { enhance } from '$app/forms';
import { goto } from '$app/navigation';
import { t } from '$lib/i18n/i18n.js';
@@ -11,6 +11,8 @@
date: '',
time: '',
location: '',
location_type: 'none',
location_url: '',
type: 'unlimited',
attendee_limit: undefined,
visibility: 'public'
@@ -46,6 +48,19 @@
}
};
const handleLocationTypeChange = (locationType: LocationType) => {
eventData.location_type = locationType;
if (locationType === 'none') {
eventData.location = '';
eventData.location_url = '';
} else if (locationType === 'text') {
eventData.location_url = '';
eventData.location = '';
} else {
eventData.location = 'Google Maps';
}
};
const handleCancel = () => {
goto(`/discover`);
};
@@ -58,7 +73,7 @@
<div class="flex min-h-screen flex-col">
<!-- Main Content -->
<div class="container mx-auto flex-1 px-4 py-8">
<div class="mx-auto max-w-md">
<div class="mx-auto max-w-2xl">
<!-- Event Creation Form -->
<div class="rounded-sm border p-8">
<h2 class="mb-8 text-center text-3xl font-bold text-violet-400">{t('create.formTitle')}</h2>
@@ -83,6 +98,7 @@
<input type="hidden" name="userId" value={currentUserId} />
<input type="hidden" name="type" value={eventData.type} />
<input type="hidden" name="visibility" value={eventData.visibility} />
<input type="hidden" name="location_type" value={eventData.location_type} />
{#if errors.server}
<div class="mb-6 rounded-sm border border-red-200 bg-red-50 p-4 text-red-700">
@@ -148,54 +164,126 @@
</div>
</div>
<!-- Location -->
<!-- Location Type -->
<div>
<label for="location" class="text-dark-800 mb-3 block text-sm font-semibold">
{t('create.locationLabel')} <span class="text-red-400">{t('common.required')}</span>
</label>
<input
id="location"
name="location"
type="text"
bind:value={eventData.location}
class="border-dark-300 placeholder-dark-500 w-full rounded-sm border-2 px-4 py-3 text-slate-900 shadow-sm transition-all"
placeholder={t('create.locationPlaceholder')}
maxlength="200"
required
/>
{#if errors.location}
<p class="mt-2 text-sm font-medium text-red-600">{errors.location}</p>
{/if}
<fieldset>
<legend class="text-dark-800 mb-3 block text-sm font-semibold">
{t('create.locationTypeLabel')}
<span class="text-red-400">{t('common.required')}</span>
</legend>
<div class="grid grid-cols-3 gap-3">
<button
type="button"
class="rounded-sm border-2 px-4 py-3 font-medium transition-all duration-200 {eventData.location_type ===
'none'
? ' border-violet-500 bg-violet-400/20 font-semibold hover:bg-violet-400/70'
: 'border-dark-300 text-dark-700'}"
on:click={() => handleLocationTypeChange('none')}
>
{t('create.locationNoneOption')}
</button>
<button
type="button"
class="rounded-sm border-2 px-4 py-3 font-medium transition-all duration-200 {eventData.location_type ===
'text'
? ' border-violet-500 bg-violet-400/20 font-semibold hover:bg-violet-400/70'
: 'border-dark-300 text-dark-700'}"
on:click={() => handleLocationTypeChange('text')}
>
{t('create.locationTextOption')}
</button>
<button
type="button"
class="rounded-sm border-2 px-4 py-3 font-medium transition-all duration-200 {eventData.location_type ===
'maps'
? ' border-violet-500 bg-violet-400/20 font-semibold hover:bg-violet-400/70'
: 'border-dark-300 text-dark-700 bg-gray-600/20 hover:bg-gray-600/70'}"
on:click={() => handleLocationTypeChange('maps')}
>
{t('create.locationMapsOption')}
</button>
</div>
<p class="mt-2 text-xs text-slate-400 italic">
{eventData.location_type === 'none'
? t('create.locationNoneDescription')
: eventData.location_type === 'text'
? t('create.locationTextDescription')
: t('create.locationMapsDescription')}
</p>
</fieldset>
</div>
<!-- Location Input (only show when not 'none') -->
{#if eventData.location_type !== 'none'}
<div>
<label for="location" class="text-dark-800 mb-3 block text-sm font-semibold">
{eventData.location_type === 'text'
? t('create.locationLabel')
: t('create.googleMapsUrlLabel')}
<span class="text-red-400">{t('common.required')}</span>
</label>
{#if eventData.location_type === 'text'}
<input
id="location"
name="location"
type="text"
bind:value={eventData.location}
class="border-dark-300 placeholder-dark-500 w-full rounded-sm border-2 px-4 py-3 text-slate-900 shadow-sm transition-all"
placeholder={t('create.locationPlaceholder')}
maxlength="200"
required
/>
{:else}
<input
id="location_url"
name="location_url"
type="url"
bind:value={eventData.location_url}
class="border-dark-300 placeholder-dark-500 w-full rounded-sm border-2 px-4 py-3 text-slate-900 shadow-sm transition-all"
placeholder={t('create.googleMapsUrlPlaceholder')}
maxlength="500"
required
/>
{/if}
{#if errors.location}
<p class="mt-2 text-sm font-medium text-red-600">{errors.location}</p>
{/if}
{#if errors.location_url}
<p class="mt-2 text-sm font-medium text-red-600">{errors.location_url}</p>
{/if}
</div>
{/if}
<!-- Event Type -->
<div>
<label class="text-dark-800 mb-3 block text-sm font-semibold">
{t('create.typeLabel')}
<span class="text-red-400">{t('common.required')}</span></label
>
<div class="grid grid-cols-2 gap-3">
<button
type="button"
class="rounded-sm border-2 px-4 py-3 font-medium transition-all duration-200 {eventData.type ===
'unlimited'
? ' border-violet-500 bg-violet-400/20 font-semibold hover:bg-violet-400/70'
: 'border-dark-300 text-dark-700'}"
on:click={() => handleTypeChange('unlimited')}
>
{t('create.unlimitedOption')}
</button>
<button
type="button"
class="rounded-sm border-2 px-4 py-3 font-medium transition-all duration-200 {eventData.type ===
'limited'
? ' border-violet-500 bg-violet-400/20 font-semibold hover:bg-violet-400/70'
: 'border-dark-300 text-dark-700 bg-gray-600/20 hover:bg-gray-600/70'}"
on:click={() => handleTypeChange('limited')}
>
{t('create.limitedOption')}
</button>
</div>
<fieldset>
<legend class="text-dark-800 mb-3 block text-sm font-semibold">
{t('create.typeLabel')}
<span class="text-red-400">{t('common.required')}</span>
</legend>
<div class="grid grid-cols-2 gap-3">
<button
type="button"
class="rounded-sm border-2 px-4 py-3 font-medium transition-all duration-200 {eventData.type ===
'unlimited'
? ' border-violet-500 bg-violet-400/20 font-semibold hover:bg-violet-400/70'
: 'border-dark-300 text-dark-700'}"
on:click={() => handleTypeChange('unlimited')}
>
{t('create.unlimitedOption')}
</button>
<button
type="button"
class="rounded-sm border-2 px-4 py-3 font-medium transition-all duration-200 {eventData.type ===
'limited'
? ' border-violet-500 bg-violet-400/20 font-semibold hover:bg-violet-400/70'
: 'border-dark-300 text-dark-700 bg-gray-600/20 hover:bg-gray-600/70'}"
on:click={() => handleTypeChange('limited')}
>
{t('create.limitedOption')}
</button>
</div>
</fieldset>
</div>
<!-- Limit (only for limited events) -->
@@ -203,7 +291,7 @@
<div>
<label for="limit" class="text-dark-800 mb-3 block text-sm font-semibold">
{t('create.attendeeLimitLabel')}
{t('common.required')}
<span class="text-red-400">{t('common.required')}</span>
</label>
<input
id="attendee_limit"
@@ -224,37 +312,39 @@
<!-- Event Visibility -->
<div>
<label class="text-dark-800 mb-3 block text-sm font-semibold">
{t('create.visibilityLabel')}
<span class="text-red-400">{t('common.required')}</span></label
>
<div class="grid grid-cols-2 gap-3">
<button
type="button"
class="rounded-sm border-2 px-4 py-3 font-medium transition-all duration-200 {eventData.visibility ===
'public'
? ' border-violet-500 bg-violet-400/20 font-semibold hover:bg-violet-400/70'
: 'border-dark-300 text-dark-700'}"
on:click={() => (eventData.visibility = 'public')}
>
{t('create.publicOption')}
</button>
<button
type="button"
class="rounded-sm border-2 px-4 py-3 font-medium transition-all duration-200 {eventData.visibility ===
'private'
? ' border-violet-500 bg-violet-400/20 font-semibold hover:bg-violet-400/70'
: 'border-dark-300 text-dark-700 bg-gray-600/20 hover:bg-gray-600/70'}"
on:click={() => (eventData.visibility = 'private')}
>
{t('create.privateOption')}
</button>
</div>
<p class="mt-2 text-xs text-slate-400">
{eventData.visibility === 'public'
? t('create.publicDescription')
: t('create.privateDescription')}
</p>
<fieldset>
<legend class="text-dark-800 mb-3 block text-sm font-semibold">
{t('create.visibilityLabel')}
<span class="text-red-400">{t('common.required')}</span>
</legend>
<div class="grid grid-cols-2 gap-3">
<button
type="button"
class="rounded-sm border-2 px-4 py-3 font-medium transition-all duration-200 {eventData.visibility ===
'public'
? ' border-violet-500 bg-violet-400/20 font-semibold hover:bg-violet-400/70'
: 'border-dark-300 text-dark-700'}"
on:click={() => (eventData.visibility = 'public')}
>
{t('create.publicOption')}
</button>
<button
type="button"
class="rounded-sm border-2 px-4 py-3 font-medium transition-all duration-200 {eventData.visibility ===
'private'
? ' border-violet-500 bg-violet-400/20 font-semibold hover:bg-violet-400/70'
: 'border-dark-300 text-dark-700 bg-gray-600/20 hover:bg-gray-600/70'}"
on:click={() => (eventData.visibility = 'private')}
>
{t('create.privateOption')}
</button>
</div>
<p class="mt-2 text-xs text-slate-400 italic">
{eventData.visibility === 'public'
? t('create.publicDescription')
: t('create.privateDescription')}
</p>
</fieldset>
</div>
<div class="flex space-x-3">
@@ -269,7 +359,7 @@
<button
type="submit"
disabled={isSubmitting}
class="hover:bg-violet-400/70'l rounded-sm border-2 border-violet-500 bg-violet-400/20 px-4 py-3 py-4 font-bold font-medium font-semibold text-white shadow-lg transition-all duration-200 hover:scale-105"
class="hover:bg-violet-400/70'l flex-2 rounded-sm border-2 border-violet-500 bg-violet-400/20 px-4 py-3 py-4 font-bold font-medium font-semibold text-white shadow-lg transition-all duration-200 hover:scale-105"
>
{#if isSubmitting}
<div class="flex items-center justify-center">

View File

@@ -19,6 +19,8 @@ export const load: PageServerLoad = async () => {
date: event.date, // Already in 'YYYY-MM-DD' format
time: event.time, // Already in 'HH:MM:SS' format
location: event.location,
location_type: event.locationType,
location_url: event.locationUrl,
type: event.type,
attendee_limit: event.attendeeLimit, // Note: schema uses camelCase
visibility: event.visibility,

View File

@@ -289,7 +289,20 @@
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
></path>
</svg>
<span>{event.location}</span>
{#if event.location_type === 'none'}
<span>N/A</span>
{:else if event.location_type === 'maps' && event.location_url}
<a
href={event.location_url}
target="_blank"
rel="noopener noreferrer"
class="text-slate-500 transition-colors duration-200 hover:text-slate-300"
>
{t('create.locationMapsOption')}
</a>
{:else}
<span>{event.location}</span>
{/if}
</div>
<div class="flex items-center space-x-2">
<span

View File

@@ -24,6 +24,8 @@ export const load = async ({ cookies }) => {
date: event.date,
time: event.time,
location: event.location,
location_type: event.locationType,
location_url: event.locationUrl,
type: event.type,
attendee_limit: event.attendeeLimit,
visibility: event.visibility,

View File

@@ -126,7 +126,20 @@
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
></path>
</svg>
<span>{event.location}</span>
{#if event.location_type === 'none'}
<span>N/A</span>
{:else if event.location_type === 'maps' && event.location_url}
<a
href={event.location_url}
target="_blank"
rel="noopener noreferrer"
class="text-slate-500 transition-colors duration-200 hover:text-slate-300"
>
Google Maps
</a>
{:else}
<span>{event.location}</span>
{/if}
</div>
<div class="flex items-center space-x-2">
<span

View File

@@ -32,6 +32,8 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
date: event.date,
time: event.time,
location: event.location,
location_type: event.locationType,
location_url: event.locationUrl,
type: event.type,
attendee_limit: event.attendeeLimit,
visibility: event.visibility,

View File

@@ -106,7 +106,7 @@
</div>
</div>
{:else if event}
<div class="mx-auto max-w-md space-y-6">
<div class="mx-auto max-w-2xl space-y-6">
<!-- Event Details Card -->
<div class="rounded-sm border p-6 shadow-2xl">
@@ -136,7 +136,7 @@
</div>
</div>
<!-- Location -->
<!-- Location (only show when not 'none') -->
<div class="flex items-center space-x-3 text-violet-400">
<div class="flex h-8 w-8 items-center justify-center rounded-sm">
<svg class=" h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -155,7 +155,20 @@
</svg>
</div>
<div>
<p class="font-semibold text-white">{event.location}</p>
{#if event.location_type === 'none'}
<p class="font-semibold text-white">N/A</p>
{:else if event.location_type === 'maps' && event.location_url}
<a
href={event.location_url}
target="_blank"
rel="noopener noreferrer"
class="font-semibold text-white transition-colors duration-200 hover:text-violet-300"
>
{t('create.locationMapsOption')}
</a>
{:else}
<p class="font-semibold text-white">{event.location}</p>
{/if}
</div>
</div>

View File

@@ -53,6 +53,8 @@ export const actions: Actions = {
const date = formData.get('date') as string;
const time = formData.get('time') as string;
const location = formData.get('location') as string;
const locationType = formData.get('location_type') as 'none' | 'text' | 'maps';
const locationUrl = formData.get('location_url') as string;
const type = formData.get('type') as 'limited' | 'unlimited';
const attendeeLimit = formData.get('attendee_limit') as string;
const visibility = formData.get('visibility') as 'public' | 'private';
@@ -63,7 +65,9 @@ export const actions: Actions = {
if (!name?.trim()) missingFields.push('name');
if (!date) missingFields.push('date');
if (!time) missingFields.push('time');
if (!location?.trim()) missingFields.push('location');
if (!locationType) missingFields.push('location_type');
if (locationType === 'text' && !location?.trim()) missingFields.push('location');
if (locationType === 'maps' && !locationUrl?.trim()) missingFields.push('location_url');
if (missingFields.length > 0) {
return fail(400, {
@@ -73,6 +77,8 @@ export const actions: Actions = {
date,
time,
location,
location_type: locationType,
location_url: locationUrl,
type,
attendee_limit: attendeeLimit,
visibility
@@ -88,14 +94,34 @@ export const actions: Actions = {
if (eventDate < today) {
return fail(400, {
error: 'Date cannot be in the past.',
values: { name, date, time, location, type, attendee_limit: attendeeLimit, visibility }
values: {
name,
date,
time,
location,
location_type: locationType,
location_url: locationUrl,
type,
attendee_limit: attendeeLimit,
visibility
}
});
}
if (type === 'limited' && (!attendeeLimit || parseInt(attendeeLimit) < 2)) {
return fail(400, {
error: 'Limit must be at least 2 for limited events.',
values: { name, date, time, location, type, attendee_limit: attendeeLimit, visibility }
values: {
name,
date,
time,
location,
location_type: locationType,
location_url: locationUrl,
type,
attendee_limit: attendeeLimit,
visibility
}
});
}
@@ -106,7 +132,9 @@ export const actions: Actions = {
name: name.trim(),
date: date,
time: time,
location: location.trim(),
location: location?.trim() || '',
locationType: locationType,
locationUrl: locationType === 'maps' ? locationUrl?.trim() : null,
type: type,
attendeeLimit: type === 'limited' ? parseInt(attendeeLimit) : null,
visibility: visibility,

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import type { EventType } from '$lib/types';
import type { EventType, LocationType } from '$lib/types';
import { enhance } from '$app/forms';
import { goto } from '$app/navigation';
import { t } from '$lib/i18n/i18n.js';
@@ -12,6 +12,8 @@
date: data.event.date,
time: data.event.time,
location: data.event.location,
location_type: data.event.locationType || 'none',
location_url: data.event.locationUrl || '',
type: data.event.type,
attendee_limit: data.event.attendeeLimit,
visibility: data.event.visibility
@@ -49,6 +51,19 @@
}
};
const handleLocationTypeChange = (locationType: LocationType) => {
eventData.location_type = locationType;
if (locationType === 'none') {
eventData.location = '';
eventData.location_url = '';
} else if (locationType === 'text') {
eventData.location_url = '';
eventData.location = '';
} else {
eventData.location = 'Google Maps';
}
};
const handleCancel = () => {
goto(`/event/${data.event.id}`);
};
@@ -61,7 +76,7 @@
<div class="flex min-h-screen flex-col">
<!-- Main Content -->
<div class="container mx-auto flex-1 px-4 py-8">
<div class="mx-auto max-w-md">
<div class="mx-auto max-w-2xl">
<!-- Event Edit Form -->
<div class="rounded-sm border p-8">
<div class="mb-8 text-center">
@@ -86,9 +101,6 @@
}}
class="space-y-6"
>
<input type="hidden" name="type" value={eventData.type} />
<input type="hidden" name="visibility" value={eventData.visibility} />
{#if errors.server}
<div class="mb-6 rounded-sm border border-red-200 bg-red-50 p-4 text-red-700">
{errors.server}
@@ -153,26 +165,96 @@
</div>
</div>
<!-- Location -->
<!-- Location Type -->
<div>
<label for="location" class="text-dark-800 mb-3 block text-sm font-semibold">
{t('common.location')} <span class="text-red-400">{t('common.required')}</span>
</label>
<input
id="location"
name="location"
type="text"
bind:value={eventData.location}
class="border-dark-300 placeholder-dark-500 w-full rounded-sm border-2 px-4 py-3 text-slate-900 shadow-sm transition-all"
placeholder={t('common.enterLocation')}
maxlength="200"
required
/>
{#if errors.location}
<p class="mt-2 text-sm font-medium text-red-600">{errors.location}</p>
{/if}
<fieldset>
<legend class="text-dark-800 mb-3 block text-sm font-semibold">
{t('create.locationTypeLabel')}
<span class="text-red-400">{t('common.required')}</span>
</legend>
<div class="grid grid-cols-3 gap-3">
<button
type="button"
class="rounded-sm border-2 px-4 py-3 font-medium transition-all duration-200 {eventData.location_type ===
'none'
? ' border-violet-500 bg-violet-400/20 font-semibold hover:bg-violet-400/70'
: 'border-dark-300 text-dark-700'}"
on:click={() => handleLocationTypeChange('none')}
>
{t('create.locationNoneOption')}
</button>
<button
type="button"
class="rounded-sm border-2 px-4 py-3 font-medium transition-all duration-200 {eventData.location_type ===
'text'
? ' border-violet-500 bg-violet-400/20 font-semibold hover:bg-violet-400/70'
: 'border-dark-300 text-dark-700'}"
on:click={() => handleLocationTypeChange('text')}
>
{t('create.locationTextOption')}
</button>
<button
type="button"
class="rounded-sm border-2 px-4 py-3 font-medium transition-all duration-200 {eventData.location_type ===
'maps'
? ' border-violet-500 bg-violet-400/20 font-semibold hover:bg-violet-400/70'
: 'border-dark-300 text-dark-700 bg-gray-600/20 hover:bg-gray-600/70'}"
on:click={() => handleLocationTypeChange('maps')}
>
{t('create.locationMapsOption')}
</button>
</div>
<p class="mt-2 text-xs text-slate-400">
{eventData.location_type === 'none'
? t('create.locationNoneDescription')
: eventData.location_type === 'text'
? t('create.locationTextDescription')
: t('create.locationMapsDescription')}
</p>
</fieldset>
</div>
<!-- Location Input (only show when not 'none') -->
{#if eventData.location_type !== 'none'}
<div>
<label for="location" class="text-dark-800 mb-3 block text-sm font-semibold">
{eventData.location_type === 'text'
? t('create.locationLabel')
: t('create.googleMapsUrlLabel')}
<span class="text-red-400">{t('common.required')}</span>
</label>
{#if eventData.location_type === 'text'}
<input
id="location"
name="location"
type="text"
bind:value={eventData.location}
class="border-dark-300 placeholder-dark-500 w-full rounded-sm border-2 px-4 py-3 text-slate-900 shadow-sm transition-all"
placeholder={t('create.locationPlaceholder')}
maxlength="200"
required
/>
{:else}
<input
id="location_url"
name="location_url"
type="url"
bind:value={eventData.location_url}
class="border-dark-300 placeholder-dark-500 w-full rounded-sm border-2 px-4 py-3 text-slate-900 shadow-sm transition-all"
placeholder={t('create.googleMapsUrlPlaceholder')}
maxlength="500"
required
/>
{/if}
{#if errors.location}
<p class="mt-2 text-sm font-medium text-red-600">{errors.location}</p>
{/if}
{#if errors.location_url}
<p class="mt-2 text-sm font-medium text-red-600">{errors.location_url}</p>
{/if}
</div>
{/if}
<!-- Event Type -->
<div>
<fieldset>