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">
|
<module type="WEB_MODULE" version="4">
|
||||||
<component name="NewModuleRootManager">
|
<component name="NewModuleRootManager">
|
||||||
<content url="file://$MODULE_DIR$">
|
<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/app" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/storage/framework" />
|
<excludeFolder url="file://$MODULE_DIR$/storage/framework" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/_laravel_idea" />
|
<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');
|
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;
|
namespace App\Providers;
|
||||||
|
|
||||||
use App\Models\Continent;
|
use App\Models\Continent;
|
||||||
|
use Illuminate\Support\Facades\URL;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class TourCountrySeeder extends Seeder
|
|||||||
\DB::table('tour_countries')->insert([
|
\DB::table('tour_countries')->insert([
|
||||||
['tour_id' => $tourMap['cantonese_charm'], 'country_id' => $countryMap['china']],
|
['tour_id' => $tourMap['cantonese_charm'], 'country_id' => $countryMap['china']],
|
||||||
['tour_id' => $tourMap['fujianese_fantasy'], '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
|
public function run(): void
|
||||||
{
|
{
|
||||||
\DB::table('tours')->insert([
|
\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' => 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": "^3.4.2",
|
||||||
"prettier-plugin-organize-imports": "^4.1.0",
|
"prettier-plugin-organize-imports": "^4.1.0",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||||
|
"sass": "^1.92.1",
|
||||||
|
"sass-loader": "^10.5.2",
|
||||||
"typescript": "^5.2.2",
|
"typescript": "^5.2.2",
|
||||||
"typescript-eslint": "^8.23.0",
|
"typescript-eslint": "^8.23.0",
|
||||||
"vite": "^7.0.4",
|
"vite": "^7.0.4",
|
||||||
@@ -39,7 +41,8 @@
|
|||||||
"tailwind-merge": "^3.2.0",
|
"tailwind-merge": "^3.2.0",
|
||||||
"tailwindcss": "^4.1.1",
|
"tailwindcss": "^4.1.1",
|
||||||
"tw-animate-css": "^1.2.5",
|
"tw-animate-css": "^1.2.5",
|
||||||
"vue": "^3.5.13"
|
"vue": "^3.5.13",
|
||||||
|
"vuetify": "^3.10.1"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rollup/rollup-linux-x64-gnu": "4.9.5",
|
"@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>
|
</nav>
|
||||||
|
|
||||||
<div class="nav-menu">
|
<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>
|
<a href="#contact" class="nav-link">Contact</a>
|
||||||
<div
|
<div
|
||||||
class="dropdown"
|
class="dropdown"
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
|
|
||||||
<div class="dropdown-menu" :class="{ 'is-visible': isDropdownVisible }">
|
<div class="dropdown-menu" :class="{ 'is-visible': isDropdownVisible }">
|
||||||
<a
|
<a
|
||||||
v-for="continent in sortedContinents"
|
v-for="continent in continents_with_tours"
|
||||||
:key="continent.id"
|
:key="continent.id"
|
||||||
:href="`/tours/${continent.internal_name}`"
|
:href="`/tours/${continent.internal_name}`"
|
||||||
>
|
>
|
||||||
@@ -48,15 +48,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, onUnmounted, computed, ref } from 'vue';
|
import { onMounted, onUnmounted, computed, ref } from 'vue';
|
||||||
import { usePage} from "@inertiajs/vue3";
|
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 { 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 dropdownOpen = ref(false)
|
||||||
const windowWidth = ref(window.innerWidth);
|
const windowWidth = ref(window.innerWidth);
|
||||||
|
|
||||||
@@ -128,7 +123,6 @@ const initDropdown = (): void => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Close dropdown when pressing escape
|
|
||||||
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
dropdown.classList.remove('open');
|
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>
|
<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>
|
<circle cx="12" cy="10" r="3"></circle>
|
||||||
</svg>
|
</svg>
|
||||||
{{ tour.countries.at(0)?.name }}, {{ tour.countries.at(0)?.continent.name }}
|
{{ tour.countries?.at(0)?.name }}
|
||||||
</div>
|
</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>
|
<p class="tour-description" v-if="tour.short_description">{{tour.short_description}}</p>
|
||||||
<div class="tour-details">
|
<div class="tour-details">
|
||||||
<div class="tour-detail">
|
<div class="tour-detail">
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
|
|
||||||
<div class="about-cta">
|
<div class="about-cta">
|
||||||
<h3>Who Are We?</h3>
|
<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>
|
<p> You are in safe hands.</p>
|
||||||
<EdgyButton classes="btn-primary">GET TO KNOW US</EdgyButton>
|
<EdgyButton classes="btn-primary">GET TO KNOW US</EdgyButton>
|
||||||
</div>
|
</div>
|
||||||
@@ -178,9 +178,32 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|
||||||
import EdgyButton from "@/components/dredgy/EdgyButton.vue";
|
import EdgyButton from "@/components/dredgy/EdgyButton.vue";
|
||||||
import GradientText from "@/components/dredgy/GradientText.vue";
|
|
||||||
import SectionContainer from "@/components/dredgy/SectionContainer.vue";
|
import SectionContainer from "@/components/dredgy/SectionContainer.vue";
|
||||||
import SectionTitle from "@/components/dredgy/SectionTitle.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>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -12,18 +12,22 @@
|
|||||||
|
|
||||||
<div class="tours-grid">
|
<div class="tours-grid">
|
||||||
<TourCard
|
<TourCard
|
||||||
v-for="tour in featuredTours"
|
v-for="(tour, index) in featuredTours"
|
||||||
|
:key="tour.id ?? index"
|
||||||
|
class="tour-card"
|
||||||
|
:data-index="index"
|
||||||
:tour="tour"
|
:tour="tour"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<br/>
|
<br/>
|
||||||
<EdgyButton classes="btn-primary btn-full">Explore All Tours</EdgyButton>
|
<EdgyButton classes="btn-primary btn-full">Explore All Adventures</EdgyButton>
|
||||||
|
|
||||||
</SectionContainer>
|
</SectionContainer>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* Featured Tours Section */
|
/* Featured Tours Section */
|
||||||
.featured-tours {
|
.featured-tours {
|
||||||
@@ -99,12 +103,77 @@ import SectionTitle from "@/components/dredgy/SectionTitle.vue";
|
|||||||
import TourCard from "@/components/dredgy/TourCard.vue";
|
import TourCard from "@/components/dredgy/TourCard.vue";
|
||||||
import { Tour } from "@/types";
|
import { Tour } from "@/types";
|
||||||
import EdgyButton from "@/components/dredgy/EdgyButton.vue";
|
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<{
|
const props = defineProps<{
|
||||||
featuredTours: Tour[]
|
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>
|
</script>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
We set the trends.
|
We set the trends.
|
||||||
</p>
|
</p>
|
||||||
<div class="hero-buttons">
|
<div class="hero-buttons">
|
||||||
<EdgyButton classes="btn-secondary">View Tours</EdgyButton>
|
<EdgyButton classes="btn-secondary">View Adventures</EdgyButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -91,6 +91,22 @@
|
|||||||
align-items: center;
|
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) {
|
@media (min-width: 640px) {
|
||||||
.hero-buttons {
|
.hero-buttons {
|
||||||
flex-direction: row;
|
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 {
|
.scroll-indicator {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 2rem;
|
bottom: 2rem;
|
||||||
@@ -136,4 +177,54 @@
|
|||||||
|
|
||||||
import EdgyButton from "@/components/dredgy/EdgyButton.vue";
|
import EdgyButton from "@/components/dredgy/EdgyButton.vue";
|
||||||
import GradientText from "@/components/dredgy/GradientText.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>
|
</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,
|
|
||||||
callback: IntersectionObserverCallback,
|
|
||||||
options: IntersectionObserverInit = {}
|
|
||||||
) => {
|
|
||||||
let observer: IntersectionObserver | null = null;
|
|
||||||
|
|
||||||
const defaultOptions: IntersectionObserverInit = {
|
|
||||||
threshold: 0.3,
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
export const createIntersectionObserver = (
|
||||||
|
callback: IntersectionObserverCallback,
|
||||||
|
options: IntersectionObserverOptions = {}
|
||||||
|
): IntersectionObserver => {
|
||||||
|
const defaultOptions: IntersectionObserverOptions = {
|
||||||
|
threshold: 0.3,
|
||||||
|
rootMargin: '0px 0px -50px 0px'
|
||||||
|
};
|
||||||
|
|
||||||
|
return new IntersectionObserver(callback, { ...defaultOptions, ...options });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import type { BreadcrumbItemType } from '@/types';
|
import type { BreadcrumbItemType } from '@/types';
|
||||||
import { onMounted } from 'vue';
|
import { onMounted } from 'vue';
|
||||||
import Header from "@/components/Header.vue";
|
import Header from "@/components/Header.vue";
|
||||||
|
import {VApp} from "vuetify/components/VApp";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
breadcrumbs?: BreadcrumbItemType[];
|
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
|
// 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 initSmoothScrolling = (): void => {
|
||||||
const navLinks = document.querySelectorAll('a[href^="#"]') as NodeListOf<HTMLAnchorElement>;
|
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
|
// Initialize all functionality when DOM is loaded
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
initAboutAnimation();
|
|
||||||
initToursAnimation();
|
|
||||||
initSmoothScrolling();
|
initSmoothScrolling();
|
||||||
initParallax();
|
|
||||||
initLoadingAnimations();
|
|
||||||
initTourCardInteractions();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add performance optimization
|
// 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 {
|
export interface Tour {
|
||||||
id: number
|
id: number
|
||||||
title: string
|
name: string
|
||||||
internal_name: string
|
internal_name: string
|
||||||
length: number
|
length: number
|
||||||
price: number
|
price: number
|
||||||
level: string
|
level: string
|
||||||
short_description: string
|
short_description: string
|
||||||
countries: Country[]
|
countries: ?Country[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,13 @@ use Inertia\Inertia;
|
|||||||
|
|
||||||
Route::get('/', function () {
|
Route::get('/', function () {
|
||||||
return Inertia::render('Home',[
|
return Inertia::render('Home',[
|
||||||
'featured_tours' => Tour::all()->random(3),
|
'featured_tours' => Tour::featuredTours(),
|
||||||
]);
|
]);
|
||||||
})->name('home');
|
})->name('home');
|
||||||
|
|
||||||
|
Route::get('/about', function () {
|
||||||
|
return Inertia::render('AboutUs');
|
||||||
|
})->name('home');
|
||||||
|
|
||||||
require __DIR__.'/settings.php';
|
require __DIR__.'/settings.php';
|
||||||
require __DIR__.'/auth.php';
|
require __DIR__.'/auth.php';
|
||||||
|
|||||||
Reference in New Issue
Block a user