feat: Implement comprehensive ride filtering system with API integration

- Added `useRideFiltering` composable for managing ride filters and fetching rides from the API.
- Created `useParkRideFiltering` for park-specific ride filtering.
- Developed `useTheme` composable for theme management with localStorage support.
- Established `rideFiltering` Pinia store for centralized state management of ride filters and UI state.
- Defined enhanced filter types in `filters.ts` for better type safety and clarity.
- Built `RideFilteringPage.vue` to provide a user interface for filtering rides with responsive design.
- Integrated filter sidebar and ride list display components for a cohesive user experience.
- Added support for filter presets and search suggestions.
- Implemented computed properties for active filters, average ratings, and operating counts.
This commit is contained in:
pacnpal
2025-08-25 12:03:22 -04:00
parent dcf890a55c
commit bf7e0c0f40
37 changed files with 9350 additions and 143 deletions

View File

@@ -0,0 +1,109 @@
<template>
<div
class="active-filter-chip inline-flex items-center gap-2 px-3 py-1.5 bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-full text-sm"
:class="{
'cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-800': !disabled && removable,
'opacity-50': disabled,
}"
>
<!-- Filter icon -->
<Icon :name="icon" class="w-4 h-4 text-blue-600 dark:text-blue-300 flex-shrink-0" />
<!-- Filter label and value -->
<div class="flex items-center gap-1.5 min-w-0">
<span class="text-blue-800 dark:text-blue-200 font-medium truncate">
{{ label }}:
</span>
<span class="text-blue-700 dark:text-blue-300 truncate">
{{ displayValue }}
</span>
</div>
<!-- Count indicator -->
<span
v-if="count !== undefined"
class="bg-blue-200 dark:bg-blue-700 text-blue-800 dark:text-blue-200 px-1.5 py-0.5 rounded-full text-xs font-medium"
>
{{ count }}
</span>
<!-- Remove button -->
<button
v-if="removable && !disabled"
@click="$emit('remove')"
class="flex items-center justify-center w-5 h-5 rounded-full hover:bg-blue-200 dark:hover:bg-blue-700 transition-colors group"
:aria-label="`Remove ${label} filter`"
>
<Icon
name="x"
class="w-3 h-3 text-blue-600 dark:text-blue-300 group-hover:text-blue-800 dark:group-hover:text-blue-100"
/>
</button>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import Icon from "@/components/ui/Icon.vue";
interface Props {
label: string;
value: any;
icon?: string;
count?: number;
removable?: boolean;
disabled?: boolean;
formatValue?: (value: any) => string;
}
const props = withDefaults(defineProps<Props>(), {
icon: "filter",
removable: true,
disabled: false,
});
defineEmits<{
remove: [];
}>();
// Computed
const displayValue = computed(() => {
if (props.formatValue) {
return props.formatValue(props.value);
}
if (Array.isArray(props.value)) {
if (props.value.length === 1) {
return String(props.value[0]);
} else if (props.value.length <= 3) {
return props.value.join(", ");
} else {
return `${props.value.slice(0, 2).join(", ")} +${props.value.length - 2} more`;
}
}
if (typeof props.value === "object" && props.value !== null) {
// Handle range objects
if ("min" in props.value && "max" in props.value) {
return `${props.value.min} - ${props.value.max}`;
}
// Handle date range objects
if ("start" in props.value && "end" in props.value) {
return `${props.value.start} to ${props.value.end}`;
}
return JSON.stringify(props.value);
}
return String(props.value);
});
</script>
<style scoped>
.active-filter-chip {
@apply transition-all duration-200;
}
.active-filter-chip:hover {
@apply shadow-sm;
}
</style>

View File

@@ -0,0 +1,415 @@
<template>
<div class="date-range-filter">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ label }}
<span v-if="required" class="text-red-500 ml-1">*</span>
</label>
<!-- Date inputs -->
<div class="grid grid-cols-2 gap-3">
<!-- Start date -->
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1"> From </label>
<div class="relative">
<input
ref="startDateInput"
type="date"
:value="startDate"
@input="handleStartDateChange"
@blur="emitChange"
:min="minDate"
:max="endDate || maxDate"
class="date-input w-full px-3 py-2 text-sm border border-gray-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
:disabled="disabled"
:placeholder="startPlaceholder"
/>
<div
class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none"
>
<Icon name="calendar" class="w-4 h-4 text-gray-400" />
</div>
</div>
</div>
<!-- End date -->
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1"> To </label>
<div class="relative">
<input
ref="endDateInput"
type="date"
:value="endDate"
@input="handleEndDateChange"
@blur="emitChange"
:min="startDate || minDate"
:max="maxDate"
class="date-input w-full px-3 py-2 text-sm border border-gray-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
:disabled="disabled"
:placeholder="endPlaceholder"
/>
<div
class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none"
>
<Icon name="calendar" class="w-4 h-4 text-gray-400" />
</div>
</div>
</div>
</div>
<!-- Current selection display -->
<div v-if="hasSelection" class="mt-2 text-sm text-gray-600 dark:text-gray-400">
<span class="font-medium">Selected:</span>
{{ formatDateRange(startDate, endDate) }}
<span v-if="duration" class="text-gray-500 ml-2"> ({{ duration }}) </span>
</div>
<!-- Quick preset buttons -->
<div v-if="showPresets && presets.length > 0" class="mt-3">
<div class="text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
Quick Select
</div>
<div class="flex flex-wrap gap-2">
<button
v-for="preset in presets"
:key="preset.label"
@click="applyPreset(preset)"
class="px-3 py-1 text-xs rounded-full border border-gray-300 hover:bg-gray-50 dark:border-gray-600 dark:hover:bg-gray-700 transition-colors"
:class="{
'bg-blue-50 border-blue-300 text-blue-700 dark:bg-blue-900 dark:border-blue-600 dark:text-blue-200': isActivePreset(
preset
),
}"
:disabled="disabled"
>
{{ preset.label }}
</button>
</div>
</div>
<!-- Validation message -->
<div v-if="validationMessage" class="mt-2 text-sm text-red-600 dark:text-red-400">
{{ validationMessage }}
</div>
<!-- Clear button -->
<button
v-if="clearable && hasSelection"
@click="clearDates"
class="mt-3 text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200"
:disabled="disabled"
>
Clear dates
</button>
<!-- Helper text -->
<div v-if="helperText" class="mt-2 text-xs text-gray-500 dark:text-gray-400">
{{ helperText }}
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from "vue";
import Icon from "@/components/ui/Icon.vue";
interface DatePreset {
label: string;
startDate: string | (() => string);
endDate: string | (() => string);
}
interface Props {
label: string;
value?: [string, string];
minDate?: string;
maxDate?: string;
startPlaceholder?: string;
endPlaceholder?: string;
required?: boolean;
disabled?: boolean;
clearable?: boolean;
showPresets?: boolean;
helperText?: string;
validateRange?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
startPlaceholder: "Start date",
endPlaceholder: "End date",
required: false,
disabled: false,
clearable: true,
showPresets: true,
validateRange: true,
});
const emit = defineEmits<{
update: [value: [string, string] | undefined];
}>();
// Local state
const startDate = ref(props.value?.[0] || "");
const endDate = ref(props.value?.[1] || "");
const validationMessage = ref("");
// Computed
const hasSelection = computed(() => {
return Boolean(startDate.value && endDate.value);
});
const duration = computed(() => {
if (!startDate.value || !endDate.value) return null;
const start = new Date(startDate.value);
const end = new Date(endDate.value);
const diffTime = Math.abs(end.getTime() - start.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays === 1) return "1 day";
if (diffDays < 7) return `${diffDays} days`;
if (diffDays < 30) return `${Math.round(diffDays / 7)} weeks`;
if (diffDays < 365) return `${Math.round(diffDays / 30)} months`;
return `${Math.round(diffDays / 365)} years`;
});
const presets = computed((): DatePreset[] => {
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const lastWeek = new Date(today);
lastWeek.setDate(lastWeek.getDate() - 7);
const lastMonth = new Date(today);
lastMonth.setMonth(lastMonth.getMonth() - 1);
const lastYear = new Date(today);
lastYear.setFullYear(lastYear.getFullYear() - 1);
const thisYear = new Date(today.getFullYear(), 0, 1);
return [
{
label: "Today",
startDate: () => formatDate(today),
endDate: () => formatDate(today),
},
{
label: "Yesterday",
startDate: () => formatDate(yesterday),
endDate: () => formatDate(yesterday),
},
{
label: "Last 7 days",
startDate: () => formatDate(lastWeek),
endDate: () => formatDate(today),
},
{
label: "Last 30 days",
startDate: () => formatDate(lastMonth),
endDate: () => formatDate(today),
},
{
label: "This year",
startDate: () => formatDate(thisYear),
endDate: () => formatDate(today),
},
{
label: "Last year",
startDate: () => formatDate(new Date(lastYear.getFullYear(), 0, 1)),
endDate: () => formatDate(new Date(lastYear.getFullYear(), 11, 31)),
},
];
});
// Methods
const formatDate = (date: Date): string => {
return date.toISOString().split("T")[0];
};
const formatDateRange = (start: string, end: string): string => {
if (!start || !end) return "";
const startDate = new Date(start);
const endDate = new Date(end);
const formatOptions: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "short",
day: "numeric",
};
if (start === end) {
return startDate.toLocaleDateString(undefined, formatOptions);
}
return `${startDate.toLocaleDateString(
undefined,
formatOptions
)} - ${endDate.toLocaleDateString(undefined, formatOptions)}`;
};
const validateDates = (): boolean => {
validationMessage.value = "";
if (!props.validateRange) return true;
if (startDate.value && endDate.value) {
const start = new Date(startDate.value);
const end = new Date(endDate.value);
if (start > end) {
validationMessage.value = "Start date cannot be after end date";
return false;
}
}
if (props.minDate && startDate.value) {
const start = new Date(startDate.value);
const min = new Date(props.minDate);
if (start < min) {
validationMessage.value = `Date cannot be before ${formatDateRange(
props.minDate,
props.minDate
)}`;
return false;
}
}
if (props.maxDate && endDate.value) {
const end = new Date(endDate.value);
const max = new Date(props.maxDate);
if (end > max) {
validationMessage.value = `Date cannot be after ${formatDateRange(
props.maxDate,
props.maxDate
)}`;
return false;
}
}
return true;
};
const handleStartDateChange = (event: Event) => {
const target = event.target as HTMLInputElement;
startDate.value = target.value;
// Auto-adjust end date if it's before start date
if (endDate.value && startDate.value > endDate.value) {
endDate.value = startDate.value;
}
};
const handleEndDateChange = (event: Event) => {
const target = event.target as HTMLInputElement;
endDate.value = target.value;
// Auto-adjust start date if it's after end date
if (startDate.value && endDate.value < startDate.value) {
startDate.value = endDate.value;
}
};
const emitChange = () => {
if (!validateDates()) return;
const hasValidRange = Boolean(startDate.value && endDate.value);
emit("update", hasValidRange ? [startDate.value, endDate.value] : undefined);
};
const applyPreset = (preset: DatePreset) => {
const start =
typeof preset.startDate === "function" ? preset.startDate() : preset.startDate;
const end = typeof preset.endDate === "function" ? preset.endDate() : preset.endDate;
startDate.value = start;
endDate.value = end;
emitChange();
};
const isActivePreset = (preset: DatePreset): boolean => {
if (!hasSelection.value) return false;
const start =
typeof preset.startDate === "function" ? preset.startDate() : preset.startDate;
const end = typeof preset.endDate === "function" ? preset.endDate() : preset.endDate;
return startDate.value === start && endDate.value === end;
};
const clearDates = () => {
startDate.value = "";
endDate.value = "";
validationMessage.value = "";
emit("update", undefined);
};
// Watch for prop changes
watch(
() => props.value,
(newValue) => {
if (newValue) {
startDate.value = newValue[0] || "";
endDate.value = newValue[1] || "";
} else {
startDate.value = "";
endDate.value = "";
}
validationMessage.value = "";
},
{ immediate: true }
);
</script>
<style scoped>
.date-input {
@apply transition-colors duration-200;
}
.date-input:focus {
@apply ring-2 ring-blue-500 border-blue-500;
}
.date-input:disabled {
@apply bg-gray-100 dark:bg-gray-600 cursor-not-allowed opacity-50;
}
/* Custom date picker styles */
.date-input::-webkit-calendar-picker-indicator {
opacity: 0;
position: absolute;
right: 0;
width: 20px;
height: 20px;
cursor: pointer;
}
.date-input::-webkit-datetime-edit {
@apply text-gray-900 dark:text-white;
}
.date-input::-webkit-datetime-edit-fields-wrapper {
@apply text-gray-900 dark:text-white;
}
.date-input::-webkit-datetime-edit-text {
@apply text-gray-500 dark:text-gray-400;
}
.date-input[type="date"]::-webkit-input-placeholder {
@apply text-gray-400 dark:text-gray-500;
}
/* Firefox */
.date-input[type="date"]::-moz-placeholder {
@apply text-gray-400 dark:text-gray-500;
}
/* Edge */
.date-input[type="date"]::-ms-input-placeholder {
@apply text-gray-400 dark:text-gray-500;
}
</style>

View File

@@ -0,0 +1,69 @@
<template>
<div class="filter-section">
<button
@click="$emit('toggle')"
class="section-header w-full flex items-center justify-between p-4 text-left hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
:aria-expanded="isExpanded"
:aria-controls="`section-${id}`"
>
<h3 class="text-sm font-medium text-gray-900 dark:text-white">
{{ title }}
</h3>
<Icon
name="chevron-down"
class="w-4 h-4 text-gray-500 dark:text-gray-400 transition-transform"
:class="{ 'rotate-180': isExpanded }"
/>
</button>
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-96"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-96"
leave-to-class="opacity-0 max-h-0"
>
<div
v-show="isExpanded"
:id="`section-${id}`"
class="section-content overflow-hidden border-t border-gray-100 dark:border-gray-700"
>
<div class="p-4">
<slot />
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import Icon from "@/components/ui/Icon.vue";
interface Props {
id: string;
title: string;
isExpanded: boolean;
}
defineProps<Props>();
defineEmits<{
toggle: [];
}>();
</script>
<style scoped>
.filter-section {
@apply border-b border-gray-100 dark:border-gray-700;
}
.section-header {
@apply focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-inset;
}
.section-content {
@apply bg-gray-50 dark:bg-gray-800;
}
</style>

View File

