Componentalization
This commit is contained in:
4
.idea/DredgeTours.iml
generated
4
.idea/DredgeTours.iml
generated
@@ -2,6 +2,10 @@
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<sourceFolder url="file://$MODULE_DIR$/app" isTestSource="false" packagePrefix="App\" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/database/factories" isTestSource="false" packagePrefix="Database\Factories\" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/database/seeders" isTestSource="false" packagePrefix="Database\Seeders\" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/tests" isTestSource="true" packagePrefix="Tests\" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/storage/app" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/storage/framework" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/_laravel_idea" />
|
||||
|
||||
@@ -14,4 +14,10 @@ class Tour extends Model
|
||||
{
|
||||
return $this->belongsToMany(Country::class, 'tour_countries', 'tour_id', 'country_id');
|
||||
}
|
||||
|
||||
public static function featuredTours(){
|
||||
return Tour::whereHas('countries.continent')
|
||||
->with('countries.continent')
|
||||
->get();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\Continent;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Inertia\Inertia;
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ class TourCountrySeeder extends Seeder
|
||||
\DB::table('tour_countries')->insert([
|
||||
['tour_id' => $tourMap['cantonese_charm'], 'country_id' => $countryMap['china']],
|
||||
['tour_id' => $tourMap['fujianese_fantasy'], 'country_id' => $countryMap['china']],
|
||||
['tour_id' => $tourMap['hebei_hijinx'], 'country_id' => $countryMap['china']],
|
||||
['tour_id' => $tourMap['hebei_harmony'], 'country_id' => $countryMap['china']],
|
||||
]);
|
||||
|
||||
}
|
||||
|
||||
@@ -13,9 +13,9 @@ class TourSeeder extends Seeder
|
||||
public function run(): void
|
||||
{
|
||||
\DB::table('tours')->insert([
|
||||
['length' => 8, 'name' => "Cantonese Charm", 'short_description' => "Guangdong is known for it's big, global cities, but there is so much more to discover and pristine natural beauty.", 'internal_name' => "cantonese_charm", 'level' => "Beginner", 'price' => 1000],
|
||||
['length' => 8, 'name' => "Cantonese Charm", 'short_description' => "Guangdong is known for it's big, global cities, but there is so much more to discover; including pristine natural beauty.", 'internal_name' => "cantonese_charm", 'level' => "Beginner", 'price' => 1000],
|
||||
['length' => 7, 'name' => "Fujianese Fantasy", 'short_description' => "Experience fresh seafood in Xiamen, and then move rurally for an authentic dive into Hakka culture", 'internal_name' => "fujianese_fantasy", 'level' => "Beginner", 'price' => 1200],
|
||||
['length' => 10, 'name' => "Hebei Hijinx", 'short_description' => "The Great Wall, Great Food and ancient treasures in one of China's most underrated provinces.", 'internal_name' => "hebei_hijinx", 'level' => "Moderate", 'price' => 1500],
|
||||
['length' => 10, 'name' => "Hebei Harmony", 'short_description' => "The Great Wall, Great Food and ancient treasures in one of China's most underrated provinces.", 'internal_name' => "hebei_harmony", 'level' => "Moderate", 'price' => 1500],
|
||||
]);
|
||||
|
||||
|
||||
|
||||
1438
package-lock.json
generated
1438
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,8 @@
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-organize-imports": "^4.1.0",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"sass": "^1.92.1",
|
||||
"sass-loader": "^10.5.2",
|
||||
"typescript": "^5.2.2",
|
||||
"typescript-eslint": "^8.23.0",
|
||||
"vite": "^7.0.4",
|
||||
@@ -39,7 +41,8 @@
|
||||
"tailwind-merge": "^3.2.0",
|
||||
"tailwindcss": "^4.1.1",
|
||||
"tw-animate-css": "^1.2.5",
|
||||
"vue": "^3.5.13"
|
||||
"vue": "^3.5.13",
|
||||
"vuetify": "^3.10.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-linux-x64-gnu": "4.9.5",
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
@@ -10,7 +10,7 @@
|
||||
</nav>
|
||||
|
||||
<div class="nav-menu">
|
||||
<a href="#about" class="nav-link">About</a>
|
||||
<a href="/about" class="nav-link">About</a>
|
||||
<a href="#contact" class="nav-link">Contact</a>
|
||||
<div
|
||||
class="dropdown"
|
||||
@@ -32,7 +32,7 @@
|
||||
|
||||
<div class="dropdown-menu" :class="{ 'is-visible': isDropdownVisible }">
|
||||
<a
|
||||
v-for="continent in sortedContinents"
|
||||
v-for="continent in continents_with_tours"
|
||||
:key="continent.id"
|
||||
:href="`/tours/${continent.internal_name}`"
|
||||
>
|
||||
@@ -48,15 +48,10 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, computed, ref } from 'vue';
|
||||
import { usePage} from "@inertiajs/vue3";
|
||||
import {Continent, GlobalProperties} from "@/types";
|
||||
import {GlobalProperties} from "@/types";
|
||||
|
||||
const { continents_with_tours } = usePage().props as unknown as GlobalProperties
|
||||
|
||||
|
||||
const sortedContinents = computed(() => {
|
||||
return [...continents_with_tours].sort((a, b) => a.name.localeCompare(b.name))
|
||||
})
|
||||
|
||||
const dropdownOpen = ref(false)
|
||||
const windowWidth = ref(window.innerWidth);
|
||||
|
||||
@@ -128,7 +123,6 @@ const initDropdown = (): void => {
|
||||
}
|
||||
});
|
||||
|
||||
// Close dropdown when pressing escape
|
||||
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
dropdown.classList.remove('open');
|
||||
|
||||
@@ -26,9 +26,9 @@ const props = defineProps<{
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path>
|
||||
<circle cx="12" cy="10" r="3"></circle>
|
||||
</svg>
|
||||
{{ tour.countries.at(0)?.name }}, {{ tour.countries.at(0)?.continent.name }}
|
||||
{{ tour.countries?.at(0)?.name }}
|
||||
</div>
|
||||
<h3 class="tour-title">{{tour.title}}</h3>
|
||||
<h3 class="tour-title">{{tour.name}}</h3>
|
||||
<p class="tour-description" v-if="tour.short_description">{{tour.short_description}}</p>
|
||||
<div class="tour-details">
|
||||
<div class="tour-detail">
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
<div class="about-cta">
|
||||
<h3>Who Are We?</h3>
|
||||
<p>Dr Edgy was founded by two close friends who met travelling in North Korea. Both of us love adventure travel with a bit of luxury. One of us is even a doctor.</p>
|
||||
<p>Dr Edgy was founded by two close friends who met travelling in North Korea. Both of us love adventure travel with a bit of luxury. One of us is even an actual doctor.</p>
|
||||
<p> You are in safe hands.</p>
|
||||
<EdgyButton classes="btn-primary">GET TO KNOW US</EdgyButton>
|
||||
</div>
|
||||
@@ -178,9 +178,32 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import EdgyButton from "@/components/dredgy/EdgyButton.vue";
|
||||
import GradientText from "@/components/dredgy/GradientText.vue";
|
||||
import SectionContainer from "@/components/dredgy/SectionContainer.vue";
|
||||
import SectionTitle from "@/components/dredgy/SectionTitle.vue";
|
||||
import {createIntersectionObserver} from "@/composables/useIntersectionObserver";
|
||||
import {onMounted} from "vue";
|
||||
|
||||
onMounted(() => {
|
||||
initAboutAnimation()
|
||||
})
|
||||
|
||||
// About section animation
|
||||
const initAboutAnimation = (): void => {
|
||||
const aboutContent = document.querySelector('.about-content') as HTMLElement | null;
|
||||
if (!aboutContent) return;
|
||||
|
||||
const observer = createIntersectionObserver((entries: IntersectionObserverEntry[]) => {
|
||||
entries.forEach((entry: IntersectionObserverEntry) => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add('visible');
|
||||
} else {
|
||||
entry.target.classList.remove('visible');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(aboutContent);
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
@@ -12,18 +12,22 @@
|
||||
|
||||
<div class="tours-grid">
|
||||
<TourCard
|
||||
v-for="tour in featuredTours"
|
||||
v-for="(tour, index) in featuredTours"
|
||||
:key="tour.id ?? index"
|
||||
class="tour-card"
|
||||
:data-index="index"
|
||||
:tour="tour"
|
||||
/>
|
||||
</div>
|
||||
<br/>
|
||||
<EdgyButton classes="btn-primary btn-full">Explore All Tours</EdgyButton>
|
||||
<EdgyButton classes="btn-primary btn-full">Explore All Adventures</EdgyButton>
|
||||
|
||||
</SectionContainer>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
|
||||
|
||||
<style scoped>
|
||||
/* Featured Tours Section */
|
||||
.featured-tours {
|
||||
@@ -99,12 +103,77 @@ import SectionTitle from "@/components/dredgy/SectionTitle.vue";
|
||||
import TourCard from "@/components/dredgy/TourCard.vue";
|
||||
import { Tour } from "@/types";
|
||||
import EdgyButton from "@/components/dredgy/EdgyButton.vue";
|
||||
const featuredTours : Tour[] = [];
|
||||
|
||||
/*
|
||||
import { defineProps } from 'vue'
|
||||
|
||||
import { defineProps, onMounted } from 'vue'
|
||||
import {createIntersectionObserver} from "@/composables/useIntersectionObserver";
|
||||
|
||||
const props = defineProps<{
|
||||
featuredTours: Tour[]
|
||||
}>()*/
|
||||
}>()
|
||||
|
||||
onMounted(() => {
|
||||
initToursAnimation()
|
||||
initTourCardInteractions()
|
||||
})
|
||||
|
||||
const initTourCardInteractions = (): void => {
|
||||
const tourCards = document.querySelectorAll('.tour-card') as NodeListOf<HTMLElement>;
|
||||
|
||||
tourCards.forEach((card: HTMLElement) => {
|
||||
// Add hover effects
|
||||
card.addEventListener('mouseenter', () => {
|
||||
card.style.transform = 'translateY(-8px) scale(1.02)';
|
||||
});
|
||||
|
||||
card.addEventListener('mouseleave', () => {
|
||||
if (card.classList.contains('visible')) {
|
||||
card.style.transform = 'translateY(0) rotate(0deg) scale(1)';
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const initToursAnimation = (): void => {
|
||||
const toursHeader = document.querySelector('.tours-header') as HTMLElement | null;
|
||||
const tourCards = document.querySelectorAll('.tour-card') as NodeListOf<HTMLElement>;
|
||||
|
||||
if (!toursHeader || !tourCards.length) return;
|
||||
|
||||
// Header animation
|
||||
const headerObserver = createIntersectionObserver((entries: IntersectionObserverEntry[]) => {
|
||||
entries.forEach((entry: IntersectionObserverEntry) => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add('visible');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
headerObserver.observe(toursHeader);
|
||||
|
||||
// Tour cards animation with staggered effect
|
||||
const cardsObserver = createIntersectionObserver((entries: IntersectionObserverEntry[]) => {
|
||||
entries.forEach((entry: IntersectionObserverEntry) => {
|
||||
const tourCard = entry.target as HTMLElement;
|
||||
const index = parseInt(tourCard.dataset.index || '0');
|
||||
|
||||
if (entry.isIntersecting) {
|
||||
// Staggered animation when scrolling down
|
||||
setTimeout(() => {
|
||||
tourCard.classList.add('visible');
|
||||
}, index * 150);
|
||||
} else {
|
||||
// Reverse staggered animation when scrolling up
|
||||
const reverseIndex = (tourCards.length - 1) - index;
|
||||
setTimeout(() => {
|
||||
tourCard.classList.remove('visible');
|
||||
}, reverseIndex * 100);
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.2 });
|
||||
|
||||
tourCards.forEach((card: HTMLElement) => {
|
||||
cardsObserver.observe(card);
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
We set the trends.
|
||||
</p>
|
||||
<div class="hero-buttons">
|
||||
<EdgyButton classes="btn-secondary">View Tours</EdgyButton>
|
||||
<EdgyButton classes="btn-secondary">View Adventures</EdgyButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -91,6 +91,22 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hero-title,
|
||||
.hero-subtitle {
|
||||
opacity: 0;
|
||||
transform: translateY(18px);
|
||||
transition: opacity 1000ms cubic-bezier(.22,.9,.32,1),
|
||||
transform 1000ms cubic-bezier(.22,.9,.32,1);
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
/* visible state toggled by JS */
|
||||
.hero-title.is-visible,
|
||||
.hero-subtitle.is-visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.hero-buttons {
|
||||
flex-direction: row;
|
||||
@@ -104,6 +120,31 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes bounce {
|
||||
0%, 20%, 53%, 80%, 100% {
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
40%, 43% {
|
||||
transform: translateX(-50%) translateY(-15px);
|
||||
}
|
||||
70% {
|
||||
transform: translateX(-50%) translateY(-7px);
|
||||
}
|
||||
90% {
|
||||
transform: translateX(-50%) translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.scroll-indicator {
|
||||
position: absolute;
|
||||
bottom: 2rem;
|
||||
@@ -136,4 +177,54 @@
|
||||
|
||||
import EdgyButton from "@/components/dredgy/EdgyButton.vue";
|
||||
import GradientText from "@/components/dredgy/GradientText.vue";
|
||||
import {onMounted} from "vue";
|
||||
|
||||
const initParallax = (): void => {
|
||||
const heroBackground = document.querySelector('.hero-bg') as HTMLElement | null;
|
||||
const heroSection = document.querySelector('.hero') as HTMLElement | null;
|
||||
if (!heroBackground || !heroSection) return;
|
||||
|
||||
window.addEventListener('scroll', () => {
|
||||
const scrolled = window.pageYOffset;
|
||||
const heroRect = heroSection.getBoundingClientRect();
|
||||
const heroHeight = heroSection.offsetHeight;
|
||||
|
||||
if (heroRect.bottom > 0) {
|
||||
const rate = scrolled * -0.15;
|
||||
const maxMovement = heroHeight * 0.1;
|
||||
const constrainedRate = Math.max(rate, -maxMovement);
|
||||
|
||||
heroBackground.style.transform = `translateY(${constrainedRate}px)`;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Add loading animations
|
||||
|
||||
const initLoadingAnimations = (): void => {
|
||||
document.body.classList.add('loaded');
|
||||
|
||||
const heroTitle = document.querySelector('.hero-title') as HTMLElement | null;
|
||||
const heroSubtitle = document.querySelector('.hero-subtitle') as HTMLElement | null;
|
||||
|
||||
if (heroTitle) {
|
||||
setTimeout(() => {
|
||||
// force reflow (safe) then add class to trigger transition
|
||||
void heroTitle.offsetWidth;
|
||||
heroTitle.classList.add('is-visible');
|
||||
}, 500);
|
||||
}
|
||||
|
||||
if (heroSubtitle) {
|
||||
setTimeout(() => {
|
||||
void heroSubtitle.offsetWidth;
|
||||
heroSubtitle.classList.add('is-visible');
|
||||
}, 750);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initParallax()
|
||||
initLoadingAnimations()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,38 +1,16 @@
|
||||
import { onMounted, onBeforeUnmount, nextTick } from 'vue';
|
||||
export interface IntersectionObserverOptions {
|
||||
threshold?: number;
|
||||
rootMargin?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* useIntersectionObserver
|
||||
*
|
||||
* @param selector - CSS selector for elements to observe
|
||||
* @param callback - called when intersection changes
|
||||
* @param options - IntersectionObserver options
|
||||
*/
|
||||
export const useIntersectionObserver = (
|
||||
selector: string,
|
||||
export const createIntersectionObserver = (
|
||||
callback: IntersectionObserverCallback,
|
||||
options: IntersectionObserverInit = {}
|
||||
) => {
|
||||
let observer: IntersectionObserver | null = null;
|
||||
|
||||
const defaultOptions: IntersectionObserverInit = {
|
||||
options: IntersectionObserverOptions = {}
|
||||
): IntersectionObserver => {
|
||||
const defaultOptions: IntersectionObserverOptions = {
|
||||
threshold: 0.3,
|
||||
rootMargin: '0px 0px -50px 0px',
|
||||
rootMargin: '0px 0px -50px 0px'
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick(); // wait for DOM to render
|
||||
|
||||
const elements = document.querySelectorAll(selector);
|
||||
if (!elements.length) return;
|
||||
|
||||
observer = new IntersectionObserver(callback, { ...defaultOptions, ...options });
|
||||
elements.forEach(el => observer!.observe(el));
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (observer) {
|
||||
observer.disconnect();
|
||||
observer = null;
|
||||
}
|
||||
});
|
||||
return new IntersectionObserver(callback, { ...defaultOptions, ...options });
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type { BreadcrumbItemType } from '@/types';
|
||||
import { onMounted } from 'vue';
|
||||
import Header from "@/components/Header.vue";
|
||||
import {VApp} from "vuetify/components/VApp";
|
||||
|
||||
interface Props {
|
||||
breadcrumbs?: BreadcrumbItemType[];
|
||||
|
||||
17
resources/js/pages/AboutUs.vue
Normal file
17
resources/js/pages/AboutUs.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import AppLayout from "@/layouts/AppLayout.vue";
|
||||
|
||||
defineOptions({
|
||||
layout: AppLayout
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section style="position: relative;min-height: 100vh;display:flex;align-items: center;justify-content: center;">
|
||||
<v-date-picker show-adjacent-months></v-date-picker>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -29,87 +29,6 @@ defineOptions({
|
||||
})
|
||||
|
||||
// Types
|
||||
interface IntersectionObserverOptions {
|
||||
threshold?: number;
|
||||
rootMargin?: string;
|
||||
}
|
||||
|
||||
// Intersection Observer for animations
|
||||
const createIntersectionObserver = (
|
||||
callback: IntersectionObserverCallback,
|
||||
options: IntersectionObserverOptions = {}
|
||||
): IntersectionObserver => {
|
||||
const defaultOptions: IntersectionObserverOptions = {
|
||||
threshold: 0.3,
|
||||
rootMargin: '0px 0px -50px 0px'
|
||||
};
|
||||
|
||||
return new IntersectionObserver(callback, { ...defaultOptions, ...options });
|
||||
};
|
||||
|
||||
// About section animation
|
||||
const initAboutAnimation = (): void => {
|
||||
const aboutContent = document.querySelector('.about-content') as HTMLElement | null;
|
||||
if (!aboutContent) return;
|
||||
|
||||
const observer = createIntersectionObserver((entries: IntersectionObserverEntry[]) => {
|
||||
entries.forEach((entry: IntersectionObserverEntry) => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add('visible');
|
||||
} else {
|
||||
entry.target.classList.remove('visible');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(aboutContent);
|
||||
};
|
||||
|
||||
// Featured tours animation
|
||||
const initToursAnimation = (): void => {
|
||||
const toursHeader = document.querySelector('.tours-header') as HTMLElement | null;
|
||||
const tourCards = document.querySelectorAll('.tour-card') as NodeListOf<HTMLElement>;
|
||||
|
||||
if (!toursHeader || !tourCards.length) return;
|
||||
|
||||
// Header animation
|
||||
const headerObserver = createIntersectionObserver((entries: IntersectionObserverEntry[]) => {
|
||||
entries.forEach((entry: IntersectionObserverEntry) => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add('visible');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
headerObserver.observe(toursHeader);
|
||||
|
||||
// Tour cards animation with staggered effect
|
||||
const cardsObserver = createIntersectionObserver((entries: IntersectionObserverEntry[]) => {
|
||||
entries.forEach((entry: IntersectionObserverEntry) => {
|
||||
const tourCard = entry.target as HTMLElement;
|
||||
const index = parseInt(tourCard.dataset.index || '0');
|
||||
|
||||
if (entry.isIntersecting) {
|
||||
// Staggered animation when scrolling down
|
||||
setTimeout(() => {
|
||||
tourCard.classList.add('visible');
|
||||
}, index * 150);
|
||||
} else {
|
||||
// Reverse staggered animation when scrolling up
|
||||
const reverseIndex = (tourCards.length - 1) - index;
|
||||
setTimeout(() => {
|
||||
tourCard.classList.remove('visible');
|
||||
}, reverseIndex * 100);
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.2 });
|
||||
|
||||
tourCards.forEach((card: HTMLElement) => {
|
||||
cardsObserver.observe(card);
|
||||
});
|
||||
};
|
||||
|
||||
// Smooth scrolling for navigation links
|
||||
const initSmoothScrolling = (): void => {
|
||||
const navLinks = document.querySelectorAll('a[href^="#"]') as NodeListOf<HTMLAnchorElement>;
|
||||
|
||||
@@ -140,87 +59,9 @@ const initSmoothScrolling = (): void => {
|
||||
});
|
||||
};
|
||||
|
||||
// Parallax effect for hero background
|
||||
const initParallax = (): void => {
|
||||
const heroBackground = document.querySelector('.hero-bg') as HTMLElement | null;
|
||||
const heroSection = document.querySelector('.hero') as HTMLElement | null;
|
||||
if (!heroBackground || !heroSection) return;
|
||||
|
||||
window.addEventListener('scroll', () => {
|
||||
const scrolled = window.pageYOffset;
|
||||
const heroRect = heroSection.getBoundingClientRect();
|
||||
const heroHeight = heroSection.offsetHeight;
|
||||
|
||||
if (heroRect.bottom > 0) {
|
||||
const rate = scrolled * -0.15;
|
||||
const maxMovement = heroHeight * 0.1;
|
||||
const constrainedRate = Math.max(rate, -maxMovement);
|
||||
|
||||
heroBackground.style.transform = `translateY(${constrainedRate}px)`;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Add loading animations
|
||||
const initLoadingAnimations = (): void => {
|
||||
// Add class to trigger CSS animations
|
||||
document.body.classList.add('loaded');
|
||||
|
||||
// Animate hero content
|
||||
const heroTitle = document.querySelector('.hero-title') as HTMLElement | null;
|
||||
const heroSubtitle = document.querySelector('.hero-subtitle') as HTMLElement | null;
|
||||
|
||||
if (heroTitle) {
|
||||
setTimeout(() => {
|
||||
heroTitle.style.opacity = '1';
|
||||
heroTitle.style.transform = 'translateY(0)';
|
||||
}, 300);
|
||||
}
|
||||
|
||||
if (heroSubtitle) {
|
||||
setTimeout(() => {
|
||||
heroSubtitle.style.opacity = '1';
|
||||
heroSubtitle.style.transform = 'translateY(0)';
|
||||
}, 600);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Tour card interactions
|
||||
const initTourCardInteractions = (): void => {
|
||||
const tourCards = document.querySelectorAll('.tour-card') as NodeListOf<HTMLElement>;
|
||||
|
||||
tourCards.forEach((card: HTMLElement) => {
|
||||
const bookButton = card.querySelector('.btn') as HTMLElement | null;
|
||||
|
||||
if (bookButton) {
|
||||
bookButton.addEventListener('click', () => {
|
||||
// Add your booking logic here
|
||||
alert('Booking functionality would be implemented here!');
|
||||
});
|
||||
}
|
||||
|
||||
// Add hover effects
|
||||
card.addEventListener('mouseenter', () => {
|
||||
card.style.transform = 'translateY(-8px) scale(1.02)';
|
||||
});
|
||||
|
||||
card.addEventListener('mouseleave', () => {
|
||||
if (card.classList.contains('visible')) {
|
||||
card.style.transform = 'translateY(0) rotate(0deg) scale(1)';
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Initialize all functionality when DOM is loaded
|
||||
onMounted(() => {
|
||||
initAboutAnimation();
|
||||
initToursAnimation();
|
||||
initSmoothScrolling();
|
||||
initParallax();
|
||||
initLoadingAnimations();
|
||||
initTourCardInteractions();
|
||||
});
|
||||
|
||||
// Add performance optimization
|
||||
|
||||
4
resources/js/types/index.d.ts
vendored
4
resources/js/types/index.d.ts
vendored
@@ -55,11 +55,11 @@ export interface Country {
|
||||
|
||||
export interface Tour {
|
||||
id: number
|
||||
title: string
|
||||
name: string
|
||||
internal_name: string
|
||||
length: number
|
||||
price: number
|
||||
level: string
|
||||
short_description: string
|
||||
countries: Country[]
|
||||
countries: ?Country[]
|
||||
}
|
||||
|
||||
@@ -7,9 +7,13 @@ use Inertia\Inertia;
|
||||
|
||||
Route::get('/', function () {
|
||||
return Inertia::render('Home',[
|
||||
'featured_tours' => Tour::all()->random(3),
|
||||
'featured_tours' => Tour::featuredTours(),
|
||||
]);
|
||||
})->name('home');
|
||||
|
||||
Route::get('/about', function () {
|
||||
return Inertia::render('AboutUs');
|
||||
})->name('home');
|
||||
|
||||
require __DIR__.'/settings.php';
|
||||
require __DIR__.'/auth.php';
|
||||
|
||||
Reference in New Issue
Block a user