feat: Implement Entity Suggestion Manager and Modal components

- Added EntitySuggestionManager.vue to manage entity suggestions and authentication.
- Created EntitySuggestionModal.vue for displaying suggestions and adding new entities.
- Integrated AuthManager for user authentication within the suggestion modal.
- Enhanced signal handling in start-servers.sh for graceful shutdown of servers.
- Improved server startup script to ensure proper cleanup and responsiveness to termination signals.
- Added documentation for signal handling fixes and usage instructions.
This commit is contained in:
pacnpal
2025-08-25 10:46:54 -04:00
parent 937eee19e4
commit dcf890a55c
61 changed files with 10328 additions and 740 deletions

View File

@@ -0,0 +1,175 @@
<template>
<div class="space-y-4">
<div class="text-center">
<div class="mb-4">
<div
class="mx-auto w-12 h-12 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center"
>
<svg
class="h-6 w-6 text-blue-600 dark:text-blue-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
</div>
</div>
<h4 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">
Sign in to contribute
</h4>
<p class="text-gray-600 dark:text-gray-400 mb-6">
You need to be signed in to add "{{ searchTerm }}" to ThrillWiki's database. Join
our community of theme park enthusiasts!
</p>
</div>
<!-- Benefits List -->
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-4 space-y-3">
<h5 class="font-medium text-gray-900 dark:text-white flex items-center gap-2">
<svg
class="h-5 w-5 text-green-600 dark:text-green-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
What you can do:
</h5>
<ul class="space-y-2 text-sm text-gray-600 dark:text-gray-400">
<li class="flex items-start gap-2">
<svg
class="h-4 w-4 text-blue-500 mt-0.5 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
Add new parks, rides, and companies
</li>
<li class="flex items-start gap-2">
<svg
class="h-4 w-4 text-blue-500 mt-0.5 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
Edit and improve existing entries
</li>
<li class="flex items-start gap-2">
<svg
class="h-4 w-4 text-blue-500 mt-0.5 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
/>
</svg>
Save your favorite places
</li>
<li class="flex items-start gap-2">
<svg
class="h-4 w-4 text-blue-500 mt-0.5 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 8h2a2 2 0 012 2v6a2 2 0 01-2 2h-2v4l-4-4H9a2 2 0 01-2-2v-6a2 2 0 012-2h8z"
/>
</svg>
Share reviews and experiences
</li>
</ul>
</div>
<!-- Action Buttons -->
<div class="flex flex-col sm:flex-row gap-3">
<button
@click="handleLogin"
class="flex-1 bg-blue-600 text-white px-6 py-3 rounded-lg font-semibold hover:bg-blue-700 transition-colors focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none"
>
Sign In
</button>
<button
@click="handleSignup"
class="flex-1 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 px-6 py-3 rounded-lg font-semibold border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none"
>
Create Account
</button>
</div>
<!-- Alternative Options -->
<div class="text-center pt-4 border-t border-gray-200 dark:border-gray-700">
<p class="text-sm text-gray-500 dark:text-gray-400 mb-3">
Or continue exploring ThrillWiki
</p>
<button
@click="handleBrowseExisting"
class="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 text-sm font-medium transition-colors"
>
Browse existing entries
</button>
</div>
</div>
</template>
<script setup lang="ts">
interface Props {
searchTerm: string;
}
const props = defineProps<Props>();
const emit = defineEmits<{
login: [];
signup: [];
browse: [];
}>();
const handleLogin = () => {
emit("login");
};
const handleSignup = () => {
emit("signup");
};
const handleBrowseExisting = () => {
emit("browse");
};
</script>

View File