@@ -0,0 +1,229 @@
<template>
<div
class="preset-item group flex items-center justify-between p-3 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
:class="{
'bg-blue-50 dark:bg-blue-900 border-blue-200 dark:border-blue-600': isActive,
'cursor-pointer': !disabled,
'opacity-50 cursor-not-allowed': disabled,
}"
@click="!disabled && $emit('select')"
>
<!-- Preset info -->
<div class="flex-1 min-w-0">
<!-- Name and description -->
<div class="flex items-center gap-2 mb-1">
<h4 class="font-medium text-gray-900 dark:text-white truncate">
{{ preset.name }}
</h4>
<span
v-if="isDefault"
class="px-2 py-0.5 text-xs bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-full"
>
Default
</span>
<span
v-if="isGlobal"
class="px-2 py-0.5 text-xs bg-green-100 dark:bg-green-800 text-green-700 dark:text-green-300 rounded-full"
>
Global
</span>
</div>
<!-- Description -->
<p
v-if="preset.description"
class="text-sm text-gray-600 dark:text-gray-400 truncate"
>
{{ preset.description }}
</p>
<!-- Filter count and last used -->
<div class="flex items-center gap-4 mt-2 text-xs text-gray-500 dark:text-gray-400">
<span class="flex items-center gap-1">
<Icon name="filter" class="w-3 h-3" />
{{ filterCount }} {{ filterCount === 1 ? "filter" : "filters" }}
</span>
<span v-if="preset.lastUsed" class="flex items-center gap-1">
<Icon name="clock" class="w-3 h-3" />
{{ formatLastUsed(preset.lastUsed) }}
</span>
</div>
</div>
<!-- Actions -->
<div class="flex items-center gap-2 ml-4">
<!-- Star/favorite button -->
<button
v-if="!isDefault && showFavorite"
@click.stop="$emit('toggle-favorite')"
class="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
:class="{
'text-yellow-500': preset.isFavorite,
'text-gray-400 dark:text-gray-500': !preset.isFavorite,
}"
:aria-label="preset.isFavorite ? 'Remove from favorites' : 'Add to favorites'"
>
<Icon :name="preset.isFavorite ? 'star-filled' : 'star'" class="w-4 h-4" />
</button>
<!-- More actions menu -->
<div class="relative" v-if="showActions">
<button
@click.stop="showMenu = !showMenu"
class="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors text-gray-400 dark:text-gray-500"
:aria-label="'More actions for ' + preset.name"
>
<Icon name="more-vertical" class="w-4 h-4" />
</button>
<!-- Dropdown menu -->
<div
v-if="showMenu"
class="absolute right-0 top-full mt-1 w-48 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-10"
@click.stop
>
<button
v-if="!isDefault"
@click="
$emit('rename');
showMenu = false;
"
class="w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<Icon name="edit" class="w-4 h-4 inline mr-2" />
Rename
</button>
<button
@click="
$emit('duplicate');
showMenu = false;
"
class="w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<Icon name="copy" class="w-4 h-4 inline mr-2" />
Duplicate
</button>
<button
v-if="!isDefault"
@click="
$emit('delete');
showMenu = false;
"
class="w-full px-4 py-2 text-left text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
>
<Icon name="trash" class="w-4 h-4 inline mr-2" />
Delete
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
import Icon from "@/components/ui/Icon.vue";
import type { FilterPreset } from "@/types/filters";
interface Props {
preset: FilterPreset;
isActive?: boolean;
isDefault?: boolean;
isGlobal?: boolean;
disabled?: boolean;
showFavorite?: boolean;
showActions?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
isActive: false,
isDefault: false,
isGlobal: false,
disabled: false,
showFavorite: true,
showActions: true,
});
defineEmits<{
select: [];
"toggle-favorite": [];
rename: [];
duplicate: [];
delete: [];
}>();
// Local state
const showMenu = ref(false);
// Computed
const filterCount = computed(() => {
let count = 0;
const filters = props.preset.filters;
if (filters.search?.trim()) count++;
if (filters.categories?.length) count++;
if (filters.manufacturers?.length) count++;
if (filters.designers?.length) count++;
if (filters.parks?.length) count++;
if (filters.status?.length) count++;
if (filters.opened?.start || filters.opened?.end) count++;
if (filters.closed?.start || filters.closed?.end) count++;
if (filters.heightRange?.min !== undefined || filters.heightRange?.max !== undefined)
count++;
if (filters.speedRange?.min !== undefined || filters.speedRange?.max !== undefined)
count++;
if (
filters.durationRange?.min !== undefined ||
filters.durationRange?.max !== undefined
)
count++;
if (
filters.capacityRange?.min !== undefined ||
filters.capacityRange?.max !== undefined
)
count++;
return count;
});
// Methods
const formatLastUsed = (lastUsed: string): string => {
const date = new Date(lastUsed);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return "Today";
} else if (diffDays === 1) {
return "Yesterday";
} else if (diffDays < 7) {
return `${diffDays} days ago`;
} else if (diffDays < 30) {
const weeks = Math.floor(diffDays / 7);
return `${weeks} ${weeks === 1 ? "week" : "weeks"} ago`;
} else {
return date.toLocaleDateString();
}
};
// Close menu when clicking outside
const handleClickOutside = () => {
showMenu.value = false;
};
// Add/remove event listener for clicking outside
if (typeof window !== "undefined") {
document.addEventListener("click", handleClickOutside);
}
</script>
<style scoped>
.preset-item {
@apply transition-all duration-200;
}
.preset-item:hover {
@apply shadow-sm;
}
</style>

View File

@@ -0,0 +1,431 @@
<template>
<div class="range-filter">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ label }}
<span v-if="required" class="text-red-500 ml-1">*</span>
</label>
<!-- Current values display -->
<div
class="flex items-center justify-between mb-3 text-sm text-gray-600 dark:text-gray-400"
>
<span>{{ unit ? `${currentMin} ${unit}` : currentMin }}</span>
<span class="text-gray-400">to</span>
<span>{{ unit ? `${currentMax} ${unit}` : currentMax }}</span>
</div>
<!-- Dual range slider -->
<div class="relative">
<div class="range-slider-container relative">
<!-- Background track -->
<div
class="range-track absolute w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-full"
></div>
<!-- Active track -->
<div
class="range-track-active absolute h-2 bg-blue-500 rounded-full"
:style="activeTrackStyle"
></div>
<!-- Min range input -->
<input
ref="minInput"
type="range"
:min="min"
:max="max"
:step="step"
:value="currentMin"
@input="handleMinChange"
@change="emitChange"
class="range-input range-input-min absolute w-full h-2 bg-transparent appearance-none cursor-pointer"
:disabled="disabled"
:aria-label="`Minimum ${label.toLowerCase()}`"
/>
<!-- Max range input -->
<input
ref="maxInput"
type="range"
:min="min"
:max="max"
:step="step"
:value="currentMax"
@input="handleMaxChange"
@change="emitChange"
class="range-input range-input-max absolute w-full h-2 bg-transparent appearance-none cursor-pointer"
:disabled="disabled"
:aria-label="`Maximum ${label.toLowerCase()}`"
/>
<!-- Min thumb -->
<div
class="range-thumb range-thumb-min absolute w-5 h-5 bg-white border-2 border-blue-500 rounded-full shadow-md cursor-pointer transform -translate-y-1.5"
:style="minThumbStyle"
@mousedown="startDrag('min', $event)"
@touchstart="startDrag('min', $event)"
></div>
<!-- Max thumb -->
<div
class="range-thumb range-thumb-max absolute w-5 h-5 bg-white border-2 border-blue-500 rounded-full shadow-md cursor-pointer transform -translate-y-1.5"
:style="maxThumbStyle"
@mousedown="startDrag('max', $event)"
@touchstart="startDrag('max', $event)"
></div>
</div>
<!-- Value tooltips -->
<div
v-if="showTooltips && isDragging"
class="absolute -top-8"
:style="{ left: minThumbPosition + '%' }"
>
<div
class="bg-gray-900 text-white text-xs rounded px-2 py-1 whitespace-nowrap transform -translate-x-1/2"
>
{{ unit ? `${currentMin} ${unit}` : currentMin }}
</div>
</div>
<div
v-if="showTooltips && isDragging"
class="absolute -top-8"
:style="{ left: maxThumbPosition + '%' }"
>
<div
class="bg-gray-900 text-white text-xs rounded px-2 py-1 whitespace-nowrap transform -translate-x-1/2"
>
{{ unit ? `${currentMax} ${unit}` : currentMax }}
</div>
</div>
</div>
<!-- Manual input fields -->
<div v-if="showInputs" class="flex items-center gap-3 mt-4">
<div class="flex-1">
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">
Min {{ unit || "" }}
</label>
<input
type="number"
:min="min"
:max="currentMax"
:step="step"
:value="currentMin"
@input="handleMinInputChange"
@blur="emitChange"
class="w-full px-3 py-1 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
:disabled="disabled"
/>
</div>
<div class="flex-1">
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">
Max {{ unit || "" }}
</label>
<input
type="number"
:min="currentMin"
:max="max"
:step="step"
:value="currentMax"
@input="handleMaxInputChange"
@blur="emitChange"
class="w-full px-3 py-1 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
:disabled="disabled"
/>
</div>
</div>
<!-- Reset button -->
<button
v-if="clearable && hasChanges"
@click="reset"
class="mt-3 text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200"
:disabled="disabled"
>
Reset to default
</button>
<!-- Step size indicator -->
<div v-if="showStepInfo" class="mt-2 text-xs text-gray-500 dark:text-gray-400">
Step: {{ step }}{{ unit ? ` ${unit}` : "" }}
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from "vue";
interface Props {
label: string;
min: number;
max: number;
value?: [number, number];
step?: number;
unit?: string;
required?: boolean;
disabled?: boolean;
clearable?: boolean;
showInputs?: boolean;
showTooltips?: boolean;
showStepInfo?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
step: 1,
required: false,
disabled: false,
clearable: true,
showInputs: false,
showTooltips: true,
showStepInfo: false,
});
const emit = defineEmits<{
update: [value: [number, number] | undefined];
}>();
// Local state
const currentMin = ref(props.value?.[0] ?? props.min);
const currentMax = ref(props.value?.[1] ?? props.max);
const isDragging = ref(false);
const dragType = ref<"min" | "max" | null>(null);
// Computed
const hasChanges = computed(() => {
return currentMin.value !== props.min || currentMax.value !== props.max;
});
const minThumbPosition = computed(() => {
return ((currentMin.value - props.min) / (props.max - props.min)) * 100;
});
const maxThumbPosition = computed(() => {
return ((currentMax.value - props.min) / (props.max - props.min)) * 100;
});
const minThumbStyle = computed(() => ({
left: `calc(${minThumbPosition.value}% - 10px)`,
}));
const maxThumbStyle = computed(() => ({
left: `calc(${maxThumbPosition.value}% - 10px)`,
}));
const activeTrackStyle = computed(() => ({
left: `${minThumbPosition.value}%`,
width: `${maxThumbPosition.value - minThumbPosition.value}%`,
}));
// Methods
const handleMinChange = (event: Event) => {
const target = event.target as HTMLInputElement;
const value = Math.min(Number(target.value), currentMax.value - props.step);
currentMin.value = value;
// Ensure min doesn't exceed max
if (currentMin.value >= currentMax.value) {
currentMin.value = currentMax.value - props.step;
}
};
const handleMaxChange = (event: Event) => {
const target = event.target as HTMLInputElement;
const value = Math.max(Number(target.value), currentMin.value + props.step);
currentMax.value = value;
// Ensure max doesn't go below min
if (currentMax.value <= currentMin.value) {
currentMax.value = currentMin.value + props.step;
}
};
const handleMinInputChange = (event: Event) => {
const target = event.target as HTMLInputElement;
const value = Number(target.value);
if (value >= props.min && value < currentMax.value) {
currentMin.value = value;
}
};
const handleMaxInputChange = (event: Event) => {
const target = event.target as HTMLInputElement;
const value = Number(target.value);
if (value <= props.max && value > currentMin.value) {
currentMax.value = value;
}
};
const emitChange = () => {
const hasDefaultValues =
currentMin.value === props.min && currentMax.value === props.max;
emit("update", hasDefaultValues ? undefined : [currentMin.value, currentMax.value]);
};
const reset = () => {
currentMin.value = props.min;
currentMax.value = props.max;
emitChange();
};
const startDrag = (type: "min" | "max", event: MouseEvent | TouchEvent) => {
if (props.disabled) return;
isDragging.value = true;
dragType.value = type;
event.preventDefault();
if (event instanceof MouseEvent) {
document.addEventListener("mousemove", handleDrag);
document.addEventListener("mouseup", endDrag);
} else {
document.addEventListener("touchmove", handleDrag);
document.addEventListener("touchend", endDrag);
}
};
const handleDrag = (event: MouseEvent | TouchEvent) => {
if (!isDragging.value || !dragType.value) return;
const container = (event.target as Element).closest(".range-slider-container");
if (!container) return;
const rect = container.getBoundingClientRect();
const clientX = event instanceof MouseEvent ? event.clientX : event.touches[0].clientX;
const percentage = Math.max(
0,
Math.min(100, ((clientX - rect.left) / rect.width) * 100)
);
const value = props.min + (percentage / 100) * (props.max - props.min);
const steppedValue = Math.round(value / props.step) * props.step;
if (dragType.value === "min") {
currentMin.value = Math.max(
props.min,
Math.min(steppedValue, currentMax.value - props.step)
);
} else {
currentMax.value = Math.min(
props.max,
Math.max(steppedValue, currentMin.value + props.step)
);
}
};
const endDrag = () => {
isDragging.value = false;
dragType.value = null;
emitChange();
document.removeEventListener("mousemove", handleDrag);
document.removeEventListener("mouseup", endDrag);
document.removeEventListener("touchmove", handleDrag);
document.removeEventListener("touchend", endDrag);
};
// Watch for prop changes
onMounted(() => {
if (props.value) {
currentMin.value = props.value[0];
currentMax.value = props.value[1];
}
});
onUnmounted(() => {
document.removeEventListener("mousemove", handleDrag);
document.removeEventListener("mouseup", endDrag);
document.removeEventListener("touchmove", handleDrag);
document.removeEventListener("touchend", endDrag);
});
</script>
<style scoped>
.range-slider-container {
height: 2rem;
margin: 0.5rem 0;
}
.range-input {
z-index: 1;
}
.range-input::-webkit-slider-thumb {
appearance: none;
width: 20px;
height: 20px;
background: transparent;
cursor: pointer;
pointer-events: all;
}
.range-input::-moz-range-thumb {
width: 20px;
height: 20px;
background: transparent;
cursor: pointer;
border: none;
pointer-events: all;
}
.range-input-min {
z-index: 2;
}
.range-input-max {
z-index: 1;
}
.range-thumb {
z-index: 3;
transition: transform 0.1s ease;
pointer-events: all;
}
.range-thumb:hover {
transform: translateY(-1.5px) scale(1.1);
}
.range-thumb:active {
transform: translateY(-1.5px) scale(1.2);
}
.range-input:disabled + .range-thumb {
opacity: 0.5;
cursor: not-allowed;
}
.range-track {
top: 50%;
transform: translateY(-50%);
}
.range-track-active {
top: 50%;
transform: translateY(-50%);
z-index: 1;
}
/* Custom focus styles */
.range-input:focus {
outline: none;
}
.range-input:focus + .range-thumb {
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3);
}
/* Dark mode adjustments */
.dark .range-thumb {
border-color: #3b82f6;
background: #1f2937;
}
.dark .range-track-active {
background: #3b82f6;
}
</style>

View File

