531 lines
17 KiB
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>
|