Files
voyage/frontend/src/lib/components/collections/ItinerarySuggestionModal.svelte

531 lines
17 KiB
Svelte

<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte';
import { t } from 'svelte-i18n';
import type { Collection } from '$lib/types';
export let collection: Collection;
export let user: any;
export let targetDate: string;
export let displayDate: string;
$: void user;
type SuggestionCategory = 'restaurant' | 'activity' | 'event' | 'lodging' | 'surprise';
type SuggestionItem = {
name: string;
description?: string;
why_fits?: string;
category?: string;
location?: string;
rating?: number | string | null;
price_level?: string | null;
};
const dispatch = createEventDispatcher();
let modal: HTMLDialogElement;
let step = 0; // 0: category, 1: filters, 2: results
let selectedCategory: SuggestionCategory | '' = '';
let filters: Record<string, any> = {};
let suggestions: SuggestionItem[] = [];
let isLoading = false;
let isAdding = false;
let addingSuggestionName = '';
let error = '';
const categories: Array<{
id: SuggestionCategory;
icon: string;
labelKey: string;
skipFilters?: boolean;
}> = [
{ id: 'restaurant', icon: '🍽️', labelKey: 'suggestions.category_restaurant' },
{ id: 'activity', icon: '🎯', labelKey: 'suggestions.category_activity' },
{ id: 'event', icon: '🎉', labelKey: 'suggestions.category_event' },
{ id: 'lodging', icon: '🏨', labelKey: 'suggestions.category_lodging' },
{ id: 'surprise', icon: '✨', labelKey: 'suggestions.surprise_me', skipFilters: true }
];
const supportedApiCategories = ['restaurant', 'activity', 'event', 'lodging'];
const activityTypes = ['outdoor', 'cultural', 'entertainment', 'other'];
const durations = ['few hours', 'half-day', 'full-day'];
const timePreferences = ['morning', 'afternoon', 'evening', 'night'];
const lodgingTypes = ['hotel', 'hostel', 'apartment', 'resort'];
const amenities = ['wifi', 'pool', 'parking', 'breakfast'];
onMount(() => {
modal = document.getElementById('suggestion_modal') as HTMLDialogElement;
if (modal) modal.showModal();
});
function getCollectionLocation(): string {
const firstLocation = collection?.locations?.[0];
if (!firstLocation) return collection?.name || '';
return (
firstLocation.location ||
firstLocation.city?.name ||
firstLocation.country?.name ||
firstLocation.name ||
collection?.name ||
''
);
}
function close() {
dispatch('close');
}
function resetFiltersForCategory(category: SuggestionCategory) {
if (category === 'restaurant') {
filters = { cuisine_type: '', price_range: '', dietary: '' };
} else if (category === 'activity') {
filters = { activity_type: '', duration: '' };
} else if (category === 'event') {
filters = { event_type: '', time_preference: '' };
} else if (category === 'lodging') {
filters = { lodging_type: '', amenities: [] };
} else {
filters = {};
}
}
async function selectCategory(category: SuggestionCategory, skipFilters = false) {
selectedCategory = category;
error = '';
suggestions = [];
resetFiltersForCategory(category);
if (skipFilters) {
await fetchSuggestions();
return;
}
step = 1;
}
function getApiCategory(): string {
if (selectedCategory !== 'surprise') return selectedCategory;
const randomIndex = Math.floor(Math.random() * supportedApiCategories.length);
return supportedApiCategories[randomIndex];
}
function getActiveFilters() {
if (selectedCategory === 'surprise') return {};
const nextFilters: Record<string, any> = {};
Object.entries(filters || {}).forEach(([key, value]) => {
if (Array.isArray(value) && value.length > 0) nextFilters[key] = value;
else if (!Array.isArray(value) && value) nextFilters[key] = value;
});
return nextFilters;
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
function normalizeText(value: unknown): string {
if (typeof value !== 'string') return '';
return value.trim();
}
function normalizeRating(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string') {
const match = value.match(/\d+(\.\d+)?/);
if (!match) return null;
const parsed = Number(match[0]);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function normalizeSuggestionItem(value: unknown): SuggestionItem | null {
const item = asRecord(value);
if (!item) return null;
const name =
normalizeText(item.name) ||
normalizeText(item.title) ||
normalizeText(item.place_name) ||
normalizeText(item.venue);
const description =
normalizeText(item.description) || normalizeText(item.summary) || normalizeText(item.details);
const whyFits =
normalizeText(item.why_fits) || normalizeText(item.whyFits) || normalizeText(item.reason);
const location =
normalizeText(item.location) ||
normalizeText(item.address) ||
normalizeText(item.neighborhood);
const category = normalizeText(item.category);
const priceLevel =
normalizeText(item.price_level) ||
normalizeText(item.priceLevel) ||
normalizeText(item.price);
const rating = normalizeRating(item.rating ?? item.score);
const finalName = name || location;
if (!finalName) return null;
return {
name: finalName,
description: description || undefined,
why_fits: whyFits || undefined,
category: category || undefined,
location: location || undefined,
rating,
price_level: priceLevel || null
};
}
function buildLocationPayload(suggestion: SuggestionItem) {
const name =
normalizeText(suggestion.name) || normalizeText(suggestion.location) || 'Suggestion';
const locationText =
normalizeText(suggestion.location) ||
getCollectionLocation() ||
normalizeText(suggestion.name);
const description =
normalizeText(suggestion.description) ||
normalizeText(suggestion.why_fits) ||
(suggestion.category ? `${suggestion.category} suggestion` : '');
const rating = normalizeRating(suggestion.rating);
return {
name,
description,
location: locationText || name,
rating,
collections: [collection.id],
is_public: false
};
}
async function fetchSuggestions() {
if (!selectedCategory) return;
step = 2;
error = '';
isLoading = true;
try {
const response = await fetch('/api/chat/suggestions/day/', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
collection_id: collection.id,
date: targetDate,
category: getApiCategory(),
filters: getActiveFilters(),
location_context: getCollectionLocation()
})
});
if (!response.ok) {
throw new Error('Failed to get suggestions');
}
const data = await response.json();
suggestions = Array.isArray(data?.suggestions)
? data.suggestions
.map((item: unknown) => normalizeSuggestionItem(item))
.filter((item: SuggestionItem | null): item is SuggestionItem => item !== null)
: [];
} catch (_err) {
error = $t('suggestions.error');
suggestions = [];
} finally {
isLoading = false;
}
}
function goBackToFilters() {
error = '';
suggestions = [];
step = selectedCategory === 'surprise' ? 0 : 1;
}
function toggleAmenity(value: string, enabled: boolean) {
const currentAmenities = Array.isArray(filters.amenities) ? filters.amenities : [];
if (enabled) {
filters = { ...filters, amenities: [...new Set([...currentAmenities, value])] };
return;
}
filters = { ...filters, amenities: currentAmenities.filter((a: string) => a !== value) };
}
function handleAmenityChange(value: string, event: Event) {
toggleAmenity(value, (event.currentTarget as HTMLInputElement).checked);
}
async function handleAddSuggestion(suggestion: SuggestionItem) {
if (!suggestion?.name || isAdding) return;
isAdding = true;
addingSuggestionName = suggestion.name;
error = '';
try {
const payload = buildLocationPayload(suggestion);
const createLocationResponse = await fetch('/api/locations/', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!createLocationResponse.ok) {
throw new Error('Failed to create location');
}
const location = await createLocationResponse.json();
if (!location?.id) {
throw new Error('Location was not created');
}
dispatch('addItem', {
type: 'location',
itemId: location.id,
updateDate: false
});
} catch (_err) {
error = $t('suggestions.error');
} finally {
isAdding = false;
addingSuggestionName = '';
}
}
</script>
<dialog id="suggestion_modal" class="modal backdrop-blur-sm">
<div
class="modal-box w-11/12 max-w-4xl bg-gradient-to-br from-base-100 via-base-100 to-base-200 border border-base-300 shadow-2xl"
>
<div
class="sticky top-0 z-10 bg-base-100/90 backdrop-blur-lg border-b border-base-300 -mx-6 -mt-6 px-6 py-4 mb-6"
>
<h3 class="text-lg font-bold text-primary">
{$t('suggestions.title')}
{$t('suggestions.for_date').replace('{date}', displayDate)}
</h3>
</div>
<div class="px-2 max-h-[28rem] overflow-y-auto space-y-4">
{#if step === 0}
<p class="text-sm opacity-80">{$t('suggestions.select_category')}</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
{#each categories as category}
<button
type="button"
class="btn btn-outline justify-start h-auto py-4"
on:click={() => selectCategory(category.id, !!category.skipFilters)}
>
<span class="text-lg">{category.icon}</span>
<span>{$t(category.labelKey)}</span>
</button>
{/each}
</div>
{:else if step === 1}
<p class="text-sm opacity-80">{$t('suggestions.refine_filters')}</p>
{#if selectedCategory === 'restaurant'}
<label class="form-control w-full">
<div class="label">
<span class="label-text">{$t('suggestions.cuisine_type')}</span>
</div>
<input class="input input-bordered" type="text" bind:value={filters.cuisine_type} />
</label>
<label class="form-control w-full">
<div class="label"><span class="label-text">{$t('suggestions.price_range')}</span></div>
<select class="select select-bordered" bind:value={filters.price_range}>
<option value="">{$t('recomendations.any')}</option>
<option value="$">$</option>
<option value="$$">$$</option>
<option value="$$$">$$$</option>
<option value="$$$$">$$$$</option>
</select>
</label>
<label class="form-control w-full">
<div class="label"><span class="label-text">{$t('suggestions.dietary')}</span></div>
<input class="input input-bordered" type="text" bind:value={filters.dietary} />
</label>
{:else if selectedCategory === 'activity'}
<label class="form-control w-full">
<div class="label">
<span class="label-text">{$t('suggestions.activity_type')}</span>
</div>
<select class="select select-bordered" bind:value={filters.activity_type}>
<option value="">{$t('recomendations.any')}</option>
{#each activityTypes as activityType}
<option value={activityType}>{activityType}</option>
{/each}
</select>
</label>
<label class="form-control w-full">
<div class="label"><span class="label-text">{$t('suggestions.duration')}</span></div>
<select class="select select-bordered" bind:value={filters.duration}>
<option value="">{$t('recomendations.any')}</option>
{#each durations as duration}
<option value={duration}>{duration}</option>
{/each}
</select>
</label>
{:else if selectedCategory === 'event'}
<label class="form-control w-full">
<div class="label"><span class="label-text">{$t('suggestions.event_type')}</span></div>
<input class="input input-bordered" type="text" bind:value={filters.event_type} />
</label>
<label class="form-control w-full">
<div class="label">
<span class="label-text">{$t('suggestions.time_preference')}</span>
</div>
<select class="select select-bordered" bind:value={filters.time_preference}>
<option value="">{$t('recomendations.any')}</option>
{#each timePreferences as timePreference}
<option value={timePreference}>{timePreference}</option>
{/each}
</select>
</label>
{:else if selectedCategory === 'lodging'}
<label class="form-control w-full">
<div class="label">
<span class="label-text">{$t('suggestions.lodging_type')}</span>
</div>
<select class="select select-bordered" bind:value={filters.lodging_type}>
<option value="">{$t('recomendations.any')}</option>
{#each lodgingTypes as lodgingType}
<option value={lodgingType}>{lodgingType}</option>
{/each}
</select>
</label>
<div class="form-control w-full">
<div class="label"><span class="label-text">{$t('suggestions.amenities')}</span></div>
<div class="grid grid-cols-2 gap-2">
{#each amenities as amenity}
<label class="label cursor-pointer justify-start gap-2">
<input
type="checkbox"
class="checkbox checkbox-sm"
checked={Array.isArray(filters.amenities) &&
filters.amenities.includes(amenity)}
on:change={(event) => handleAmenityChange(amenity, event)}
/>
<span class="label-text capitalize">{amenity}</span>
</label>
{/each}
</div>
</div>
{/if}
{:else if isLoading}
<div class="flex flex-col items-center justify-center py-12 gap-4">
<span class="loading loading-spinner loading-lg text-primary"></span>
<p class="opacity-80">{$t('suggestions.loading')}</p>
</div>
{:else if error}
<div class="alert alert-error">
<span>{error}</span>
</div>
{:else if suggestions.length === 0}
<div class="alert alert-info">
<span>{$t('suggestions.no_results')}</span>
</div>
{:else}
<div class="space-y-3">
{#each suggestions as suggestion}
<div class="card bg-base-100 border border-base-300 shadow-sm">
<div class="card-body p-4">
<div class="flex items-start justify-between gap-3">
<div>
<h4 class="font-semibold text-base">{suggestion.name}</h4>
{#if suggestion.description}
<p class="text-sm opacity-80 mt-1">{suggestion.description}</p>
{/if}
</div>
</div>
{#if suggestion.why_fits}
<div class="mt-2 p-3 rounded-lg bg-primary/10 border border-primary/20">
<p class="text-xs uppercase tracking-wide opacity-70 mb-1">
{$t('suggestions.why_fits')}
</p>
<p class="text-sm">{suggestion.why_fits}</p>
</div>
{/if}
<div class="mt-3 flex flex-wrap gap-2 text-xs">
{#if suggestion.location}
<span class="badge badge-outline">📍 {suggestion.location}</span>
{/if}
{#if suggestion.rating !== null && suggestion.rating !== undefined && suggestion.rating !== ''}
<span class="badge badge-outline">{suggestion.rating}</span>
{/if}
{#if suggestion.price_level}
<span class="badge badge-outline">{suggestion.price_level}</span>
{/if}
</div>
<div class="card-actions justify-end mt-3">
<button
type="button"
class="btn btn-primary btn-sm"
disabled={isAdding}
on:click={() => handleAddSuggestion(suggestion)}
>
{#if isAdding && addingSuggestionName === suggestion.name}
<span class="loading loading-spinner loading-xs"></span>
{/if}
{$t('suggestions.add_to_day')}
</button>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
<div
class="sticky bottom-0 bg-base-100/90 backdrop-blur-lg border-t border-base-300 -mx-6 -mb-6 px-4 py-3 mt-6"
>
<div class="flex justify-between gap-3">
<button class="btn" type="button" on:click={close}>{$t('adventures.cancel')}</button>
{#if step === 1}
<div class="flex gap-2">
<button class="btn btn-outline" type="button" on:click={() => (step = 0)}
>{$t('adventures.back')}</button
>
<button class="btn btn-primary" type="button" on:click={fetchSuggestions}
>{$t('suggestions.get_suggestions')}</button
>
</div>
{:else if step === 2}
<button
class="btn btn-outline"
type="button"
on:click={goBackToFilters}
disabled={isLoading || isAdding}
>
{$t('suggestions.try_again')}
</button>
{/if}
</div>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button type="button" on:click={close}>close</button>
</form>
</dialog>