Added About Page

This commit is contained in:
2025-09-17 23:34:37 +10:00
parent 2f6006626d
commit b9e1f6827a
30 changed files with 1270 additions and 529 deletions

View File

@@ -5,6 +5,9 @@ import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
import type { DefineComponent } from 'vue';
import { createApp, h } from 'vue';
import { initializeTheme } from './composables/useAppearance';
import vuetify from "@/plugins/vuetify.js";
import {intersectionTransition} from "@/directives/intersectionTransition";
import {hoverEffects} from "@/directives/hoverEffects";
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
@@ -13,8 +16,12 @@ createInertiaApp({
title: (title) => (title ? `${title} - ${appName}` : appName),
resolve: (name) => resolvePageComponent(`./pages/${name}.vue`, import.meta.glob<DefineComponent>('./pages/**/*.vue')),
setup({ el, App, props, plugin }) {
console.log('Inertia app mounted', props.initialPage);
createApp({ render: () => h(App, props) })
.use(plugin)
.use(vuetify)
.directive('show-on-intersect', intersectionTransition)
.directive('hover', hoverEffects)
.mount(el);
},
progress: {

View File

@@ -0,0 +1,39 @@
<script setup lang="ts">
</script>
<template>
<footer class="footer">
<span>&copy; Dr Edgy Adventures</span>
</footer>
</template>
<style scoped>
.footer {
will-change: transform, opacity;
display: flex;
align-items: center;
justify-content: center;
min-height: 5dvh;
/* glassy background */
background: hsla(30, 100%, 46%, 0.8);
/* frosted borders */
border-top: 1px solid rgba(255, 255, 255, 0.25);
border-bottom: 1px solid rgba(0, 0, 0, 0.15);
/* chamfered corners */
clip-path: polygon(
20px 0, /* top-left chamfer */
calc(100% - 20px) 0, /* top-right chamfer */
100% 20px,
100% 100%,
0 100%,
0 20px /* bottom-left chamfer */
);
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.15);
color: white;
}
</style>

View File

@@ -3,29 +3,25 @@
<header class="header">
<section class="navContainer">
<nav class="nav">
<div class="nav-brand">Dr Edgy Adventures</div>
<div class="nav-brand">
<Link href="/">
<img src="/img/logos/logo_main.png" alt="Dr Edgy Logo" style="height:5vh; width:auto;" />
</Link>
</div>
<button class="mobile-menu-btn">
<span></span><span></span><span></span>
</button>
</nav>
<div class="nav-menu">
<a href="/about" class="nav-link">About</a>
<a href="#contact" class="nav-link">Contact</a>
<div
class="dropdown"
:class="{ open: dropdownOpen }"
>
<Link href="/about" @click="dropdownOpen = false" class="nav-link">About</Link>
<div class="dropdown" ref="dropdownRef" :class="{ open: dropdownOpen }">
<button
class="dropdown-toggle nav-link"
@click="dropdownOpen = !dropdownOpen"
@click.stop="dropdownOpen = !dropdownOpen"
>
Adventures
<svg
class="dropdown-icon"
width="16" height="16" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2"
>
<svg class="dropdown-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6,9 12,15 18,9"></polyline>
</svg>
</button>
@@ -47,36 +43,52 @@
<script setup lang="ts">
import { onMounted, onUnmounted, computed, ref } from 'vue';
import { usePage} from "@inertiajs/vue3";
import {GlobalProperties} from "@/types";
import { usePage } from "@inertiajs/vue3";
import { GlobalProperties } from "@/types";
import { Link } from '@inertiajs/vue3';
const { continents_with_tours } = usePage().props as unknown as GlobalProperties
const { continents_with_tours } = usePage().props as unknown as GlobalProperties;
const dropdownOpen = ref(false)
const dropdownOpen = ref(false);
const windowWidth = ref(window.innerWidth);
const dropdownRef = ref<HTMLElement | null>(null);
onMounted(() =>{
initDropdown();
initHeaderScroll()
initMobileMenu()
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
});
const isDropdownVisible = computed(() => {
return dropdownOpen.value || windowWidth.value < 768;
});
const isDropdownVisible = computed(() => dropdownOpen.value || windowWidth.value < 768);
const handleResize = () => {
windowWidth.value = window.innerWidth;
};
// Close when clicking outside (desktop only)
const handleClickOutside = (e: Event) => {
if (!dropdownRef.value) return;
if (windowWidth.value < 768) return; // on mobile we show inline; skip closing here
// Support composedPath for shadow DOM correctness
const path = (e as MouseEvent).composedPath?.();
const clickedInside = path ? path.includes(dropdownRef.value) : dropdownRef.value.contains(e.target as Node);
if (!clickedInside) {
dropdownOpen.value = false;
}
};
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') dropdownOpen.value = false;
};
onMounted(() => {
initMobileMenu(); // keep your mobile menu init
window.addEventListener('resize', handleResize);
document.addEventListener('click', handleClickOutside);
document.addEventListener('keydown', handleEsc);
});
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
document.removeEventListener('click', handleClickOutside);
document.removeEventListener('keydown', handleEsc);
});
// Mobile menu functionality
const initMobileMenu = (): void => {
@@ -85,50 +97,21 @@ const initMobileMenu = (): void => {
if (!mobileMenuBtn || !navMenu) return;
// Toggle menu open/close
mobileMenuBtn.addEventListener('click', () => {
navMenu.classList.toggle('open');
mobileMenuBtn.classList.toggle('open');
});
};
// Header background on scroll
const initHeaderScroll = (): void => {
const header = document.querySelector('.header') as HTMLElement | null;
if (!header) return;
window.addEventListener('scroll', () => {
if (window.scrollY > 100) {
header.classList.add('scrolled');
} else {
header.classList.remove('scrolled');
}
// Close menu when a link is clicked
navMenu.querySelectorAll('.nav-link').forEach(link => {
link.addEventListener('click', () => {
navMenu.classList.remove('open');
mobileMenuBtn.classList.remove('open');
});
});
};
const initDropdown = (): void => {
const dropdown = document.querySelector('.dropdown') as HTMLElement | null;
const dropdownToggle = dropdown?.querySelector('.dropdown-toggle') as HTMLElement | null;
if (!dropdown || !dropdownToggle) return;
dropdownToggle.addEventListener('click', (e: Event) => {
e.preventDefault();
dropdown.classList.toggle('open');
});
// Close dropdown when clicking outside
document.addEventListener('click', (e: Event) => {
if (!dropdown.contains(e.target as Node)) {
dropdown.classList.remove('open');
}
});
document.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Escape') {
dropdown.classList.remove('open');
}
});
};
</script>
<style scoped>
@@ -208,20 +191,9 @@ const initDropdown = (): void => {
align-items: center;
justify-content: space-between;
width: 100%;
padding: 1em;
/* padding / width handled in the desktop guard above */
}
/* brand */
.nav-brand {
font-size: 1.5rem;
font-weight: bold;
background: var(--gradient-adventure);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
/* links container (desktop default) */
.nav-menu {
display: flex;
@@ -359,6 +331,8 @@ const initDropdown = (): void => {
overflow-y: auto;
}
.nav-menu.open {
transform: translateY(0);
opacity: 1;
@@ -370,6 +344,20 @@ const initDropdown = (): void => {
font-size: 1.5rem;
}
.nav-brand {
position:relative;
padding: 1em;
display: flex;
align-items: center;
justify-content: center;
width: 90dvw;
}
.nav-brand img {
display: block;
margin-left: calc(10dvw / 2);
}
/* Dropdown in mobile: behave as inline sublist */
.dropdown {
width: 100%;
@@ -408,6 +396,7 @@ const initDropdown = (): void => {
.mobile-menu-btn {
display: flex;
width: 10dvw;
}
}
</style>

View File

@@ -0,0 +1,139 @@
<script setup lang="ts">
defineProps({
name: String,
photo: String,
blurb: String,
slide: String,
position: {
type: String,
default: undefined
},
photoSide: {
type: String as () => 'left' | 'right',
default: 'left'
}
});
</script>
<template>
<article v-hover="{ transform: 'translateY(-8px) rotate(0deg) scale(1.02)'}" class="profile-card" v-show-on-intersect="{type: 'slide-'+slide}">
<div :class="['profile-flex', photoSide === 'right' ? 'reverse' : '']">
<div class="profile-image">
<img class="profile-photo" :src="photo" :alt="'Photo of ' + name" />
</div>
<div class="profile-body">
<h3 class="profile-name">{{ name }}</h3>
<p v-if="position" class="profile-position">{{ position }}</p>
<p class="profile-blurb">{{ blurb }}</p>
</div>
</div>
</article>
</template>
<style scoped>
.profile-card {
cursor: pointer;
background: var(--card);
border-radius: 0;
clip-path: polygon(0 0, calc(100% - 20px) 0, 100% 20px, 100% 100%, 20px 100%, 0 calc(100% - 20px));
padding: 1.25rem;
}
.profile-card img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 1.5s ease;
}
.profile-card:hover img {
transform: scale(1.1);
}
.profile-flex {
display: flex;
flex-direction: column;
gap: 1.5rem;
align-items: center;
}
.profile-flex.reverse {
flex-direction: column-reverse;
}
@media (min-width: 768px) {
.profile-flex {
flex-direction: row;
align-items: flex-start; /* align top for image and text */
}
.profile-flex.reverse {
flex-direction: row-reverse;
}
}
@media (max-width: 767px) {
.profile-card {
padding: 1rem;
}
/* Always photo above text on mobile */
.profile-flex,
.profile-flex.reverse {
flex-direction: column !important;
}
}
/* Make the text take remaining space */
.profile-body {
padding: 0.5rem;
flex: 1 1 0; /* allow body to grow */
}
.profile-image {
position: relative;
width: 100%;
max-width: 320px; /* similar to your old grid column */
aspect-ratio: 4 / 3;
background: var(--muted);
overflow: hidden;
border: 1px solid var(--border);
flex-shrink: 0;
clip-path: polygon(
50% 0%, 50% 0%,
100% 25%, 100% 75%,
50% 100%, 50% 100%,
0% 75%, 0% 25%
);
}
.profile-photo {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.profile-name {
font-size: 1.5rem;
margin-bottom: 0.25rem;
background: var(--gradient-adventure);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.profile-position {
color: var(--muted-foreground);
font-size: 0.95rem;
letter-spacing: 0.2px;
margin-bottom: 0.75rem;
opacity: 0.9;
}
.profile-blurb {
color: var(--muted-foreground);
font-size: 1.0625rem;
line-height: 1.7;
}
</style>

View File

@@ -0,0 +1,79 @@
<template>
<!-- wrapper controls opacity so animation on the inner element is unaffected -->
<div class="scroll-indicator-fade" :style="{ opacity: indicatorOpacity }">
<div class="scroll-indicator">
<div class="scroll-mouse">
<div class="scroll-dot"></div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
const indicatorOpacity = ref(1)
function handleScroll() {
const maxScroll = 200
const scrolled = window.scrollY
indicatorOpacity.value = Math.max(0, 1 - scrolled / maxScroll)
}
onMounted(() => {
window.addEventListener('scroll', handleScroll, { passive: true })
})
onUnmounted(() => {
window.removeEventListener('scroll', handleScroll)
})
</script>
<style scoped>
/* wrapper: centering + fade transition */
.scroll-indicator-fade {
position: absolute;
bottom: 2rem;
left: 50%;
transform: translateX(-50%); /* keep horizontal centering here */
transition: opacity 0.3s ease;
pointer-events: none; /* prevents accidental clicks while visible */
}
/* inner element has the bounce animation — animate translateY only */
.scroll-indicator {
animation: bounce 2s infinite;
/* no translateX here; wrapper handles the horizontal offset */
}
.scroll-mouse {
width: 24px;
height: 40px;
border: 2px solid var(--foreground);
border-radius: 12px;
display: flex;
justify-content: center;
padding-top: 8px;
}
.scroll-dot {
width: 4px;
height: 12px;
background: var(--foreground);
border-radius: 2px;
animation: pulse 2s infinite;
}
/* bounce now only moves Y — no translateX so it won't conflict */
@keyframes bounce {
0%, 20%, 53%, 80%, 100% { transform: translateY(0); }
40%, 43% { transform: translateY(-15px); }
70% { transform: translateY(-7px); }
90% { transform: translateY(-2px); }
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
</style>

View File

@@ -38,7 +38,6 @@
<script setup lang="ts">
import GradientText from "@/components/dredgy/GradientText.vue";
import { defineProps } from 'vue'
const props = defineProps<{

View File

@@ -9,11 +9,13 @@ const formatPrice = (price: number) => {
const props = defineProps<{
tour: Tour
dataIndex: number
}>()
</script>
<template>
<div class="tour-card" data-index="0">
<div v-hover="{ transform: 'translateY(-8px) rotate(0deg) scale(1.02)'}" v-show-on-intersect="dataIndex % 2 == 0 ? 'rotate-left' : 'rotate-right'" class="tour-card" :data-index="dataIndex">
<div class="tour-image">
<img :src="`/img/tours/${tour.internal_name}.jpg`" alt="">
<div class="tour-overlay"></div>
@@ -67,18 +69,8 @@ const props = defineProps<{
transform-origin: center bottom;
}
.tour-card:nth-child(2n) {
transform: translateY(100px) rotate(6deg) scale(0.9);
}
.tour-card.visible {
transform: translateY(0) rotate(0deg) scale(1);
opacity: 1;
}
.tour-card:hover {
box-shadow: var(--shadow-intense);
transform: translateY(-8px) rotate(0deg) scale(1.02);
}
.tour-image {
@@ -187,47 +179,4 @@ const props = defineProps<{
font-size: 0.875rem;
}
/* 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;
}
}
.animate-fade {
animation: fadeInUp 1s ease-out;
}
.animate-slide {
animation: fadeInUp 1s ease-out 0.2s both;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -7,32 +7,39 @@
</div>
<SectionContainer>
<div class="about-content">
<SectionTitle title="Beyond" gradient="Borders" />
<div class="about-grid">
<div class="about-text">
<p>
We've been travelling the world our whole lives. From North Korea to Afghanistan, Comoros to Venezuela, Suriname to Uruguay. We know the world, and we want to show it to you.
</p>
<p>
Dr Edgy offers immersive, authentic small group and private adventures, leveraging an extensive global network forged through decades of adventure travel.
</p>
<p>
Our main focus for now is China, where we offer deep-dive adventures of individual provinces and subjects-of-interest and going to places that most other tours completely bypass.
We can arrange private adventures in many other countries on request.
</p>
<div
v-show-on-intersect="'fade-up'"
class="about-content"
>
<div>
<SectionTitle title="Beyond" gradient="Borders" />
<div class="about-grid">
<div class="about-text" v-show-on-intersect="{type: 'slide-right'}">
<p>
We've been travelling the world our whole lives. From North Korea to Afghanistan, Comoros to Venezuela, Suriname to Uruguay. We know the world, and we want to show it to you.
</p>
<p>
Dr Edgy offers immersive, authentic small group and private adventures, leveraging an extensive global network forged through decades of adventure travel.
</p>
<p>
Our main focus for now is China, where we offer deep-dive adventures of individual provinces and subjects-of-interest and going to places that most other tours completely bypass.
We can arrange private adventures in many other countries on request.
</p>
</div>
<div class="about-cta" v-show-on-intersect="{type:'slide-left'}">
<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 an actual doctor.</p>
<p>You are in safe hands.</p>
<EdgyButton classes="btn-primary">
<Link href="/about">
GET TO KNOW US
</Link>
</EdgyButton>
</div>
</div>
</div>
<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 an actual doctor.</p>
<p> You are in safe hands.</p>
<EdgyButton classes="btn-primary">GET TO KNOW US</EdgyButton>
</div>
</div>
</div>
</SectionContainer>
</section>
@@ -83,17 +90,6 @@
background: var(--gradient-adventure);
}
.about-content {
opacity: 0;
transform: translateY(50px);
transition: all 1s cubic-bezier(0.4, 0, 0.2, 1);
}
.about-content.visible {
opacity: 1;
transform: translateY(0);
}
.about-grid {
display: grid;
gap: 3rem;
@@ -113,33 +109,6 @@
font-size: 1.125rem;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 2rem;
margin-top: 2rem;
}
.stat {
text-align: center;
}
.stat-number {
font-size: 2.5rem;
font-weight: bold;
background: var(--gradient-adventure);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.stat-label {
color: var(--muted-foreground);
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.about-cta {
text-align: center;
padding: 2rem;
@@ -176,34 +145,15 @@
</style>
<script setup lang="ts">
import { ref } from 'vue'
import EdgyButton from "@/components/dredgy/EdgyButton.vue";
import SectionContainer from "@/components/dredgy/SectionContainer.vue";
import SectionTitle from "@/components/dredgy/SectionTitle.vue";
import {createIntersectionObserver} from "@/composables/useIntersectionObserver";
import {onMounted} from "vue";
import {Link} from "@inertiajs/vue3";
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);
};
const isVisible = ref(false)
const onIntersect = (isIntersecting: boolean, entries: IntersectionObserverEntry[]) => {
isVisible.value = isIntersecting
}
</script>

View File

@@ -6,28 +6,33 @@
</div>
<SectionContainer>
<div class="tours-header">
<div
v-show-on-intersect="{ type: 'fade-up', duration: '1s' }"
class="tours-header"
>
<SectionTitle title="Featured" gradient="Adventures" subtitle="Discover somewhere new, in a new way" />
</div>
<div class="tours-grid">
<TourCard
v-for="(tour, index) in featuredTours"
:key="tour.id ?? index"
:key="index"
class="tour-card"
:data-index="index"
:tour="tour"
/>
</div>
<br/>
<EdgyButton classes="btn-primary btn-full">Explore All Adventures</EdgyButton>
<EdgyButton
v-show-on-intersect="{ type: 'fade-up', duration: '0.6s' }"
classes="btn-primary btn-full"
>
Explore All Adventures
</EdgyButton>
</SectionContainer>
</section>
</template>
<style scoped>
/* Featured Tours Section */
.featured-tours {
@@ -69,19 +74,12 @@
.tours-header {
text-align: center;
margin-bottom: 4rem;
opacity: 0;
transform: translateY(50px);
transition: all 1s cubic-bezier(0.4, 0, 0.2, 1);
}
.tours-header.visible {
opacity: 1;
transform: translateY(0);
}
.tours-grid {
display: grid;
gap: 2rem;
overflow: hidden;
}
@media (min-width: 768px) {
@@ -95,85 +93,27 @@
grid-template-columns: repeat(3, 1fr);
}
}
/* Hover effects for tour cards */
.tour-card {
transition: transform 0.3s ease;
cursor: pointer;
overflow: hidden;
}
.tour-card:hover {
transform: translateY(-8px) scale(1.02);
}
</style>
<script setup lang="ts">
import SectionContainer from "@/components/dredgy/SectionContainer.vue";
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";
import { Tour } from "@/types";
import { defineProps, onMounted } from 'vue'
import {createIntersectionObserver} from "@/composables/useIntersectionObserver";
const props = defineProps<{
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

@@ -1,183 +1,126 @@
<template>
<section class="hero">
<div class="hero-bg"></div>
<div class="hero-overlay"></div>
<section class="hero">
<div class="hero-bg"></div>
<div class="hero-overlay"></div>
<div class="hero-content">
<h1 class="hero-title animate-fade">
<GradientText>Travel.</GradientText><br>
<span>On the Edge</span>
</h1>
<div class="hero-content">
<h1 class="hero-title animate-fade">
<GradientText>Travel.</GradientText><br>
<span>On the Edge</span>
</h1>
<p class="hero-subtitle animate-slide">
We don't go wherever is trendy. <br/>
We set the trends.
</p>
<div class="hero-buttons">
<EdgyButton classes="btn-secondary">View Adventures</EdgyButton>
</div>
<p class="hero-subtitle animate-slide">
We don't go wherever is trendy. <br/>
We set the trends.
</p>
<div class="hero-buttons">
<EdgyButton classes="btn-secondary">View Adventures</EdgyButton>
</div>
<div class="scroll-indicator">
<div class="scroll-mouse">
<div class="scroll-dot"></div>
</div>
</div>
</section>
</div>
<ScrollIndicator />
</section>
</template>
<style scoped>
/* Hero Section */
.hero {
position: relative;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
position: relative;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.hero-bg {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 120%;
background-image: url('/img/hero.jpg');
background-size: cover;
background-position: center;
will-change: transform;
position: absolute;
top: 0;
left: 0;
right: 0;
height: 120%;
background-image: url('/img/hero.jpg');
background-size: cover;
background-position: center;
will-change: transform;
}
.hero-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 120%;
background: var(--gradient-hero);
opacity: 0.8;
position: absolute;
top: 0;
left: 0;
right: 0;
height: 120%;
background: var(--gradient-hero);
opacity: 0.8;
}
.hero-content {
position: relative;
z-index: 10;
text-align: center;
max-width: 1000px;
padding: 0 2rem;
position: relative;
z-index: 10;
text-align: center;
max-width: 1000px;
padding: 0 2rem;
}
.hero-title {
font-size: clamp(3rem, 8vw, 6rem);
font-weight: bold;
margin-bottom: 1.5rem;
line-height: 1.1;
font-size: clamp(3rem, 8vw, 6rem);
font-weight: bold;
margin-bottom: 1.5rem;
line-height: 1.1;
}
.hero-subtitle {
font-size: clamp(1.125rem, 2.5vw, 1.5rem);
color: var(--muted-foreground);
margin-bottom: 2rem;
max-width: 600px;
margin-left: auto;
margin-right: auto;
font-size: clamp(1.125rem, 2.5vw, 1.5rem);
color: var(--muted-foreground);
margin-bottom: 2rem;
max-width: 600px;
margin-left: auto;
margin-right: auto;
}
.hero-buttons {
display: flex;
flex-direction: column;
gap: 1rem;
align-items: center;
display: flex;
flex-direction: column;
gap: 1rem;
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;
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);
opacity: 1;
transform: translateY(0);
}
@media (min-width: 640px) {
.hero-buttons {
flex-direction: row;
justify-content: center;
}
.hero-buttons {
flex-direction: row;
justify-content: center;
}
}
@media (max-width: 767px) {
.hero-content {
padding: 0 1rem;
}
.hero-content {
padding: 0 1rem;
}
}
/* 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;
left: 50%;
transform: translateX(-50%);
animation: bounce 2s infinite;
}
.scroll-mouse {
width: 24px;
height: 40px;
border: 2px solid var(--foreground);
border-radius: 12px;
display: flex;
justify-content: center;
padding-top: 8px;
}
.scroll-dot {
width: 4px;
height: 12px;
background: var(--foreground);
border-radius: 2px;
animation: pulse 2s infinite;
}
</style>
<script setup lang="ts">
import EdgyButton from "@/components/dredgy/EdgyButton.vue";
import GradientText from "@/components/dredgy/GradientText.vue";
import {onMounted} from "vue";
import { onMounted } from "vue";
import ScrollIndicator from "@/components/dredgy/ScrollIndicator.vue";
const initParallax = (): void => {
const heroBackground = document.querySelector('.hero-bg') as HTMLElement | null;

View File

@@ -0,0 +1,242 @@
// directives/hoverEffects.ts
import { Directive } from 'vue'
export interface HoverOptions {
type?: 'lift' | 'scale' | 'tilt' | 'glow' | 'rotate' | 'slide-up' | 'slide-down' | 'bounce' | 'shake'
duration?: number | string
easing?: string
intensity?: number // Scale factor for the effect (0.1 = subtle, 1.0 = normal, 2.0 = dramatic)
transform?: string // Custom transform for hover state
boxShadow?: string // Custom box shadow for hover state
backgroundColor?: string // Custom background color for hover state
borderColor?: string // Custom border color for hover state
color?: string // Custom text color for hover state
scale?: number // Custom scale value
translateY?: number // Custom Y translation in pixels
translateX?: number // Custom X translation in pixels
rotateZ?: number // Custom rotation in degrees
disabled?: boolean // Disable hover effects
}
export type HoverValue = string | HoverOptions
interface HoverElement extends HTMLElement {
_hoverOptions?: HoverOptions
_originalStyles?: {
transition?: string
transform?: string
boxShadow?: string
backgroundColor?: string
borderColor?: string
color?: string
}
_mouseEnterHandler?: (e: MouseEvent) => void
_mouseLeaveHandler?: (e: MouseEvent) => void
}
function setupHoverEffects(el: HoverElement, binding: any) {
// Clean up existing handlers
if (el._mouseEnterHandler) {
el.removeEventListener('mouseenter', el._mouseEnterHandler)
el.removeEventListener('mouseleave', el._mouseLeaveHandler!)
}
let options: HoverOptions = {}
if (typeof binding.value === 'string') {
options = { type: binding.value }
} else if (typeof binding.value === 'object') {
options = { ...binding.value }
}
const {
type = 'lift',
duration = '0.3s',
easing = 'ease',
intensity = 1.0,
transform,
boxShadow,
backgroundColor,
borderColor,
color,
scale,
translateY,
translateX,
rotateZ,
disabled = false
} = options
el._hoverOptions = options
if (disabled) return
// Store original styles
const computedStyle = window.getComputedStyle(el)
el._originalStyles = {
transition: computedStyle.transition,
transform: computedStyle.transform,
boxShadow: computedStyle.boxShadow,
backgroundColor: computedStyle.backgroundColor,
borderColor: computedStyle.borderColor,
color: computedStyle.color
}
// Set base transition
el.style.transition = `all ${duration} ${easing}`
// Get hover effects based on type
const hoverEffects = getHoverEffects(type, intensity, {
transform,
boxShadow,
backgroundColor,
borderColor,
color,
scale,
translateY,
translateX,
rotateZ
})
// Mouse enter handler
el._mouseEnterHandler = (e: MouseEvent) => {
Object.assign(el.style, hoverEffects.enter)
}
// Mouse leave handler
el._mouseLeaveHandler = (e: MouseEvent) => {
Object.assign(el.style, hoverEffects.leave)
}
el.addEventListener('mouseenter', el._mouseEnterHandler)
el.addEventListener('mouseleave', el._mouseLeaveHandler)
}
function getHoverEffects(
type: string,
intensity: number,
customValues: Partial<HoverOptions>
) {
const effects = {
enter: {} as any,
leave: {} as any
}
// Custom values take precedence
if (customValues.transform) {
effects.enter.transform = customValues.transform
effects.leave.transform = 'none'
return effects
}
// Preset effects
switch (type) {
case 'lift':
effects.enter.transform = `translateY(${customValues.translateY || -8 * intensity}px) scale(${customValues.scale || 1 + 0.02 * intensity})`
effects.enter.boxShadow = customValues.boxShadow || `0 ${10 * intensity}px ${25 * intensity}px rgba(0,0,0,0.15)`
break
case 'scale':
effects.enter.transform = `scale(${customValues.scale || 1 + 0.05 * intensity})`
break
case 'tilt':
effects.enter.transform = `perspective(1000px) rotateY(${customValues.rotateZ || 5 * intensity}deg) scale(${1 + 0.02 * intensity})`
break
case 'glow':
effects.enter.boxShadow = customValues.boxShadow || `0 0 ${20 * intensity}px rgba(59, 130, 246, 0.5)`
effects.enter.transform = `scale(${1 + 0.02 * intensity})`
break
case 'rotate':
effects.enter.transform = `rotate(${customValues.rotateZ || 5 * intensity}deg) scale(${1 + 0.02 * intensity})`
break
case 'slide-up':
effects.enter.transform = `translateY(${customValues.translateY || -5 * intensity}px)`
break
case 'slide-down':
effects.enter.transform = `translateY(${customValues.translateY || 5 * intensity}px)`
break
case 'bounce':
effects.enter.transform = `translateY(${-3 * intensity}px) scale(${1 + 0.05 * intensity})`
effects.enter.transition = `all 0.2s cubic-bezier(0.68, -0.55, 0.265, 1.55)`
break
case 'shake':
effects.enter.animation = `shake-${intensity} 0.5s ease-in-out`
break
}
// Add custom color effects
if (customValues.backgroundColor) {
effects.enter.backgroundColor = customValues.backgroundColor
}
if (customValues.borderColor) {
effects.enter.borderColor = customValues.borderColor
}
if (customValues.color) {
effects.enter.color = customValues.color
}
// Leave effects (return to original)
effects.leave.transform = 'none'
effects.leave.boxShadow = ''
effects.leave.backgroundColor = ''
effects.leave.borderColor = ''
effects.leave.color = ''
effects.leave.animation = ''
return effects
}
export const hoverEffects: Directive = {
mounted(el: HoverElement, binding) {
setupHoverEffects(el, binding)
},
updated(el: HoverElement, binding) {
if (JSON.stringify(binding.value) !== JSON.stringify(binding.oldValue)) {
setupHoverEffects(el, binding)
}
},
unmounted(el: HoverElement) {
if (el._mouseEnterHandler) {
el.removeEventListener('mouseenter', el._mouseEnterHandler)
el.removeEventListener('mouseleave', el._mouseLeaveHandler!)
}
}
}
// Add shake keyframes to document if not already added
if (typeof window !== 'undefined') {
const addShakeKeyframes = () => {
if (document.querySelector('#hover-directive-styles')) return
const style = document.createElement('style')
style.id = 'hover-directive-styles'
style.innerHTML = `
@keyframes shake-1 {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-2px); }
75% { transform: translateX(2px); }
}
@keyframes shake-2 {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-5px); }
75% { transform: translateX(5px); }
}
`
document.head.appendChild(style)
}
// Add styles when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', addShakeKeyframes)
} else {
addShakeKeyframes()
}
}

View File

@@ -0,0 +1,122 @@
// directives/intersectionTransition.ts
import { Directive } from 'vue'
interface TransitionOptions {
type?: string
duration?: number | string
easing?: string
delay?: number | string
opacity?: number | string
transform?: string
threshold?: number
}
interface IntersectionElement extends HTMLElement {
_observer?: IntersectionObserver
_options?: TransitionOptions
}
function setupTransition(el: IntersectionElement, binding: any) {
// Handle different binding value types
let options: TransitionOptions = {}
if (typeof binding.value === 'string') {
// Simple string usage: v-show-on-intersect="'fade-up'"
options = { type: binding.value }
} else if (typeof binding.value === 'object') {
// Object usage: v-show-on-intersect="{ type: 'fade-up', duration: '2s', transform: 'translateX(-100px)' }"
options = { ...binding.value }
}
// Set defaults
const {
type = 'fade-up',
duration = '1s',
easing = 'cubic-bezier(0.4, 0, 0.2, 1)',
delay = '0s',
opacity = '0',
transform,
threshold = 0.1
} = options
el._options = options
// Get initial transform (custom or preset)
const initialTransform = transform || getInitialTransform(type)
// Initially hide the element
el.style.opacity = String(opacity)
el.style.transform = initialTransform
el.style.transition = `all ${duration} ${easing} ${delay}`
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
// Show element
el.style.opacity = '1'
el.style.transform = 'none'
} else {
// Hide element
el.style.opacity = String(opacity)
el.style.transform = initialTransform
}
})
}, {
threshold: threshold as number
})
el._observer = observer
observer.observe(el)
}
export const intersectionTransition: Directive = {
mounted(el: IntersectionElement, binding) {
setupTransition(el, binding)
},
updated(el: IntersectionElement, binding) {
// Handle dynamic updates to the directive value
if (JSON.stringify(binding.value) !== JSON.stringify(binding.oldValue)) {
// Disconnect old observer and setup with new options
if (el._observer) {
el._observer.disconnect()
}
setupTransition(el, binding)
}
},
unmounted(el: IntersectionElement) {
if (el._observer) {
el._observer.disconnect()
}
}
}
function getInitialTransform(transitionType: string): string {
switch (transitionType) {
case 'fade-up':
return 'translateY(5em)'
case 'fade-down':
return 'translateY(-5em)'
case 'slide-left':
return 'translateX(5em)'
case 'slide-right':
return 'translateX(-5em)'
case 'scale':
return 'scale(0.8)'
case 'scale-up':
return 'scale(1.2)'
case 'rotate':
return 'rotate(180deg)'
case 'rotate-left':
return 'translateY(100px) rotate(12deg) scale(0.9)'
case 'rotate-right':
return 'translateY(100px) rotate(-12deg) scale(0.9)'
case 'flip-x':
return 'rotateX(90deg)'
case 'flip-y':
return 'rotateY(90deg)'
default:
return ''
}
}

View File

@@ -2,7 +2,7 @@
import type { BreadcrumbItemType } from '@/types';
import { onMounted } from 'vue';
import Header from "@/components/Header.vue";
import {VApp} from "vuetify/components/VApp";
import Footer from "@/components/Footer.vue";
interface Props {
breadcrumbs?: BreadcrumbItemType[];
@@ -17,10 +17,34 @@ withDefaults(defineProps<Props>(), {
<template>
<Header />
<slot />
</template>
<transition name="fade" mode="out-in">
<main :key="$page.url">
<slot />
<Footer />
</main>
</transition>
</template>
<style scoped>
/* fade transition */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s ease; /* change 0.5s to slower/faster */
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.fade-enter-to,
.fade-leave-from {
opacity: 1;
}
</style>
<style>
:root {
/* Colors */
--primary: hsl(210, 100%, 50%);
@@ -64,11 +88,46 @@ withDefaults(defineProps<Props>(), {
box-sizing: border-box;
}
html, body{
scrollbar-width: none;
-ms-overflow-style:none;
scroll-behavior: smooth;
}
/* Temporary debug styles */
/* Hide scrollbar for webkit browsers (Chrome, Safari, Edge) */
::-webkit-scrollbar {
display: none;
}
::-webkit-scrollbar-button {
display: none;
}
html::-webkit-scrollbar,
body::-webkit-scrollbar {
display: none;
}
html{
overflow:auto;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background: var(--background);
color: var(--foreground);
line-height: 1.6;
overflow-x: hidden;
display:flex;
flex-direction: column;
}
main{
margin-top: 5dvh;
min-height: 90dvh;
flex-direction: column;
flex: 1;
}
</style>

View File

@@ -1,5 +1,8 @@
<script setup lang="ts">
import AppLayout from "@/layouts/AppLayout.vue";
import SectionContainer from "@/components/dredgy/SectionContainer.vue";
import SectionTitle from "@/components/dredgy/SectionTitle.vue";
import ProfileCard from "@/components/ProfileCard.vue";
defineOptions({
layout: AppLayout
@@ -7,11 +10,119 @@ defineOptions({
</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>
<section class="about about-us" id="about-us">
<div class="about-decorations">
<div class="about-decoration about-decoration-1"></div>
<div class="about-decoration about-decoration-2"></div>
<div class="about-decoration about-decoration-3"></div>
</div>
<SectionContainer>
<div v-show-on-intersect="'fade-up'" class="about-content">
<SectionTitle title="Meet" gradient="The Team" subtitle="Dr Edgy was started by friends who can't get enough of adventuring and who enjoy the thrill of planning and sharing their experiences." />
<div class="company-blurb" v-show-on-intersect="{type:'fade-up'}">
<p>
</p>
</div>
<div class="profiles">
<ProfileCard
photoSide="left"
name="Joshua Dredge"
position="Founder & Guide"
slide="left"
photo="/img/staff/josh_dredge.jpg"
blurb="Josh has been travelling since he was an infant, and has no plans to stop! He loves the act of discovery and is always going to places that he can't find an information on! He has a particular penchant for China, Central Asia and anywhere with a desert.
His background is in bartending, small-business management, wine logistics and software development; but made the move to guiding so that he's not tied down! He can't wait to introduce you to new and exciting places!"
/>
</div>
<div class="profiles">
<ProfileCard
slide="right"
photoSide="right"
name="Dr Rainbow Yuan"
position="Founder & Tour Designer (China)"
photo="/img/staff/rainbow_yuan.jpg"
blurb="Born and raised in Dongguan, China, Rainbow earned her doctorate of education and has taught widely across China, Malaysia, Thailand and even
at Kim il-Sung university in North Korea! She's of Hakka descent and is fluent in English, Mandarin and Cantonese. Rainbow has travelled far and wide
and is always eager to share her knowledge and experiences with you."
/>
</div>
</div>
</SectionContainer>
</section>
</template>
<style scoped>
/* Company blurb */
.company-blurb {
margin-top: 1.25rem;
display: flex;
align-items: center;
justify-content: center;
}
.company-blurb p {
color: var(--muted-foreground);
font-size: 1.08rem;
line-height: 1.7;
max-width: 70ch;
text-align: center;
}
/* Base "About" styling to match the Home page aesthetic */
.about {
position: relative;
padding: 5rem 0;
background: var(--background);
overflow: hidden;
min-height: 90dvh;
}
.about-decorations {
position: absolute;
inset: 0;
pointer-events: none;
}
.about-decoration {
position: absolute;
opacity: 0.05;
clip-path: polygon(0 0, calc(100% - 20px) 0, 100% 20px, 100% 100%, 20px 100%, 0 calc(100% - 20px));
}
.about-decoration-1 {
top: 2rem;
right: 2rem;
width: 100px;
height: 100px;
background: var(--gradient-adventure);
}
.about-decoration-2 {
bottom: 3rem;
left: 3rem;
width: 80px;
height: 80px;
background: var(--gradient-accent);
}
.about-decoration-3 {
top: 50%;
right: 5rem;
width: 6px;
height: 100px;
background: var(--gradient-adventure);
}
/* Profiles layout */
.profiles {
display: grid;
gap: 2.5rem;
margin-top: 3rem;
}
/* Mobile tweaks */
</style>

View File

@@ -28,57 +28,4 @@ defineOptions({
layout: AppLayout
})
// Types
const initSmoothScrolling = (): void => {
const navLinks = document.querySelectorAll('a[href^="#"]') as NodeListOf<HTMLAnchorElement>;
navLinks.forEach((link: HTMLAnchorElement) => {
link.addEventListener('click', (e: Event) => {
e.preventDefault();
const targetId = link.getAttribute('href');
const targetElement = targetId ? document.querySelector(targetId) as HTMLElement | null : null;
if (targetElement) {
const header = document.querySelector('.header') as HTMLElement | null;
const headerHeight = header ? header.offsetHeight : 0;
const targetPosition = targetElement.offsetTop - headerHeight;
window.scrollTo({
top: targetPosition,
behavior: 'smooth'
});
// Close dropdown if open
const dropdown = document.querySelector('.dropdown') as HTMLElement | null;
if (dropdown) {
dropdown.classList.remove('open');
}
}
});
});
};
// Initialize all functionality when DOM is loaded
onMounted(() => {
initSmoothScrolling();
});
// Add performance optimization
const debounce = <T extends (...args: any[]) => void>(func: T, wait: number): T => {
let timeout: number | undefined;
return ((...args: Parameters<T>) => {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
}) as T;
};
// Optimize scroll events
window.addEventListener('scroll', debounce(() => {
// Scroll-based animations can be added here
}, 16)); // ~60fps
</script>

View File

@@ -0,0 +1,17 @@
import { createVuetify } from 'vuetify';
import 'vuetify/styles';
import { aliases, mdi } from 'vuetify/iconsets/mdi';
import '@mdi/font/css/materialdesignicons.css';
export default createVuetify({
icons: {
defaultSet: 'mdi',
aliases,
sets: {
mdi,
},
},
theme: {
defaultTheme: 'dark',
}
});

View File

@@ -1,5 +1,13 @@
import { AppPageProps } from '@/types/index';
// resources/js/types/inertia.d.ts
import { Page } from '@inertiajs/core';
import { ComponentCustomProperties } from 'vue';
declare module '@vue/runtime-core' {
interface ComponentCustomProperties {
$page: Page<any>;
}
}
// Extend ImportMeta interface for Vite...
declare module 'vite/client' {
interface ImportMetaEnv {