@@ -0,0 +1,484 @@
<template>
<div class="ride-filter-sidebar">
<!-- Filter Header -->
<div class="filter-header">
<div
class="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700"
>
<div class="flex items-center gap-3">
<Icon name="filter" class="w-5 h-5 text-gray-600 dark:text-gray-400" />
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Filters</h2>
<span
v-if="activeFiltersCount > 0"
class="px-2 py-1 text-xs font-medium text-white bg-blue-600 rounded-full"
>
{{ activeFiltersCount }}
</span>
</div>
<!-- Mobile close button -->
<button
v-if="isMobile"
@click="closeFilterForm"
class="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 lg:hidden"
aria-label="Close filters"
>
<Icon name="x" class="w-5 h-5" />
</button>
</div>
<!-- Filter Actions -->
<div
class="flex items-center gap-2 p-4 border-b border-gray-200 dark:border-gray-700"
>
<button
@click="applyFilters"
:disabled="!hasUnsavedChanges"
class="flex-1 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors"
>
Apply Filters
</button>
<button
@click="clearAllFilters"
:disabled="!hasActiveFilters"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Clear All
</button>
<button
@click="expandAllSections"
class="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
title="Expand all sections"
>
<Icon name="chevron-down" class="w-4 h-4" />
</button>
</div>
</div>
<!-- Filter Sections -->
<div class="filter-sections flex-1 overflow-y-auto">
<!-- Search Section -->
<FilterSection
id="search"
title="Search"
:is-expanded="formState.expandedSections.search"
@toggle="toggleSection('search')"
>
<SearchFilter />
</FilterSection>
<!-- Basic Filters Section -->
<FilterSection
id="basic"
title="Basic Filters"
:is-expanded="formState.expandedSections.basic"
@toggle="toggleSection('basic')"
>
<div class="space-y-4">
<SelectFilter
id="category"
label="Category"
:options="filterOptions?.categories || []"
:value="filters.category"
@update="updateFilter('category', $event)"
multiple
/>
<SelectFilter
id="status"
label="Status"
:options="filterOptions?.statuses || []"
:value="filters.status"
@update="updateFilter('status', $event)"
multiple
/>
</div>
</FilterSection>
<!-- Manufacturer Section -->
<FilterSection
id="manufacturer"
title="Manufacturer & Design"
:is-expanded="formState.expandedSections.manufacturer"
@toggle="toggleSection('manufacturer')"
>
<div class="space-y-4">
<SearchableSelect
id="manufacturer"
label="Manufacturer"
:options="filterOptions?.manufacturers || []"
:value="filters.manufacturer"
@update="updateFilter('manufacturer', $event)"
multiple
searchable
/>
<SearchableSelect
id="designer"
label="Designer"
:options="filterOptions?.designers || []"
:value="filters.designer"
@update="updateFilter('designer', $event)"
multiple
searchable
/>
</div>
</FilterSection>
<!-- Specifications Section -->
<FilterSection
id="specifications"
title="Specifications"
:is-expanded="formState.expandedSections.specifications"
@toggle="toggleSection('specifications')"
>
<div class="space-y-6">
<RangeFilter
id="height"
label="Height"
unit="m"
:min-value="filters.height_min"
:max-value="filters.height_max"
:min-limit="0"
:max-limit="200"
@update-min="updateFilter('height_min', $event)"
@update-max="updateFilter('height_max', $event)"
/>
<RangeFilter
id="speed"
label="Speed"
unit="km/h"
:min-value="filters.speed_min"
:max-value="filters.speed_max"
:min-limit="0"
:max-limit="250"
@update-min="updateFilter('speed_min', $event)"
@update-max="updateFilter('speed_max', $event)"
/>
<RangeFilter
id="length"
label="Length"
unit="m"
:min-value="filters.length_min"
:max-value="filters.length_max"
:min-limit="0"
:max-limit="3000"
@update-min="updateFilter('length_min', $event)"
@update-max="updateFilter('length_max', $event)"
/>
<RangeFilter
id="capacity"
label="Capacity"
unit="people"
:min-value="filters.capacity_min"
:max-value="filters.capacity_max"
:min-limit="1"
:max-limit="50"
@update-min="updateFilter('capacity_min', $event)"
@update-max="updateFilter('capacity_max', $event)"
/>
<RangeFilter
id="duration"
label="Duration"
unit="seconds"
:min-value="filters.duration_min"
:max-value="filters.duration_max"
:min-limit="30"
:max-limit="600"
@update-min="updateFilter('duration_min', $event)"
@update-max="updateFilter('duration_max', $event)"
/>
<RangeFilter
id="inversions"
label="Inversions"
unit=""
:min-value="filters.inversions_min"
:max-value="filters.inversions_max"
:min-limit="0"
:max-limit="20"
@update-min="updateFilter('inversions_min', $event)"
@update-max="updateFilter('inversions_max', $event)"
/>
</div>
</FilterSection>
<!-- Dates Section -->
<FilterSection
id="dates"
title="Opening & Closing Dates"
:is-expanded="formState.expandedSections.dates"
@toggle="toggleSection('dates')"
>
<div class="space-y-4">
<DateRangeFilter
id="opening_date"
label="Opening Date"
:from-value="filters.opening_date_from"
:to-value="filters.opening_date_to"
@update-from="updateFilter('opening_date_from', $event)"
@update-to="updateFilter('opening_date_to', $event)"
/>
<DateRangeFilter
id="closing_date"
label="Closing Date"
:from-value="filters.closing_date_from"
:to-value="filters.closing_date_to"
@update-from="updateFilter('closing_date_from', $event)"
@update-to="updateFilter('closing_date_to', $event)"
/>
</div>
</FilterSection>
<!-- Location Section -->
<FilterSection
id="location"
title="Location"
:is-expanded="formState.expandedSections.location"
@toggle="toggleSection('location')"
>
<div class="space-y-4">
<SearchableSelect
id="park"
label="Park"
:options="filterOptions?.parks || []"
:value="filters.park"
@update="updateFilter('park', $event)"
multiple
searchable
/>
<SelectFilter
id="country"
label="Country"
:options="filterOptions?.countries || []"
:value="filters.country"
@update="updateFilter('country', $event)"
multiple
/>
<SelectFilter
id="region"
label="Region"
:options="filterOptions?.regions || []"
:value="filters.region"
@update="updateFilter('region', $event)"
multiple
/>
</div>
</FilterSection>
<!-- Advanced Section -->
<FilterSection
id="advanced"
title="Advanced Options"
:is-expanded="formState.expandedSections.advanced"
@toggle="toggleSection('advanced')"
>
<div class="space-y-4">
<SelectFilter
id="ordering"
label="Sort By"
:options="sortingOptions"
:value="filters.ordering"
@update="updateFilter('ordering', $event)"
/>
<SelectFilter
id="page_size"
label="Results Per Page"
:options="pageSizeOptions"
:value="filters.page_size"
@update="updateFilter('page_size', $event)"
/>
</div>
</FilterSection>
</div>
<!-- Active Filters Display -->
<div
v-if="hasActiveFilters"
class="active-filters border-t border-gray-200 dark:border-gray-700 p-4"
>
<h3 class="text-sm font-medium text-gray-900 dark:text-white mb-3">
Active Filters ({{ activeFiltersCount }})
</h3>
<div class="flex flex-wrap gap-2">
<ActiveFilterChip
v-for="filter in activeFiltersList"
:key="`${filter.key}-${filter.value}`"
:filter="filter"
@remove="clearFilter(filter.key)"
/>
</div>
</div>
<!-- Filter Presets -->
<div
v-if="savedPresets.length > 0"
class="filter-presets border-t border-gray-200 dark:border-gray-700 p-4"
>
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-medium text-gray-900 dark:text-white">Saved Presets</h3>
<button
@click="showSavePresetDialog = true"
class="text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400"
>
Save Current
</button>
</div>
<div class="space-y-2">
<PresetItem
v-for="preset in savedPresets"
:key="preset.id"
:preset="preset"
:is-active="currentPreset === preset.id"
@load="loadPreset(preset.id)"
@delete="deletePreset(preset.id)"
/>
</div>
</div>
</div>
<!-- Save Preset Dialog -->
<SavePresetDialog
v-if="showSavePresetDialog"
@save="handleSavePreset"
@cancel="showSavePresetDialog = false"
/>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useRideFilteringStore } from "@/stores/rideFiltering";
import { storeToRefs } from "pinia";
// Components
import FilterSection from "./FilterSection.vue";
import SearchFilter from "./SearchFilter.vue";
import SelectFilter from "./SelectFilter.vue";
import SearchableSelect from "./SearchableSelect.vue";
import RangeFilter from "./RangeFilter.vue";
import DateRangeFilter from "./DateRangeFilter.vue";
import ActiveFilterChip from "./ActiveFilterChip.vue";
import PresetItem from "./PresetItem.vue";
import SavePresetDialog from "./SavePresetDialog.vue";
import Icon from "@/components/ui/Icon.vue";
// Store
const store = useRideFilteringStore();
const {
filters,
filterOptions,
formState,
hasActiveFilters,
activeFiltersCount,
activeFiltersList,
hasUnsavedChanges,
savedPresets,
currentPreset,
} = storeToRefs(store);
const {
updateFilter,
clearFilter,
clearAllFilters,
applyFilters,
toggleSection,
expandAllSections,
collapseAllSections,
closeFilterForm,
savePreset,
loadPreset,
deletePreset,
} = store;
// Local state
const showSavePresetDialog = ref(false);
const isMobile = ref(false);
// Computed
const sortingOptions = computed(() => [
{ value: "name", label: "Name (A-Z)" },
{ value: "-name", label: "Name (Z-A)" },
{ value: "opening_date", label: "Oldest First" },
{ value: "-opening_date", label: "Newest First" },
{ value: "-height", label: "Tallest First" },
{ value: "height", label: "Shortest First" },
{ value: "-speed", label: "Fastest First" },
{ value: "speed", label: "Slowest First" },
{ value: "-rating", label: "Highest Rated" },
{ value: "rating", label: "Lowest Rated" },
]);
const pageSizeOptions = computed(() => [
{ value: 10, label: "10 per page" },
{ value: 25, label: "25 per page" },
{ value: 50, label: "50 per page" },
{ value: 100, label: "100 per page" },
]);
// Methods
const handleSavePreset = (name: string) => {
savePreset(name);
showSavePresetDialog.value = false;
};
const checkMobile = () => {
isMobile.value = window.innerWidth < 1024;
};
// Lifecycle
onMounted(() => {
checkMobile();
window.addEventListener("resize", checkMobile);
});
</script>
<style scoped>
.ride-filter-sidebar {
@apply flex flex-col h-full bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700;
}
.filter-header {
@apply flex-shrink-0;
}
.filter-sections {
@apply flex-1 overflow-y-auto;
}
.active-filters {
@apply flex-shrink-0;
}
.filter-presets {
@apply flex-shrink-0;
}
/* Custom scrollbar for filter sections */
.filter-sections::-webkit-scrollbar {
@apply w-2;
}
.filter-sections::-webkit-scrollbar-track {
@apply bg-gray-100 dark:bg-gray-700;
}
.filter-sections::-webkit-scrollbar-thumb {
@apply bg-gray-300 dark:bg-gray-600 rounded-full;
}
.filter-sections::-webkit-scrollbar-thumb:hover {
@apply bg-gray-400 dark:bg-gray-500;
}
</style>

View File

@@ -0,0 +1,401 @@
<template>
<div
v-if="isOpen"
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
@click="$emit('close')"
>
<div
class="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full mx-4"
@click.stop
>
<!-- Header -->
<div
class="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700"
>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ editMode ? "Edit Preset" : "Save Filter Preset" }}
</h3>
<button
@click="$emit('close')"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
aria-label="Close dialog"
>
<Icon name="x" class="w-5 h-5" />
</button>
</div>
<!-- Form -->
<form @submit.prevent="handleSave" class="p-6">
<!-- Name field -->
<div class="mb-4">
<label
for="preset-name"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
Preset Name *
</label>
<input
id="preset-name"
v-model="formData.name"
type="text"
required
maxlength="50"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white transition-colors"
:class="{
'border-red-300 dark:border-red-600': errors.name,
}"
placeholder="Enter preset name..."
@blur="validateName"
/>
<p v-if="errors.name" class="mt-1 text-sm text-red-600 dark:text-red-400">
{{ errors.name }}
</p>
</div>
<!-- Description field -->
<div class="mb-4">
<label
for="preset-description"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
Description
</label>
<textarea
id="preset-description"
v-model="formData.description"
rows="3"
maxlength="200"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white transition-colors resize-none"
placeholder="Optional description for this preset..."
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ formData.description?.length || 0 }}/200 characters
</p>
</div>
<!-- Scope selection -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Preset Scope
</label>
<div class="space-y-2">
<label class="flex items-center">
<input
v-model="formData.scope"
type="radio"
value="personal"
class="mr-2 text-blue-600"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">
Personal (only visible to me)
</span>
</label>
<label v-if="allowGlobal" class="flex items-center">
<input
v-model="formData.scope"
type="radio"
value="global"
class="mr-2 text-blue-600"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">
Global (visible to all users)
</span>
</label>
</div>
</div>
<!-- Make default checkbox -->
<div class="mb-6">
<label class="flex items-center">
<input
v-model="formData.isDefault"
type="checkbox"
class="mr-2 text-blue-600 rounded"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">
Set as my default preset
</span>
</label>
</div>
<!-- Filter summary -->
<div class="mb-6 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Filters to Save
</h4>
<div class="space-y-1">
<p
v-if="filterSummary.length === 0"
class="text-sm text-gray-500 dark:text-gray-400"
>
No active filters
</p>
<p
v-for="filter in filterSummary"
:key="filter.key"
class="text-sm text-gray-600 dark:text-gray-400"
>
{{ filter.label }}: {{ filter.value }}
</p>
</div>
</div>
<!-- Buttons -->
<div class="flex gap-3 justify-end">
<button
type="button"
@click="$emit('close')"
class="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
Cancel
</button>
<button
type="submit"
:disabled="!isValid || isLoading"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white rounded-lg transition-colors flex items-center gap-2"
>
<Icon v-if="isLoading" name="loading" class="w-4 h-4 animate-spin" />
{{ editMode ? "Update" : "Save" }} Preset
</button>
</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from "vue";
import Icon from "@/components/ui/Icon.vue";
import type { FilterState, FilterPreset } from "@/types/filters";
interface Props {
isOpen: boolean;
filters: FilterState;
editMode?: boolean;
existingPreset?: FilterPreset;
allowGlobal?: boolean;
existingNames?: string[];
}
const props = withDefaults(defineProps<Props>(), {
editMode: false,
allowGlobal: false,
existingNames: () => [],
});
defineEmits<{
close: [];
save: [preset: Partial<FilterPreset>];
}>();
// Form data
const formData = ref({
name: "",
description: "",
scope: "personal" as "personal" | "global",
isDefault: false,
});
// Form state
const errors = ref({
name: "",
});
const isLoading = ref(false);
// Computed
const isValid = computed(() => {
return formData.value.name.trim().length > 0 && !errors.value.name;
});
const filterSummary = computed(() => {
const summary: Array<{ key: string; label: string; value: string }> = [];
const filters = props.filters;
if (filters.search?.trim()) {
summary.push({
key: "search",
label: "Search",
value: filters.search,
});
}
if (filters.categories?.length) {
summary.push({
key: "categories",
label: "Categories",
value: filters.categories.join(", "),
});
}
if (filters.manufacturers?.length) {
summary.push({
key: "manufacturers",
label: "Manufacturers",
value: filters.manufacturers.join(", "),
});
}
if (filters.designers?.length) {
summary.push({
key: "designers",
label: "Designers",
value: filters.designers.join(", "),
});
}
if (filters.parks?.length) {
summary.push({
key: "parks",
label: "Parks",
value: filters.parks.join(", "),
});
}
if (filters.status?.length) {
summary.push({
key: "status",
label: "Status",
value: filters.status.join(", "),
});
}
if (filters.opened?.start || filters.opened?.end) {
const start = filters.opened.start || "Any";
const end = filters.opened.end || "Any";
summary.push({
key: "opened",
label: "Opened",
value: `${start} to ${end}`,
});
}
if (filters.closed?.start || filters.closed?.end) {
const start = filters.closed.start || "Any";
const end = filters.closed.end || "Any";
summary.push({
key: "closed",
label: "Closed",
value: `${start} to ${end}`,
});
}
// Range filters
const ranges = [
{ key: "heightRange", label: "Height", data: filters.heightRange, unit: "m" },
{ key: "speedRange", label: "Speed", data: filters.speedRange, unit: "km/h" },
{ key: "durationRange", label: "Duration", data: filters.durationRange, unit: "min" },
{ key: "capacityRange", label: "Capacity", data: filters.capacityRange, unit: "" },
];
ranges.forEach(({ key, label, data, unit }) => {
if (data?.min !== undefined || data?.max !== undefined) {
const min = data.min ?? "Any";
const max = data.max ?? "Any";
summary.push({
key,
label,
value: `${min} - ${max}${unit ? " " + unit : ""}`,
});
}
});
return summary;
});
// Methods
const validateName = () => {
const name = formData.value.name.trim();
errors.value.name = "";
if (name.length === 0) {
errors.value.name = "Preset name is required";
} else if (name.length < 2) {
errors.value.name = "Preset name must be at least 2 characters";
} else if (name.length > 50) {
errors.value.name = "Preset name must be 50 characters or less";
} else if (
props.existingNames.includes(name.toLowerCase()) &&
(!props.editMode || name.toLowerCase() !== props.existingPreset?.name.toLowerCase())
) {
errors.value.name = "A preset with this name already exists";
}
};
const handleSave = async () => {
validateName();
if (!isValid.value) return;
isLoading.value = true;
try {
const preset: Partial<FilterPreset> = {
name: formData.value.name.trim(),
description: formData.value.description?.trim() || undefined,
filters: props.filters,
scope: formData.value.scope,
isDefault: formData.value.isDefault,
lastUsed: new Date().toISOString(),
};
if (props.editMode && props.existingPreset) {
preset.id = props.existingPreset.id;
}
// Emit save event
await new Promise((resolve) => {
const emit = defineEmits<{
save: [preset: Partial<FilterPreset>];
}>();
emit("save", preset);
setTimeout(resolve, 100); // Small delay to simulate async operation
});
} finally {
isLoading.value = false;
}
};
// Watchers
watch(
() => props.isOpen,
async (isOpen) => {
if (isOpen) {
if (props.editMode && props.existingPreset) {
formData.value = {
name: props.existingPreset.name,
description: props.existingPreset.description || "",
scope: props.existingPreset.scope || "personal",
isDefault: props.existingPreset.isDefault || false,
};
} else {
formData.value = {
name: "",
description: "",
scope: "personal",
isDefault: false,
};
}
errors.value.name = "";
// Focus the name input
await nextTick();
const nameInput = document.getElementById("preset-name");
if (nameInput) {
nameInput.focus();
}
}
},
{ immediate: true }
);
watch(
() => formData.value.name,
() => {
if (errors.value.name) {
validateName();
}
}
);
</script>
<style scoped>
/* Add any custom styles if needed */
</style>

View File