@@ -0,0 +1,194 @@
<template>
<div
class="group relative bg-gray-50 dark:bg-gray-700 rounded-lg p-4 hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors cursor-pointer border border-gray-200 dark:border-gray-600"
@click="handleSelect"
>
<!-- Entity Type Badge -->
<div class="flex items-start justify-between mb-3">
<div class="flex items-center gap-2">
<span :class="entityTypeBadgeClasses">
<component :is="entityIcon" class="h-4 w-4" />
{{ entityTypeLabel }}
</span>
<span
v-if="suggestion.confidence_score"
:class="confidenceClasses"
class="text-xs px-2 py-1 rounded-full font-medium"
>
{{ confidenceLabel }}
</span>
</div>
<!-- Select Arrow -->
<div class="opacity-0 group-hover:opacity-100 transition-opacity">
<svg
class="h-5 w-5 text-gray-400 dark:text-gray-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</div>
</div>
<!-- Entity Name -->
<h4 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">
{{ suggestion.name }}
</h4>
<!-- Entity Details -->
<div class="space-y-2">
<!-- Location for Parks -->
<div
v-if="suggestion.entity_type === 'park' && suggestion.location"
class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
{{ suggestion.location }}
</div>
<!-- Park for Rides -->
<div
v-if="suggestion.entity_type === 'ride' && suggestion.park_name"
class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
/>
</svg>
At {{ suggestion.park_name }}
</div>
<!-- Description -->
<p
v-if="suggestion.description"
class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2"
>
{{ suggestion.description }}
</p>
<!-- Match Reason -->
<div
v-if="suggestion.match_reason"
class="text-xs text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20 px-2 py-1 rounded"
>
{{ suggestion.match_reason }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import type { EntitySuggestion } from "../../services/api";
interface Props {
suggestion: EntitySuggestion;
}
const props = defineProps<Props>();
const emit = defineEmits<{
select: [suggestion: EntitySuggestion];
}>();
// Entity type configurations
const entityTypeConfig = {
park: {
label: "Park",
badgeClass: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
icon: "BuildingStorefrontIcon",
},
ride: {
label: "Ride",
badgeClass: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
icon: "SparklesIcon",
},
company: {
label: "Company",
badgeClass: "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200",
icon: "BuildingOfficeIcon",
},
};
// Computed properties
const entityTypeLabel = computed(
() => entityTypeConfig[props.suggestion.entity_type]?.label || "Entity"
);
const entityTypeBadgeClasses = computed(() => {
const baseClasses =
"inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium";
const typeClasses =
entityTypeConfig[props.suggestion.entity_type]?.badgeClass ||
"bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200";
return `${baseClasses} ${typeClasses}`;
});
const confidenceLabel = computed(() => {
const score = props.suggestion.confidence_score;
if (score >= 0.8) return "High Match";
if (score >= 0.6) return "Good Match";
if (score >= 0.4) return "Possible Match";
return "Low Match";
});
const confidenceClasses = computed(() => {
const score = props.suggestion.confidence_score;
if (score >= 0.8)
return "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300";
if (score >= 0.6)
return "bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300";
if (score >= 0.4)
return "bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300";
return "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300";
});
// Simple icon components
const entityIcon = computed(() => {
const type = props.suggestion.entity_type;
// Return appropriate icon component name or a default SVG
if (type === "park") return "BuildingStorefrontIcon";
if (type === "ride") return "SparklesIcon";
if (type === "company") return "BuildingOfficeIcon";
return "QuestionMarkCircleIcon";
});
// Event handlers
const handleSelect = () => {
emit("select", props.suggestion);
};
</script>
<style scoped>
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,235 @@
<template>
<EntitySuggestionModal
:show="showModal"
:search-term="searchTerm"
:suggestions="suggestions"
:is-authenticated="isAuthenticated"
@close="handleClose"
@select-suggestion="handleSuggestionSelect"
@add-entity="handleAddEntity"
@login="handleLogin"
@signup="handleSignup"
/>
<!-- Authentication Manager -->
<AuthManager
:show="showAuthModal"
:initial-mode="authMode"
@close="handleAuthClose"
@success="handleAuthSuccess"
/>
</template>
<script setup lang="ts">
import { ref, computed, watch, readonly } from "vue";
import { useRouter } from "vue-router";
import { useAuth } from "../../composables/useAuth";
import { ThrillWikiApi, type EntitySuggestion } from "../../services/api";
import EntitySuggestionModal from "./EntitySuggestionModal.vue";
import AuthManager from "../auth/AuthManager.vue";
interface Props {
searchTerm: string;
show?: boolean;
entityTypes?: string[];
parkContext?: string;
maxSuggestions?: number;
}
const props = withDefaults(defineProps<Props>(), {
show: false,
entityTypes: () => ["park", "ride", "company"],
maxSuggestions: 5,
});
const emit = defineEmits<{
close: [];
entitySelected: [entity: EntitySuggestion];
entityAdded: [entityType: string, name: string];
error: [message: string];
}>();
// Dependencies
const router = useRouter();
const { user, isAuthenticated, login, signup } = useAuth();
const api = new ThrillWikiApi();
// Reactive state
const showModal = ref(props.show);
const suggestions = ref<EntitySuggestion[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
// Authentication modal state
const showAuthModal = ref(false);
const authMode = ref<'login' | 'signup'>('login');
// Computed properties
const hasValidSearchTerm = computed(() => {
return props.searchTerm && props.searchTerm.trim().length > 0;
});
// Watch for prop changes
watch(
() => props.show,
(newShow) => {
showModal.value = newShow;
if (newShow && hasValidSearchTerm.value) {
performFuzzySearch();
}
},
{ immediate: true }
);
watch(
() => props.searchTerm,
(newTerm) => {
if (showModal.value && newTerm && newTerm.trim().length > 0) {
performFuzzySearch();
}
}
);
// Methods
const performFuzzySearch = async () => {
if (!hasValidSearchTerm.value) {
suggestions.value = [];
return;
}
loading.value = true;
error.value = null;
try {
const response = await api.entitySearch.fuzzySearch({
query: props.searchTerm.trim(),
entityTypes: props.entityTypes,
parkContext: props.parkContext,
maxResults: props.maxSuggestions,
minConfidence: 0.3,
});
suggestions.value = response.suggestions || [];
} catch (err) {
console.error("Fuzzy search failed:", err);
error.value = "Failed to search for similar entities. Please try again.";
suggestions.value = [];
emit("error", error.value);
} finally {
loading.value = false;
}
};
const handleClose = () => {
showModal.value = false;
emit("close");
};
const handleSuggestionSelect = (suggestion: EntitySuggestion) => {
emit("entitySelected", suggestion);
handleClose();
// Navigate to the selected entity
navigateToEntity(suggestion);
};
const handleAddEntity = async (entityType: string, name: string) => {
try {
// Emit event for parent to handle
emit("entityAdded", entityType, name);
// For now, just close the modal
// In a real implementation, this might navigate to an add entity form
handleClose();
// You could also show a success message here
console.log(`Entity creation initiated: ${entityType} - ${name}`);
} catch (err) {
console.error("Failed to initiate entity creation:", err);
error.value = "Failed to initiate entity creation. Please try again.";
emit("error", error.value);
}
};
const handleLogin = () => {
authMode.value = 'login';
showAuthModal.value = true;
};
const handleSignup = () => {
authMode.value = 'signup';
showAuthModal.value = true;
};
// Authentication modal handlers
const handleAuthClose = () => {
showAuthModal.value = false;
};
const handleAuthSuccess = () => {
showAuthModal.value = false;
// Optionally refresh suggestions now that user is authenticated
if (hasValidSearchTerm.value && showModal.value) {
performFuzzySearch();
}
};
const navigateToEntity = (entity: EntitySuggestion) => {
try {
let route = "";
switch (entity.entity_type) {
case "park":
route = `/parks/${entity.slug}`;
break;
case "ride":
if (entity.park_slug) {
route = `/parks/${entity.park_slug}/rides/${entity.slug}`;
} else {
route = `/rides/${entity.slug}`;
}
break;
case "company":
route = `/companies/${entity.slug}`;
break;
default:
console.warn(`Unknown entity type: ${entity.entity_type}`);
return;
}
router.push(route);
} catch (err) {
console.error("Failed to navigate to entity:", err);
error.value = "Failed to navigate to the selected entity.";
emit("error", error.value);
}
};
// Public methods for external control
const show = () => {
showModal.value = true;
if (hasValidSearchTerm.value) {
performFuzzySearch();
}
};
const hide = () => {
showModal.value = false;
};
const refresh = () => {
if (showModal.value && hasValidSearchTerm.value) {
performFuzzySearch();
}
};
// Expose methods for parent components
defineExpose({
show,
hide,
refresh,
suggestions: readonly(suggestions),
loading: readonly(loading),
error: readonly(error),
});
</script>

View File

@@ -0,0 +1,226 @@
<template>
<Teleport to="body">
<Transition
enter-active-class="duration-300 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="duration-200 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="show"
class="fixed inset-0 z-50 overflow-y-auto"
@click="closeOnBackdrop && handleBackdropClick"
>
<!-- Backdrop -->
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm"></div>
<!-- Modal Container -->
<div class="flex min-h-full items-center justify-center p-4">
<Transition
enter-active-class="duration-300 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="duration-200 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="show"
class="relative w-full max-w-2xl transform overflow-hidden rounded-2xl bg-white dark:bg-gray-800 shadow-2xl transition-all"
@click.stop
>
<!-- Header -->
<div
class="flex items-center justify-between p-6 pb-4 border-b border-gray-200 dark:border-gray-700"
>
<div>
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
Entity Not Found
</h2>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
We couldn't find "{{ searchTerm }}" but here are some suggestions
</p>
</div>
<button
@click="$emit('close')"
class="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300 transition-colors"
>
<svg
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- Content -->
<div class="px-6 pb-6">
<!-- Suggestions Section -->
<div v-if="suggestions.length > 0" class="mb-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Did you mean one of these?
</h3>
<div class="space-y-3">
<EntitySuggestionCard
v-for="suggestion in suggestions"
:key="`${suggestion.entity_type}-${suggestion.slug}`"
:suggestion="suggestion"
@select="handleSuggestionSelect"
/>
</div>
</div>
<!-- No Suggestions / Add New Section -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Can't find what you're looking for?
</h3>
<!-- Authenticated User - Add Entity -->
<div v-if="isAuthenticated" class="space-y-4">
<p class="text-gray-600 dark:text-gray-400">
You can help improve ThrillWiki by adding this entity to our
database.
</p>
<div class="flex gap-3">
<button
@click="handleAddEntity('park')"
:disabled="loading"
class="flex-1 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Add as Park
</button>
<button
@click="handleAddEntity('ride')"
:disabled="loading"
class="flex-1 bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Add as Ride
</button>
<button
@click="handleAddEntity('company')"
:disabled="loading"
class="flex-1 bg-purple-600 text-white px-4 py-2 rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Add as Company
</button>
</div>
</div>
<!-- Unauthenticated User - Auth Prompt -->
<AuthPrompt
v-else
:search-term="searchTerm"
@login="handleLogin"
@signup="handleSignup"
/>
</div>
</div>
<!-- Loading Overlay -->
<div
v-if="loading"
class="absolute inset-0 bg-white/80 dark:bg-gray-800/80 flex items-center justify-center rounded-2xl"
>
<div class="flex items-center gap-3">
<div
class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"
></div>
<span class="text-gray-700 dark:text-gray-300">{{
loadingMessage
}}</span>
</div>
</div>
</div>
</Transition>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, toRefs, onUnmounted, watch } from "vue";
import type { EntitySuggestion } from "../../services/api";
import EntitySuggestionCard from "./EntitySuggestionCard.vue";
import AuthPrompt from "./AuthPrompt.vue";
interface Props {
show: boolean;
searchTerm: string;
suggestions: EntitySuggestion[];
isAuthenticated: boolean;
closeOnBackdrop?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
closeOnBackdrop: true,
});
const emit = defineEmits<{
close: [];
selectSuggestion: [suggestion: EntitySuggestion];
addEntity: [entityType: string, name: string];
login: [];
signup: [];
}>();
// Loading state
const loading = ref(false);
const loadingMessage = ref("");
const handleBackdropClick = (event: MouseEvent) => {
if (props.closeOnBackdrop && event.target === event.currentTarget) {
emit("close");
}
};
const handleSuggestionSelect = (suggestion: EntitySuggestion) => {
emit("selectSuggestion", suggestion);
};
const handleAddEntity = async (entityType: string) => {
loading.value = true;
loadingMessage.value = `Adding ${entityType}...`;
try {
emit("addEntity", entityType, props.searchTerm);
} finally {
loading.value = false;
loadingMessage.value = "";
}
};
const handleLogin = () => {
emit("login");
};
const handleSignup = () => {
emit("signup");
};
// Prevent body scroll when modal is open
const { show } = toRefs(props);
watch(show, (isShown) => {
if (isShown) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
});
// Clean up on unmount
onUnmounted(() => {
document.body.style.overflow = "";
});
</script>

View File

@@ -0,0 +1,7 @@
// Entity suggestion components
export { default as EntitySuggestionModal } from './EntitySuggestionModal.vue'
export { default as EntitySuggestionCard } from './EntitySuggestionCard.vue'
export { default as AuthPrompt } from './AuthPrompt.vue'
// Main integration component
export { default as EntitySuggestionManager } from './EntitySuggestionManager.vue'

View File

