feat(frontend): apply itinerary UI and docs refinements
This commit is contained in:
@@ -23,8 +23,6 @@
|
||||
import DeleteWarning from '../DeleteWarning.svelte';
|
||||
import CardCarousel from '../CardCarousel.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import Star from '~icons/mdi/star';
|
||||
import StarOutline from '~icons/mdi/star-outline';
|
||||
import Eye from '~icons/mdi/eye';
|
||||
import EyeOff from '~icons/mdi/eye-off';
|
||||
import CollectionItineraryPlanner from '../collections/CollectionItineraryPlanner.svelte';
|
||||
@@ -112,16 +110,6 @@
|
||||
? `${adventure.user.first_name} ${adventure.user.last_name || ''}`.trim()
|
||||
: adventure.user?.username || 'Unknown User';
|
||||
|
||||
// Helper functions for display
|
||||
|
||||
function renderStars(rating: number) {
|
||||
const stars = [];
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
stars.push(i <= rating);
|
||||
}
|
||||
return stars;
|
||||
}
|
||||
|
||||
function changeDay() {
|
||||
dispatch('changeDay', { type: 'location', item: adventure, forcePicker: true });
|
||||
}
|
||||
@@ -577,7 +565,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Inline stats: location, rating, visits -->
|
||||
<!-- Inline stats: location and price -->
|
||||
<div
|
||||
class="flex flex-wrap items-center text-base-content/70 min-w-0"
|
||||
class:gap-2={compact}
|
||||
@@ -592,21 +580,6 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if adventure.rating}
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="flex -ml-1">
|
||||
{#each renderStars(adventure.rating) as filled}
|
||||
{#if filled}
|
||||
<Star class="w-4 h-4 text-warning fill-current" />
|
||||
{:else}
|
||||
<StarOutline class="w-4 h-4 text-base-content/30" />
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
<span class="text-xs text-base-content/60">({adventure.rating}/5)</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if adventurePriceLabel}
|
||||
<span class="badge badge-ghost badge-sm whitespace-nowrap">💰 {adventurePriceLabel}</span>
|
||||
{/if}
|
||||
|
||||
@@ -14,8 +14,6 @@
|
||||
import CardCarousel from '../CardCarousel.svelte';
|
||||
import Eye from '~icons/mdi/eye';
|
||||
import EyeOff from '~icons/mdi/eye-off';
|
||||
import Star from '~icons/mdi/star';
|
||||
import StarOutline from '~icons/mdi/star-outline';
|
||||
import MapMarker from '~icons/mdi/map-marker';
|
||||
import DotsHorizontal from '~icons/mdi/dots-horizontal';
|
||||
import CalendarRemove from '~icons/mdi/calendar-remove';
|
||||
@@ -61,14 +59,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
function renderStars(rating: number) {
|
||||
const stars = [];
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
stars.push(i <= rating);
|
||||
}
|
||||
return stars;
|
||||
}
|
||||
|
||||
export let lodging: Lodging;
|
||||
export let user: User | null = null;
|
||||
export let collection: Collection | null = null;
|
||||
@@ -532,23 +522,8 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Rating & Info Badges -->
|
||||
<!-- Info Badges -->
|
||||
<div class="flex flex-wrap items-center gap-2 text-sm">
|
||||
{#if lodging.rating}
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="flex -ml-1">
|
||||
{#each renderStars(lodging.rating) as filled}
|
||||
{#if filled}
|
||||
<Star class="w-4 h-4 text-warning fill-current" />
|
||||
{:else}
|
||||
<StarOutline class="w-4 h-4 text-base-content/30" />
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
<span class="text-xs text-base-content/60">({lodging.rating}/5)</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if lodging.user == user?.uuid || (collection && user && collection.shared_with?.includes(user.uuid))}
|
||||
{#if lodging.reservation_number}
|
||||
<span class="badge badge-primary badge-sm font-medium">
|
||||
|
||||
@@ -15,8 +15,6 @@
|
||||
|
||||
import Eye from '~icons/mdi/eye';
|
||||
import EyeOff from '~icons/mdi/eye-off';
|
||||
import Star from '~icons/mdi/star';
|
||||
import StarOutline from '~icons/mdi/star-outline';
|
||||
import Calendar from '~icons/mdi/calendar';
|
||||
import DotsHorizontal from '~icons/mdi/dots-horizontal';
|
||||
import CalendarRemove from '~icons/mdi/calendar-remove';
|
||||
@@ -59,14 +57,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
function renderStars(rating: number) {
|
||||
const stars = [];
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
stars.push(i <= rating);
|
||||
}
|
||||
return stars;
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
const localTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC';
|
||||
@@ -86,6 +76,8 @@
|
||||
export let collection: Collection | null = null;
|
||||
export let readOnly: boolean = false;
|
||||
export let itineraryItem: CollectionItineraryItem | null = null;
|
||||
export let compact: boolean = false;
|
||||
export let showImage: boolean = true;
|
||||
|
||||
const toMiles = (km: any) => (Number(km) * 0.621371).toFixed(1);
|
||||
|
||||
@@ -173,62 +165,77 @@
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="card w-full max-w-md bg-base-300 shadow hover:shadow-md transition-all duration-200 border border-base-300 group"
|
||||
class="card w-full bg-base-300 shadow hover:shadow-md transition-all duration-200 border border-base-300 group"
|
||||
class:max-w-md={!compact}
|
||||
aria-label="transportation-card"
|
||||
>
|
||||
<!-- Image Section with Overlay -->
|
||||
<div class="relative overflow-hidden rounded-t-2xl">
|
||||
{#if routeGeojson}
|
||||
<TransportationRoutePreview
|
||||
geojson={routeGeojson}
|
||||
name={transportation.name}
|
||||
images={transportation.images}
|
||||
/>
|
||||
{:else}
|
||||
<CardCarousel
|
||||
images={transportation.images}
|
||||
icon={getTransportationIcon(transportation.type)}
|
||||
name={transportation.name}
|
||||
/>
|
||||
{/if}
|
||||
{#if showImage}
|
||||
<!-- Image Section with Overlay -->
|
||||
<div class="relative overflow-hidden rounded-t-2xl">
|
||||
{#if routeGeojson}
|
||||
<TransportationRoutePreview
|
||||
geojson={routeGeojson}
|
||||
name={transportation.name}
|
||||
images={transportation.images}
|
||||
/>
|
||||
{:else}
|
||||
<CardCarousel
|
||||
images={transportation.images}
|
||||
icon={getTransportationIcon(transportation.type)}
|
||||
name={transportation.name}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Privacy Indicator -->
|
||||
<div class="absolute top-2 right-4">
|
||||
<div
|
||||
class="tooltip tooltip-left"
|
||||
data-tip={transportation.is_public ? $t('adventures.public') : $t('adventures.private')}
|
||||
>
|
||||
<!-- Privacy Indicator -->
|
||||
<div class="absolute top-2 right-4">
|
||||
<div
|
||||
class="badge badge-sm p-1 rounded-full text-base-content shadow-sm"
|
||||
role="img"
|
||||
aria-label={transportation.is_public ? $t('adventures.public') : $t('adventures.private')}
|
||||
class="tooltip tooltip-left"
|
||||
data-tip={transportation.is_public ? $t('adventures.public') : $t('adventures.private')}
|
||||
>
|
||||
{#if transportation.is_public}
|
||||
<Eye class="w-4 h-4" />
|
||||
{:else}
|
||||
<EyeOff class="w-4 h-4" />
|
||||
{/if}
|
||||
<div
|
||||
class="badge badge-sm p-1 rounded-full text-base-content shadow-sm"
|
||||
role="img"
|
||||
aria-label={transportation.is_public
|
||||
? $t('adventures.public')
|
||||
: $t('adventures.private')}
|
||||
>
|
||||
{#if transportation.is_public}
|
||||
<Eye class="w-4 h-4" />
|
||||
{:else}
|
||||
<EyeOff class="w-4 h-4" />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Category Badge -->
|
||||
{#if transportation.type}
|
||||
<div class="absolute bottom-4 left-4">
|
||||
<div class="badge badge-primary shadow-lg font-medium">
|
||||
{$t(`transportation.modes.${transportation.type}`)}
|
||||
{getTransportationIcon(transportation.type)}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Category Badge -->
|
||||
{#if transportation.type}
|
||||
<div class="absolute bottom-4 left-4">
|
||||
<div class="badge badge-primary shadow-lg font-medium">
|
||||
{$t(`transportation.modes.${transportation.type}`)}
|
||||
{getTransportationIcon(transportation.type)}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="card-body p-4 space-y-3 min-w-0">
|
||||
<div
|
||||
class="card-body min-w-0"
|
||||
class:p-3={compact}
|
||||
class:p-4={!compact}
|
||||
class:space-y-2={compact}
|
||||
class:space-y-3={!compact}
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<a
|
||||
href="/transportations/{transportation.id}"
|
||||
class="hover:text-primary transition-colors duration-200 line-clamp-2 text-lg font-semibold"
|
||||
class="hover:text-primary transition-colors duration-200 line-clamp-2"
|
||||
class:text-base={compact}
|
||||
class:text-lg={!compact}
|
||||
class:font-medium={compact}
|
||||
class:font-semibold={!compact}
|
||||
>
|
||||
{transportation.name}
|
||||
</a>
|
||||
@@ -343,6 +350,33 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !showImage}
|
||||
<div class="flex items-center gap-2 text-xs text-base-content/70 min-w-0">
|
||||
<div
|
||||
class="tooltip tooltip-left"
|
||||
data-tip={transportation.is_public ? $t('adventures.public') : $t('adventures.private')}
|
||||
>
|
||||
<div
|
||||
class="badge badge-sm p-1 rounded-full text-base-content shadow-sm"
|
||||
role="img"
|
||||
aria-label={transportation.is_public ? $t('adventures.public') : $t('adventures.private')}
|
||||
>
|
||||
{#if transportation.is_public}
|
||||
<Eye class="w-4 h-4" />
|
||||
{:else}
|
||||
<EyeOff class="w-4 h-4" />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if transportation.type}
|
||||
<div class="badge badge-primary badge-sm font-medium">
|
||||
{$t(`transportation.modes.${transportation.type}`)}
|
||||
{getTransportationIcon(transportation.type)}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Route & Flight Info -->
|
||||
{#if routeFromLabel || routeToLabel}
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
@@ -466,7 +500,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Stats & Rating -->
|
||||
<!-- Stats -->
|
||||
<div class="flex flex-wrap items-center gap-2 text-sm">
|
||||
{#if transportationPriceLabel}
|
||||
<span class="badge badge-ghost badge-sm">💰 {transportationPriceLabel}</span>
|
||||
@@ -483,20 +517,6 @@
|
||||
<span class="badge badge-ghost badge-sm">⏱️ {travelDurationLabel}</span>
|
||||
{/if}
|
||||
|
||||
{#if transportation.rating}
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="flex -ml-1">
|
||||
{#each renderStars(transportation.rating) as filled}
|
||||
{#if filled}
|
||||
<Star class="w-4 h-4 text-warning fill-current" />
|
||||
{:else}
|
||||
<StarOutline class="w-4 h-4 text-base-content/30" />
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
<span class="text-xs text-base-content/60">({transportation.rating}/5)</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -408,17 +408,6 @@
|
||||
return '🚗';
|
||||
}
|
||||
|
||||
function formatTransportationDuration(minutes: number | null | undefined): string | null {
|
||||
if (minutes === null || minutes === undefined || Number.isNaN(minutes)) return null;
|
||||
const safeMinutes = Math.max(0, Math.floor(minutes));
|
||||
const hours = Math.floor(safeMinutes / 60);
|
||||
const mins = safeMinutes % 60;
|
||||
const parts = [] as string[];
|
||||
if (hours) parts.push(`${hours}h`);
|
||||
parts.push(`${mins}m`);
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
function formatTransportationDistance(distanceKm: number | null | undefined): string | null {
|
||||
if (distanceKm === null || distanceKm === undefined || Number.isNaN(distanceKm)) return null;
|
||||
if (distanceKm < 10) return `${distanceKm.toFixed(1)} km`;
|
||||
@@ -509,7 +498,8 @@
|
||||
};
|
||||
};
|
||||
|
||||
type ConnectableItemType = 'location' | 'lodging';
|
||||
type ConnectableItemType = 'location' | 'lodging' | 'transportation';
|
||||
type TransportationCoordinateRole = 'origin' | 'destination';
|
||||
|
||||
type RouteMetricResult = {
|
||||
distance_label?: string;
|
||||
@@ -523,17 +513,37 @@
|
||||
let activeConnectorFetchVersion = 0;
|
||||
|
||||
function isConnectableItemType(type: string): type is ConnectableItemType {
|
||||
return type === 'location' || type === 'lodging';
|
||||
return type === 'location' || type === 'lodging' || type === 'transportation';
|
||||
}
|
||||
|
||||
function getCoordinatesFromItineraryItem(
|
||||
item: ResolvedItineraryItem | null
|
||||
item: ResolvedItineraryItem | null,
|
||||
transportationRole: TransportationCoordinateRole = 'origin'
|
||||
): { latitude: number; longitude: number } | null {
|
||||
if (!item) return null;
|
||||
|
||||
const itemType = item.item?.type || '';
|
||||
if (!isConnectableItemType(itemType)) return null;
|
||||
|
||||
if (itemType === 'transportation') {
|
||||
const transportation = item.resolvedObject as Transportation | null;
|
||||
if (!transportation) return null;
|
||||
|
||||
const latitude = normalizeCoordinate(
|
||||
transportationRole === 'origin'
|
||||
? transportation.origin_latitude
|
||||
: transportation.destination_latitude
|
||||
);
|
||||
const longitude = normalizeCoordinate(
|
||||
transportationRole === 'origin'
|
||||
? transportation.origin_longitude
|
||||
: transportation.destination_longitude
|
||||
);
|
||||
if (latitude === null || longitude === null) return null;
|
||||
|
||||
return { latitude, longitude };
|
||||
}
|
||||
|
||||
const resolvedObj = item.resolvedObject as Location | Lodging | null;
|
||||
if (!resolvedObj) return null;
|
||||
|
||||
@@ -544,20 +554,20 @@
|
||||
return { latitude, longitude };
|
||||
}
|
||||
|
||||
function getFirstLocationItem(items: ResolvedItineraryItem[]): ResolvedItineraryItem | null {
|
||||
function getFirstConnectableItem(items: ResolvedItineraryItem[]): ResolvedItineraryItem | null {
|
||||
for (const item of items) {
|
||||
if (item?.[SHADOW_ITEM_MARKER_PROPERTY_NAME]) continue;
|
||||
if ((item.item?.type || '') === 'location') return item;
|
||||
if (isConnectableItemType(item.item?.type || '')) return item;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getLastLocationItem(items: ResolvedItineraryItem[]): ResolvedItineraryItem | null {
|
||||
function getLastConnectableItem(items: ResolvedItineraryItem[]): ResolvedItineraryItem | null {
|
||||
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||
const item = items[index];
|
||||
if (item?.[SHADOW_ITEM_MARKER_PROPERTY_NAME]) continue;
|
||||
if ((item.item?.type || '') === 'location') return item;
|
||||
if (isConnectableItemType(item.item?.type || '')) return item;
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -691,8 +701,14 @@
|
||||
const nextType = nextItem.item?.type || '';
|
||||
if (!isConnectableItemType(currentType) || !isConnectableItemType(nextType)) return null;
|
||||
|
||||
const fromCoordinates = getCoordinatesFromItineraryItem(currentItem);
|
||||
const toCoordinates = getCoordinatesFromItineraryItem(nextItem);
|
||||
const fromCoordinates = getCoordinatesFromItineraryItem(
|
||||
currentItem,
|
||||
currentType === 'transportation' ? 'destination' : 'origin'
|
||||
);
|
||||
const toCoordinates = getCoordinatesFromItineraryItem(
|
||||
nextItem,
|
||||
'origin'
|
||||
);
|
||||
if (!fromCoordinates || !toCoordinates) return null;
|
||||
|
||||
const key = getLocationConnectorKey(currentItem, nextItem);
|
||||
@@ -705,7 +721,7 @@
|
||||
};
|
||||
}
|
||||
|
||||
function findNextLocationItem(
|
||||
function findNextConnectableItem(
|
||||
items: ResolvedItineraryItem[],
|
||||
currentIndex: number
|
||||
): ResolvedItineraryItem | null {
|
||||
@@ -714,7 +730,7 @@
|
||||
if (candidate?.[SHADOW_ITEM_MARKER_PROPERTY_NAME]) {
|
||||
continue;
|
||||
}
|
||||
if ((candidate?.item?.type || '') === 'location') {
|
||||
if (isConnectableItemType(candidate?.item?.type || '')) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
@@ -734,11 +750,11 @@
|
||||
|
||||
for (const dayGroup of dayGroups) {
|
||||
const dayTimelineItems = getDayTimelineItems(dayGroup);
|
||||
const firstLocationItem = getFirstLocationItem(dayGroup.items);
|
||||
const lastLocationItem = getLastLocationItem(dayGroup.items);
|
||||
const firstConnectableItem = getFirstConnectableItem(dayGroup.items);
|
||||
const lastConnectableItem = getLastConnectableItem(dayGroup.items);
|
||||
|
||||
if (dayGroup.preTimelineLodging && firstLocationItem) {
|
||||
pushPair(getConnectorPair(dayGroup.preTimelineLodging, firstLocationItem));
|
||||
if (dayGroup.preTimelineLodging && firstConnectableItem) {
|
||||
pushPair(getConnectorPair(dayGroup.preTimelineLodging, firstConnectableItem));
|
||||
}
|
||||
|
||||
for (let index = 0; index < dayTimelineItems.length - 1; index += 1) {
|
||||
@@ -746,12 +762,12 @@
|
||||
if (currentItem?.[SHADOW_ITEM_MARKER_PROPERTY_NAME]) {
|
||||
continue;
|
||||
}
|
||||
const nextLocationItem = findNextLocationItem(dayTimelineItems, index);
|
||||
pushPair(getConnectorPair(currentItem, nextLocationItem));
|
||||
const nextConnectableItem = findNextConnectableItem(dayTimelineItems, index);
|
||||
pushPair(getConnectorPair(currentItem, nextConnectableItem));
|
||||
}
|
||||
|
||||
if (dayGroup.postTimelineLodging && lastLocationItem) {
|
||||
pushPair(getConnectorPair(lastLocationItem, dayGroup.postTimelineLodging));
|
||||
if (dayGroup.postTimelineLodging && lastConnectableItem) {
|
||||
pushPair(getConnectorPair(lastConnectableItem, dayGroup.postTimelineLodging));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -885,11 +901,20 @@
|
||||
unavailable: true
|
||||
};
|
||||
|
||||
const currentLocation = currentItem.resolvedObject as Location | Lodging | null;
|
||||
const nextLocation = nextItem.resolvedObject as Location | Lodging | null;
|
||||
if (!currentLocation || !nextLocation) return unavailableConnector;
|
||||
const fromCoordinates = getCoordinatesFromItineraryItem(
|
||||
currentItem,
|
||||
currentType === 'transportation' ? 'destination' : 'origin'
|
||||
);
|
||||
const toCoordinates = getCoordinatesFromItineraryItem(
|
||||
nextItem,
|
||||
'origin'
|
||||
);
|
||||
if (!fromCoordinates || !toCoordinates) return unavailableConnector;
|
||||
|
||||
const distanceKm = haversineDistanceKm(currentLocation, nextLocation);
|
||||
const distanceKm = haversineDistanceKm(
|
||||
{ latitude: fromCoordinates.latitude, longitude: fromCoordinates.longitude } as Location,
|
||||
{ latitude: toCoordinates.latitude, longitude: toCoordinates.longitude } as Location
|
||||
);
|
||||
if (distanceKm === null) return unavailableConnector;
|
||||
|
||||
const walkingMinutes = (distanceKm / WALKING_SPEED_KMH) * 60;
|
||||
@@ -926,14 +951,20 @@
|
||||
const nextType = nextItem.item?.type || '';
|
||||
if (!isConnectableItemType(currentType) || !isConnectableItemType(nextType)) return null;
|
||||
|
||||
const currentLocation = currentItem.resolvedObject as Location | Lodging | null;
|
||||
const nextLocation = nextItem.resolvedObject as Location | Lodging | null;
|
||||
if (!currentLocation || !nextLocation) return null;
|
||||
const fromCoordinates = getCoordinatesFromItineraryItem(
|
||||
currentItem,
|
||||
currentType === 'transportation' ? 'destination' : 'origin'
|
||||
);
|
||||
const toCoordinates = getCoordinatesFromItineraryItem(
|
||||
nextItem,
|
||||
'origin'
|
||||
);
|
||||
if (!fromCoordinates || !toCoordinates) return null;
|
||||
|
||||
const fromLatitude = normalizeCoordinate(currentLocation.latitude);
|
||||
const fromLongitude = normalizeCoordinate(currentLocation.longitude);
|
||||
const toLatitude = normalizeCoordinate(nextLocation.latitude);
|
||||
const toLongitude = normalizeCoordinate(nextLocation.longitude);
|
||||
const fromLatitude = fromCoordinates.latitude;
|
||||
const fromLongitude = fromCoordinates.longitude;
|
||||
const toLatitude = toCoordinates.latitude;
|
||||
const toLongitude = toCoordinates.longitude;
|
||||
|
||||
if (
|
||||
fromLatitude === null ||
|
||||
@@ -955,50 +986,6 @@
|
||||
return translated && translated !== key ? translated : fallback;
|
||||
}
|
||||
|
||||
function editTransportationInline(transportation: Transportation) {
|
||||
handleEditTransportation({ detail: transportation } as CustomEvent<Transportation>);
|
||||
}
|
||||
|
||||
async function removeItineraryEntry(item: CollectionItineraryItem) {
|
||||
if (!item?.id) return;
|
||||
try {
|
||||
const res = await fetch(`/api/itineraries/${item.id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to remove itinerary item');
|
||||
handleRemoveItineraryItem(new CustomEvent('removeFromItinerary', { detail: item }) as any);
|
||||
addToast('info', $t('itinerary.item_remove_success'));
|
||||
} catch (error) {
|
||||
console.error('Error removing itinerary item:', error);
|
||||
addToast('error', $t('itinerary.item_remove_error'));
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTransportationFromItinerary(
|
||||
item: CollectionItineraryItem,
|
||||
transportation: Transportation
|
||||
) {
|
||||
const confirmed = window.confirm($t('adventures.transportation_delete_confirm'));
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/transportations/${transportation.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error('Failed to delete transportation');
|
||||
|
||||
addToast('info', $t('transportation.transportation_deleted'));
|
||||
handleItemDelete(new CustomEvent('delete', { detail: transportation.id }) as any);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete transportation:', error);
|
||||
addToast('error', $t('transportation.transportation_delete_error'));
|
||||
}
|
||||
}
|
||||
|
||||
function upsertNote(note: Note) {
|
||||
const notes = collection.notes ? [...collection.notes] : [];
|
||||
const idx = notes.findIndex((n) => n.id === note.id);
|
||||
@@ -2399,6 +2386,9 @@
|
||||
transportation={resolvedObj}
|
||||
{user}
|
||||
{collection}
|
||||
readOnly={!canModify}
|
||||
compact={true}
|
||||
showImage={false}
|
||||
on:delete={handleItemDelete}
|
||||
itineraryItem={item}
|
||||
on:removeFromItinerary={handleRemoveItineraryItem}
|
||||
@@ -2463,34 +2453,34 @@
|
||||
{@const preTimelineLodging = day.preTimelineLodging}
|
||||
{@const postTimelineLodging = day.postTimelineLodging}
|
||||
{@const dayTimelineItems = getDayTimelineItems(day)}
|
||||
{@const firstLocationItem = getFirstLocationItem(day.items)}
|
||||
{@const lastLocationItem = getLastLocationItem(day.items)}
|
||||
{@const noLocationsInDay = !firstLocationItem && !lastLocationItem}
|
||||
{@const firstConnectableItem = getFirstConnectableItem(day.items)}
|
||||
{@const lastConnectableItem = getLastConnectableItem(day.items)}
|
||||
{@const noLocationsInDay = !firstConnectableItem && !lastConnectableItem}
|
||||
{@const shouldCollapseBoundaryLodging =
|
||||
noLocationsInDay &&
|
||||
preTimelineLodging?.id &&
|
||||
postTimelineLodging?.id &&
|
||||
preTimelineLodging.id === postTimelineLodging.id}
|
||||
{@const startBoundaryConnector =
|
||||
preTimelineLodging && firstLocationItem
|
||||
? getLocationConnector(preTimelineLodging, firstLocationItem)
|
||||
preTimelineLodging && firstConnectableItem
|
||||
? getLocationConnector(preTimelineLodging, firstConnectableItem)
|
||||
: null}
|
||||
{@const startBoundaryDirectionsUrl =
|
||||
preTimelineLodging && firstLocationItem
|
||||
preTimelineLodging && firstConnectableItem
|
||||
? buildDirectionsUrl(
|
||||
preTimelineLodging,
|
||||
firstLocationItem,
|
||||
firstConnectableItem,
|
||||
startBoundaryConnector?.mode || 'walking'
|
||||
)
|
||||
: null}
|
||||
{@const endBoundaryConnector =
|
||||
postTimelineLodging && lastLocationItem
|
||||
? getLocationConnector(lastLocationItem, postTimelineLodging)
|
||||
postTimelineLodging && lastConnectableItem
|
||||
? getLocationConnector(lastConnectableItem, postTimelineLodging)
|
||||
: null}
|
||||
{@const endBoundaryDirectionsUrl =
|
||||
postTimelineLodging && lastLocationItem
|
||||
postTimelineLodging && lastConnectableItem
|
||||
? buildDirectionsUrl(
|
||||
lastLocationItem,
|
||||
lastConnectableItem,
|
||||
postTimelineLodging,
|
||||
endBoundaryConnector?.mode || 'walking'
|
||||
)
|
||||
@@ -2737,11 +2727,11 @@
|
||||
{@const objectType = item.item?.type || ''}
|
||||
{@const resolvedObj = item.resolvedObject}
|
||||
{@const multiDay = isMultiDay(item)}
|
||||
{@const nextLocationItem = findNextLocationItem(dayTimelineItems, index)}
|
||||
{@const locationConnector = getLocationConnector(item, nextLocationItem)}
|
||||
{@const nextConnectableItem = findNextConnectableItem(dayTimelineItems, index)}
|
||||
{@const locationConnector = getLocationConnector(item, nextConnectableItem)}
|
||||
{@const directionsUrl = buildDirectionsUrl(
|
||||
item,
|
||||
nextLocationItem,
|
||||
nextConnectableItem,
|
||||
locationConnector?.mode || 'walking'
|
||||
)}
|
||||
{@const isDraggingShadow = item[SHADOW_ITEM_MARKER_PROPERTY_NAME]}
|
||||
@@ -2797,84 +2787,25 @@
|
||||
{/if}
|
||||
|
||||
{#if objectType === 'transportation'}
|
||||
<div class="rounded-xl border border-base-300 bg-base-100 px-4 py-3">
|
||||
<div class="flex items-center justify-between gap-3 mb-2">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-lg"
|
||||
>{getTransportationIcon(resolvedObj.type)}</span
|
||||
>
|
||||
<p class="font-semibold truncate">{resolvedObj.name}</p>
|
||||
<span class="badge badge-outline badge-sm truncate">
|
||||
{$t(`transportation.modes.${resolvedObj.type}`) ||
|
||||
resolvedObj.type}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs opacity-70 flex items-center gap-2 shrink-0">
|
||||
{#if formatTransportationDuration(resolvedObj.travel_duration_minutes)}
|
||||
<span
|
||||
>{formatTransportationDuration(
|
||||
resolvedObj.travel_duration_minutes
|
||||
)}</span
|
||||
>
|
||||
{/if}
|
||||
{#if formatTransportationDistance(resolvedObj.distance)}
|
||||
<span>{formatTransportationDistance(resolvedObj.distance)}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm opacity-80 truncate">
|
||||
{resolvedObj.from_location || '—'} → {resolvedObj.to_location ||
|
||||
'—'}
|
||||
</div>
|
||||
{#if canModify}
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-ghost"
|
||||
on:click={() => editTransportationInline(resolvedObj)}
|
||||
>
|
||||
{$t('transportation.edit')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-ghost"
|
||||
on:click={() =>
|
||||
handleOpenDayPickerForItem(
|
||||
'transportation',
|
||||
resolvedObj,
|
||||
true,
|
||||
day.date
|
||||
)}
|
||||
>
|
||||
{$t('itinerary.change_day')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-ghost"
|
||||
on:click={() =>
|
||||
moveItemToGlobal('transportation', resolvedObj.id)}
|
||||
>
|
||||
{$t('itinerary.move_to_trip_context') || 'Move to Trip Context'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-ghost"
|
||||
on:click={() => removeItineraryEntry(item)}
|
||||
>
|
||||
{$t('itinerary.remove_from_itinerary')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-error btn-outline"
|
||||
on:click={() =>
|
||||
deleteTransportationFromItinerary(item, resolvedObj)}
|
||||
>
|
||||
{$t('adventures.delete')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<TransportationCard
|
||||
transportation={resolvedObj}
|
||||
{user}
|
||||
{collection}
|
||||
itineraryItem={item}
|
||||
compact={true}
|
||||
showImage={false}
|
||||
on:delete={handleItemDelete}
|
||||
on:removeFromItinerary={handleRemoveItineraryItem}
|
||||
on:edit={handleEditTransportation}
|
||||
on:moveToGlobal={(e) => moveItemToGlobal(e.detail.type, e.detail.id)}
|
||||
on:changeDay={(e) =>
|
||||
handleOpenDayPickerForItem(
|
||||
e.detail.type,
|
||||
e.detail.item,
|
||||
e.detail.forcePicker,
|
||||
day.date
|
||||
)}
|
||||
/>
|
||||
{:else}
|
||||
{#if multiDay && objectType === 'lodging'}
|
||||
<div class="mb-2">
|
||||
@@ -3443,6 +3374,9 @@
|
||||
transportation={item}
|
||||
{user}
|
||||
{collection}
|
||||
readOnly={!canModify}
|
||||
compact={true}
|
||||
showImage={false}
|
||||
on:delete={handleItemDelete}
|
||||
on:edit={handleEditTransportation}
|
||||
/>
|
||||
|
||||
@@ -355,12 +355,6 @@
|
||||
return Array.from(types.values()).sort((a, b) => b.distance - a.distance);
|
||||
})();
|
||||
|
||||
$: averageLocationRating = (() => {
|
||||
const rated = visitedLocations.filter((loc) => loc.rating !== null && loc.rating !== undefined);
|
||||
if (rated.length === 0) return 0;
|
||||
return rated.reduce((sum, loc) => sum + (loc.rating || 0), 0) / rated.length;
|
||||
})();
|
||||
|
||||
$: checklistStats = (() => {
|
||||
let totalItems = 0;
|
||||
let checkedItems = 0;
|
||||
@@ -743,18 +737,9 @@
|
||||
</div>
|
||||
|
||||
<!-- Additional Stats Row -->
|
||||
{#if averageLocationRating > 0 || checklistStats.total > 0 || lodgingTypeBreakdown.length > 0}
|
||||
{#if checklistStats.total > 0 || lodgingTypeBreakdown.length > 0}
|
||||
<div class="divider">{$t('adventures.more_details')}</div>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{#if averageLocationRating > 0}
|
||||
<div class="stat bg-base-300 rounded-lg p-4">
|
||||
<div class="stat-figure text-2xl">⭐</div>
|
||||
<div class="stat-title text-xs">{$t('adventures.avg_rating')}</div>
|
||||
<div class="stat-value text-lg">{averageLocationRating.toFixed(1)}</div>
|
||||
<div class="stat-desc text-xs">{$t('adventures.of_locations')}</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if checklistStats.total > 0}
|
||||
<div class="stat bg-base-300 rounded-lg p-4">
|
||||
<div class="stat-figure text-2xl">✓</div>
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
import GenerateIcon from '~icons/mdi/lightning-bolt';
|
||||
import ArrowLeftIcon from '~icons/mdi/arrow-left';
|
||||
import SaveIcon from '~icons/mdi/content-save';
|
||||
import ClearIcon from '~icons/mdi/close-circle';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
@@ -34,7 +33,6 @@
|
||||
let location: {
|
||||
name: string;
|
||||
category: Category | null;
|
||||
rating: number;
|
||||
price: number | null;
|
||||
price_currency: string | null;
|
||||
is_public: boolean;
|
||||
@@ -48,7 +46,6 @@
|
||||
} = {
|
||||
name: '',
|
||||
category: null,
|
||||
rating: NaN,
|
||||
price: null,
|
||||
price_currency: DEFAULT_CURRENCY,
|
||||
is_public: false,
|
||||
@@ -239,7 +236,6 @@
|
||||
if (!location.name) location.name = initialLocation.name || '';
|
||||
if (!location.link) location.link = initialLocation.link || '';
|
||||
if (!location.description) location.description = initialLocation.description || '';
|
||||
if (Number.isNaN(location.rating)) location.rating = initialLocation.rating || NaN;
|
||||
if (location.price === null || location.price === undefined) {
|
||||
const money = toMoneyValue(
|
||||
initialLocation.price,
|
||||
@@ -359,44 +355,6 @@
|
||||
}}
|
||||
/>
|
||||
|
||||
<!-- Rating Field -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="rating">
|
||||
<span class="label-text font-medium">{$t('adventures.rating')}</span>
|
||||
</label>
|
||||
<div
|
||||
class="flex items-center gap-4 p-3 bg-base-100/80 border border-base-300 rounded-lg"
|
||||
>
|
||||
<div class="rating">
|
||||
<input
|
||||
type="radio"
|
||||
name="rating"
|
||||
id="rating"
|
||||
class="rating-hidden"
|
||||
checked={Number.isNaN(location.rating)}
|
||||
/>
|
||||
{#each [1, 2, 3, 4, 5] as star}
|
||||
<input
|
||||
type="radio"
|
||||
name="rating"
|
||||
class="mask mask-star-2 bg-warning"
|
||||
on:click={() => (location.rating = star)}
|
||||
checked={location.rating === star}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{#if !Number.isNaN(location.rating)}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-error btn-outline gap-2"
|
||||
on:click={() => (location.rating = NaN)}
|
||||
>
|
||||
<ClearIcon class="w-4 h-4" />
|
||||
{$t('adventures.remove')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column -->
|
||||
|
||||
@@ -53,7 +53,6 @@
|
||||
link: null,
|
||||
description: null,
|
||||
tags: [],
|
||||
rating: NaN,
|
||||
price: null,
|
||||
price_currency: null,
|
||||
is_public: false,
|
||||
@@ -81,7 +80,6 @@
|
||||
link: locationToEdit?.link || null,
|
||||
description: locationToEdit?.description || null,
|
||||
tags: locationToEdit?.tags || [],
|
||||
rating: locationToEdit?.rating || NaN,
|
||||
price: locationToEdit?.price ?? null,
|
||||
price_currency: locationToEdit?.price_currency ?? null,
|
||||
is_public: locationToEdit?.is_public || false,
|
||||
@@ -295,7 +293,6 @@
|
||||
on:save={(e) => {
|
||||
location.name = e.detail.name;
|
||||
location.category = e.detail.category;
|
||||
location.rating = e.detail.rating;
|
||||
location.is_public = e.detail.is_public;
|
||||
location.link = e.detail.link;
|
||||
location.description = e.detail.description;
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
|
||||
// Icons
|
||||
import MapIcon from '~icons/mdi/map';
|
||||
import ClearIcon from '~icons/mdi/close';
|
||||
import InfoIcon from '~icons/mdi/information';
|
||||
import GenerateIcon from '~icons/mdi/lightning-bolt';
|
||||
import ArrowLeftIcon from '~icons/mdi/arrow-left';
|
||||
@@ -49,7 +48,6 @@
|
||||
name: string;
|
||||
type: string;
|
||||
description: string;
|
||||
rating: number;
|
||||
link: string;
|
||||
check_in: string | null;
|
||||
check_out: string | null;
|
||||
@@ -67,7 +65,6 @@
|
||||
name: '',
|
||||
type: '',
|
||||
description: '',
|
||||
rating: NaN,
|
||||
link: '',
|
||||
check_in: null,
|
||||
check_out: null,
|
||||
@@ -442,7 +439,6 @@
|
||||
lodging.type = initialLodging.type || '';
|
||||
lodging.link = initialLodging.link || '';
|
||||
lodging.description = initialLodging.description || '';
|
||||
lodging.rating = initialLodging.rating ?? NaN;
|
||||
lodging.is_public = initialLodging.is_public ?? true;
|
||||
lodging.reservation_number = initialLodging.reservation_number || null;
|
||||
const money = toMoneyValue(
|
||||
@@ -565,45 +561,6 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Rating Field -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="rating">
|
||||
<span class="label-text font-medium">{$t('adventures.rating')}</span>
|
||||
</label>
|
||||
<div
|
||||
class="flex items-center gap-4 p-3 bg-base-100/80 border border-base-300 rounded-lg"
|
||||
>
|
||||
<div class="rating">
|
||||
<input
|
||||
type="radio"
|
||||
name="rating"
|
||||
id="rating"
|
||||
class="rating-hidden"
|
||||
checked={Number.isNaN(lodging.rating)}
|
||||
/>
|
||||
{#each [1, 2, 3, 4, 5] as star}
|
||||
<input
|
||||
type="radio"
|
||||
name="rating"
|
||||
class="mask mask-star-2 bg-warning"
|
||||
on:click={() => (lodging.rating = star)}
|
||||
checked={lodging.rating === star}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{#if !Number.isNaN(lodging.rating)}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-error btn-outline gap-2"
|
||||
on:click={() => (lodging.rating = NaN)}
|
||||
>
|
||||
<ClearIcon class="w-4 h-4" />
|
||||
{$t('adventures.remove')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reservation Number -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="reservation">
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
name: '',
|
||||
type: '',
|
||||
description: null,
|
||||
rating: null,
|
||||
link: null,
|
||||
check_in: null,
|
||||
check_out: null,
|
||||
@@ -84,7 +83,6 @@
|
||||
name: lodgingToEdit.name || '',
|
||||
type: lodgingToEdit.type || '',
|
||||
description: lodgingToEdit.description || null,
|
||||
rating: lodgingToEdit.rating || null,
|
||||
link: lodgingToEdit.link || null,
|
||||
check_in: lodgingToEdit.check_in || null,
|
||||
check_out: lodgingToEdit.check_out || null,
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
|
||||
// Icons
|
||||
import MapIcon from '~icons/mdi/map';
|
||||
import ClearIcon from '~icons/mdi/close';
|
||||
import InfoIcon from '~icons/mdi/information';
|
||||
import GenerateIcon from '~icons/mdi/lightning-bolt';
|
||||
import ArrowLeftIcon from '~icons/mdi/arrow-left';
|
||||
@@ -50,7 +49,6 @@
|
||||
name: '',
|
||||
type: '',
|
||||
description: '',
|
||||
rating: NaN,
|
||||
link: '',
|
||||
date: null,
|
||||
end_date: null,
|
||||
@@ -516,7 +514,6 @@
|
||||
transportation.type = initialTransportation.type || '';
|
||||
transportation.link = initialTransportation.link || '';
|
||||
transportation.description = initialTransportation.description || '';
|
||||
transportation.rating = initialTransportation.rating ?? NaN;
|
||||
transportation.is_public = initialTransportation.is_public ?? true;
|
||||
transportation.flight_number = initialTransportation.flight_number || null;
|
||||
transportation.start_code = initialTransportation.start_code || null;
|
||||
@@ -714,44 +711,6 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Rating Field -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="rating">
|
||||
<span class="label-text font-medium">{$t('adventures.rating')}</span>
|
||||
</label>
|
||||
<div
|
||||
class="flex items-center gap-4 p-3 bg-base-100/80 border border-base-300 rounded-lg"
|
||||
>
|
||||
<div class="rating">
|
||||
<input
|
||||
type="radio"
|
||||
name="rating"
|
||||
id="rating"
|
||||
class="rating-hidden"
|
||||
checked={Number.isNaN(transportation.rating)}
|
||||
/>
|
||||
{#each [1, 2, 3, 4, 5] as star}
|
||||
<input
|
||||
type="radio"
|
||||
name="rating"
|
||||
class="mask mask-star-2 bg-warning"
|
||||
on:click={() => (transportation.rating = star)}
|
||||
checked={transportation.rating === star}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{#if !Number.isNaN(transportation.rating)}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-error btn-outline gap-2"
|
||||
on:click={() => (transportation.rating = NaN)}
|
||||
>
|
||||
<ClearIcon class="w-4 h-4" />
|
||||
{$t('adventures.remove')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column -->
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
name: '',
|
||||
type: '',
|
||||
description: null,
|
||||
rating: null,
|
||||
link: null,
|
||||
date: null,
|
||||
end_date: null,
|
||||
@@ -91,7 +90,6 @@
|
||||
name: transportationToEdit.name || '',
|
||||
type: transportationToEdit.type || '',
|
||||
description: transportationToEdit.description || null,
|
||||
rating: transportationToEdit.rating || null,
|
||||
link: transportationToEdit.link || null,
|
||||
date: transportationToEdit.date || null,
|
||||
end_date: transportationToEdit.end_date || null,
|
||||
|
||||
@@ -45,7 +45,6 @@ export type Location = {
|
||||
location?: string | null;
|
||||
tags?: string[] | null;
|
||||
description?: string | null;
|
||||
rating?: number | null;
|
||||
price?: number | null;
|
||||
price_currency?: string | null;
|
||||
link?: string | null;
|
||||
@@ -202,7 +201,6 @@ export type Transportation = {
|
||||
type: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
rating: number | null;
|
||||
price: number | null;
|
||||
price_currency: string | null;
|
||||
link: string | null;
|
||||
@@ -341,7 +339,6 @@ export type Lodging = {
|
||||
name: string;
|
||||
type: string;
|
||||
description: string | null;
|
||||
rating: number | null;
|
||||
link: string | null;
|
||||
check_in: string | null; // ISO 8601 date string
|
||||
check_out: string | null; // ISO 8601 date string
|
||||
|
||||
Reference in New Issue
Block a user