@@ -0,0 +1,339 @@
<template>
<div class="search-filter">
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Icon name="search" class="w-4 h-4 text-gray-400" />
</div>
<input
ref="searchInput"
v-model="searchQuery"
type="text"
placeholder="Search rides, parks, manufacturers..."
class="search-input block w-full pl-10 pr-10 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white text-sm"
@input="handleSearchInput"
@focus="showSuggestions = true"
@blur="handleBlur"
@keydown="handleKeydown"
:aria-expanded="showSuggestions && suggestions.length > 0"
aria-haspopup="listbox"
role="combobox"
autocomplete="off"
/>
<div class="absolute inset-y-0 right-0 flex items-center pr-3">
<button
v-if="searchQuery"
@click="clearSearch"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
aria-label="Clear search"
>
<Icon name="x" class="w-4 h-4" />
</button>
</div>
</div>
<!-- Search Suggestions -->
<Transition
enter-active-class="transition ease-out duration-200"
enter-from-class="opacity-0 translate-y-1"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition ease-in duration-150"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 translate-y-1"
>
<div
v-if="showSuggestions && suggestions.length > 0"
class="suggestions-dropdown absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg max-h-60 overflow-auto"
role="listbox"
>
<div
v-for="(suggestion, index) in suggestions"
:key="`${suggestion.type}-${suggestion.value}`"
@click="selectSuggestion(suggestion)"
@mouseenter="highlightedIndex = index"
class="suggestion-item flex items-center px-3 py-2 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700"
:class="{
'bg-blue-50 dark:bg-blue-900': highlightedIndex === index,
}"
role="option"
:aria-selected="highlightedIndex === index"
>
<Icon
:name="getSuggestionIcon(suggestion.type)"
class="w-4 h-4 mr-3 text-gray-500 dark:text-gray-400"
/>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-gray-900 dark:text-white truncate">
{{ suggestion.label }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 capitalize">
{{ suggestion.type }}
</div>
</div>
<div v-if="suggestion.count" class="text-xs text-gray-400 ml-2">
{{ suggestion.count }}
</div>
</div>
<div v-if="isLoadingSuggestions" class="flex items-center justify-center py-3">
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
<span class="ml-2 text-sm text-gray-500 dark:text-gray-400">Loading...</span>
</div>
</div>
</Transition>
<!-- Quick Search Filters -->
<div v-if="quickFilters.length > 0" class="mt-3">
<div class="text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
Quick Filters
</div>
<div class="flex flex-wrap gap-2">
<button
v-for="filter in quickFilters"
:key="filter.value"
@click="applyQuickFilter(filter)"
class="inline-flex items-center px-2 py-1 text-xs font-medium text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 transition-colors"
>
<Icon :name="filter.icon" class="w-3 h-3 mr-1" />
{{ filter.label }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick } from "vue";
import { useRideFilteringStore } from "@/stores/rideFiltering";
import { useRideFiltering } from "@/composables/useRideFiltering";
import { storeToRefs } from "pinia";
import { debounce } from "lodash-es";
import Icon from "@/components/ui/Icon.vue";
// Store
const store = useRideFilteringStore();
const {
searchQuery,
searchSuggestions,
showSuggestions: showStoreSuggestions,
} = storeToRefs(store);
const { setSearchQuery, showSearchSuggestions, hideSearchSuggestions } = store;
// Composable
const { getSearchSuggestions } = useRideFiltering();
// Local state
const searchInput = ref<HTMLInputElement>();
const highlightedIndex = ref(-1);
const showSuggestions = ref(false);
const isLoadingSuggestions = ref(false);
// Computed
const suggestions = computed(() => searchSuggestions.value || []);
const quickFilters = computed(() => [
{
value: "operating",
label: "Operating",
icon: "play",
filter: { status: ["operating"] },
},
{
value: "roller_coaster",
label: "Roller Coasters",
icon: "trending-up",
filter: { category: ["roller_coaster"] },
},
{
value: "water_ride",
label: "Water Rides",
icon: "droplet",
filter: { category: ["water_ride"] },
},
{
value: "family",
label: "Family Friendly",
icon: "users",
filter: { category: ["family"] },
},
]);
// Methods
const handleSearchInput = debounce(async (event: Event) => {
const target = event.target as HTMLInputElement;
const query = target.value;
setSearchQuery(query);
if (query.length >= 2) {
isLoadingSuggestions.value = true;
showSuggestions.value = true;
try {
await getSearchSuggestions(query);
} catch (error) {
console.error("Failed to load search suggestions:", error);
} finally {
isLoadingSuggestions.value = false;
}
} else {
showSuggestions.value = false;
highlightedIndex.value = -1;
}
}, 300);
const handleBlur = () => {
// Delay hiding suggestions to allow for clicks
setTimeout(() => {
showSuggestions.value = false;
highlightedIndex.value = -1;
}, 150);
};
const handleKeydown = async (event: KeyboardEvent) => {
if (!showSuggestions.value || suggestions.value.length === 0) return;
switch (event.key) {
case "ArrowDown":
event.preventDefault();
highlightedIndex.value = Math.min(
highlightedIndex.value + 1,
suggestions.value.length - 1
);
break;
case "ArrowUp":
event.preventDefault();
highlightedIndex.value = Math.max(highlightedIndex.value - 1, -1);
break;
case "Enter":
event.preventDefault();
if (highlightedIndex.value >= 0) {
selectSuggestion(suggestions.value[highlightedIndex.value]);
}
break;
case "Escape":
showSuggestions.value = false;
highlightedIndex.value = -1;
searchInput.value?.blur();
break;
}
};
const selectSuggestion = (suggestion: any) => {
setSearchQuery(suggestion.label);
showSuggestions.value = false;
highlightedIndex.value = -1;
// Apply additional filters based on suggestion type
if (suggestion.filters) {
Object.entries(suggestion.filters).forEach(([key, value]) => {
store.updateFilter(key as any, value);
});
}
};
const clearSearch = () => {
setSearchQuery("");
showSuggestions.value = false;
highlightedIndex.value = -1;
searchInput.value?.focus();
};
const applyQuickFilter = (filter: any) => {
Object.entries(filter.filter).forEach(([key, value]) => {
store.updateFilter(key as any, value);
});
};
const getSuggestionIcon = (type: string): string => {
const icons: Record<string, string> = {
ride: "activity",
park: "map-pin",
manufacturer: "building",
designer: "user",
category: "tag",
location: "globe",
};
return icons[type] || "search";
};
// Lifecycle
onMounted(() => {
// Focus search input on mount if no active filters
if (!store.hasActiveFilters) {
nextTick(() => {
searchInput.value?.focus();
});
}
});
// Handle clicks outside
const handleClickOutside = (event: Event) => {
if (searchInput.value && !searchInput.value.contains(event.target as Node)) {
showSuggestions.value = false;
highlightedIndex.value = -1;
}
};
onMounted(() => {
document.addEventListener("click", handleClickOutside);
});
onUnmounted(() => {
document.removeEventListener("click", handleClickOutside);
});
</script>
<style scoped>
.search-filter {
@apply relative;
}
.search-input {
@apply transition-colors duration-200;
}
.search-input:focus {
@apply ring-2 ring-blue-500 border-blue-500;
}
.suggestions-dropdown {
@apply border shadow-lg;
}
.suggestion-item {
@apply transition-colors duration-150;
}
.suggestion-item:first-child {
@apply rounded-t-md;
}
.suggestion-item:last-child {
@apply rounded-b-md;
}
/* Custom scrollbar for suggestions */
.suggestions-dropdown::-webkit-scrollbar {
@apply w-2;
}
.suggestions-dropdown::-webkit-scrollbar-track {
@apply bg-gray-100 dark:bg-gray-700;
}
.suggestions-dropdown::-webkit-scrollbar-thumb {
@apply bg-gray-300 dark:bg-gray-600 rounded-full;
}
.suggestions-dropdown::-webkit-scrollbar-thumb:hover {
@apply bg-gray-400 dark:bg-gray-500;
}
</style>

View File

@@ -0,0 +1,484 @@
<template>
<div class="searchable-select">
<label
:for="id"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
{{ label }}
<span v-if="required" class="text-red-500 ml-1">*</span>
</label>
<div class="relative">
<!-- Search input -->
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Icon name="search" class="w-4 h-4 text-gray-400" />
</div>
<input
ref="searchInput"
v-model="searchQuery"
@input="handleSearchInput"
@focus="handleFocus"
@blur="handleBlur"
@keydown="handleKeydown"
type="text"
:id="id"
:placeholder="searchPlaceholder"
class="search-input block w-full pl-10 pr-10 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white text-sm"
:class="{
'text-gray-400 dark:text-gray-500': !hasSelection && !searchQuery,
}"
:required="required"
:disabled="disabled"
autocomplete="off"
:aria-expanded="isOpen"
:aria-haspopup="true"
role="combobox"
/>
<div class="absolute inset-y-0 right-0 flex items-center pr-3">
<button
v-if="searchQuery || hasSelection"
@click="clearAll"
type="button"
class="text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
:disabled="disabled"
>
<Icon name="x" class="w-4 h-4" />
</button>
<Icon
v-else
name="chevron-down"
class="w-4 h-4 text-gray-500 dark:text-gray-400 transition-transform ml-1"
:class="{ 'rotate-180': isOpen }"
/>
</div>
</div>
<!-- Selected items display -->
<div v-if="hasSelection && !isOpen" class="mt-2 flex flex-wrap gap-1">
<span
v-for="selectedOption in selectedOptions"
:key="selectedOption.value"
class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
>
{{ selectedOption.label }}
<button
@click="removeOption(selectedOption.value)"
class="ml-1 text-blue-600 hover:text-blue-800 dark:text-blue-300 dark:hover:text-blue-100"
:disabled="disabled"
>
<Icon name="x" class="w-3 h-3" />
</button>
</span>
</div>
<!-- Dropdown -->
<Transition
enter-active-class="transition ease-out duration-200"
enter-from-class="opacity-0 translate-y-1"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition ease-in duration-150"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 translate-y-1"
>
<div
v-if="isOpen"
class="dropdown-menu absolute z-20 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg max-h-60 overflow-auto"
>
<!-- Loading state -->
<div v-if="isLoading" class="flex items-center justify-center py-4">
<div
class="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"
></div>
<span class="ml-2 text-sm text-gray-500 dark:text-gray-400">Loading...</span>
</div>
<!-- Options -->
<div v-else-if="filteredOptions.length > 0">
<div
v-for="(option, index) in filteredOptions"
:key="option.value"
@click="toggleOption(option.value)"
@mouseenter="highlightedIndex = index"
class="dropdown-item flex items-center px-3 py-2 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700"
:class="{
'bg-blue-50 dark:bg-blue-900': highlightedIndex === index,
'bg-green-50 dark:bg-green-900': isSelected(option.value),
}"
role="option"
:aria-selected="isSelected(option.value)"
>
<div class="flex items-center mr-3">
<input
type="checkbox"
:checked="isSelected(option.value)"
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
readonly
/>
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-gray-900 dark:text-white truncate">
<span v-html="highlightSearchTerm(option.label)"></span>
</div>
<div
v-if="option.description"
class="text-xs text-gray-500 dark:text-gray-400 truncate"
>
{{ option.description }}
</div>
</div>
<div v-if="option.count !== undefined" class="text-xs text-gray-400 ml-2">
{{ option.count }}
</div>
</div>
</div>
<!-- No results -->
<div
v-else-if="searchQuery"
class="px-3 py-4 text-sm text-gray-500 dark:text-gray-400 text-center"
>
No results found for "{{ searchQuery }}"
<button
v-if="allowCreate"
@click="createOption"
class="block mt-2 text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200"
>
Create "{{ searchQuery }}"
</button>
</div>
<!-- No options message -->
<div
v-else
class="px-3 py-4 text-sm text-gray-500 dark:text-gray-400 text-center"
>
{{ noOptionsMessage }}
</div>
</div>
</Transition>
</div>
<!-- Selected count -->
<div v-if="hasSelection" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ selectedCount }} selected
<button
v-if="clearable"
@click="clearSelection"
class="ml-2 text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200"
:disabled="disabled"
>
Clear all
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from "vue";
import { debounce } from "lodash-es";
import Icon from "@/components/ui/Icon.vue";
interface Option {
value: string | number;
label: string;
description?: string;
count?: number;
disabled?: boolean;
}
interface Props {
id: string;
label: string;
value?: (string | number)[];
options: Option[];
searchPlaceholder?: string;
noOptionsMessage?: string;
required?: boolean;
disabled?: boolean;
clearable?: boolean;
allowCreate?: boolean;
isLoading?: boolean;
minSearchLength?: number;
}
const props = withDefaults(defineProps<Props>(), {
searchPlaceholder: "Search options...",
noOptionsMessage: "No options available",
required: false,
disabled: false,
clearable: true,
allowCreate: false,
isLoading: false,
minSearchLength: 0,
});
const emit = defineEmits<{
update: [value: (string | number)[] | undefined];
search: [query: string];
create: [value: string];
}>();
// Local state
const searchInput = ref<HTMLInputElement>();
const searchQuery = ref("");
const isOpen = ref(false);
const highlightedIndex = ref(-1);
// Computed
const selectedValues = computed(() => {
return Array.isArray(props.value) ? props.value : [];
});
const selectedOptions = computed(() => {
return props.options.filter((option) => selectedValues.value.includes(option.value));
});
const hasSelection = computed(() => {
return selectedValues.value.length > 0;
});
const selectedCount = computed(() => {
return selectedValues.value.length;
});
const filteredOptions = computed(() => {
if (!searchQuery.value || searchQuery.value.length < props.minSearchLength) {
return props.options;
}
const query = searchQuery.value.toLowerCase();
return props.options.filter(
(option) =>
option.label.toLowerCase().includes(query) ||
(option.description && option.description.toLowerCase().includes(query))
);
});
// Methods
const handleSearchInput = debounce((event: Event) => {
const target = event.target as HTMLInputElement;
const query = target.value;
searchQuery.value = query;
if (query.length >= props.minSearchLength) {
emit("search", query);
}
if (!isOpen.value && query) {
isOpen.value = true;
}
}, 300);
const handleFocus = () => {
if (!props.disabled) {
isOpen.value = true;
highlightedIndex.value = -1;
}
};
const handleBlur = () => {
// Delay hiding to allow for clicks
setTimeout(() => {
if (!searchInput.value?.matches(":focus")) {
isOpen.value = false;
highlightedIndex.value = -1;
}
}, 150);
};
const handleKeydown = (event: KeyboardEvent) => {
if (!isOpen.value) {
if (event.key === "ArrowDown" || event.key === "Enter") {
event.preventDefault();
isOpen.value = true;
return;
}
return;
}
switch (event.key) {
case "ArrowDown":
event.preventDefault();
highlightedIndex.value = Math.min(
highlightedIndex.value + 1,
filteredOptions.value.length - 1
);
break;
case "ArrowUp":
event.preventDefault();
highlightedIndex.value = Math.max(highlightedIndex.value - 1, -1);
break;
case "Enter":
event.preventDefault();
if (highlightedIndex.value >= 0 && filteredOptions.value[highlightedIndex.value]) {
toggleOption(filteredOptions.value[highlightedIndex.value].value);
} else if (props.allowCreate && searchQuery.value) {
createOption();
}
break;
case "Escape":
isOpen.value = false;
highlightedIndex.value = -1;
searchInput.value?.blur();
break;
case "Backspace":
if (!searchQuery.value && hasSelection.value) {
// Remove last selected item when backspacing with empty search
const lastSelected = selectedValues.value[selectedValues.value.length - 1];
removeOption(lastSelected);
}
break;
}
};
const toggleOption = (optionValue: string | number) => {
if (props.disabled) return;
const currentValues = [...selectedValues.value];
const index = currentValues.indexOf(optionValue);
if (index >= 0) {
currentValues.splice(index, 1);
} else {
currentValues.push(optionValue);
}
emit("update", currentValues.length > 0 ? currentValues : undefined);
// Clear search after selection
searchQuery.value = "";
nextTick(() => {
searchInput.value?.focus();
});
};
const removeOption = (optionValue: string | number) => {
if (props.disabled) return;
const currentValues = [...selectedValues.value];
const index = currentValues.indexOf(optionValue);
if (index >= 0) {
currentValues.splice(index, 1);
emit("update", currentValues.length > 0 ? currentValues : undefined);
}
};
const isSelected = (optionValue: string | number): boolean => {
return selectedValues.value.includes(optionValue);
};
const clearSelection = () => {
if (!props.disabled) {
emit("update", undefined);
}
};
const clearAll = () => {
searchQuery.value = "";
if (hasSelection.value) {
clearSelection();
}
searchInput.value?.focus();
};
const createOption = () => {
if (props.allowCreate && searchQuery.value.trim()) {
emit("create", searchQuery.value.trim());
searchQuery.value = "";
}
};
const highlightSearchTerm = (text: string): string => {
if (!searchQuery.value) return text;
const regex = new RegExp(`(${searchQuery.value})`, "gi");
return text.replace(regex, '<mark class="bg-yellow-200 dark:bg-yellow-800">$1</mark>');
};
// Handle clicks outside
const handleClickOutside = (event: Event) => {
const target = event.target as HTMLElement;
if (!target.closest(".searchable-select")) {
isOpen.value = false;
highlightedIndex.value = -1;
}
};
// Watch for options changes to reset highlighted index
watch(
() => filteredOptions.value.length,
() => {
highlightedIndex.value = -1;
}
);
onMounted(() => {
document.addEventListener("click", handleClickOutside);
});
onUnmounted(() => {
document.removeEventListener("click", handleClickOutside);
});
</script>
<style scoped>
.search-input {
@apply transition-colors duration-200;
}
.search-input:focus {
@apply ring-2 ring-blue-500 border-blue-500;
}
.search-input:disabled {
@apply bg-gray-100 dark:bg-gray-600 cursor-not-allowed opacity-50;
}
.dropdown-menu {
@apply border shadow-lg;
}
.dropdown-item {
@apply transition-colors duration-150;
}
.dropdown-item:first-child {
@apply rounded-t-md;
}
.dropdown-item:last-child {
@apply rounded-b-md;
}
/* Custom scrollbar for dropdown */
.dropdown-menu::-webkit-scrollbar {
@apply w-2;
}
.dropdown-menu::-webkit-scrollbar-track {
@apply bg-gray-100 dark:bg-gray-700;
}
.dropdown-menu::-webkit-scrollbar-thumb {
@apply bg-gray-300 dark:bg-gray-600 rounded-full;
}
.dropdown-menu::-webkit-scrollbar-thumb:hover {
@apply bg-gray-400 dark:bg-gray-500;
}
/* Highlight search terms */
:deep(mark) {
@apply bg-yellow-200 dark:bg-yellow-800 px-0;
}
</style>