@@ -22,144 +22,160 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed } from "vue";
interface CardProps {
variant?: 'default' | 'outline' | 'ghost' | 'elevated'
size?: 'sm' | 'md' | 'lg'
title?: string
padding?: 'none' | 'sm' | 'md' | 'lg'
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl'
shadow?: 'none' | 'sm' | 'md' | 'lg' | 'xl'
hover?: boolean
interactive?: boolean
variant?: "default" | "outline" | "ghost" | "elevated" | "featured";
size?: "sm" | "md" | "lg" | "xl";
title?: string;
padding?: "none" | "sm" | "md" | "lg" | "xl";
rounded?: "none" | "sm" | "md" | "lg" | "xl" | "2xl";
shadow?: "none" | "sm" | "md" | "lg" | "xl" | "2xl";
hover?: boolean;
interactive?: boolean;
bordered?: boolean;
}
const props = withDefaults(defineProps<CardProps>(), {
variant: 'default',
size: 'md',
padding: 'md',
rounded: 'lg',
shadow: 'sm',
variant: "default",
size: "md",
padding: "md",
rounded: "lg",
shadow: "sm",
hover: false,
interactive: false,
})
bordered: true,
});
// Base card classes
const baseClasses =
'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 transition-all duration-200'
// Base card classes - Consistent background for both light and dark modes
const baseClasses = "bg-white dark:bg-gray-800 transition-all duration-200 ease-in-out";
// Variant classes
const variantClasses = computed(() => {
const variants = {
default: 'border',
outline: 'border-2',
ghost: 'border-0 bg-transparent dark:bg-transparent',
elevated: 'border-0',
}
return variants[props.variant]
})
default: props.bordered ? "border border-gray-200 dark:border-gray-700" : "border-0",
outline: "border-2 border-gray-300 dark:border-gray-600",
ghost: "border-0 bg-transparent dark:bg-transparent",
elevated: "border-0",
featured:
"border border-blue-200 dark:border-blue-800 bg-gradient-to-br from-blue-50 to-white dark:from-blue-950 dark:to-gray-800",
};
return variants[props.variant];
});
// Shadow classes
const shadowClasses = computed(() => {
if (props.variant === 'ghost') return ''
if (props.variant === "ghost") return "";
const shadows = {
none: '',
sm: 'shadow-sm',
md: 'shadow-md',
lg: 'shadow-lg',
xl: 'shadow-xl',
}
return shadows[props.shadow]
})
none: "",
sm: "shadow-sm hover:shadow-md",
md: "shadow-md hover:shadow-lg",
lg: "shadow-lg hover:shadow-xl",
xl: "shadow-xl hover:shadow-2xl",
"2xl": "shadow-2xl hover:shadow-2xl",
};
return shadows[props.shadow];
});
// Rounded classes
const roundedClasses = computed(() => {
const rounded = {
none: 'rounded-none',
sm: 'rounded-sm',
md: 'rounded-md',
lg: 'rounded-lg',
xl: 'rounded-xl',
}
return rounded[props.rounded]
})
none: "rounded-none",
sm: "rounded-sm",
md: "rounded-md",
lg: "rounded-lg",
xl: "rounded-xl",
"2xl": "rounded-2xl",
};
return rounded[props.rounded];
});
// Hover classes
const hoverClasses = computed(() => {
if (!props.hover && !props.interactive) return ''
if (!props.hover && !props.interactive) return "";
let classes = ''
if (props.hover) {
classes += ' hover:shadow-md'
if (props.variant !== 'ghost') {
classes += ' hover:border-gray-300 dark:hover:border-gray-600'
let classes = "";
if (props.hover || props.interactive) {
if (props.variant !== "ghost") {
classes += " hover:border-gray-300 dark:hover:border-gray-600";
}
if (props.variant === "featured") {
classes +=
" hover:from-blue-100 hover:to-blue-50 dark:hover:from-blue-900 dark:hover:to-gray-700";
}
}
if (props.interactive) {
classes += ' cursor-pointer hover:scale-[1.02] active:scale-[0.98]'
classes +=
" cursor-pointer hover:scale-[1.01] active:scale-[0.99] hover:-translate-y-0.5";
}
return classes
})
return classes;
});
// Padding classes for different sections
const paddingClasses = computed(() => {
const paddings = {
none: '',
sm: 'p-3',
md: 'p-4',
lg: 'p-6',
}
return paddings[props.padding]
})
none: "",
sm: "p-3",
md: "p-4",
lg: "p-6",
xl: "p-8",
};
return paddings[props.padding];
});
const headerPadding = computed(() => {
if (props.padding === 'none') return ''
if (props.padding === "none") return "";
const paddings = {
sm: 'px-3 pt-3',
md: 'px-4 pt-4',
lg: 'px-6 pt-6',
}
return paddings[props.padding]
})
sm: "px-3 pt-3",
md: "px-4 pt-4",
lg: "px-6 pt-6",
xl: "px-8 pt-8",
};
return paddings[props.padding];
});
const contentPadding = computed(() => {
if (props.padding === 'none') return ''
if (props.padding === "none") return "";
const hasHeader = props.title || props.$slots?.header
const hasFooter = props.$slots?.footer
const hasHeader = props.title || props.$slots?.header;
const hasFooter = props.$slots?.footer;
let classes = ''
let classes = "";
if (props.padding === 'sm') {
classes = 'px-3'
if (!hasHeader) classes += ' pt-3'
if (!hasFooter) classes += ' pb-3'
} else if (props.padding === 'md') {
classes = 'px-4'
if (!hasHeader) classes += ' pt-4'
if (!hasFooter) classes += ' pb-4'
} else if (props.padding === 'lg') {
classes = 'px-6'
if (!hasHeader) classes += ' pt-6'
if (!hasFooter) classes += ' pb-6'
if (props.padding === "sm") {
classes = "px-3";
if (!hasHeader) classes += " pt-3";
if (!hasFooter) classes += " pb-3";
} else if (props.padding === "md") {
classes = "px-4";
if (!hasHeader) classes += " pt-4";
if (!hasFooter) classes += " pb-4";
} else if (props.padding === "lg") {
classes = "px-6";
if (!hasHeader) classes += " pt-6";
if (!hasFooter) classes += " pb-6";
} else if (props.padding === "xl") {
classes = "px-8";
if (!hasHeader) classes += " pt-8";
if (!hasFooter) classes += " pb-8";
}
return classes
})
return classes;
});
const footerPadding = computed(() => {
if (props.padding === 'none') return ''
if (props.padding === "none") return "";
const paddings = {
sm: 'px-3 pb-3',
md: 'px-4 pb-4',
lg: 'px-6 pb-6',
}
return paddings[props.padding]
})
sm: "px-3 pb-3",
md: "px-4 pb-4",
lg: "px-6 pb-6",
xl: "px-8 pb-8",
};
return paddings[props.padding];
});
// Combined classes
const cardClasses = computed(() => {
@@ -169,38 +185,39 @@ const cardClasses = computed(() => {
shadowClasses.value,
roundedClasses.value,
hoverClasses.value,
props.padding === 'none' ? '' : '',
props.padding === "none" ? "" : "",
]
.filter(Boolean)
.join(' ')
})
.join(" ");
});
const headerClasses = computed(() => {
let classes = headerPadding.value
if (props.padding !== 'none') {
classes += ' border-b border-gray-200 dark:border-gray-700'
let classes = headerPadding.value;
if (props.padding !== "none") {
classes += " border-b border-gray-200 dark:border-gray-700";
}
return classes
})
return classes;
});
const contentClasses = computed(() => {
return contentPadding.value
})
return contentPadding.value;
});
const footerClasses = computed(() => {
let classes = footerPadding.value
if (props.padding !== 'none') {
classes += ' border-t border-gray-200 dark:border-gray-700'
let classes = footerPadding.value;
if (props.padding !== "none") {
classes += " border-t border-gray-200 dark:border-gray-700";
}
return classes
})
return classes;
});
const titleClasses = computed(() => {
const sizes = {
sm: 'text-lg font-semibold',
md: 'text-xl font-semibold',
lg: 'text-2xl font-semibold',
}
return `${sizes[props.size]} text-gray-900 dark:text-gray-100`
})
sm: "text-lg font-semibold leading-6",
md: "text-xl font-semibold leading-7",
lg: "text-2xl font-semibold leading-8",
xl: "text-3xl font-bold leading-9",
};
return `${sizes[props.size]} text-gray-900 dark:text-gray-100 tracking-tight`;
});
</script>

View File

