Componentalization

This commit is contained in:
2025-09-16 22:01:42 +10:00
parent e965cc2780
commit 91771e9573
19 changed files with 1683 additions and 223 deletions

4
.idea/DredgeTours.iml generated
View File

@@ -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" />

View File

@@ -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();
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Providers;
use App\Models\Continent;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider;
use Inertia\Inertia;

View File

@@ -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']],
]);
}

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@@ -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');

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 });
};

View File

@@ -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[];

View 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>

View File

@@ -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

View File

@@ -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[]
}

View File

@@ -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';