View File

@@ -0,0 +1,356 @@
<template>
<div class="select-filter">
<label
:for="id"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
{{ label }}
<span v-if="required" class="text-red-500 ml-1">*</span>
</label>
<div class="relative">
<select
v-if="!multiple"
:id="id"
:value="value"
@change="handleSingleChange"
class="select-input block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white text-sm"
:class="{
'text-gray-400 dark:text-gray-500': !value,
}"
:required="required"
:disabled="disabled"
>
<option value="" class="text-gray-500">
{{ placeholder || `Select ${label.toLowerCase()}...` }}
</option>
<option
v-for="option in normalizedOptions"
:key="option.value"
:value="option.value"
class="text-gray-900 dark:text-white"
>
{{ option.label }}
</option>
</select>
<!-- Multiple select with custom UI -->
<div v-else class="multi-select-container">
<button
type="button"
@click="toggleDropdown"
class="select-trigger flex items-center justify-between w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white text-sm"
:class="{
'text-gray-400 dark:text-gray-500': !hasSelection,
}"
:disabled="disabled"
:aria-expanded="isOpen"
:aria-haspopup="true"
>
<span class="flex-1 text-left truncate">
<span v-if="!hasSelection">
{{ placeholder || `Select ${label.toLowerCase()}...` }}
</span>
<span v-else class="flex flex-wrap gap-1">
<span
v-for="selectedOption in selectedOptions"
:key="selectedOption.value"
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
>
{{ selectedOption.label }}
<button
@click.stop="removeOption(selectedOption.value)"
class="ml-1 text-blue-600 hover:text-blue-800 dark:text-blue-300 dark:hover:text-blue-100"
:disabled="disabled"
>
<Icon name="x" class="w-3 h-3" />
</button>
</span>
</span>
</span>
<Icon
name="chevron-down"
class="w-4 h-4 text-gray-500 dark:text-gray-400 transition-transform"
:class="{ 'rotate-180': isOpen }"
/>
</button>
<Transition
enter-active-class="transition ease-out duration-200"
enter-from-class="opacity-0 translate-y-1"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition ease-in duration-150"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 translate-y-1"
>
<div
v-if="isOpen"
class="dropdown-menu absolute z-10 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg max-h-60 overflow-auto"
>
<div
v-for="option in normalizedOptions"
:key="option.value"
@click="toggleOption(option.value)"
class="dropdown-item flex items-center px-3 py-2 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700"
:class="{
'bg-blue-50 dark:bg-blue-900': isSelected(option.value),
}"
>
<div class="flex items-center mr-3">
<input
type="checkbox"
:checked="isSelected(option.value)"
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
readonly
/>
</div>
<span class="flex-1 text-sm text-gray-900 dark:text-white">
{{ option.label }}
</span>
<span
v-if="option.count !== undefined"
class="text-xs text-gray-500 dark:text-gray-400"
>
{{ option.count }}
</span>
</div>
<div
v-if="normalizedOptions.length === 0"
class="px-3 py-2 text-sm text-gray-500 dark:text-gray-400"
>
No options available
</div>
</div>
</Transition>
</div>
</div>
<!-- Selected count indicator for multiple -->
<div
v-if="multiple && hasSelection"
class="mt-1 text-xs text-gray-500 dark:text-gray-400"
>
{{ selectedCount }} selected
</div>
<!-- Clear button -->
<button
v-if="clearable && hasSelection"
@click="clearSelection"
class="mt-2 text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200"
:disabled="disabled"
>
Clear {{ multiple ? "all" : "selection" }}
</button>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from "vue";
import Icon from "@/components/ui/Icon.vue";
interface Option {
value: string | number;
label: string;
count?: number;
disabled?: boolean;
}
interface Props {
id: string;
label: string;
value?: string | number | (string | number)[];
options: (Option | string | number)[];
multiple?: boolean;
placeholder?: string;
required?: boolean;
disabled?: boolean;
clearable?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
multiple: false,
required: false,
disabled: false,
clearable: true,
});
const emit = defineEmits<{
update: [value: string | number | (string | number)[] | undefined];
}>();
// Local state
const isOpen = ref(false);
// Computed
const normalizedOptions = computed((): Option[] => {
return props.options.map((option) => {
if (typeof option === "string" || typeof option === "number") {
return {
value: option,
label: String(option),
};
}
return option;
});
});
const selectedValues = computed(() => {
if (!props.multiple) {
return props.value ? [props.value] : [];
}
return Array.isArray(props.value) ? props.value : [];
});
const selectedOptions = computed(() => {
return normalizedOptions.value.filter((option) =>
selectedValues.value.includes(option.value)
);
});
const hasSelection = computed(() => {
return selectedValues.value.length > 0;
});
const selectedCount = computed(() => {
return selectedValues.value.length;
});
// Methods
const handleSingleChange = (event: Event) => {
const target = event.target as HTMLSelectElement;
const value = target.value;
emit("update", value || undefined);
};
const toggleDropdown = () => {
if (!props.disabled) {
isOpen.value = !isOpen.value;
}
};
const toggleOption = (optionValue: string | number) => {
if (props.disabled) return;
if (!props.multiple) {
emit("update", optionValue);
isOpen.value = false;
return;
}
const currentValues = Array.isArray(props.value) ? [...props.value] : [];
const index = currentValues.indexOf(optionValue);
if (index >= 0) {
currentValues.splice(index, 1);
} else {
currentValues.push(optionValue);
}
emit("update", currentValues.length > 0 ? currentValues : undefined);
};
const removeOption = (optionValue: string | number) => {
if (props.disabled) return;
if (!props.multiple) {
emit("update", undefined);
return;
}
const currentValues = Array.isArray(props.value) ? [...props.value] : [];
const index = currentValues.indexOf(optionValue);
if (index >= 0) {
currentValues.splice(index, 1);
emit("update", currentValues.length > 0 ? currentValues : undefined);
}
};
const isSelected = (optionValue: string | number): boolean => {
return selectedValues.value.includes(optionValue);
};
const clearSelection = () => {
if (!props.disabled) {
emit("update", undefined);
}
};
// Handle clicks outside
const handleClickOutside = (event: Event) => {
const target = event.target as HTMLElement;
if (!target.closest(".multi-select-container")) {
isOpen.value = false;
}
};
onMounted(() => {
document.addEventListener("click", handleClickOutside);
});
onUnmounted(() => {
document.removeEventListener("click", handleClickOutside);
});
</script>
<style scoped>
.select-input {
@apply transition-colors duration-200;
}
.select-input:focus {
@apply ring-2 ring-blue-500 border-blue-500;
}
.select-input:disabled {
@apply bg-gray-100 dark:bg-gray-600 cursor-not-allowed opacity-50;
}
.select-trigger {
@apply transition-colors duration-200;
}
.select-trigger:focus {
@apply ring-2 ring-blue-500 border-blue-500;
}
.select-trigger:disabled {
@apply bg-gray-100 dark:bg-gray-600 cursor-not-allowed opacity-50;
}
.dropdown-menu {
@apply border shadow-lg;
}
.dropdown-item {
@apply transition-colors duration-150;
}
.dropdown-item:first-child {
@apply rounded-t-md;
}
.dropdown-item:last-child {
@apply rounded-b-md;
}
/* Custom scrollbar for dropdown */
.dropdown-menu::-webkit-scrollbar {
@apply w-2;
}
.dropdown-menu::-webkit-scrollbar-track {
@apply bg-gray-100 dark:bg-gray-700;
}
.dropdown-menu::-webkit-scrollbar-thumb {
@apply bg-gray-300 dark:bg-gray-600 rounded-full;
}
.dropdown-menu::-webkit-scrollbar-thumb:hover {
@apply bg-gray-400 dark:bg-gray-500;
}
</style>

View File