@@ -12,8 +12,58 @@ import type {
PasswordResetRequest,
PasswordChangeRequest,
SocialAuthProvider,
TrendingResponse,
NewContentResponse,
TrendingItem,
NewContentItem,
RideRanking,
RideRankingDetail,
HeadToHeadComparison,
RankingSnapshot,
RankingStatistics,
} from '@/types'
// Entity fuzzy matching types
export interface EntitySuggestion {
name: string
slug: string
entity_type: 'park' | 'ride' | 'company'
match_type: 'exact' | 'fuzzy' | 'partial'
confidence_score: number
additional_info?: {
park_name?: string
opened_date?: string
location?: string
}
}
export interface FuzzyMatchResult {
query: string
exact_matches: EntitySuggestion[]
fuzzy_matches: EntitySuggestion[]
suggestions: EntitySuggestion[]
total_matches: number
search_time_ms: number
authentication_required: boolean
can_add_entity: boolean
}
export interface EntityNotFoundResponse {
entity_name: string
entity_type?: string
suggestions: EntitySuggestion[]
can_add_entity: boolean
authentication_required: boolean
add_entity_url?: string
login_url: string
signup_url: string
}
export interface QuickSuggestionResponse {
suggestions: EntitySuggestion[]
total_available: number
}
// History-specific types
export interface HistoryEvent {
id: string
@@ -311,7 +361,7 @@ export class ParksApi {
count: number
days: number
parks: Park[]
}>('/api/v1/parks/recent_changes/', params)
}>('/api/parks/recent_changes/', params)
}
/**
@@ -328,7 +378,7 @@ export class ParksApi {
count: number
days: number
parks: Park[]
}>('/api/v1/parks/recent_openings/', params)
}>('/api/parks/recent_openings/', params)
}
/**
@@ -345,7 +395,7 @@ export class ParksApi {
count: number
days: number
parks: Park[]
}>('/api/v1/parks/recent_closures/', params)
}>('/api/parks/recent_closures/', params)
}
/**
@@ -362,12 +412,12 @@ export class ParksApi {
count: number
days: number
parks: Park[]
}>('/api/v1/parks/recent_name_changes/', params)
}>('/api/parks/recent_name_changes/', params)
}
}
/**
* Rides API service
* Rides API service with context-aware endpoint selection
*/
export class RidesApi {
private client: ApiClient
@@ -377,7 +427,22 @@ export class RidesApi {
}
/**
* Get all rides with pagination
* Choose appropriate endpoint based on context
* @param parkSlug - If provided, use nested endpoint for park context
* @param operation - The operation being performed
* @returns The appropriate base URL
*/
private getEndpointUrl(parkSlug?: string, operation: 'list' | 'detail' | 'search' = 'list'): string {
if (parkSlug && (operation === 'list' || operation === 'detail')) {
// Use nested endpoint for park-contextual operations
return `/api/parks/${parkSlug}/rides`
}
// Use global endpoint for cross-park operations or when no park context
return '/api/rides'
}
/**
* Get all rides with pagination - uses global endpoint for cross-park view
*/
async getRides(params?: {
page?: number
@@ -390,28 +455,52 @@ export class RidesApi {
if (params?.search) queryParams.search = params.search
if (params?.ordering) queryParams.ordering = params.ordering
return this.client.get<ApiResponse<Ride>>('/api/rides/', queryParams)
return this.client.get<ApiResponse<Ride>>(`${this.getEndpointUrl()}/`, queryParams)
}
/**
* Get a single ride by park and ride slug
* Get rides for a specific park - uses nested endpoint for park context
*/
async getRidesByPark(parkSlug: string, params?: {
page?: number
search?: string
ordering?: string
}): Promise<ApiResponse<Ride>> {
const queryParams: Record<string, string> = {}
if (params?.page) queryParams.page = params.page.toString()
if (params?.search) queryParams.search = params.search
if (params?.ordering) queryParams.ordering = params.ordering
return this.client.get<ApiResponse<Ride>>(`${this.getEndpointUrl(parkSlug, 'list')}/`, queryParams)
}
/**
* Get a single ride by park and ride slug - uses nested endpoint for park context
*/
async getRide(parkSlug: string, rideSlug: string): Promise<Ride> {
return this.client.get<Ride>(`/api/rides/${parkSlug}/${rideSlug}/`)
return this.client.get<Ride>(`${this.getEndpointUrl(parkSlug, 'detail')}/${rideSlug}/`)
}
/**
* Search rides
* Get a ride by global ID (fallback method) - uses global endpoint
*/
async getRideById(rideId: string): Promise<Ride> {
return this.client.get<Ride>(`${this.getEndpointUrl()}/${rideId}/`)
}
/**
* Search rides globally - uses global endpoint for cross-park search
*/
async searchRides(query: string): Promise<SearchResponse<Ride>> {
return this.client.get<SearchResponse<Ride>>('/api/rides/search/', { q: query })
return this.client.get<SearchResponse<Ride>>(`${this.getEndpointUrl()}/search/`, { q: query })
}
/**
* Get rides by park
* Search rides within a specific park - uses nested endpoint for park context
*/
async getRidesByPark(parkSlug: string): Promise<SearchResponse<Ride>> {
return this.client.get<SearchResponse<Ride>>(`/api/rides/by-park/${parkSlug}/`)
async searchRidesInPark(parkSlug: string, query: string): Promise<SearchResponse<Ride>> {
return this.client.get<SearchResponse<Ride>>(`${this.getEndpointUrl(parkSlug, 'search')}/search/`, { q: query })
}
/**
@@ -435,7 +524,7 @@ export class RidesApi {
}
/**
* Get recently changed rides
* Get recently changed rides - uses global endpoint for cross-park analysis
*/
async getRecentChanges(days?: number): Promise<{
count: number
@@ -448,11 +537,11 @@ export class RidesApi {
count: number
days: number
rides: Ride[]
}>('/api/v1/rides/recent_changes/', params)
}>('/api/rides/recent_changes/', params)
}
/**
* Get recently opened rides
* Get recently opened rides - uses global endpoint for cross-park analysis
*/
async getRecentOpenings(days?: number): Promise<{
count: number
@@ -465,11 +554,11 @@ export class RidesApi {
count: number
days: number
rides: Ride[]
}>('/api/v1/rides/recent_openings/', params)
}>('/api/rides/recent_openings/', params)
}
/**
* Get recently closed rides
* Get recently closed rides - uses global endpoint for cross-park analysis
*/
async getRecentClosures(days?: number): Promise<{
count: number
@@ -482,11 +571,11 @@ export class RidesApi {
count: number
days: number
rides: Ride[]
}>('/api/v1/rides/recent_closures/', params)
}>('/api/rides/recent_closures/', params)
}
/**
* Get rides with recent name changes
* Get rides with recent name changes - uses global endpoint for cross-park analysis
*/
async getRecentNameChanges(days?: number): Promise<{
count: number
@@ -499,11 +588,11 @@ export class RidesApi {
count: number
days: number
rides: Ride[]
}>('/api/v1/rides/recent_name_changes/', params)
}>('/api/rides/recent_name_changes/', params)
}
/**
* Get rides that have been relocated recently
* Get rides that have been relocated recently - uses global endpoint for cross-park analysis
*/
async getRecentRelocations(days?: number): Promise<{
count: number
@@ -516,7 +605,7 @@ export class RidesApi {
count: number
days: number
rides: Ride[]
}>('/api/v1/rides/recent_relocations/', params)
}>('/api/rides/recent_relocations/', params)
}
}
@@ -530,11 +619,25 @@ export class AuthApi {
this.client = client
}
/**
* Set authentication token
*/
setAuthToken(token: string | null): void {
this.client.setAuthToken(token)
}
/**
* Get authentication token
*/
getAuthToken(): string | null {
return this.client.getAuthToken()
}
/**
* Login with username/email and password
*/
async login(credentials: LoginCredentials): Promise<AuthResponse> {
const response = await this.client.post<AuthResponse>('/api/accounts/login/', credentials)
const response = await this.client.post<AuthResponse>('/api/auth/login/', credentials)
if (response.token) {
this.client.setAuthToken(response.token)
}
@@ -620,7 +723,7 @@ export class HistoryApi {
if (params?.model_type) queryParams.model_type = params.model_type
if (params?.significance) queryParams.significance = params.significance
return this.client.get<UnifiedHistoryTimeline>('/api/v1/history/timeline/', queryParams)
return this.client.get<UnifiedHistoryTimeline>('/api/history/timeline/', queryParams)
}
/**
@@ -635,14 +738,14 @@ export class HistoryApi {
if (params?.start_date) queryParams.start_date = params.start_date
if (params?.end_date) queryParams.end_date = params.end_date
return this.client.get<HistoryEvent[]>(`/api/v1/parks/${parkSlug}/history/`, queryParams)
return this.client.get<HistoryEvent[]>(`/api/parks/${parkSlug}/history/`, queryParams)
}
/**
* Get complete park history with current state and summary
*/
async getParkHistoryDetail(parkSlug: string): Promise<ParkHistoryResponse> {
return this.client.get<ParkHistoryResponse>(`/api/v1/parks/${parkSlug}/history/detail/`)
return this.client.get<ParkHistoryResponse>(`/api/parks/${parkSlug}/history/detail/`)
}
/**
@@ -662,7 +765,7 @@ export class HistoryApi {
if (params?.end_date) queryParams.end_date = params.end_date
return this.client.get<HistoryEvent[]>(
`/api/v1/parks/${parkSlug}/rides/${rideSlug}/history/`,
`/api/parks/${parkSlug}/rides/${rideSlug}/history/`,
queryParams
)
}
@@ -672,7 +775,7 @@ export class HistoryApi {
*/
async getRideHistoryDetail(parkSlug: string, rideSlug: string): Promise<RideHistoryResponse> {
return this.client.get<RideHistoryResponse>(
`/api/v1/parks/${parkSlug}/rides/${rideSlug}/history/detail/`
`/api/parks/${parkSlug}/rides/${rideSlug}/history/detail/`
)
}
@@ -726,6 +829,300 @@ export class HistoryApi {
}
}
/**
* Trending API service for trending content and new content
*/
export class TrendingApi {
private client: ApiClient
constructor(client: ApiClient = new ApiClient()) {
this.client = client
}
/**
* Get trending content (rides, parks, reviews)
*/
async getTrendingContent(): Promise<TrendingResponse> {
return this.client.get<TrendingResponse>('/api/trending/content/')
}
/**
* Get new content (recently added, newly opened, upcoming)
*/
async getNewContent(): Promise<NewContentResponse> {
return this.client.get<NewContentResponse>('/api/trending/new/')
}
/**
* Get trending rides specifically
*/
async getTrendingRides(): Promise<TrendingItem[]> {
const response = await this.getTrendingContent()
return response.trending_rides
}
/**
* Get trending parks specifically
*/
async getTrendingParks(): Promise<TrendingItem[]> {
const response = await this.getTrendingContent()
return response.trending_parks
}
/**
* Get latest reviews specifically
*/
async getLatestReviews(): Promise<TrendingItem[]> {
const response = await this.getTrendingContent()
return response.latest_reviews
}
/**
* Get recently added content specifically
*/
async getRecentlyAdded(): Promise<NewContentItem[]> {
const response = await this.getNewContent()
return response.recently_added
}
/**
* Get newly opened content specifically
*/
async getNewlyOpened(): Promise<NewContentItem[]> {
const response = await this.getNewContent()
return response.newly_opened
}
/**
* Get upcoming content specifically
*/
async getUpcoming(): Promise<NewContentItem[]> {
const response = await this.getNewContent()
return response.upcoming
}
}
/**
* Ride Rankings API service
*/
export class RankingsApi {
private client: ApiClient
constructor(client: ApiClient = new ApiClient()) {
this.client = client
}
/**
* Get paginated list of ride rankings
*/
async getRankings(params?: {
page?: number
page_size?: number
category?: 'RC' | 'DR' | 'FR' | 'WR' | 'TR' | 'OT'
min_riders?: number
park?: string
ordering?: 'rank' | '-rank' | 'winning_percentage' | '-winning_percentage'
}): Promise<ApiResponse<RideRanking>> {
const queryParams: Record<string, string> = {}
if (params?.page) queryParams.page = params.page.toString()
if (params?.page_size) queryParams.page_size = params.page_size.toString()
if (params?.category) queryParams.category = params.category
if (params?.min_riders) queryParams.min_riders = params.min_riders.toString()
if (params?.park) queryParams.park = params.park
if (params?.ordering) queryParams.ordering = params.ordering
return this.client.get<ApiResponse<RideRanking>>('/api/rankings/', queryParams)
}
/**
* Get detailed ranking information for a specific ride
*/
async getRankingDetail(rideSlug: string): Promise<RideRankingDetail> {
return this.client.get<RideRankingDetail>(`/api/rankings/${rideSlug}/`)
}
/**
* Get historical ranking data for a specific ride (last 90 days)
*/
async getRankingHistory(rideSlug: string): Promise<RankingSnapshot[]> {
return this.client.get<RankingSnapshot[]>(`/api/rankings/${rideSlug}/history/`)
}
/**
* Get head-to-head comparison data for a ride
*/
async getHeadToHeadComparisons(rideSlug: string): Promise<HeadToHeadComparison[]> {
return this.client.get<HeadToHeadComparison[]>(`/api/rankings/${rideSlug}/comparisons/`)
}
/**
* Get system-wide ranking statistics
*/
async getRankingStatistics(): Promise<RankingStatistics> {
return this.client.get<RankingStatistics>('/api/rankings/statistics/')
}
/**
* Trigger ranking calculation (Admin only)
*/
async calculateRankings(category?: string): Promise<{
status: string
rides_ranked: number
comparisons_made: number
duration: number
timestamp: string
}> {
const data = category ? { category } : {}
return this.client.post<{
status: string
rides_ranked: number
comparisons_made: number
duration: number
timestamp: string
}>('/api/rankings/calculate/', data)
}
/**
* Get top N rankings (convenience method)
*/
async getTopRankings(limit: number = 10, category?: string): Promise<RideRanking[]> {
const response = await this.getRankings({
page_size: limit,
category: category as any,
ordering: 'rank'
})
return response.results
}
/**
* Get rankings for a specific park
*/
async getParkRankings(parkSlug: string, params?: {
page?: number
page_size?: number
category?: string
}): Promise<ApiResponse<RideRanking>> {
return this.getRankings({
...params,
park: parkSlug,
category: params?.category as any
})
}
/**
* Search rankings by ride name
*/
async searchRankings(query: string): Promise<RideRanking[]> {
// This would ideally have a dedicated search endpoint, but for now
// we'll fetch all and filter client-side (not ideal for large datasets)
const response = await this.getRankings({ page_size: 100 })
return response.results.filter(ranking =>
ranking.ride.name.toLowerCase().includes(query.toLowerCase())
)
}
/**
* Get rank change information for a ride
*/
async getRankChange(rideSlug: string): Promise<{
current_rank: number
previous_rank: number | null
change: number
direction: 'up' | 'down' | 'same'
}> {
const detail = await this.getRankingDetail(rideSlug)
const change = detail.rank_change || 0
return {
current_rank: detail.rank,
previous_rank: detail.previous_rank,
change: Math.abs(change),
direction: change > 0 ? 'down' : change < 0 ? 'up' : 'same'
}
}
}
/**
* Entity Search API service for fuzzy matching and suggestions
*/
export class EntitySearchApi {
private client: ApiClient
constructor(client: ApiClient = new ApiClient()) {
this.client = client
}
/**
* Perform fuzzy search for entities
*/
async fuzzySearch(query: string, params?: {
entity_types?: string[]
context_park?: string
limit?: number
min_confidence?: number
}): Promise<FuzzyMatchResult> {
const queryParams: Record<string, string> = { q: query }
if (params?.entity_types) queryParams.entity_types = params.entity_types.join(',')
if (params?.context_park) queryParams.context_park = params.context_park
if (params?.limit) queryParams.limit = params.limit.toString()
if (params?.min_confidence) queryParams.min_confidence = params.min_confidence.toString()
return this.client.get<FuzzyMatchResult>('/api/entities/fuzzy-search/', queryParams)
}
/**
* Handle entity not found scenarios with suggestions
*/
async handleNotFound(entityName: string, entityType?: string, context?: {
park_slug?: string
path?: string
}): Promise<EntityNotFoundResponse> {
const data: Record<string, any> = { entity_name: entityName }
if (entityType) data.entity_type = entityType
if (context?.park_slug) data.park_slug = context.park_slug
if (context?.path) data.path = context.path
return this.client.post<EntityNotFoundResponse>('/api/entities/not-found/', data)
}
/**
* Get quick suggestions for autocomplete
*/
async getQuickSuggestions(query: string, params?: {
entity_types?: string[]
limit?: number
park_context?: string
}): Promise<QuickSuggestionResponse> {
const queryParams: Record<string, string> = { q: query }
if (params?.entity_types) queryParams.entity_types = params.entity_types.join(',')
if (params?.limit) queryParams.limit = params.limit.toString()
if (params?.park_context) queryParams.park_context = params.park_context
return this.client.get<QuickSuggestionResponse>('/api/entities/quick-suggestions/', queryParams)
}
/**
* Check if user is authenticated and can add entities
*/
isAuthenticated(): boolean {
return !!this.client.getAuthToken()
}
/**
* Get authentication URLs for redirects
*/
getAuthUrls(): { login: string; signup: string } {
const baseUrl = this.client['baseUrl']
return {
login: `${baseUrl}/auth/login/`,
signup: `${baseUrl}/auth/signup/`
}
}
}
/**
* Main API service that combines all endpoints
*/
@@ -734,6 +1131,9 @@ export class ThrillWikiApi {
public rides: RidesApi
public auth: AuthApi
public history: HistoryApi
public trending: TrendingApi
public rankings: RankingsApi
public entitySearch: EntitySearchApi
private client: ApiClient
constructor() {
@@ -742,6 +1142,9 @@ export class ThrillWikiApi {
this.rides = new RidesApi(this.client)
this.auth = new AuthApi(this.client)
this.history = new HistoryApi(this.client)
this.trending = new TrendingApi(this.client)
this.rankings = new RankingsApi(this.client)
this.entitySearch = new EntitySearchApi(this.client)
}
/**
@@ -785,6 +1188,9 @@ export const parksApi = api.parks
export const ridesApi = api.rides
export const authApi = api.auth
export const historyApi = api.history
export const trendingApi = api.trending
export const rankingsApi = api.rankings
export const entitySearchApi = api.entitySearch
// Export types for use in components
export type {
@@ -796,5 +1202,9 @@ export type {
ParkHistoryResponse,
RideHistoryResponse,
UnifiedHistoryTimeline,
HistoryParams
HistoryParams,
EntitySuggestion,
FuzzyMatchResult,
EntityNotFoundResponse,
QuickSuggestionResponse
}

View File

@@ -179,3 +179,163 @@ export interface SocialAuthProvider {
iconUrl?: string
authUrl: string
}
// Trending and New Content types
export interface TrendingItem {
id: number
name: string
location: string
category: string
rating: number
rank: number
views: number
views_change: number
slug: string
}
export interface NewContentItem {
id: number
name: string
location: string
category: string
date_added: string
slug: string
}
export interface TrendingResponse {
trending_rides: TrendingItem[]
trending_parks: TrendingItem[]
latest_reviews: TrendingItem[]
}
export interface NewContentResponse {
recently_added: NewContentItem[]
newly_opened: NewContentItem[]
upcoming: NewContentItem[]
}
// Enhanced Park and Ride interfaces for trending support
export interface ParkWithTrending extends Park {
rating?: number
rank?: number
views?: number
views_change?: number
date_added?: string
}
export interface RideWithTrending extends Ride {
rating?: number
rank?: number
views?: number
views_change?: number
date_added?: string
}
// Ride Ranking types
export interface RideRanking {
id: number
rank: number
ride: {
id: number
name: string
slug: string
park: {
id: number
name: string
slug: string
}
category: 'RC' | 'DR' | 'FR' | 'WR' | 'TR' | 'OT'
}
wins: number
losses: number
ties: number
winning_percentage: number
mutual_riders_count: number
comparison_count: number
average_rating: number
last_calculated: string
rank_change?: number
previous_rank?: number | null
}
export interface RideRankingDetail extends RideRanking {
ride: {
id: number
name: string
slug: string
description?: string
park: {
id: number
name: string
slug: string
location?: {
city?: string
state?: string
country?: string
}
}
category: 'RC' | 'DR' | 'FR' | 'WR' | 'TR' | 'OT'
manufacturer?: {
id: number
name: string
}
opening_date?: string
status: string
}
calculation_version?: string
head_to_head_comparisons?: HeadToHeadComparison[]
ranking_history?: RankingSnapshot[]
}
export interface HeadToHeadComparison {
opponent: {
id: number
name: string
slug: string
park: string
}
wins: number
losses: number
ties: number
result: 'win' | 'loss' | 'tie'
mutual_riders: number
}
export interface RankingSnapshot {
date: string
rank: number
winning_percentage: number
}
export interface RankingStatistics {
total_ranked_rides: number
total_comparisons: number
last_calculation_time: string
calculation_duration: number
top_rated_ride?: {
id: number
name: string
slug: string
park: string
rank: number
winning_percentage: number
average_rating: number
}
most_compared_ride?: {
id: number
name: string
slug: string
park: string
comparison_count: number
}
biggest_rank_change?: {
ride: {
id: number
name: string
slug: string
}
current_rank: number
previous_rank: number
change: number
}
}

View File

@@ -8,13 +8,16 @@
<div class="text-center max-w-4xl mx-auto">
<h1 class="text-4xl md:text-6xl font-bold mb-6">Discover Your Next Thrill</h1>
<p class="text-xl md:text-2xl mb-8 text-purple-100">
Search through thousands of amusement rides and parks in an expansive community database
Search through thousands of amusement rides and parks in an expansive
community database
</p>
<!-- Search Bar -->
<div class="max-w-2xl mx-auto mb-16">
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<div
class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none"
>
<svg
class="h-6 w-6 text-gray-400"
fill="none"
@@ -77,8 +80,14 @@
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-12">
<div class="flex items-center justify-center mb-4">
<svg class="h-6 w-6 text-orange-500 mr-2" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2L13.09 8.26L22 9L13.09 9.74L12 16L10.91 9.74L2 9L10.91 8.26L12 2Z" />
<svg
class="h-6 w-6 text-orange-500 mr-2"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M12 2L13.09 8.26L22 9L13.09 9.74L12 16L10.91 9.74L2 9L10.91 8.26L12 2Z"
/>
</svg>
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white">
What's Trending
@@ -109,19 +118,70 @@
</div>
<!-- Trending Content -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<!-- Loading State -->
<div v-if="isLoadingTrending" class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div
v-for="i in 3"
:key="'trending-skeleton-' + i"
class="bg-white dark:bg-gray-700 rounded-lg shadow-lg overflow-hidden animate-pulse"
>
<div class="h-48 bg-gray-200 dark:bg-gray-600"></div>
<div class="p-6">
<div class="h-6 bg-gray-200 dark:bg-gray-600 rounded mb-2"></div>
<div class="h-4 bg-gray-200 dark:bg-gray-600 rounded mb-3 w-2/3"></div>
<div class="flex justify-between">
<div class="h-4 bg-gray-200 dark:bg-gray-600 rounded w-1/4"></div>
<div class="h-4 bg-gray-200 dark:bg-gray-600 rounded w-1/4"></div>
</div>
</div>
</div>
</div>
<!-- Error State -->
<div v-else-if="trendingError" class="mb-8">
<div
class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-6 text-center"
>
<svg
class="h-12 w-12 text-red-400 mx-auto mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.5 0L4.314 15.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
<h3 class="text-lg font-medium text-red-800 dark:text-red-200 mb-2">
Failed to Load Trending Content
</h3>
<p class="text-red-600 dark:text-red-400 mb-4">{{ trendingError }}</p>
<button
@click="fetchTrendingContent()"
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg font-medium transition-colors"
>
Try Again
</button>
</div>
</div>
<!-- Content State -->
<div v-else class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<article
v-for="item in getTrendingContent()"
:key="item.id"
class="bg-white dark:bg-gray-700 rounded-lg shadow-lg overflow-hidden hover:shadow-xl transition-shadow cursor-pointer group"
class="bg-white dark:bg-gray-800 rounded-xl shadow-sm hover:shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden transition-all duration-200 cursor-pointer group hover:scale-[1.01] hover:-translate-y-0.5"
@click="viewTrendingItem(item)"
>
<!-- Image placeholder -->
<div
class="h-48 bg-gradient-to-br from-gray-200 to-gray-300 dark:from-gray-600 dark:to-gray-700 flex items-center justify-center relative"
class="h-48 bg-gradient-to-br from-gray-100 to-gray-200 dark:from-gray-700 dark:to-gray-800 flex items-center justify-center relative"
>
<svg
class="h-12 w-12 text-gray-400 dark:text-gray-500"
class="h-10 w-10 text-gray-400 dark:text-gray-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -129,22 +189,28 @@
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1"
stroke-width="1.5"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<!-- Trending badge -->
<div class="absolute top-3 left-3">
<span class="bg-orange-500 text-white text-xs font-medium px-2 py-1 rounded-full">
<div class="absolute top-4 left-4">
<span
class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-orange-500 text-white shadow-sm"
>
#{{ item.rank }}
</span>
</div>
<!-- Rating badge -->
<div class="absolute top-3 right-3">
<div class="absolute top-4 right-4">
<span
class="bg-gray-900 bg-opacity-75 text-white text-xs font-medium px-2 py-1 rounded-full flex items-center"
class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-black/75 text-white backdrop-blur-sm"
>
<svg class="h-3 w-3 text-yellow-400 mr-1" fill="currentColor" viewBox="0 0 20 20">
<svg
class="h-3 w-3 text-yellow-400 mr-1 flex-shrink-0"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"
/>
@@ -156,19 +222,28 @@
<div class="p-6">
<h3
class="text-lg font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors"
class="text-xl font-semibold text-gray-900 dark:text-white mb-3 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors leading-tight"
>
{{ item.name }}
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
<span class="font-medium">{{ item.location }}</span>
<span v-if="item.category"> {{ item.category }}</span>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4 leading-relaxed">
<span class="font-medium text-gray-700 dark:text-gray-300">{{
item.location
}}</span>
<span v-if="item.category" class="text-gray-500 dark:text-gray-500">
{{ item.category }}</span
>
</p>
<div class="flex items-center justify-between">
<span
class="text-green-600 dark:text-green-400 text-sm font-medium flex items-center"
class="inline-flex items-center text-sm font-medium text-green-600 dark:text-green-400"
>
<svg class="h-4 w-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg
class="h-4 w-4 mr-1.5 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
@@ -179,13 +254,13 @@
+{{ item.views_change }}%
</span>
<span
class="text-blue-600 dark:text-blue-400 text-sm font-medium group-hover:underline"
class="text-sm font-medium text-gray-500 dark:text-gray-400 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors"
>
{{ item.views }} views
{{ item.views.toLocaleString() }} views
</span>
</div>
</div>
</div>
</article>
</div>
</div>
</section>
@@ -195,10 +270,18 @@
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-12">
<div class="flex items-center justify-center mb-4">
<svg class="h-6 w-6 text-blue-500 mr-2" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2L15.09 8.26L22 9L15.09 9.74L12 16L8.91 9.74L2 9L8.91 8.26L12 2Z" />
<svg
class="h-6 w-6 text-blue-500 mr-2"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M12 2L15.09 8.26L22 9L15.09 9.74L12 16L8.91 9.74L2 9L8.91 8.26L12 2Z"
/>
</svg>
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white">What's New</h2>
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white">
What's New
</h2>
</div>
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
Stay up to date with the latest additions and upcoming attractions
@@ -225,19 +308,70 @@
</div>
<!-- New Content -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 mb-8">
<!-- Loading State -->
<div v-if="isLoadingNew" class="grid grid-cols-1 md:grid-cols-2 gap-8 mb-8">
<div
v-for="i in 2"
:key="'new-skeleton-' + i"
class="bg-white dark:bg-gray-800 rounded-lg shadow-lg overflow-hidden animate-pulse"
>
<div class="h-48 bg-gray-200 dark:bg-gray-600"></div>
<div class="p-6">
<div class="h-6 bg-gray-200 dark:bg-gray-600 rounded mb-2"></div>
<div class="h-4 bg-gray-200 dark:bg-gray-600 rounded mb-3 w-2/3"></div>
<div class="flex justify-between">
<div class="h-4 bg-gray-200 dark:bg-gray-600 rounded w-1/3"></div>
<div class="h-4 bg-gray-200 dark:bg-gray-600 rounded w-1/4"></div>
</div>
</div>
</div>
</div>
<!-- Error State -->
<div v-else-if="newError" class="mb-8">
<div
class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-6 text-center"
>
<svg
class="h-12 w-12 text-red-400 mx-auto mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.5 0L4.314 15.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
<h3 class="text-lg font-medium text-red-800 dark:text-red-200 mb-2">
Failed to Load New Content
</h3>
<p class="text-red-600 dark:text-red-400 mb-4">{{ newError }}</p>
<button
@click="fetchNewContent()"
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg font-medium transition-colors"
>
Try Again
</button>
</div>
</div>
<!-- Content State -->
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-8 mb-8">
<article
v-for="item in getNewContent()"
:key="item.id"
class="bg-white dark:bg-gray-800 rounded-lg shadow-lg overflow-hidden hover:shadow-xl transition-shadow cursor-pointer group"
class="bg-white dark:bg-gray-800 rounded-xl shadow-sm hover:shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden transition-all duration-200 cursor-pointer group hover:scale-[1.01] hover:-translate-y-0.5"
@click="viewNewItem(item)"
>
<!-- Image placeholder -->
<div
class="h-48 bg-gradient-to-br from-gray-200 to-gray-300 dark:from-gray-600 dark:to-gray-700 flex items-center justify-center relative"
class="h-48 bg-gradient-to-br from-gray-100 to-gray-200 dark:from-gray-700 dark:to-gray-800 flex items-center justify-center relative"
>
<svg
class="h-12 w-12 text-gray-400 dark:text-gray-500"
class="h-10 w-10 text-gray-400 dark:text-gray-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -245,13 +379,15 @@
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1"
stroke-width="1.5"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<!-- New badge -->
<div class="absolute top-3 left-3">
<span class="bg-green-500 text-white text-xs font-medium px-2 py-1 rounded-full">
<div class="absolute top-4 left-4">
<span
class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-green-500 text-white shadow-sm"
>
New
</span>
</div>
@@ -259,26 +395,30 @@
<div class="p-6">
<h3
class="text-lg font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors"
class="text-xl font-semibold text-gray-900 dark:text-white mb-3 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors leading-tight"
>
{{ item.name }}
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
<span class="font-medium">{{ item.location }}</span>
<span v-if="item.category"> {{ item.category }}</span>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4 leading-relaxed">
<span class="font-medium text-gray-700 dark:text-gray-300">{{
item.location
}}</span>
<span v-if="item.category" class="text-gray-500 dark:text-gray-500">
{{ item.category }}</span
>
</p>
<div class="flex items-center justify-between">
<span class="text-gray-600 dark:text-gray-400 text-sm">
<span class="text-sm text-gray-600 dark:text-gray-400 font-medium">
Added {{ item.date_added }}
</span>
<span
class="text-blue-600 dark:text-blue-400 text-sm font-medium group-hover:underline"
class="text-sm font-medium text-blue-600 dark:text-blue-400 group-hover:text-blue-700 dark:group-hover:text-blue-300 transition-colors"
>
View All New Additions
Learn More
</span>
</div>
</div>
</div>
</article>
</div>
</div>
</section>
@@ -290,8 +430,8 @@
Join the ThrillWiki Community
</h2>
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-3xl mx-auto mb-8">
Share your experiences, contribute to our database, and connect with fellow theme park
enthusiasts.
Share your experiences, contribute to our database, and connect with fellow
theme park enthusiasts.
</p>
<button
class="px-8 py-4 bg-gray-900 hover:bg-gray-800 dark:bg-gray-700 dark:hover:bg-gray-600 text-white font-medium rounded-lg transition-colors"
@@ -304,13 +444,21 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ref, onMounted } from "vue";
import { useRouter } from "vue-router";
import { trendingApi } from "@/services/api";
import type { TrendingItem, NewContentItem } from "@/types";
const router = useRouter()
const heroSearchQuery = ref('')
const activeTrendingTab = ref('rides')
const activeNewTab = ref('recently-added')
const router = useRouter();
const heroSearchQuery = ref("");
const activeTrendingTab = ref("rides");
const activeNewTab = ref("recently-added");
// Loading states
const isLoadingTrending = ref(true);
const isLoadingNew = ref(true);
const trendingError = ref<string | null>(null);
const newError = ref<string | null>(null);
// Sample data matching the design
const stats = ref({
@@ -318,236 +466,282 @@ const stats = ref({
rides: 10,
reviews: 20,
photos: 1,
})
});
const trendingTabs = [
{ id: 'rides', label: 'Trending Rides' },
{ id: 'parks', label: 'Trending Parks' },
{ id: 'reviews', label: 'Latest Reviews' },
]
{ id: "rides", label: "Trending Rides" },
{ id: "parks", label: "Trending Parks" },
{ id: "reviews", label: "Latest Reviews" },
];
const newTabs = [
{ id: 'recently-added', label: 'Recently Added' },
{ id: 'newly-opened', label: 'Newly Opened' },
{ id: 'upcoming', label: 'Upcoming' },
]
{ id: "recently-added", label: "Recently Added" },
{ id: "newly-opened", label: "Newly Opened" },
{ id: "upcoming", label: "Upcoming" },
];
const trendingRides = ref([
{
id: 1,
name: 'Steel Vengeance',
location: 'Cedar Point',
category: 'Hybrid Coaster',
rating: 4.9,
rank: 1,
views: 4820,
views_change: 23,
slug: 'steel-vengeance',
},
{
id: 2,
name: 'Kingda Ka',
location: 'Six Flags Great Adventure',
category: 'Launched Coaster',
rating: 4.8,
rank: 2,
views: 3647,
views_change: 18,
slug: 'kingda-ka',
},
{
id: 3,
name: 'Pirates of the Caribbean',
location: 'Disneyland',
category: 'Dark Ride',
rating: 4.7,
rank: 3,
views: 3156,
views_change: 12,
slug: 'pirates-of-the-caribbean',
},
])
const trendingParks = ref([
{
id: 1,
name: 'Cedar Point',
location: 'Sandusky, Ohio',
category: 'Amusement Park',
rating: 4.8,
rank: 1,
views: 8920,
views_change: 15,
slug: 'cedar-point',
},
{
id: 2,
name: 'Magic Kingdom',
location: 'Orlando, Florida',
category: 'Theme Park',
rating: 4.9,
rank: 2,
views: 7654,
views_change: 12,
slug: 'magic-kingdom',
},
{
id: 3,
name: 'Europa-Park',
location: 'Rust, Germany',
category: 'Theme Park',
rating: 4.7,
rank: 3,
views: 5432,
views_change: 22,
slug: 'europa-park',
},
])
const latestReviews = ref([
{
id: 1,
name: 'Steel Vengeance Review',
location: 'Cedar Point',
category: 'Roller Coaster',
rating: 5.0,
rank: 1,
views: 1234,
views_change: 45,
slug: 'steel-vengeance-review',
},
{
id: 2,
name: 'Kingda Ka Experience',
location: 'Six Flags Great Adventure',
category: 'Launch Coaster',
rating: 4.8,
rank: 2,
views: 987,
views_change: 32,
slug: 'kingda-ka-review',
},
{
id: 3,
name: 'Pirates Ride Review',
location: 'Disneyland',
category: 'Dark Ride',
rating: 4.6,
rank: 3,
views: 765,
views_change: 28,
slug: 'pirates-review',
},
])
const recentlyAdded = ref([
{
id: 1,
name: 'Guardians of the Galaxy: Cosmic Rewind',
location: 'EPCOT',
category: 'Indoor Coaster',
date_added: '2024-01-20',
slug: 'guardians-cosmic-rewind',
},
{
id: 2,
name: 'VelociCoaster',
location: "Universal's Islands of Adventure",
category: 'Launch Coaster',
date_added: '2024-01-18',
slug: 'velocicoaster',
},
])
const newlyOpened = ref([
{
id: 1,
name: 'TRON Lightcycle / Run',
location: 'Magic Kingdom',
category: 'Launch Coaster',
date_added: '2023-04-04',
slug: 'tron-lightcycle-run',
},
{
id: 2,
name: "Hagrid's Magical Creatures Motorbike Adventure",
location: "Universal's Islands of Adventure",
category: 'Story Coaster',
date_added: '2019-06-13',
slug: 'hagrids-motorbike-adventure',
},
])
const upcoming = ref([
{
id: 1,
name: 'Epic Universe',
location: 'Universal Orlando',
category: 'Theme Park',
date_added: 'Opening 2025',
slug: 'epic-universe',
},
{
id: 2,
name: 'New Fantasyland Expansion',
location: 'Magic Kingdom',
category: 'Land Expansion',
date_added: 'Opening 2026',
slug: 'fantasyland-expansion',
},
])
// Data from API
const trendingRides = ref<TrendingItem[]>([]);
const trendingParks = ref<TrendingItem[]>([]);
const latestReviews = ref<TrendingItem[]>([]);
const recentlyAdded = ref<NewContentItem[]>([]);
const newlyOpened = ref<NewContentItem[]>([]);
const upcoming = ref<NewContentItem[]>([]);
// Methods
const handleHeroSearch = () => {
if (heroSearchQuery.value.trim()) {
router.push({
name: 'search-results',
name: "search-results",
query: { q: heroSearchQuery.value.trim() },
})
});
}
}
};
const getTrendingContent = () => {
switch (activeTrendingTab.value) {
case 'rides':
return trendingRides.value
case 'parks':
return trendingParks.value
case 'reviews':
return latestReviews.value
case "rides":
return trendingRides.value;
case "parks":
return trendingParks.value;
case "reviews":
return latestReviews.value;
default:
return trendingRides.value
return trendingRides.value;
}
}
};
const getNewContent = () => {
switch (activeNewTab.value) {
case 'recently-added':
return recentlyAdded.value
case 'newly-opened':
return newlyOpened.value
case 'upcoming':
return upcoming.value
case "recently-added":
return recentlyAdded.value;
case "newly-opened":
return newlyOpened.value;
case "upcoming":
return upcoming.value;
default:
return recentlyAdded.value
return recentlyAdded.value;
}
}
};
const viewTrendingItem = (item: any) => {
if (activeTrendingTab.value === 'parks') {
router.push({ name: 'park-detail', params: { slug: item.slug } })
} else if (activeTrendingTab.value === 'rides') {
router.push({ name: 'global-ride-detail', params: { rideSlug: item.slug } })
if (activeTrendingTab.value === "parks") {
router.push({ name: "park-detail", params: { slug: item.slug } });
} else if (activeTrendingTab.value === "rides") {
router.push({ name: "global-ride-detail", params: { rideSlug: item.slug } });
}
}
};
const viewNewItem = (item: any) => {
router.push({ name: 'park-detail', params: { slug: item.slug } })
}
router.push({ name: "park-detail", params: { slug: item.slug } });
};
// In a real app, you would fetch this data from your Django API
onMounted(() => {
// TODO: Fetch actual data from Django backend
console.log('Home view mounted')
})
// API calls
const fetchTrendingContent = async () => {
try {
isLoadingTrending.value = true;
trendingError.value = null;
const response = await trendingApi.getTrendingContent();
trendingRides.value = response.trending_rides;
trendingParks.value = response.trending_parks;
latestReviews.value = response.latest_reviews;
} catch (error) {
console.error("Error fetching trending content:", error);
trendingError.value = "Failed to load trending content";
// Fallback to sample data on error
trendingRides.value = [
{
id: 1,
name: "Steel Vengeance",
location: "Cedar Point",
category: "Hybrid Coaster",
rating: 4.9,
rank: 1,
views: 4820,
views_change: 23,
slug: "steel-vengeance",
},
{
id: 2,
name: "Kingda Ka",
location: "Six Flags Great Adventure",
category: "Launched Coaster",
rating: 4.8,
rank: 2,
views: 3647,
views_change: 18,
slug: "kingda-ka",
},
{
id: 3,
name: "Pirates of the Caribbean",
location: "Disneyland",
category: "Dark Ride",
rating: 4.7,
rank: 3,
views: 3156,
views_change: 12,
slug: "pirates-of-the-caribbean",
},
];
trendingParks.value = [
{
id: 1,
name: "Cedar Point",
location: "Sandusky, Ohio",
category: "Amusement Park",
rating: 4.8,
rank: 1,
views: 8920,
views_change: 15,
slug: "cedar-point",
},
{
id: 2,
name: "Magic Kingdom",
location: "Orlando, Florida",
category: "Theme Park",
rating: 4.9,
rank: 2,
views: 7654,
views_change: 12,
slug: "magic-kingdom",
},
{
id: 3,
name: "Europa-Park",
location: "Rust, Germany",
category: "Theme Park",
rating: 4.7,
rank: 3,
views: 5432,
views_change: 22,
slug: "europa-park",
},
];
latestReviews.value = [
{
id: 1,
name: "Steel Vengeance Review",
location: "Cedar Point",
category: "Roller Coaster",
rating: 5.0,
rank: 1,
views: 1234,
views_change: 45,
slug: "steel-vengeance-review",
},
{
id: 2,
name: "Kingda Ka Experience",
location: "Six Flags Great Adventure",
category: "Launch Coaster",
rating: 4.8,
rank: 2,
views: 987,
views_change: 32,
slug: "kingda-ka-review",
},
{
id: 3,
name: "Pirates Ride Review",
location: "Disneyland",
category: "Dark Ride",
rating: 4.6,
rank: 3,
views: 765,
views_change: 28,
slug: "pirates-review",
},
];
} finally {
isLoadingTrending.value = false;
}
};
const fetchNewContent = async () => {
try {
isLoadingNew.value = true;
newError.value = null;
const response = await trendingApi.getNewContent();
recentlyAdded.value = response.recently_added;
newlyOpened.value = response.newly_opened;
upcoming.value = response.upcoming;
} catch (error) {
console.error("Error fetching new content:", error);
newError.value = "Failed to load new content";
// Fallback to sample data on error
recentlyAdded.value = [
{
id: 1,
name: "Guardians of the Galaxy: Cosmic Rewind",
location: "EPCOT",
category: "Indoor Coaster",
date_added: "2024-01-20",
slug: "guardians-cosmic-rewind",
},
{
id: 2,
name: "VelociCoaster",
location: "Universal's Islands of Adventure",
category: "Launch Coaster",
date_added: "2024-01-18",
slug: "velocicoaster",
},
];
newlyOpened.value = [
{
id: 1,
name: "TRON Lightcycle / Run",
location: "Magic Kingdom",
category: "Launch Coaster",
date_added: "2023-04-04",
slug: "tron-lightcycle-run",
},
{
id: 2,
name: "Hagrid's Magical Creatures Motorbike Adventure",
location: "Universal's Islands of Adventure",
category: "Story Coaster",
date_added: "2019-06-13",
slug: "hagrids-motorbike-adventure",
},
];
upcoming.value = [
{
id: 1,
name: "Epic Universe",
location: "Universal Orlando",
category: "Theme Park",
date_added: "Opening 2025",
slug: "epic-universe",
},
{
id: 2,
name: "New Fantasyland Expansion",
location: "Magic Kingdom",
category: "Land Expansion",
date_added: "Opening 2026",
slug: "fantasyland-expansion",
},
];
} finally {
isLoadingNew.value = false;
}
};
onMounted(async () => {
console.log("Home view mounted - fetching trending data from API");
// Fetch both trending and new content in parallel
await Promise.all([fetchTrendingContent(), fetchNewContent()]);
});
</script>