@@ -0,0 +1,289 @@
<template>
<div
class="group relative bg-white dark:bg-gray-800 rounded-lg shadow-sm hover:shadow-md transition-all duration-200 border border-gray-200 dark:border-gray-700 overflow-hidden cursor-pointer"
@click="$emit('click')"
>
<!-- Ride Image -->
<div class="aspect-w-16 aspect-h-9 bg-gray-100 dark:bg-gray-700">
<img
v-if="ride.image_url"
:src="ride.image_url"
:alt="ride.name"
class="w-full h-48 object-cover group-hover:scale-105 transition-transform duration-200"
/>
<div
v-else
class="w-full h-48 flex items-center justify-center bg-gradient-to-br from-blue-500 to-purple-600"
>
<Icon name="camera" class="w-12 h-12 text-white opacity-60" />
</div>
</div>
<!-- Status Badge -->
<div class="absolute top-3 right-3">
<span
:class="[
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium',
getStatusColor(ride.status),
]"
>
{{ getStatusDisplay(ride.status) }}
</span>
</div>
<!-- Content -->
<div class="p-4">
<!-- Ride Name and Category -->
<div class="mb-2">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 line-clamp-1">
{{ ride.name }}
</h3>
<p class="text-sm text-blue-600 dark:text-blue-400 font-medium">
{{ ride.category_display || ride.category }}
</p>
</div>
<!-- Park Name -->
<div class="flex items-center mb-3">
<Icon name="map-pin" class="w-4 h-4 text-gray-400 mr-1.5" />
<span class="text-sm text-gray-600 dark:text-gray-400">
{{ ride.park_name }}
</span>
</div>
<!-- Stats Row -->
<div class="grid grid-cols-2 gap-3 mb-3 text-sm">
<!-- Height -->
<div v-if="ride.height_ft" class="flex items-center">
<Icon name="trending-up" class="w-4 h-4 text-gray-400 mr-1.5" />
<span class="text-gray-600 dark:text-gray-400">{{ ride.height_ft }}ft</span>
</div>
<!-- Speed -->
<div v-if="ride.speed_mph" class="flex items-center">
<Icon name="zap" class="w-4 h-4 text-gray-400 mr-1.5" />
<span class="text-gray-600 dark:text-gray-400">{{ ride.speed_mph }} mph</span>
</div>
<!-- Duration -->
<div v-if="ride.ride_duration_seconds" class="flex items-center">
<Icon name="clock" class="w-4 h-4 text-gray-400 mr-1.5" />
<span class="text-gray-600 dark:text-gray-400">
{{ formatDuration(ride.ride_duration_seconds) }}
</span>
</div>
<!-- Capacity -->
<div v-if="ride.capacity_per_hour" class="flex items-center">
<Icon name="users" class="w-4 h-4 text-gray-400 mr-1.5" />
<span class="text-gray-600 dark:text-gray-400">
{{ formatCapacity(ride.capacity_per_hour) }}/hr
</span>
</div>
</div>
<!-- Rating and Opening Date -->
<div class="flex items-center justify-between">
<!-- Rating -->
<div v-if="ride.average_rating" class="flex items-center">
<Icon name="star" class="w-4 h-4 text-yellow-400 mr-1" />
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ ride.average_rating.toFixed(1) }}
</span>
<span class="text-sm text-gray-500 dark:text-gray-400 ml-1">
({{ ride.review_count || 0 }})
</span>
</div>
<!-- Opening Date -->
<div v-if="ride.opening_date" class="text-xs text-gray-500 dark:text-gray-400">
Opened {{ formatYear(ride.opening_date) }}
</div>
</div>
<!-- Description Preview -->
<div
v-if="ride.description"
class="mt-3 pt-3 border-t border-gray-100 dark:border-gray-700"
>
<p class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
{{ ride.description }}
</p>
</div>
<!-- Manufacturer/Designer -->
<div
v-if="ride.manufacturer_name || ride.designer_name"
class="mt-3 pt-3 border-t border-gray-100 dark:border-gray-700"
>
<div class="flex flex-wrap gap-2 text-xs">
<span
v-if="ride.manufacturer_name"
class="inline-flex items-center px-2 py-1 rounded bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300"
>
<Icon name="building" class="w-3 h-3 mr-1" />
{{ ride.manufacturer_name }}
</span>
<span
v-if="ride.designer_name"
class="inline-flex items-center px-2 py-1 rounded bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300"
>
<Icon name="user" class="w-3 h-3 mr-1" />
{{ ride.designer_name }}
</span>
</div>
</div>
<!-- Special Features -->
<div
v-if="hasSpecialFeatures"
class="mt-3 pt-3 border-t border-gray-100 dark:border-gray-700"
>
<div class="flex flex-wrap gap-1.5">
<span
v-if="ride.inversions && ride.inversions > 0"
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-200"
>
{{ ride.inversions }} inversion{{ ride.inversions !== 1 ? "s" : "" }}
</span>
<span
v-if="ride.launch_type"
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-200"
>
{{ ride.launch_type }} launch
</span>
<span
v-if="ride.track_material"
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200"
>
{{ ride.track_material }}
</span>
</div>
</div>
</div>
<!-- Hover Overlay -->
<div
class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-5 transition-all duration-200 pointer-events-none"
></div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import type { Ride } from "@/types";
import Icon from "@/components/ui/Icon.vue";
// Props
interface Props {
ride: Ride;
}
const props = defineProps<Props>();
// Emits
defineEmits<{
click: [];
}>();
// Computed properties
const hasSpecialFeatures = computed(() => {
return !!(
(props.ride.inversions && props.ride.inversions > 0) ||
props.ride.launch_type ||
props.ride.track_material
);
});
// Methods
const getStatusColor = (status: string) => {
switch (status?.toLowerCase()) {
case "operating":
return "bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200";
case "closed":
case "permanently_closed":
return "bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-200";
case "under_construction":
return "bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-200";
case "seasonal":
return "bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200";
case "maintenance":
return "bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-200";
default:
return "bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200";
}
};
const getStatusDisplay = (status: string) => {
switch (status?.toLowerCase()) {
case "operating":
return "Operating";
case "closed":
return "Closed";
case "permanently_closed":
return "Permanently Closed";
case "under_construction":
return "Under Construction";
case "seasonal":
return "Seasonal";
case "maintenance":
return "Maintenance";
default:
return status || "Unknown";
}
};
const formatDuration = (seconds: number) => {
if (seconds < 60) {
return `${seconds}s`;
}
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
if (remainingSeconds === 0) {
return `${minutes}m`;
}
return `${minutes}m ${remainingSeconds}s`;
};
const formatCapacity = (capacity: number) => {
if (capacity >= 1000) {
return `${(capacity / 1000).toFixed(1)}k`;
}
return capacity.toString();
};
const formatYear = (dateString: string) => {
return new Date(dateString).getFullYear();
};
</script>
<style scoped>
.line-clamp-1 {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.aspect-w-16 {
position: relative;
padding-bottom: calc(9 / 16 * 100%);
}
.aspect-w-16 > * {
position: absolute;
height: 100%;
width: 100%;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
</style>

View File

@@ -0,0 +1,516 @@
<template>
<div class="ride-list-display">
<!-- Loading State -->
<div v-if="isLoading" class="flex items-center justify-center py-12">
<div class="flex items-center space-x-3">
<Icon name="spinner" class="w-6 h-6 text-blue-600 animate-spin" />
<span class="text-gray-600 dark:text-gray-300">Loading rides...</span>
</div>
</div>
<!-- Error State -->
<div
v-else-if="error"
class="rounded-lg bg-red-50 dark:bg-red-900/20 p-4 border border-red-200 dark:border-red-800"
>
<div class="flex items-center">
<Icon name="exclamation-triangle" class="w-5 h-5 text-red-500 mr-2" />
<h3 class="text-sm font-medium text-red-800 dark:text-red-200">
Error loading rides
</h3>
</div>
<p class="mt-2 text-sm text-red-700 dark:text-red-300">{{ error }}</p>
<button
@click="retrySearch"
class="mt-3 text-sm font-medium text-red-600 dark:text-red-400 hover:text-red-500 focus:outline-none focus:underline"
>
Try again
</button>
</div>
<!-- Results Header -->
<div v-else-if="rides.length > 0 || hasActiveFilters" class="space-y-4">
<!-- Results Count and Sort -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div class="flex items-center space-x-4">
<p class="text-sm text-gray-600 dark:text-gray-400">
<span class="font-medium text-gray-900 dark:text-gray-100">{{
totalCount
}}</span>
{{ totalCount === 1 ? "ride" : "rides" }} found
<span v-if="hasActiveFilters" class="text-gray-500 dark:text-gray-400">
with active filters
</span>
</p>
<!-- Active Filters Count -->
<span
v-if="activeFilterCount > 0"
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200"
>
{{ activeFilterCount }}
{{ activeFilterCount === 1 ? "filter" : "filters" }} active
</span>
</div>
<!-- Sort Controls -->
<div class="flex items-center space-x-2">
<label
for="sort-select"
class="text-sm font-medium text-gray-700 dark:text-gray-300"
>
Sort by:
</label>
<select
id="sort-select"
v-model="currentSort"
@change="handleSortChange"
class="rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-1.5 text-sm text-gray-900 dark:text-gray-100 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="name">Name (A-Z)</option>
<option value="-name">Name (Z-A)</option>
<option value="-opening_date">Newest First</option>
<option value="opening_date">Oldest First</option>
<option value="-average_rating">Highest Rated</option>
<option value="average_rating">Lowest Rated</option>
<option value="-height_ft">Tallest First</option>
<option value="height_ft">Shortest First</option>
<option value="-speed_mph">Fastest First</option>
<option value="speed_mph">Slowest First</option>
</select>
</div>
</div>
<!-- Active Filters Display -->
<div v-if="activeFilters.length > 0" class="flex flex-wrap items-center gap-2">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300"
>Active filters:</span
>
<ActiveFilterChip
v-for="filter in activeFilters"
:key="filter.key"
:label="filter.label"
:value="filter.value"
@remove="removeFilter(filter)"
/>
<button
@click="clearAllFilters"
class="ml-2 text-sm font-medium text-red-600 dark:text-red-400 hover:text-red-500 focus:outline-none focus:underline"
>
Clear all
</button>
</div>
<!-- Ride Grid -->
<div
v-if="rides.length > 0"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
>
<RideCard
v-for="ride in rides"
:key="ride.id"
:ride="ride"
@click="handleRideClick(ride)"
/>
</div>
<!-- No Results -->
<div v-else class="text-center py-12">
<Icon
name="search"
class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500 mb-4"
/>
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
No rides found
</h3>
<p class="text-gray-600 dark:text-gray-400 mb-4">
No rides match your current search criteria.
</p>
<button
@click="clearAllFilters"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-900/40 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Clear all filters
</button>
</div>
<!-- Pagination -->
<div
v-if="totalPages > 1"
class="flex items-center justify-between pt-6 border-t border-gray-200 dark:border-gray-700"
>
<div class="flex-1 flex justify-between sm:hidden">
<button
@click="goToPage(currentPage - 1)"
:disabled="currentPage <= 1"
class="relative inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 text-sm font-medium rounded-md text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<button
@click="goToPage(currentPage + 1)"
:disabled="currentPage >= totalPages"
class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 text-sm font-medium rounded-md text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p class="text-sm text-gray-700 dark:text-gray-300">
Showing <span class="font-medium">{{ startItem }}</span> to
<span class="font-medium">{{ endItem }}</span> of{' '}
<span class="font-medium">{{ totalCount }}</span> results
</p>
</div>
<div>
<nav
class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px"
aria-label="Pagination"
>
<button
@click="goToPage(currentPage - 1)"
:disabled="currentPage <= 1"
class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm font-medium text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Icon name="chevron-left" class="h-5 w-5" />
</button>
<button
v-for="page in visiblePages"
:key="page"
@click="goToPage(page)"
:class="[
'relative inline-flex items-center px-4 py-2 border text-sm font-medium',
page === currentPage
? 'z-10 bg-blue-50 dark:bg-blue-900/30 border-blue-500 text-blue-600 dark:text-blue-400'
: 'bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-600 text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700',
]"
>
{{ page }}
</button>
<button
@click="goToPage(currentPage + 1)"
:disabled="currentPage >= totalPages"
class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm font-medium text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Icon name="chevron-right" class="h-5 w-5" />
</button>
</nav>
</div>
</div>
</div>
</div>
<!-- Empty State (no filters) -->
<div v-else class="text-center py-12">
<Icon
name="search"
class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500 mb-4"
/>
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
Find amazing rides
</h3>
<p class="text-gray-600 dark:text-gray-400">
Use the search and filters to discover rides that match your interests.
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, watch } from "vue";
import { useRouter } from "vue-router";
import { storeToRefs } from "pinia";
import { useRideFilteringStore } from "@/stores/rideFiltering";
import type { Ride } from "@/types";
import Icon from "@/components/ui/Icon.vue";
import ActiveFilterChip from "@/components/filters/ActiveFilterChip.vue";
import RideCard from "@/components/rides/RideCard.vue";
// Props
interface Props {
parkSlug?: string;
}
const props = withDefaults(defineProps<Props>(), {
parkSlug: undefined,
});
// Composables
const router = useRouter();
const rideFilteringStore = useRideFilteringStore();
// Store state
const {
rides,
isLoading,
error,
totalCount,
totalPages,
currentPage,
filters,
} = storeToRefs(rideFilteringStore);
// Computed properties
const currentSort = computed({
get: () => filters.value.sort,
set: (value: string) => {
rideFilteringStore.updateFilters({ sort: value });
},
});
const hasActiveFilters = computed(() => {
const f = filters.value;
return !!(
f.search ||
f.categories.length > 0 ||
f.manufacturers.length > 0 ||
f.designers.length > 0 ||
f.parks.length > 0 ||
f.status.length > 0 ||
f.heightRange[0] > 0 ||
f.heightRange[1] < 500 ||
f.speedRange[0] > 0 ||
f.speedRange[1] < 200 ||
f.capacityRange[0] > 0 ||
f.capacityRange[1] < 10000 ||
f.durationRange[0] > 0 ||
f.durationRange[1] < 600 ||
f.openingDateRange[0] ||
f.openingDateRange[1] ||
f.closingDateRange[0] ||
f.closingDateRange[1]
);
});
const activeFilterCount = computed(() => {
const f = filters.value;
let count = 0;
if (f.search) count++;
if (f.categories.length > 0) count++;
if (f.manufacturers.length > 0) count++;
if (f.designers.length > 0) count++;
if (f.parks.length > 0) count++;
if (f.status.length > 0) count++;
if (f.heightRange[0] > 0 || f.heightRange[1] < 500) count++;
if (f.speedRange[0] > 0 || f.speedRange[1] < 200) count++;
if (f.capacityRange[0] > 0 || f.capacityRange[1] < 10000) count++;
if (f.durationRange[0] > 0 || f.durationRange[1] < 600) count++;
if (f.openingDateRange[0] || f.openingDateRange[1]) count++;
if (f.closingDateRange[0] || f.closingDateRange[1]) count++;
return count;
});
const activeFilters = computed(() => {
const f = filters.value;
const active: Array<{ key: string; label: string; value: string }> = [];
if (f.search) {
active.push({ key: "search", label: "Search", value: f.search });
}
if (f.categories.length > 0) {
active.push({
key: "categories",
label: "Categories",
value: `${f.categories.length} selected`,
});
}
if (f.manufacturers.length > 0) {
active.push({
key: "manufacturers",
label: "Manufacturers",
value: `${f.manufacturers.length} selected`,
});
}
if (f.designers.length > 0) {
active.push({
key: "designers",
label: "Designers",
value: `${f.designers.length} selected`,
});
}
if (f.parks.length > 0) {
active.push({ key: "parks", label: "Parks", value: `${f.parks.length} selected` });
}
if (f.status.length > 0) {
active.push({ key: "status", label: "Status", value: `${f.status.length} selected` });
}
if (f.heightRange[0] > 0 || f.heightRange[1] < 500) {
active.push({
key: "height",
label: "Height",
value: `${f.heightRange[0]}-${f.heightRange[1]} ft`,
});
}
if (f.speedRange[0] > 0 || f.speedRange[1] < 200) {
active.push({
key: "speed",
label: "Speed",
value: `${f.speedRange[0]}-${f.speedRange[1]} mph`,
});
}
if (f.capacityRange[0] > 0 || f.capacityRange[1] < 10000) {
active.push({
key: "capacity",
label: "Capacity",
value: `${f.capacityRange[0]}-${f.capacityRange[1]}/hr`,
});
}
if (f.durationRange[0] > 0 || f.durationRange[1] < 600) {
active.push({
key: "duration",
label: "Duration",
value: `${f.durationRange[0]}-${f.durationRange[1]}s`,
});
}
if (f.openingDateRange[0] || f.openingDateRange[1]) {
const start = f.openingDateRange[0] || "earliest";
const end = f.openingDateRange[1] || "latest";
active.push({ key: "opening", label: "Opening Date", value: `${start} - ${end}` });
}
if (f.closingDateRange[0] || f.closingDateRange[1]) {
const start = f.closingDateRange[0] || "earliest";
const end = f.closingDateRange[1] || "latest";
active.push({ key: "closing", label: "Closing Date", value: `${start} - ${end}` });
}
return active;
});
const startItem = computed(() => {
return (currentPage.value - 1) * 20 + 1;
});
const endItem = computed(() => {
return Math.min(currentPage.value * 20, totalCount.value);
});
const visiblePages = computed(() => {
const total = totalPages.value;
const current = currentPage.value;
const pages: number[] = [];
// Always show first page
if (total >= 1) pages.push(1);
// Show pages around current page
const start = Math.max(2, current - 2);
const end = Math.min(total - 1, current + 2);
// Add ellipsis if there's a gap
if (start > 2) pages.push(-1); // -1 represents ellipsis
// Add pages around current
for (let i = start; i <= end; i++) {
if (i > 1 && i < total) pages.push(i);
}
// Add ellipsis if there's a gap
if (end < total - 1) pages.push(-1);
// Always show last page
if (total > 1) pages.push(total);
return pages;
});
// Methods
const handleSortChange = () => {
rideFilteringStore.searchRides({ parkSlug: props.parkSlug });
};
const removeFilter = (filter: { key: string; label: string; value: string }) => {
const updates: any = {};
switch (filter.key) {
case "search":
updates.search = "";
break;
case "categories":
updates.categories = [];
break;
case "manufacturers":
updates.manufacturers = [];
break;
case "designers":
updates.designers = [];
break;
case "parks":
updates.parks = [];
break;
case "status":
updates.status = [];
break;
case "height":
updates.heightRange = [0, 500];
break;
case "speed":
updates.speedRange = [0, 200];
break;
case "capacity":
updates.capacityRange = [0, 10000];
break;
case "duration":
updates.durationRange = [0, 600];
break;
case "opening":
updates.openingDateRange = [null, null];
break;
case "closing":
updates.closingDateRange = [null, null];
break;
}
rideFilteringStore.updateFilters(updates);
};
const clearAllFilters = () => {
rideFilteringStore.resetFilters();
};
const goToPage = (page: number) => {
if (page >= 1 && page <= totalPages.value) {
rideFilteringStore.updateFilters({ page });
}
};
const retrySearch = () => {
rideFilteringStore.searchRides({ parkSlug: props.parkSlug });
};
const handleRideClick = (ride: Ride) => {
if (props.parkSlug) {
router.push(`/parks/${props.parkSlug}/rides/${ride.slug}`);
} else {
router.push(`/rides/${ride.slug}`);
}
};
// Watch for filter changes
watch(
() => filters.value,
() => {
rideFilteringStore.searchRides({ parkSlug: props.parkSlug });
},
{ deep: true }
);
// Initialize search on mount
rideFilteringStore.searchRides({ parkSlug: props.parkSlug });
</script>

View File

@@ -0,0 +1,505 @@
<template>
<svg
:class="classes"
:width="size"
:height="size"
:viewBox="viewBox"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<!-- Search icon -->
<path
v-if="name === 'search'"
d="m21 21-6-6m2-5a7 7 0 1 1-14 0 7 7 0 0 1 14 0Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- Filter icon -->
<path
v-else-if="name === 'filter'"
d="M22 3H2l8 9.46V19l4 2v-8.54L22 3z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- X (close) icon -->
<g v-else-if="name === 'x'">
<path
d="m18 6-12 12"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="m6 6 12 12"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<!-- Chevron down icon -->
<path
v-else-if="name === 'chevron-down'"
d="m6 9 6 6 6-6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- Chevron up icon -->
<path
v-else-if="name === 'chevron-up'"
d="m18 15-6-6-6 6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- Chevron right icon -->
<path
v-else-if="name === 'chevron-right'"
d="m9 18 6-6-6-6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- Chevron left icon -->
<path
v-else-if="name === 'chevron-left'"
d="m15 18-6-6 6-6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- Check icon -->
<path
v-else-if="name === 'check'"
d="M20 6 9 17l-5-5"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- Plus icon -->
<g v-else-if="name === 'plus'">
<path
d="M12 5v14"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M5 12h14"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<!-- Minus icon -->
<path
v-else-if="name === 'minus'"
d="M5 12h14"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- Calendar icon -->
<g v-else-if="name === 'calendar'">
<path
d="M8 2v4"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M16 2v4"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<rect
width="18"
height="18"
x="3"
y="4"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
rx="2"
/>
<path
d="M3 10h18"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<!-- Clock icon -->
<g v-else-if="name === 'clock'">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" />
<polyline
points="12,6 12,12 16,14"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<!-- Star icon (outline) -->
<path
v-else-if="name === 'star'"
d="m12 2 3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- Star icon (filled) -->
<path
v-else-if="name === 'star-filled'"
d="m12 2 3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2Z"
fill="currentColor"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- More vertical (three dots) icon -->
<g v-else-if="name === 'more-vertical'">
<circle cx="12" cy="12" r="1" fill="currentColor" />
<circle cx="12" cy="5" r="1" fill="currentColor" />
<circle cx="12" cy="19" r="1" fill="currentColor" />
</g>
<!-- Edit icon -->
<g v-else-if="name === 'edit'">
<path
d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<!-- Copy icon -->
<g v-else-if="name === 'copy'">
<rect
width="14"
height="14"
x="8"
y="8"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
rx="2"
ry="2"
/>
<path
d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<!-- Trash icon -->
<g v-else-if="name === 'trash'">
<path
d="M3 6h18"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6m3 0V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<line
x1="10"
x2="10"
y1="11"
y2="17"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<line
x1="14"
x2="14"
y1="11"
y2="17"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<!-- Loading spinner icon -->
<path
v-else-if="name === 'loading'"
d="M21 12a9 9 0 1 1-6.219-8.56"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- Settings icon -->
<g v-else-if="name === 'settings'">
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="2" />
<path
d="M12 1v6m0 6v6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="m21 12-6-3 6-3"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="m9 12-6 3 6 3"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<!-- Reset/refresh icon -->
<g v-else-if="name === 'refresh'">
<path
d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M21 3v5h-5"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M8 16H3v5"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<!-- Save icon -->
<g v-else-if="name === 'save'">
<path
d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<polyline
points="17,21 17,13 7,13 7,21"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<polyline
points="7,3 7,8 15,8"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<!-- Alert circle icon -->
<g v-else-if="name === 'alert-circle'">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" />
<line
x1="12"
x2="12"
y1="8"
y2="12"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="12" cy="16" r="1" fill="currentColor" />
</g>
<!-- Info icon -->
<g v-else-if="name === 'info'">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" />
<path
d="M12 16v-4"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="12" cy="8" r="1" fill="currentColor" />
</g>
<!-- External link icon -->
<g v-else-if="name === 'external-link'">
<path
d="M15 3h6v6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M10 14 21 3"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<!-- Eye icon -->
<g v-else-if="name === 'eye'">
<path
d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="2" />
</g>
<!-- Eye off icon -->
<g v-else-if="name === 'eye-off'">
<path
d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="m1 1 22 22"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<!-- Fallback: question mark for unknown icons -->
<g v-else>
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" />
<path
d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="12" cy="17" r="1" fill="currentColor" />
</g>
</svg>
</template>
<script setup lang="ts">
import { computed } from "vue";
interface Props {
name: string;
size?: number | string;
class?: string;
}
const props = withDefaults(defineProps<Props>(), {
size: 24,
});
// Computed
const viewBox = computed(() => "0 0 24 24");
const classes = computed(() => {
const baseClasses = ["inline-block", "flex-shrink-0"];
if (props.class) {
baseClasses.push(props.class);
}
return baseClasses.join(" ");
});
</script>
<style scoped>
/* Ensure icons maintain their aspect ratio */
svg {
vertical-align: middle;
}
</style>

View File

@@ -0,0 +1,379 @@
/**
* Composable for ride filtering API communication
*/
import { ref, computed, watch, type Ref } from 'vue'
import { api } from '@/services/api'
import type {
Ride,
RideFilters,
FilterOptions,
CompanySearchResult,
RideModelSearchResult,
SearchSuggestion,
ApiResponse
} from '@/types'
export function useRideFiltering(initialFilters: RideFilters = {}) {
// State
const isLoading = ref(false)
const error = ref<string | null>(null)
const rides = ref<Ride[]>([])
const totalCount = ref(0)
const currentPage = ref(1)
const hasNextPage = ref(false)
const hasPreviousPage = ref(false)
// Filter state
const filters = ref<RideFilters>({ ...initialFilters })
const filterOptions = ref<FilterOptions | null>(null)
// Debounced search
const searchDebounceTimeout = ref<number | null>(null)
// Computed
const hasActiveFilters = computed(() => {
return Object.values(filters.value).some(value => {
if (Array.isArray(value)) return value.length > 0
return value !== undefined && value !== null && value !== ''
})
})
const isFirstLoad = computed(() => rides.value.length === 0 && !isLoading.value)
/**
* Build query parameters from filters
*/
const buildQueryParams = (filterData: RideFilters): Record<string, string> => {
const params: Record<string, string> = {}
Object.entries(filterData).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') return
if (Array.isArray(value)) {
if (value.length > 0) {
params[key] = value.join(',')
}
} else {
params[key] = String(value)
}
})
return params
}
/**
* Fetch rides with current filters
*/
const fetchRides = async (resetPagination = true) => {
if (resetPagination) {
currentPage.value = 1
filters.value.page = 1
}
isLoading.value = true
error.value = null
try {
const queryParams = buildQueryParams(filters.value)
const response = await api.client.get<ApiResponse<Ride>>('/rides/api/', queryParams)
rides.value = response.results
totalCount.value = response.count
hasNextPage.value = !!response.next
hasPreviousPage.value = !!response.previous
return response
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch rides'
console.error('Error fetching rides:', err)
throw err
} finally {
isLoading.value = false
}
}
/**
* Load more rides (pagination)
*/
const loadMore = async () => {
if (!hasNextPage.value || isLoading.value) return
currentPage.value += 1
filters.value.page = currentPage.value
isLoading.value = true
try {
const queryParams = buildQueryParams(filters.value)
const response = await api.client.get<ApiResponse<Ride>>('/rides/api/', queryParams)
rides.value.push(...response.results)
totalCount.value = response.count
hasNextPage.value = !!response.next
hasPreviousPage.value = !!response.previous
return response
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load more rides'
console.error('Error loading more rides:', err)
throw err
} finally {
isLoading.value = false
}
}
/**
* Fetch filter options from API
*/
const fetchFilterOptions = async () => {
try {
const response = await api.client.get<FilterOptions>('/rides/api/filter-options/')
filterOptions.value = response
return response
} catch (err) {
console.error('Error fetching filter options:', err)
throw err
}
}
/**
* Search companies for manufacturer/designer autocomplete
*/
const searchCompanies = async (query: string, role?: 'manufacturer' | 'designer' | 'both'): Promise<CompanySearchResult[]> => {
if (!query.trim()) return []
try {
const params: Record<string, string> = { q: query }
if (role) params.role = role
const response = await api.client.get<CompanySearchResult[]>('/rides/api/search-companies/', params)
return response
} catch (err) {
console.error('Error searching companies:', err)
return []
}
}
/**
* Search ride models for autocomplete
*/
const searchRideModels = async (query: string): Promise<RideModelSearchResult[]> => {
if (!query.trim()) return []
try {
const response = await api.client.get<RideModelSearchResult[]>('/rides/api/search-ride-models/', { q: query })
return response
} catch (err) {
console.error('Error searching ride models:', err)
return []
}
}
/**
* Get search suggestions
*/
const getSearchSuggestions = async (query: string): Promise<SearchSuggestion[]> => {
if (!query.trim()) return []
try {
const response = await api.client.get<SearchSuggestion[]>('/rides/api/search-suggestions/', { q: query })
return response
} catch (err) {
console.error('Error getting search suggestions:', err)
return []
}
}
/**
* Update a specific filter
*/
const updateFilter = (key: keyof RideFilters, value: any) => {
filters.value = {
...filters.value,
[key]: value
}
}
/**
* Update multiple filters at once
*/
const updateFilters = (newFilters: Partial<RideFilters>) => {
filters.value = {
...filters.value,
...newFilters
}
}
/**
* Clear all filters
*/
const clearFilters = () => {
filters.value = {}
currentPage.value = 1
}
/**
* Clear a specific filter
*/
const clearFilter = (key: keyof RideFilters) => {
const newFilters = { ...filters.value }
delete newFilters[key]
filters.value = newFilters
}
/**
* Debounced search for text inputs
*/
const debouncedSearch = (query: string, delay = 500) => {
if (searchDebounceTimeout.value) {
clearTimeout(searchDebounceTimeout.value)
}
searchDebounceTimeout.value = window.setTimeout(() => {
updateFilter('search', query)
fetchRides()
}, delay)
}
/**
* Set sorting
*/
const setSorting = (ordering: string) => {
updateFilter('ordering', ordering)
fetchRides()
}
/**
* Set page size
*/
const setPageSize = (pageSize: number) => {
updateFilter('page_size', pageSize)
fetchRides()
}
/**
* Export current results
*/
const exportResults = async (format: 'csv' | 'json' = 'csv') => {
try {
const queryParams = buildQueryParams({
...filters.value,
export: format,
page_size: 1000 // Export more results
})
const response = await fetch(`${api.getBaseUrl()}/rides/api/?${new URLSearchParams(queryParams)}`, {
headers: {
'Accept': format === 'csv' ? 'text/csv' : 'application/json'
}
})
if (!response.ok) throw new Error('Export failed')
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `rides_export.${format}`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
} catch (err) {
console.error('Error exporting results:', err)
throw err
}
}
// Watch for filter changes and auto-fetch
watch(
() => filters.value,
(newFilters, oldFilters) => {
// Skip if this is the initial setup
if (!oldFilters) return
// Don't auto-fetch for search queries (use debounced search instead)
if (newFilters.search !== oldFilters.search) return
// Auto-fetch for other filter changes
fetchRides()
},
{ deep: true }
)
return {
// State
isLoading,
error,
rides,
totalCount,
currentPage,
hasNextPage,
hasPreviousPage,
filters,
filterOptions,
// Computed
hasActiveFilters,
isFirstLoad,
// Methods
fetchRides,
loadMore,
fetchFilterOptions,
searchCompanies,
searchRideModels,
getSearchSuggestions,
updateFilter,
updateFilters,
clearFilters,
clearFilter,
debouncedSearch,
setSorting,
setPageSize,
exportResults,
// Utilities
buildQueryParams
}
}
// Park-specific ride filtering
export function useParkRideFiltering(parkSlug: string, initialFilters: RideFilters = {}) {
const baseComposable = useRideFiltering(initialFilters)
// Override the fetch method to use park-specific endpoint
const fetchRides = async (resetPagination = true) => {
if (resetPagination) {
baseComposable.currentPage.value = 1
baseComposable.filters.value.page = 1
}
baseComposable.isLoading.value = true
baseComposable.error.value = null
try {
const queryParams = baseComposable.buildQueryParams(baseComposable.filters.value)
const response = await api.client.get<ApiResponse<Ride>>(`/parks/${parkSlug}/rides/`, queryParams)
baseComposable.rides.value = response.results
baseComposable.totalCount.value = response.count
baseComposable.hasNextPage.value = !!response.next
baseComposable.hasPreviousPage.value = !!response.previous
return response
} catch (err) {
baseComposable.error.value = err instanceof Error ? err.message : 'Failed to fetch park rides'
console.error('Error fetching park rides:', err)
throw err
} finally {
baseComposable.isLoading.value = false
}
}
return {
...baseComposable,
fetchRides
}
}

View File

@@ -0,0 +1,100 @@
import { ref, watch, onMounted } from 'vue'
// Theme state
const isDark = ref(false)
// Theme management composable
export function useTheme() {
// Initialize theme from localStorage or system preference
const initializeTheme = () => {
const savedTheme = localStorage.getItem('theme')
if (savedTheme) {
isDark.value = savedTheme === 'dark'
} else {
// Check system preference
isDark.value = window.matchMedia('(prefers-color-scheme: dark)').matches
}
updateTheme()
}
// Update theme in DOM and localStorage
const updateTheme = () => {
if (isDark.value) {
document.documentElement.classList.add('dark')
localStorage.setItem('theme', 'dark')
} else {
document.documentElement.classList.remove('dark')
localStorage.setItem('theme', 'light')
}
}
// Toggle theme
const toggleTheme = () => {
isDark.value = !isDark.value
updateTheme()
}
// Set specific theme
const setTheme = (theme: 'light' | 'dark') => {
isDark.value = theme === 'dark'
updateTheme()
}
// Watch for changes and update DOM
watch(isDark, updateTheme, { immediate: false })
// Listen for system theme changes
const watchSystemTheme = () => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
const handleChange = (e: MediaQueryListEvent) => {
// Only update if user hasn't set a manual preference
if (!localStorage.getItem('theme')) {
isDark.value = e.matches
}
}
mediaQuery.addEventListener('change', handleChange)
// Return cleanup function
return () => mediaQuery.removeEventListener('change', handleChange)
}
// Auto-initialize on mount
onMounted(() => {
initializeTheme()
watchSystemTheme()
})
return {
isDark,
toggleTheme,
setTheme,
initializeTheme
}
}
// Global theme utilities
export const themeUtils = {
// Get current theme
getCurrentTheme: (): 'light' | 'dark' => {
return document.documentElement.classList.contains('dark') ? 'dark' : 'light'
},
// Check if dark mode is preferred by system
getSystemTheme: (): 'light' | 'dark' => {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
},
// Force apply theme without composable
applyTheme: (theme: 'light' | 'dark') => {
if (theme === 'dark') {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
localStorage.setItem('theme', theme)
}
}

View File

@@ -607,6 +607,57 @@ export class RidesApi {
rides: Ride[]
}>('/api/rides/recent_relocations/', params)
}
/**
* Get filter options for ride filtering
*/
async getFilterOptions(): Promise<any> {
return this.client.get('/rides/api/filter-options/')
}
/**
* Search companies for manufacturer/designer autocomplete
*/
async searchCompanies(query: string, role?: 'manufacturer' | 'designer' | 'both'): Promise<any[]> {
const params: Record<string, string> = { q: query }
if (role) params.role = role
return this.client.get('/rides/api/search-companies/', params)
}
/**
* Search ride models for autocomplete
*/
async searchRideModels(query: string): Promise<any[]> {
return this.client.get('/rides/api/search-ride-models/', { q: query })
}
/**
* Get search suggestions
*/
async getSearchSuggestions(query: string): Promise<any[]> {
return this.client.get('/rides/api/search-suggestions/', { q: query })
}
/**
* Advanced ride filtering with comprehensive options
*/
async getFilteredRides(filters: Record<string, any>): Promise<ApiResponse<Ride>> {
const params: Record<string, string> = {}
Object.entries(filters).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') return
if (Array.isArray(value)) {
if (value.length > 0) {
params[key] = value.join(',')
}
} else {
params[key] = String(value)
}
})
return this.client.get<ApiResponse<Ride>>('/rides/api/', params)
}
}
/**

View File

@@ -0,0 +1,441 @@
/**
* Pinia store for ride filtering state management
*/
import { defineStore } from 'pinia'
import { ref, computed, watch } from 'vue'
import type {
RideFilters,
FilterOptions,
ActiveFilter,
FilterFormState,
Ride
} from '@/types'
import { useRideFiltering } from '@/composables/useRideFiltering'
export const useRideFilteringStore = defineStore('rideFiltering', () => {
// Core state
const filters = ref<RideFilters>({})
const filterOptions = ref<FilterOptions | null>(null)
const isLoading = ref(false)
const error = ref<string | null>(null)
// UI state
const formState = ref<FilterFormState>({
isOpen: false,
expandedSections: {
search: true,
basic: true,
manufacturer: false,
specifications: false,
dates: false,
location: false,
advanced: false
},
hasChanges: false,
appliedFilters: {},
pendingFilters: {}
})
// Results state
const rides = ref<Ride[]>([])
const totalCount = ref(0)
const currentPage = ref(1)
const hasNextPage = ref(false)
const hasPreviousPage = ref(false)
// Search state
const searchQuery = ref('')
const searchSuggestions = ref<any[]>([])
const showSuggestions = ref(false)
// Filter presets
const savedPresets = ref<any[]>([])
const currentPreset = ref<string | null>(null)
// Computed properties
const hasActiveFilters = computed(() => {
return Object.values(filters.value).some(value => {
if (Array.isArray(value)) return value.length > 0
return value !== undefined && value !== null && value !== ''
})
})
const activeFiltersCount = computed(() => {
let count = 0
Object.entries(filters.value).forEach(([key, value]) => {
if (key === 'page' || key === 'page_size' || key === 'ordering') return
if (Array.isArray(value) && value.length > 0) count++
else if (value !== undefined && value !== null && value !== '') count++
})
return count
})
const activeFiltersList = computed((): ActiveFilter[] => {
const list: ActiveFilter[] = []
Object.entries(filters.value).forEach(([key, value]) => {
if (!value || key === 'page' || key === 'page_size') return
if (Array.isArray(value) && value.length > 0) {
list.push({
key,
label: getFilterLabel(key),
value: value.join(', '),
displayValue: value.join(', '),
category: 'select'
})
} else if (value !== undefined && value !== null && value !== '') {
let displayValue = String(value)
let category: 'search' | 'select' | 'range' | 'date' = 'select'
if (key === 'search') {
category = 'search'
} else if (key.includes('_min') || key.includes('_max')) {
category = 'range'
displayValue = formatRangeValue(key, value)
} else if (key.includes('date')) {
category = 'date'
displayValue = formatDateValue(value)
}
list.push({
key,
label: getFilterLabel(key),
value,
displayValue,
category
})
}
})
return list
})
const isFilterFormOpen = computed(() => formState.value.isOpen)
const hasUnsavedChanges = computed(() => formState.value.hasChanges)
// Helper functions
const getFilterLabel = (key: string): string => {
const labels: Record<string, string> = {
search: 'Search',
category: 'Category',
status: 'Status',
manufacturer: 'Manufacturer',
designer: 'Designer',
park: 'Park',
country: 'Country',
region: 'Region',
height_min: 'Min Height',
height_max: 'Max Height',
speed_min: 'Min Speed',
speed_max: 'Max Speed',
length_min: 'Min Length',
length_max: 'Max Length',
capacity_min: 'Min Capacity',
capacity_max: 'Max Capacity',
duration_min: 'Min Duration',
duration_max: 'Max Duration',
inversions_min: 'Min Inversions',
inversions_max: 'Max Inversions',
opening_date_from: 'Opened After',
opening_date_to: 'Opened Before',
closing_date_from: 'Closed After',
closing_date_to: 'Closed Before',
ordering: 'Sort By'
}
return labels[key] || key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())
}
const formatRangeValue = (key: string, value: any): string => {
const numValue = Number(value)
if (key.includes('height')) return `${numValue}m`
if (key.includes('speed')) return `${numValue} km/h`
if (key.includes('length')) return `${numValue}m`
if (key.includes('duration')) return `${numValue}s`
if (key.includes('capacity')) return `${numValue} people`
return String(value)
}
const formatDateValue = (value: any): string => {
if (typeof value === 'string') {
const date = new Date(value)
return date.toLocaleDateString()
}
return String(value)
}
// Actions
const updateFilter = (key: keyof RideFilters, value: any) => {
filters.value = {
...filters.value,
[key]: value
}
formState.value.hasChanges = true
}
const updateMultipleFilters = (newFilters: Partial<RideFilters>) => {
filters.value = {
...filters.value,
...newFilters
}
formState.value.hasChanges = true
}
const clearFilter = (key: keyof RideFilters) => {
const newFilters = { ...filters.value }
delete newFilters[key]
filters.value = newFilters
formState.value.hasChanges = true
}
const clearAllFilters = () => {
const preserveKeys = ['page_size', 'ordering']
const newFilters: RideFilters = {}
preserveKeys.forEach(key => {
if (filters.value[key as keyof RideFilters]) {
newFilters[key as keyof RideFilters] = filters.value[key as keyof RideFilters]
}
})
filters.value = newFilters
currentPage.value = 1
formState.value.hasChanges = true
}
const applyFilters = () => {
formState.value.appliedFilters = { ...filters.value }
formState.value.hasChanges = false
currentPage.value = 1
}
const resetFilters = () => {
filters.value = { ...formState.value.appliedFilters }
formState.value.hasChanges = false
}
const toggleFilterForm = () => {
formState.value.isOpen = !formState.value.isOpen
}
const openFilterForm = () => {
formState.value.isOpen = true
}
const closeFilterForm = () => {
formState.value.isOpen = false
}
const toggleSection = (sectionId: string) => {
formState.value.expandedSections[sectionId] = !formState.value.expandedSections[sectionId]
}
const expandSection = (sectionId: string) => {
formState.value.expandedSections[sectionId] = true
}
const collapseSection = (sectionId: string) => {
formState.value.expandedSections[sectionId] = false
}
const expandAllSections = () => {
Object.keys(formState.value.expandedSections).forEach(key => {
formState.value.expandedSections[key] = true
})
}
const collapseAllSections = () => {
Object.keys(formState.value.expandedSections).forEach(key => {
formState.value.expandedSections[key] = false
})
}
const setSearchQuery = (query: string) => {
searchQuery.value = query
updateFilter('search', query)
}
const setSorting = (ordering: string) => {
updateFilter('ordering', ordering)
}
const setPageSize = (pageSize: number) => {
updateFilter('page_size', pageSize)
currentPage.value = 1
}
const goToPage = (page: number) => {
currentPage.value = page
updateFilter('page', page)
}
const nextPage = () => {
if (hasNextPage.value) {
goToPage(currentPage.value + 1)
}
}
const previousPage = () => {
if (hasPreviousPage.value) {
goToPage(currentPage.value - 1)
}
}
const setRides = (newRides: Ride[]) => {
rides.value = newRides
}
const appendRides = (newRides: Ride[]) => {
rides.value.push(...newRides)
}
const setTotalCount = (count: number) => {
totalCount.value = count
}
const setPagination = (pagination: {
hasNext: boolean
hasPrevious: boolean
totalCount: number
}) => {
hasNextPage.value = pagination.hasNext
hasPreviousPage.value = pagination.hasPrevious
totalCount.value = pagination.totalCount
}
const setLoading = (loading: boolean) => {
isLoading.value = loading
}
const setError = (errorMessage: string | null) => {
error.value = errorMessage
}
const setFilterOptions = (options: FilterOptions) => {
filterOptions.value = options
}
const setSearchSuggestions = (suggestions: any[]) => {
searchSuggestions.value = suggestions
}
const showSearchSuggestions = () => {
showSuggestions.value = true
}
const hideSearchSuggestions = () => {
showSuggestions.value = false
}
// Preset management
const savePreset = (name: string) => {
const preset = {
id: Date.now().toString(),
name,
filters: { ...filters.value },
createdAt: new Date().toISOString()
}
savedPresets.value.push(preset)
currentPreset.value = preset.id
// Save to localStorage
localStorage.setItem('ride-filter-presets', JSON.stringify(savedPresets.value))
}
const loadPreset = (presetId: string) => {
const preset = savedPresets.value.find(p => p.id === presetId)
if (preset) {
filters.value = { ...preset.filters }
currentPreset.value = presetId
formState.value.hasChanges = true
}
}
const deletePreset = (presetId: string) => {
savedPresets.value = savedPresets.value.filter(p => p.id !== presetId)
if (currentPreset.value === presetId) {
currentPreset.value = null
}
// Update localStorage
localStorage.setItem('ride-filter-presets', JSON.stringify(savedPresets.value))
}
const loadPresetsFromStorage = () => {
try {
const stored = localStorage.getItem('ride-filter-presets')
if (stored) {
savedPresets.value = JSON.parse(stored)
}
} catch (error) {
console.error('Failed to load filter presets:', error)
}
}
// Initialize presets from localStorage
loadPresetsFromStorage()
return {
// State
filters,
filterOptions,
isLoading,
error,
formState,
rides,
totalCount,
currentPage,
hasNextPage,
hasPreviousPage,
searchQuery,
searchSuggestions,
showSuggestions,
savedPresets,
currentPreset,
// Computed
hasActiveFilters,
activeFiltersCount,
activeFiltersList,
isFilterFormOpen,
hasUnsavedChanges,
// Actions
updateFilter,
updateMultipleFilters,
clearFilter,
clearAllFilters,
applyFilters,
resetFilters,
toggleFilterForm,
openFilterForm,
closeFilterForm,
toggleSection,
expandSection,
collapseSection,
expandAllSections,
collapseAllSections,
setSearchQuery,
setSorting,
setPageSize,
goToPage,
nextPage,
previousPage,
setRides,
appendRides,
setTotalCount,
setPagination,
setLoading,
setError,
setFilterOptions,
setSearchSuggestions,
showSearchSuggestions,
hideSearchSuggestions,
savePreset,
loadPreset,
deletePreset,
loadPresetsFromStorage
}
})

View File

@@ -0,0 +1,172 @@
/**
* Enhanced filter types for comprehensive ride filtering system
*/
// Enhanced ride filter types based on backend API design
export interface RideFilters {
// Search and basic filters
search?: string
category?: string | string[]
status?: string | string[]
park?: string | string[]
// Manufacturer and design filters
manufacturer?: string | string[]
designer?: string | string[]
manufacturer_role?: 'manufacturer' | 'designer' | 'both'
// Numeric range filters
height_min?: number
height_max?: number
speed_min?: number
speed_max?: number
length_min?: number
length_max?: number
capacity_min?: number
capacity_max?: number
duration_min?: number
duration_max?: number
inversions_min?: number
inversions_max?: number
// Date range filters
opening_date_from?: string
opening_date_to?: string
closing_date_from?: string
closing_date_to?: string
// Location filters
country?: string | string[]
region?: string | string[]
// Sorting
ordering?: string
// Pagination
page?: number
page_size?: number
}
// Filter options from backend
export interface FilterOptions {
categories: FilterChoice[]
statuses: FilterChoice[]
manufacturers: FilterChoice[]
designers: FilterChoice[]
countries: FilterChoice[]
regions: FilterChoice[]
parks: FilterChoice[]
ordering_options: OrderingChoice[]
numeric_ranges: NumericRanges
}
export interface FilterChoice {
value: string
label: string
count?: number
}
export interface OrderingChoice {
value: string
label: string
direction: 'asc' | 'desc'
}
export interface NumericRanges {
height: { min: number; max: number }
speed: { min: number; max: number }
length: { min: number; max: number }
capacity: { min: number; max: number }
duration: { min: number; max: number }
inversions: { min: number; max: number }
}
// Active filter display
export interface ActiveFilter {
key: string
label: string
value: string | number
displayValue: string
category: 'search' | 'select' | 'range' | 'date'
}
// Filter form state
export interface FilterFormState {
isOpen: boolean
expandedSections: Record<string, boolean>
hasChanges: boolean
appliedFilters: RideFilters
pendingFilters: RideFilters
}
// Search suggestions
export interface SearchSuggestion {
text: string
type: 'ride' | 'park' | 'manufacturer' | 'designer'
context?: string
}
// Company search for manufacturer/designer autocomplete
export interface CompanySearchResult {
id: number
name: string
role: 'manufacturer' | 'designer' | 'both'
ride_count: number
}
// Ride model search for autocomplete
export interface RideModelSearchResult {
manufacturer: string
model: string
ride_count: number
}
// Filter section configuration
export interface FilterSection {
id: string
title: string
icon?: string
defaultExpanded: boolean
order: number
}
// Range filter configuration
export interface RangeFilterConfig {
min: number
max: number
step: number
unit?: string
format?: (value: number) => string
}
// Date range filter configuration
export interface DateRangeConfig {
minDate?: string
maxDate?: string
placeholder?: string
}
// Filter validation
export interface FilterValidation {
isValid: boolean
errors: Record<string, string[]>
}
// Filter persistence
export interface FilterPreset {
id: string
name: string
filters: RideFilters
isDefault?: boolean
createdAt: string
}
// Filter analytics
export interface FilterStats {
totalResults: number
filterBreakdown: Record<string, number>
popularFilters: Array<{
filter: string
usage: number
}>
}

View File

@@ -53,7 +53,7 @@ export interface Ride {
updated: string
}
// Search and filter types
// Search and filter types - Basic legacy interface (keeping for compatibility)
export interface SearchFilters {
query?: string
category?: string
@@ -67,6 +67,26 @@ export interface SearchFilters {
maxSpeed?: number
}
// Import comprehensive filter types
export type {
RideFilters,
FilterOptions,
FilterChoice,
OrderingChoice,
NumericRanges,
ActiveFilter,
FilterFormState,
SearchSuggestion,
CompanySearchResult,
RideModelSearchResult,
FilterSection,
RangeFilterConfig,
DateRangeConfig,
FilterValidation,
FilterPreset,
FilterStats
} from './filters'
export interface ParkFilters {
query?: string
status?: string

View File

@@ -0,0 +1,375 @@
<template>
<div class="min-h-screen bg-gray-50 dark:bg-gray-900 transition-colors duration-200">
<!-- Header -->
<header
class="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 sticky top-0 z-40"
>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16">
<!-- Left side - Title and breadcrumb -->
<div class="flex items-center space-x-4">
<nav class="flex" aria-label="Breadcrumb">
<ol class="flex items-center space-x-2">
<li>
<router-link
to="/"
class="text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
>
<Icon name="home" class="w-5 h-5" />
</router-link>
</li>
<li>
<Icon
name="chevron-right"
class="w-4 h-4 text-gray-400 dark:text-gray-500"
/>
</li>
<li v-if="parkSlug">
<router-link
:to="`/parks/${parkSlug}`"
class="text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
>
{{ parkName || "Park" }}
</router-link>
</li>
<li v-if="parkSlug">
<Icon
name="chevron-right"
class="w-4 h-4 text-gray-400 dark:text-gray-500"
/>
</li>
<li>
<span class="text-gray-900 dark:text-gray-100 font-medium">
{{ pageTitle }}
</span>
</li>
</ol>
</nav>
</div>
<!-- Right side - Actions -->
<div class="flex items-center space-x-4">
<!-- Filter toggle for mobile -->
<button
@click="toggleMobileFilters"
class="md:hidden inline-flex items-center px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
<Icon name="filter" class="w-4 h-4 mr-2" />
Filters
<span
v-if="activeFilterCount > 0"
class="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200"
>
{{ activeFilterCount }}
</span>
</button>
<!-- Theme toggle -->
<button
@click="toggleTheme"
class="p-2 rounded-md text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
:title="isDark ? 'Switch to light mode' : 'Switch to dark mode'"
>
<Icon :name="isDark ? 'sun' : 'moon'" class="w-5 h-5" />
</button>
</div>
</div>
</div>
</header>
<!-- Main content -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div class="lg:grid lg:grid-cols-5 lg:gap-8">
<!-- Filter sidebar -->
<aside class="lg:col-span-1">
<!-- Mobile filter overlay -->
<div
v-if="showMobileFilters"
class="fixed inset-0 z-50 lg:hidden"
@click="closeMobileFilters"
>
<div class="fixed inset-0 bg-black bg-opacity-50" />
<div
class="fixed inset-y-0 left-0 w-80 bg-white dark:bg-gray-800 shadow-xl overflow-y-auto"
>
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
Filters
</h2>
<button
@click="closeMobileFilters"
class="p-2 rounded-md text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<Icon name="x" class="w-5 h-5" />
</button>
</div>
</div>
<div class="p-4">
<RideFilterSidebar :park-slug="parkSlug" />
</div>
</div>
</div>
<!-- Desktop filter sidebar -->
<div class="hidden lg:block">
<div
class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 sticky top-24"
>
<div class="p-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-6">
Filter Rides
</h2>
<RideFilterSidebar :park-slug="parkSlug" />
</div>
</div>
</div>
</aside>
<!-- Main content area -->
<main class="lg:col-span-4">
<!-- Page description -->
<div class="mb-6">
<h1
class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100 mb-2"
>
{{ pageTitle }}
</h1>
<p class="text-gray-600 dark:text-gray-400 text-lg">
{{ pageDescription }}
</p>
</div>
<!-- Quick stats -->
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-6">
<div
class="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700"
>
<div class="flex items-center">
<Icon name="map-pin" class="w-8 h-8 text-blue-500 mr-3" />
<div>
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">
Total Rides
</p>
<p class="text-2xl font-semibold text-gray-900 dark:text-gray-100">
{{ totalCount || 0 }}
</p>
</div>
</div>
</div>
<div
class="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700"
>
<div class="flex items-center">
<Icon name="filter" class="w-8 h-8 text-green-500 mr-3" />
<div>
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">
Active Filters
</p>
<p class="text-2xl font-semibold text-gray-900 dark:text-gray-100">
{{ activeFilterCount }}
</p>
</div>
</div>
</div>
<div
class="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700"
>
<div class="flex items-center">
<Icon name="star" class="w-8 h-8 text-yellow-500 mr-3" />
<div>
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">
Avg Rating
</p>
<p class="text-2xl font-semibold text-gray-900 dark:text-gray-100">
{{ averageRating ? averageRating.toFixed(1) : "--" }}
</p>
</div>
</div>
</div>
<div
class="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700"
>
<div class="flex items-center">
<Icon name="zap" class="w-8 h-8 text-purple-500 mr-3" />
<div>
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">
Operating
</p>
<p class="text-2xl font-semibold text-gray-900 dark:text-gray-100">
{{ operatingCount || 0 }}
</p>
</div>
</div>
</div>
</div>
<!-- Ride list -->
<div
class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700"
>
<div class="p-6">
<RideListDisplay :park-slug="parkSlug" />
</div>
</div>
</main>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, onMounted, watchEffect } from "vue";
import { useRoute } from "vue-router";
import { storeToRefs } from "pinia";
import { useRideFilteringStore } from "@/stores/rideFiltering";
import { useTheme } from "@/composables/useTheme";
import RideFilterSidebar from "@/components/filters/RideFilterSidebar.vue";
import RideListDisplay from "@/components/rides/RideListDisplay.vue";
import Icon from "@/components/ui/Icon.vue";
// Props
interface Props {
parkSlug?: string;
parkName?: string;
}
const props = withDefaults(defineProps<Props>(), {
parkSlug: undefined,
parkName: undefined,
});
// Composables
const route = useRoute();
const rideFilteringStore = useRideFilteringStore();
const { isDark, toggleTheme } = useTheme();
// Store state
const { totalCount, rides, filters } = storeToRefs(rideFilteringStore);
// Reactive state
const showMobileFilters = ref(false);
// Computed properties
const pageTitle = computed(() => {
if (props.parkSlug) {
return `${props.parkName || "Park"} Rides`;
}
return "All Rides";
});
const pageDescription = computed(() => {
if (props.parkSlug) {
return `Discover and explore all the exciting rides at ${
props.parkName || "this park"
}. Use the filters to find exactly what you're looking for.`;
}
return "Discover and explore amazing rides from theme parks around the world. Use the advanced filters to find exactly what you're looking for.";
});
const activeFilterCount = computed(() => {
const f = filters.value;
let count = 0;
if (f.search) count++;
if (f.categories.length > 0) count++;
if (f.manufacturers.length > 0) count++;
if (f.designers.length > 0) count++;
if (f.parks.length > 0) count++;
if (f.status.length > 0) count++;
if (f.heightRange[0] > 0 || f.heightRange[1] < 500) count++;
if (f.speedRange[0] > 0 || f.speedRange[1] < 200) count++;
if (f.capacityRange[0] > 0 || f.capacityRange[1] < 10000) count++;
if (f.durationRange[0] > 0 || f.durationRange[1] < 600) count++;
if (f.openingDateRange[0] || f.openingDateRange[1]) count++;
if (f.closingDateRange[0] || f.closingDateRange[1]) count++;
return count;
});
const averageRating = computed(() => {
if (!rides.value.length) return null;
const ridesWithRatings = rides.value.filter((ride) => ride.average_rating);
if (!ridesWithRatings.length) return null;
const sum = ridesWithRatings.reduce((acc, ride) => acc + (ride.average_rating || 0), 0);
return sum / ridesWithRatings.length;
});
const operatingCount = computed(() => {
return rides.value.filter((ride) => ride.status?.toLowerCase() === "operating").length;
});
// Methods
const toggleMobileFilters = () => {
showMobileFilters.value = !showMobileFilters.value;
};
const closeMobileFilters = () => {
showMobileFilters.value = false;
};
// Handle route changes
watchEffect(() => {
// Update park context when route changes
if (route.params.parkSlug !== props.parkSlug) {
// Reset filters when switching between park-specific and global views
rideFilteringStore.resetFilters();
}
});
// Initialize the page
onMounted(() => {
// Close mobile filters when clicking outside
document.addEventListener("click", (event) => {
const target = event.target as Element;
if (showMobileFilters.value && !target.closest(".mobile-filter-sidebar")) {
closeMobileFilters();
}
});
});
</script>
<style scoped>
/* Custom scrollbar for filter sidebar */
.mobile-filter-sidebar::-webkit-scrollbar {
width: 6px;
}
.mobile-filter-sidebar::-webkit-scrollbar-track {
background: theme("colors.gray.100");
}
.mobile-filter-sidebar::-webkit-scrollbar-thumb {
background: theme("colors.gray.400");
border-radius: 3px;
}
.mobile-filter-sidebar::-webkit-scrollbar-thumb:hover {
background: theme("colors.gray.500");
}
/* Dark mode scrollbar */
.dark .mobile-filter-sidebar::-webkit-scrollbar-track {
background: theme("colors.gray.700");
}
.dark .mobile-filter-sidebar::-webkit-scrollbar-thumb {
background: theme("colors.gray.500");
}
.dark .mobile-filter-sidebar::-webkit-scrollbar-thumb:hover {
background: theme("colors.gray.400");
}
/* Smooth transitions */
* {
transition-property: background-color, border-color, color, fill, stroke;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 200ms;
}
</style>