feat: initial commit — site + admin panel + Postgres content pipeline
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<section
|
||||
ref="bannerSectionRef"
|
||||
class="relative isolate overflow-hidden px-4 py-8 md:px-8 bg-light"
|
||||
>
|
||||
<div
|
||||
class="relative mx-auto max-w-7xl overflow-hidden py-10 text-dark md:py-20"
|
||||
>
|
||||
<div
|
||||
class="relative z-10 grid items-center gap-10 md:grid-cols-[0.9fr_1.1fr]"
|
||||
>
|
||||
<div
|
||||
class="relative mx-auto h-[468px] md:h-[628px] min-h-[420px] w-full max-w-[420px] overflow-hidden rounded-t-full rounded-b-[2rem] bg-neutral-100 shadow-xl"
|
||||
>
|
||||
<img
|
||||
ref="imageRef"
|
||||
src="/images/banner/1.webp"
|
||||
alt="Banner image"
|
||||
class="absolute left-0 -top-28 md:-top-44 h-[135%] w-[115%] max-w-none object-cover object-center will-change-transform"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<h2
|
||||
class="max-w-3xl text-4xl font-black leading-[0.95] tracking-tight md:text-5xl"
|
||||
>
|
||||
<span v-html="section.title" />
|
||||
</h2>
|
||||
|
||||
<p class="mt-8 max-w-xl text-base leading-8 text-black/60 md:text-lg">
|
||||
{{ section.description }}
|
||||
</p>
|
||||
|
||||
<div class="mt-9 flex flex-wrap items-center gap-4">
|
||||
<Button :button="section.button" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import gsap from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
import Button from "@/components/Button.vue";
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
defineProps<{
|
||||
section: {
|
||||
title: string;
|
||||
description: string;
|
||||
button: {
|
||||
enabled: boolean;
|
||||
label: string;
|
||||
url: string;
|
||||
external: boolean;
|
||||
color?: null;
|
||||
icon?: {
|
||||
name?:
|
||||
| "mail"
|
||||
| "document"
|
||||
| "code"
|
||||
| "external"
|
||||
| "arrowRight"
|
||||
| "arrowUp"
|
||||
| "none";
|
||||
position?: "left" | "right" | "center";
|
||||
};
|
||||
};
|
||||
};
|
||||
}>();
|
||||
|
||||
const bannerSectionRef = ref<HTMLElement | null>(null);
|
||||
const imageRef = ref<HTMLImageElement | null>(null);
|
||||
|
||||
let ctx: gsap.Context | null = null;
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
|
||||
if (!bannerSectionRef.value || !imageRef.value) return;
|
||||
|
||||
const section = bannerSectionRef.value;
|
||||
const image = imageRef.value;
|
||||
|
||||
ctx = gsap.context(() => {
|
||||
gsap.set(image, {
|
||||
xPercent: -10,
|
||||
y: -90,
|
||||
scale: 1.08,
|
||||
transformOrigin: "center center",
|
||||
});
|
||||
|
||||
gsap.to(image, {
|
||||
y: 90,
|
||||
ease: "none",
|
||||
scrollTrigger: {
|
||||
trigger: section,
|
||||
start: "top bottom",
|
||||
end: "bottom top",
|
||||
scrub: true,
|
||||
invalidateOnRefresh: true,
|
||||
},
|
||||
});
|
||||
}, section);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
ctx?.revert();
|
||||
ctx = null;
|
||||
});
|
||||
</script>
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,160 @@
|
||||
<template>
|
||||
<section ref="ctaSectionRef" class="relative overflow-hidden py-12 md:py-24">
|
||||
<div
|
||||
ref="bgRef"
|
||||
class="relative bg-[#d2024e] text-white"
|
||||
style="transform-origin: center center"
|
||||
>
|
||||
<div
|
||||
class="absolute left-1/2 top-0 h-12 w-[140%] -translate-x-1/2 -translate-y-1/2 rounded-b-[100%] bg-white md:h-24 md:w-[120%]"
|
||||
></div>
|
||||
|
||||
<div
|
||||
class="relative z-10 mx-auto grid max-w-5xl items-center gap-8 px-6 py-16 md:grid-cols-2 md:gap-10 md:py-24"
|
||||
>
|
||||
<div ref="textRef" class="text-center md:text-left">
|
||||
<p class="mb-4 text-sm font-medium text-white">
|
||||
{{ t("Online Order") }}
|
||||
</p>
|
||||
|
||||
<h2 class="mb-6 text-3xl font-bold leading-tight md:text-5xl">
|
||||
{{ t("Müco is at your door with Yemeksepeti!") }}
|
||||
</h2>
|
||||
|
||||
<Button
|
||||
:label="t('Order')"
|
||||
href="https://yemek.go.link/3PDtu"
|
||||
external
|
||||
variant="white"
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div ref="imageRef" class="relative flex justify-center">
|
||||
<img
|
||||
src="/images/cta.webp"
|
||||
alt="Müco Yemeksepeti"
|
||||
class="w-full max-w-64 drop-shadow-2xl sm:max-w-[320px] md:absolute md:top-1/2 md:max-h-[480px] md:w-auto md:max-w-none md:-translate-y-1/2"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute bottom-0 left-1/2 h-12 w-[140%] -translate-x-1/2 translate-y-1/2 rounded-t-[100%] bg-white md:h-24 md:w-[120%]"
|
||||
></div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import gsap from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const ctaSectionRef = ref<HTMLElement | null>(null);
|
||||
const bgRef = ref<HTMLElement | null>(null);
|
||||
const textRef = ref<HTMLElement | null>(null);
|
||||
const imageRef = ref<HTMLElement | null>(null);
|
||||
|
||||
let ctx: gsap.Context | null = null;
|
||||
let mm: gsap.MatchMedia | null = null;
|
||||
|
||||
const createAnimation = () => {
|
||||
if (
|
||||
!ctaSectionRef.value ||
|
||||
!bgRef.value ||
|
||||
!textRef.value ||
|
||||
!imageRef.value
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bg = bgRef.value;
|
||||
const text = textRef.value;
|
||||
const image = imageRef.value;
|
||||
const section = ctaSectionRef.value;
|
||||
|
||||
gsap.set(bg, {
|
||||
scaleY: 0,
|
||||
transformOrigin: "center center",
|
||||
});
|
||||
|
||||
gsap.set(text, {
|
||||
x: -120,
|
||||
opacity: 0,
|
||||
});
|
||||
|
||||
gsap.set(image, {
|
||||
x: 120,
|
||||
opacity: 0,
|
||||
});
|
||||
|
||||
const tl = gsap.timeline({
|
||||
scrollTrigger: {
|
||||
trigger: section,
|
||||
start: "top 70%",
|
||||
end: "top 0%",
|
||||
scrub: 1,
|
||||
invalidateOnRefresh: true,
|
||||
},
|
||||
});
|
||||
|
||||
tl.to(bg, {
|
||||
scaleY: 1,
|
||||
ease: "power2.out",
|
||||
duration: 1,
|
||||
})
|
||||
.to(
|
||||
text,
|
||||
{
|
||||
x: 0,
|
||||
opacity: 1,
|
||||
ease: "power3.out",
|
||||
duration: 0.8,
|
||||
},
|
||||
"-=0.55",
|
||||
)
|
||||
.to(
|
||||
image,
|
||||
{
|
||||
x: 0,
|
||||
opacity: 1,
|
||||
ease: "power3.out",
|
||||
duration: 0.8,
|
||||
},
|
||||
"-=0.75",
|
||||
);
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
|
||||
if (!ctaSectionRef.value) return;
|
||||
|
||||
ctx = gsap.context(() => {
|
||||
mm = gsap.matchMedia();
|
||||
|
||||
mm.add("(min-width: 768px)", () => {
|
||||
createAnimation();
|
||||
});
|
||||
|
||||
mm.add("(max-width: 767px)", () => {
|
||||
createAnimation();
|
||||
});
|
||||
}, ctaSectionRef.value);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
mm?.revert();
|
||||
ctx?.revert();
|
||||
|
||||
mm = null;
|
||||
ctx = null;
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,754 @@
|
||||
<template>
|
||||
<section
|
||||
v-if="section?.enabled"
|
||||
class="relative flex items-center"
|
||||
id="contact"
|
||||
>
|
||||
<div class="relative mx-auto w-full max-w-4xl">
|
||||
<div class="mb-10 flex justify-center text-center gap-6">
|
||||
<div>
|
||||
<h2
|
||||
v-if="section?.title"
|
||||
class="text-2xl sm:text-5xl font-bold tracking-tight text-dark"
|
||||
>
|
||||
{{ section.title }}
|
||||
</h2>
|
||||
|
||||
<p v-if="section?.description" class="mt-2 text-md text-dark">
|
||||
{{ section?.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="onSubmit" novalidate class="group">
|
||||
<div v-if="showSuccess" ref="successPanel">
|
||||
<div class="flex items-center justify-center text-center gap-4">
|
||||
<div>
|
||||
<div class="flex justify-center items-center py-6">
|
||||
<img
|
||||
src="/images/egg-walking.gif"
|
||||
alt="Egg Gif"
|
||||
aria-hidden="true"
|
||||
class="h-56 w-56"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3 class="text-2xl font-semibold text-softlight">
|
||||
{{ t("Message sent!") }}
|
||||
</h3>
|
||||
|
||||
<p class="mt-1 text-lg text-dark">
|
||||
{{ t("We'll get back to you soon.") }}
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="
|
||||
showSuccess = false;
|
||||
nextTick(() => firstInput?.focus());
|
||||
"
|
||||
class="cursor-pointer mt-6 inline-flex items-center gap-2 border border-dark px-4 py-2 text-dark font-bold hover:bg-light focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-light focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-900"
|
||||
>
|
||||
{{ t("Send another message") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div class="sm:p-2 md:p-6">
|
||||
<div class="grid sm:grid-cols-2">
|
||||
<!-- Full name field -->
|
||||
<div class="p-4">
|
||||
<label
|
||||
for="full-name"
|
||||
class="block text-md font-medium text-dark px-1"
|
||||
>
|
||||
{{ t("Full Name") }} <span class="text-red-600">*</span>
|
||||
</label>
|
||||
|
||||
<input
|
||||
id="full-name"
|
||||
ref="firstInput"
|
||||
v-model.trim="form.fullName"
|
||||
@input="touched.fullName = true"
|
||||
type="text"
|
||||
:aria-invalid="!!errors.fullName || undefined"
|
||||
:aria-describedby="
|
||||
errors.fullName ? 'full-name-error' : undefined
|
||||
"
|
||||
class="bg-[#fff]/40 mt-2 w-full border border-dark/20 px-3 py-3 text-dark placeholder-dark/40 outline-none focus:ring-1 focus:ring-light focus:border-transparent transition"
|
||||
:placeholder="t('Full Name')"
|
||||
autocomplete="name"
|
||||
/>
|
||||
|
||||
<p
|
||||
v-if="touched.fullName && errors.fullName"
|
||||
id="full-name-error"
|
||||
class="mt-2 text-sm text-red-500 px-1"
|
||||
>
|
||||
{{ errors.fullName }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Subject selection -->
|
||||
<!-- Subject selection -->
|
||||
<div class="p-4">
|
||||
<label
|
||||
for="subject"
|
||||
class="block text-md font-medium text-dark px-1"
|
||||
>
|
||||
{{ t("Subject") }} <span class="text-red-600">*</span>
|
||||
</label>
|
||||
|
||||
<div class="relative mt-2">
|
||||
<!-- Trigger -->
|
||||
<button
|
||||
type="button"
|
||||
@click="isSubjectOpen = !isSubjectOpen"
|
||||
@blur="handleSubjectBlur"
|
||||
:aria-invalid="!!errors.subject || undefined"
|
||||
:aria-describedby="
|
||||
errors.subject ? 'subject-error' : undefined
|
||||
"
|
||||
class="group flex w-full items-center justify-between border border-dark/20 bg-[#fff]/40 px-4 py-3 text-left text-dark outline-none transition hover:bg-white/60 focus:ring-1 focus:ring-light"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
form.subject
|
||||
? 'text-dark'
|
||||
: 'text-dark/40'
|
||||
"
|
||||
>
|
||||
{{
|
||||
selectedSubjectLabel ||
|
||||
t("Select a subject")
|
||||
}}
|
||||
</span>
|
||||
|
||||
<svg
|
||||
class="h-5 w-5 shrink-0 text-dark/50 transition"
|
||||
:class="isSubjectOpen ? 'rotate-180' : ''"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M6 9l6 6 6-6"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Dropdown -->
|
||||
<transition
|
||||
enter-active-class="transition duration-150 ease-out"
|
||||
enter-from-class="opacity-0 -translate-y-1"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
leave-active-class="transition duration-100 ease-in"
|
||||
leave-from-class="opacity-100 translate-y-0"
|
||||
leave-to-class="opacity-0 -translate-y-1"
|
||||
>
|
||||
<div
|
||||
v-if="isSubjectOpen"
|
||||
class="absolute left-0 right-0 z-20 mt-2 overflow-hidden border border-dark/10 bg-white/90 shadow-xl backdrop-blur-xl"
|
||||
>
|
||||
<button
|
||||
v-for="opt in subjects"
|
||||
:key="opt.value"
|
||||
type="button"
|
||||
@click="selectSubject(opt.value)"
|
||||
class="flex w-full items-center justify-between px-4 py-3 text-left text-dark transition hover:bg-dark/3"
|
||||
>
|
||||
<span>
|
||||
{{ opt.label }}
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="form.subject === opt.value"
|
||||
class="text-sm text-softlight"
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<label
|
||||
v-if="form.subject === 'something-else'"
|
||||
for="subject-other"
|
||||
class="sr-only"
|
||||
>
|
||||
{{ t("Other Subject") }}
|
||||
</label>
|
||||
|
||||
<input
|
||||
v-if="form.subject === 'something-else'"
|
||||
id="subject-other"
|
||||
v-model.trim="form.subjectOther"
|
||||
@input="touched.subject = true"
|
||||
type="text"
|
||||
:placeholder="t('Other Subject')"
|
||||
class="bg-[#fff]/40 mt-3 w-full border border-dark/20 px-3 py-3 text-dark placeholder-dark/40 outline-none transition focus:border-transparent focus:ring-1 focus:ring-light"
|
||||
name="subjectOther"
|
||||
/>
|
||||
|
||||
<p
|
||||
v-if="touched.subject && errors.subject"
|
||||
id="subject-error"
|
||||
class="mt-2 px-1 text-sm text-red-500"
|
||||
>
|
||||
{{ errors.subject }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid sm:grid-cols-2">
|
||||
<!-- Email field -->
|
||||
<div class="p-4">
|
||||
<label
|
||||
for="email"
|
||||
class="block text-md font-medium text-dark px-1"
|
||||
>
|
||||
{{ t("Email") }} <span class="text-red-600">*</span>
|
||||
</label>
|
||||
|
||||
<input
|
||||
id="email"
|
||||
v-model.trim="form.email"
|
||||
@input="touched.email = true"
|
||||
type="email"
|
||||
:aria-invalid="!!errors.email || undefined"
|
||||
:aria-describedby="errors.email ? 'email-error' : undefined"
|
||||
class="bg-[#fff]/40 mt-2 w-full border border-dark/20 px-3 py-3 text-dark placeholder-dark/40 outline-none focus:ring-1 focus:ring-light focus:border-transparent transition"
|
||||
placeholder="you@example.com"
|
||||
autocomplete="email"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
inputmode="email"
|
||||
/>
|
||||
|
||||
<p
|
||||
v-if="touched.email && errors.email"
|
||||
id="email-error"
|
||||
class="mt-2 text-sm text-red-500 px-1"
|
||||
>
|
||||
{{ errors.email }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Phone field -->
|
||||
<div class="p-4">
|
||||
<label
|
||||
for="phone"
|
||||
class="block text-md font-medium text-dark px-1"
|
||||
>
|
||||
{{ t("Phone") }}
|
||||
</label>
|
||||
|
||||
<input
|
||||
id="phone"
|
||||
v-model.trim="form.phone"
|
||||
@input="touched.phone = true"
|
||||
type="tel"
|
||||
:aria-invalid="!!errors.phone || undefined"
|
||||
:aria-describedby="errors.phone ? 'phone-error' : undefined"
|
||||
class="bg-[#fff]/40 mt-2 w-full border border-dark/20 px-3 py-3 text-dark placeholder-dark/40 outline-none focus:ring-1 focus:ring-light focus:border-transparent transition"
|
||||
:placeholder="t('Phone')"
|
||||
autocomplete="tel"
|
||||
inputmode="tel"
|
||||
/>
|
||||
|
||||
<p
|
||||
v-if="touched.phone && errors.phone"
|
||||
id="phone-error"
|
||||
class="mt-2 text-sm text-red-500 px-1"
|
||||
>
|
||||
{{ errors.phone }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Message -->
|
||||
<div class="mt-3 p-4 relative">
|
||||
<label
|
||||
for="message"
|
||||
class="block text-md font-medium text-dark px-1"
|
||||
>
|
||||
{{ t("Message") }} <span class="text-red-600">*</span>
|
||||
</label>
|
||||
|
||||
<textarea
|
||||
id="message"
|
||||
v-model.trim="form.message"
|
||||
@input="
|
||||
touched.message = true;
|
||||
if (form.message.length > 600)
|
||||
form.message = form.message.slice(0, 600);
|
||||
"
|
||||
rows="5"
|
||||
:aria-invalid="!!errors.message || undefined"
|
||||
:aria-describedby="errors.message ? 'message-error' : undefined"
|
||||
class="bg-[#fff]/40 border border-dark/20 mt-2 w-full px-3 py-3 text-dark placeholder-dark/40 outline-none focus:ring-1 focus:ring-light focus:border-transparent transition resize-y min-h-36"
|
||||
:placeholder="t('Write your message here…')"
|
||||
maxlength="600"
|
||||
/>
|
||||
|
||||
<span
|
||||
class="absolute bottom-0 right-4 text-xs select-none"
|
||||
:class="
|
||||
form.message.length >= 600 ? 'text-red-500' : 'text-dark'
|
||||
"
|
||||
aria-live="polite"
|
||||
>
|
||||
{{ form.message.length }}/600
|
||||
</span>
|
||||
|
||||
<p
|
||||
v-if="touched.message && errors.message"
|
||||
id="message-error"
|
||||
class="mt-2 text-sm text-red-500 px-1"
|
||||
>
|
||||
{{ errors.message }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Consent -->
|
||||
<div class="mt-2 p-4">
|
||||
<label
|
||||
for="consent"
|
||||
class="flex items-start gap-3 cursor-pointer select-none"
|
||||
>
|
||||
<input
|
||||
id="consent"
|
||||
v-model="form.consent"
|
||||
@input="touched.consent = true"
|
||||
type="checkbox"
|
||||
class="bg-[#fff]/40 peer sr-only"
|
||||
:aria-invalid="!!errors.consent || undefined"
|
||||
:aria-describedby="
|
||||
errors.consent ? 'consent-error' : undefined
|
||||
"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="flex h-5 w-5 shrink-0 items-center justify-center rounded-md border-2 border-dark/20 bg-white transition-all duration-200 peer-checked:border-highlight peer-checked:bg-highlight peer-focus:ring-2 peer-focus:ring-light peer-focus:ring-offset-2 peer-focus:ring-offset-highlight"
|
||||
>
|
||||
<svg
|
||||
class="h-3.5 w-3.5 scale-0 text-white transition-all duration-200 peer-checked:scale-100"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<span class="text-sm text-dark">
|
||||
{{ t("I agree to be contacted regarding my inquiry.") }}
|
||||
<span class="text-red-600">*</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<p
|
||||
v-if="touched.consent && errors.consent"
|
||||
id="consent-error"
|
||||
class="mt-2 px-1 text-sm text-red-500"
|
||||
>
|
||||
{{ errors.consent }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Honeypot -->
|
||||
<div class="sr-only" aria-hidden="true">
|
||||
<label for="checkIfYouAreNotARobot">
|
||||
Check if you are not a robot
|
||||
</label>
|
||||
|
||||
<input
|
||||
id="checkIfYouAreNotARobot"
|
||||
v-model.trim="form.checkIfYouAreNotARobot"
|
||||
type="text"
|
||||
tabindex="-1"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Turnstile -->
|
||||
<div class="p-4">
|
||||
<div
|
||||
v-if="turnstileSiteKey"
|
||||
ref="turnstileContainer"
|
||||
:id="turnstileId"
|
||||
></div>
|
||||
|
||||
<p v-else class="text-xs text-dark">
|
||||
Bot protection not configured (recommended).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<input type="hidden" :value="turnstileToken" />
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="flex flex-col text-center gap-4 p-4">
|
||||
<p
|
||||
v-if="status.message"
|
||||
:class="status.ok ? 'text-emerald-400' : 'text-red-500'"
|
||||
class="text-sm font-bold"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
{{ status.message }}
|
||||
</p>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
:disabled="submitting || !isValid"
|
||||
variant="highlight"
|
||||
size="sm"
|
||||
class="cursor-pointer sm:w-auto sm:min-w-40 ml-auto"
|
||||
>
|
||||
<span v-if="!submitting">{{ t("Send") }}</span>
|
||||
<span v-else>{{ t("Sending…") }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, computed, ref, watch, nextTick, onMounted } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps<{
|
||||
section?: {
|
||||
enabled?: boolean;
|
||||
title?: string | null;
|
||||
description?: string | null;
|
||||
};
|
||||
defaultSubject?: string;
|
||||
}>();
|
||||
|
||||
const turnstileSiteKey =
|
||||
(typeof window !== "undefined" && (window as any).__TURNSTILE_SITEKEY) ||
|
||||
import.meta.env?.VITE_TURNSTILE_SITEKEY ||
|
||||
"";
|
||||
|
||||
const subjects = computed(() => [
|
||||
{ value: "job-application", label: t("Job Application") },
|
||||
{ value: "suggestion", label: t("Suggestion") },
|
||||
{ value: "something-else", label: t("Something Else") },
|
||||
]);
|
||||
|
||||
const form = reactive({
|
||||
fullName: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
subject: "",
|
||||
subjectOther: "",
|
||||
message: "",
|
||||
consent: false,
|
||||
checkIfYouAreNotARobot: "",
|
||||
});
|
||||
|
||||
const touched = reactive({
|
||||
fullName: false,
|
||||
email: false,
|
||||
phone: false,
|
||||
subject: false,
|
||||
message: false,
|
||||
consent: false,
|
||||
});
|
||||
|
||||
const submitting = ref(false);
|
||||
const isSubjectOpen = ref(false);
|
||||
|
||||
const status = reactive<{ ok: boolean; message: string | null }>({
|
||||
ok: false,
|
||||
message: null,
|
||||
});
|
||||
|
||||
const showSuccess = ref(false);
|
||||
const successPanel = ref<HTMLElement | null>(null);
|
||||
const firstInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const turnstileToken = ref("");
|
||||
const turnstileId = `turnstile-${Math.random().toString(36).slice(2, 9)}`;
|
||||
const turnstileContainer = ref<HTMLElement | null>(null);
|
||||
|
||||
const selectedSubjectLabel = computed(() => {
|
||||
return subjects.value.find((item) => item.value === form.subject)?.label || "";
|
||||
});
|
||||
|
||||
watch(showSuccess, async (v) => {
|
||||
if (v) {
|
||||
await nextTick();
|
||||
|
||||
try {
|
||||
successPanel.value?.focus?.();
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => form.subject,
|
||||
(val) => {
|
||||
if (val !== "something-else") {
|
||||
form.subjectOther = "";
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function selectSubject(value: string) {
|
||||
form.subject = value;
|
||||
touched.subject = true;
|
||||
isSubjectOpen.value = false;
|
||||
}
|
||||
|
||||
function handleSubjectBlur() {
|
||||
window.setTimeout(() => {
|
||||
isSubjectOpen.value = false;
|
||||
}, 120);
|
||||
}
|
||||
|
||||
function validateFullName(v: string) {
|
||||
if (!v) return t("Name is required");
|
||||
if (v.length < 4) return t("Please enter at least 4 characters");
|
||||
return "";
|
||||
}
|
||||
|
||||
function validateEmail(v: string) {
|
||||
if (!v) return t("Email is required");
|
||||
|
||||
const re = /^(?:[^\s@]+)@(?:[^\s@]+)\.[^\s@]{2,}$/i;
|
||||
|
||||
if (!re.test(v)) return t("Please enter a valid email");
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function validatePhone(v: string) {
|
||||
if (!v) return "";
|
||||
|
||||
const re =
|
||||
/^\+?[1-9]\d{0,3}[\s\-().]?\d{1,4}[\s\-().]?\d{1,4}[\s\-().]?\d{1,9}$/;
|
||||
|
||||
if (!re.test(v)) return t("Please enter a valid phone number");
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function validateSubject(v: string) {
|
||||
if (!v) return t("Subject is required");
|
||||
|
||||
if (v === "something-else" && !form.subjectOther) {
|
||||
return t("Please specify the subject");
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function validateMessage(v: string) {
|
||||
if (!v) return t("Message is required");
|
||||
if (v.length < 25) return t("Please write at least 25 characters");
|
||||
if (v.length > 600) return t("Please keep your message under 600 characters");
|
||||
return "";
|
||||
}
|
||||
|
||||
function validateConsent(v: boolean) {
|
||||
if (!v) return t("Please confirm you agree to be contacted");
|
||||
return "";
|
||||
}
|
||||
|
||||
const errors = reactive<{ [k: string]: string | "" }>({
|
||||
fullName: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
subject: "",
|
||||
message: "",
|
||||
consent: "",
|
||||
});
|
||||
|
||||
const isValid = computed(() => {
|
||||
errors.fullName = validateFullName(form.fullName);
|
||||
errors.email = validateEmail(form.email);
|
||||
errors.phone = validatePhone(form.phone);
|
||||
errors.subject = validateSubject(form.subject);
|
||||
errors.message = validateMessage(form.message);
|
||||
errors.consent = validateConsent(form.consent);
|
||||
|
||||
return (
|
||||
!errors.fullName &&
|
||||
!errors.email &&
|
||||
!errors.phone &&
|
||||
!errors.subject &&
|
||||
!errors.message &&
|
||||
!errors.consent
|
||||
);
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (
|
||||
props.defaultSubject &&
|
||||
subjects.value.some((item) => item.value === props.defaultSubject)
|
||||
) {
|
||||
form.subject = props.defaultSubject;
|
||||
touched.subject = true;
|
||||
}
|
||||
|
||||
if (!turnstileSiteKey) return;
|
||||
|
||||
if (!(window as any).turnstile) {
|
||||
const s = document.createElement("script");
|
||||
s.src = "https://challenges.cloudflare.com/turnstile/v0/api.js";
|
||||
s.async = true;
|
||||
s.defer = true;
|
||||
document.head.appendChild(s);
|
||||
|
||||
s.onload = () => {
|
||||
renderTurnstile();
|
||||
};
|
||||
} else {
|
||||
renderTurnstile();
|
||||
}
|
||||
});
|
||||
|
||||
function renderTurnstile() {
|
||||
try {
|
||||
if (!(window as any).turnstile || !turnstileContainer.value) return;
|
||||
|
||||
(window as any).turnstile.render(turnstileContainer.value, {
|
||||
sitekey: turnstileSiteKey,
|
||||
theme: "light",
|
||||
language: document?.documentElement?.lang || "auto",
|
||||
callback: (token: string) => {
|
||||
turnstileToken.value = token;
|
||||
},
|
||||
"expired-callback": () => {
|
||||
turnstileToken.value = "";
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn("Turnstile render failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (form.checkIfYouAreNotARobot) {
|
||||
status.ok = true;
|
||||
status.message = t("I'll get back to you soon.");
|
||||
return;
|
||||
}
|
||||
|
||||
touched.fullName = true;
|
||||
touched.email = true;
|
||||
touched.phone = true;
|
||||
touched.subject = true;
|
||||
touched.message = true;
|
||||
touched.consent = true;
|
||||
|
||||
if (!isValid.value) {
|
||||
status.ok = false;
|
||||
status.message = t("Please fix the errors above.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (turnstileSiteKey && !turnstileToken.value) {
|
||||
status.ok = false;
|
||||
status.message = t("Please complete the bot verification.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
submitting.value = true;
|
||||
status.ok = false;
|
||||
status.message = null;
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
fullName: form.fullName,
|
||||
email: form.email,
|
||||
phone: form.phone,
|
||||
subject: form.subject,
|
||||
subjectOther: form.subjectOther,
|
||||
message: form.message,
|
||||
consent: form.consent,
|
||||
checkIfYouAreNotARobot: form.checkIfYouAreNotARobot,
|
||||
};
|
||||
|
||||
if (turnstileToken.value) {
|
||||
payload.turnstileToken = turnstileToken.value;
|
||||
}
|
||||
|
||||
const res = await fetch("/contact", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const result = await res.json().catch(() => ({ success: false }));
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || "Unknown error");
|
||||
}
|
||||
|
||||
status.ok = true;
|
||||
showSuccess.value = true;
|
||||
status.message = null;
|
||||
|
||||
nextTick(() => {
|
||||
if (successPanel.value) {
|
||||
const top =
|
||||
successPanel.value.getBoundingClientRect().top + window.scrollY;
|
||||
|
||||
window.scrollTo({
|
||||
top: top - 250,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Object.assign(form, {
|
||||
fullName: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
subject: "",
|
||||
subjectOther: "",
|
||||
message: "",
|
||||
consent: false,
|
||||
checkIfYouAreNotARobot: "",
|
||||
});
|
||||
|
||||
(Object.keys(touched) as (keyof typeof touched)[]).forEach((key) => {
|
||||
touched[key] = false;
|
||||
});
|
||||
|
||||
turnstileToken.value = "";
|
||||
|
||||
try {
|
||||
if ((window as any).turnstile?.reset) {
|
||||
(window as any).turnstile.reset();
|
||||
}
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
status.ok = false;
|
||||
status.message = t("Something went wrong. Please try again.");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,253 @@
|
||||
<template>
|
||||
<section ref="gallerySectionRef" class="pt-16 pb-28">
|
||||
<div class="container mx-auto max-w-5xl px-4">
|
||||
<h2
|
||||
class="text-2xl sm:text-5xl font-bold tracking-tight text-dark mb-16 text-center"
|
||||
>
|
||||
<span v-html="t('Photo Gallery')"></span>
|
||||
</h2>
|
||||
|
||||
<!-- MOBILE -->
|
||||
<div
|
||||
class="-mx-4 flex snap-x snap-mandatory gap-4 overflow-x-auto px-4 py-4 md:hidden"
|
||||
>
|
||||
<button
|
||||
v-for="(image, index) in galleryImages"
|
||||
:key="`mobile-${index}`"
|
||||
ref="mobileItemRefs"
|
||||
type="button"
|
||||
class="relative shrink-0 snap-center overflow-hidden rounded-2xl"
|
||||
@click="openLightbox(index)"
|
||||
>
|
||||
<img
|
||||
:src="image"
|
||||
alt="Müco Gallery Image"
|
||||
class="aspect-square w-[78vw] max-w-[320px] object-cover"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- DESKTOP -->
|
||||
<div class="hidden gap-5 md:block md:columns-5">
|
||||
<button
|
||||
v-for="(image, index) in galleryImages"
|
||||
:key="`desktop-${index}`"
|
||||
ref="desktopItemRefs"
|
||||
type="button"
|
||||
class="group relative mb-5 block w-full overflow-hidden rounded-2xl"
|
||||
@click="openLightbox(index)"
|
||||
>
|
||||
<img
|
||||
:src="image"
|
||||
alt="Müco Gallery Image"
|
||||
class="w-full object-cover transition duration-500 group-hover:scale-110"
|
||||
:class="imageRatios[index % imageRatios.length]"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="absolute inset-0 bg-black/0 transition group-hover:bg-black/40"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center opacity-0 transition group-hover:opacity-100"
|
||||
>
|
||||
<div class="rounded-full bg-white/90 p-3 text-black">
|
||||
<component :is="icons.plus" class="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LIGHTBOX -->
|
||||
<div
|
||||
v-if="selectedImage"
|
||||
class="fixed inset-0 z-50 flex touch-pan-y items-center justify-center bg-black/85 px-4"
|
||||
@click.self="closeLightbox"
|
||||
@touchstart.passive="onTouchStart"
|
||||
@touchmove.passive="onTouchMove"
|
||||
@touchend="onTouchEnd"
|
||||
>
|
||||
<!-- CLOSE -->
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
class="absolute right-5 top-5 z-20 text-4xl text-white hover:opacity-70"
|
||||
@click="closeLightbox"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
<!-- PREV -->
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Previous image"
|
||||
class="absolute left-1 top-1/2 z-20 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full bg-white/80 text-2xl text-black hover:bg-white md:left-8 md:h-11 md:w-11"
|
||||
@click.stop="prev"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
|
||||
<!-- IMAGE -->
|
||||
<img
|
||||
:key="selectedImage"
|
||||
:src="selectedImage"
|
||||
alt="Müco Gallery Large Image"
|
||||
class="max-h-[90vh] max-w-full select-none rounded-2xl object-contain"
|
||||
draggable="false"
|
||||
/>
|
||||
|
||||
<!-- NEXT -->
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Next image"
|
||||
class="absolute right-1 top-1/2 z-20 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full bg-white/80 text-2xl text-black hover:bg-white md:right-8 md:h-11 md:w-11"
|
||||
@click.stop="next"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import gsap from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
import { useIcons } from "@/composables/useIcons";
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
const icons = useIcons();
|
||||
const { t } = useI18n();
|
||||
|
||||
const gallerySectionRef = ref<HTMLElement | null>(null);
|
||||
const mobileItemRefs = ref<HTMLElement[]>([]);
|
||||
const desktopItemRefs = ref<HTMLElement[]>([]);
|
||||
|
||||
let ctx: gsap.Context | null = null;
|
||||
let mm: gsap.MatchMedia | null = null;
|
||||
|
||||
const galleryImages = Array.from(
|
||||
{ length: 10 },
|
||||
(_, i) => `/images/gallery/${i + 1}.jpg`,
|
||||
);
|
||||
|
||||
const imageRatios = ["aspect-square", "aspect-[4/5]", "aspect-[3/4]"];
|
||||
|
||||
const selectedIndex = ref<number | null>(null);
|
||||
|
||||
const selectedImage = computed(() => {
|
||||
if (selectedIndex.value === null) return null;
|
||||
return galleryImages[selectedIndex.value];
|
||||
});
|
||||
|
||||
const openLightbox = (index: number) => {
|
||||
selectedIndex.value = index;
|
||||
document.body.style.overflow = "hidden";
|
||||
};
|
||||
|
||||
const closeLightbox = () => {
|
||||
selectedIndex.value = null;
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
|
||||
const next = () => {
|
||||
if (selectedIndex.value === null) return;
|
||||
selectedIndex.value = (selectedIndex.value + 1) % galleryImages.length;
|
||||
};
|
||||
|
||||
const prev = () => {
|
||||
if (selectedIndex.value === null) return;
|
||||
selectedIndex.value =
|
||||
(selectedIndex.value - 1 + galleryImages.length) % galleryImages.length;
|
||||
};
|
||||
|
||||
const touchStartX = ref(0);
|
||||
const touchStartY = ref(0);
|
||||
const touchEndX = ref(0);
|
||||
const touchEndY = ref(0);
|
||||
|
||||
const minSwipeDistance = 50;
|
||||
|
||||
const onTouchStart = (event: TouchEvent) => {
|
||||
touchStartX.value = event.changedTouches[0].screenX;
|
||||
touchStartY.value = event.changedTouches[0].screenY;
|
||||
touchEndX.value = event.changedTouches[0].screenX;
|
||||
touchEndY.value = event.changedTouches[0].screenY;
|
||||
};
|
||||
|
||||
const onTouchMove = (event: TouchEvent) => {
|
||||
touchEndX.value = event.changedTouches[0].screenX;
|
||||
touchEndY.value = event.changedTouches[0].screenY;
|
||||
};
|
||||
|
||||
const onTouchEnd = () => {
|
||||
const diffX = touchStartX.value - touchEndX.value;
|
||||
const diffY = touchStartY.value - touchEndY.value;
|
||||
|
||||
const isHorizontalSwipe = Math.abs(diffX) > Math.abs(diffY);
|
||||
const isValidSwipe = Math.abs(diffX) > minSwipeDistance;
|
||||
|
||||
if (!isHorizontalSwipe || !isValidSwipe) return;
|
||||
|
||||
if (diffX > 0) {
|
||||
next();
|
||||
} else {
|
||||
prev();
|
||||
}
|
||||
};
|
||||
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (selectedIndex.value === null) return;
|
||||
|
||||
if (e.key === "Escape") closeLightbox();
|
||||
if (e.key === "ArrowRight") next();
|
||||
if (e.key === "ArrowLeft") prev();
|
||||
};
|
||||
|
||||
const animate = (items: HTMLElement[]) => {
|
||||
if (!gallerySectionRef.value || !items.length) return;
|
||||
|
||||
gsap.set(items, { scale: 0, opacity: 0 });
|
||||
|
||||
gsap.to(items, {
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
ease: "back.out(2.2)",
|
||||
stagger: { each: 0.08, from: "random" },
|
||||
scrollTrigger: {
|
||||
trigger: gallerySectionRef.value,
|
||||
start: "top 90%",
|
||||
end: "top 25%",
|
||||
scrub: 1,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
|
||||
ctx = gsap.context(() => {
|
||||
mm = gsap.matchMedia();
|
||||
|
||||
mm.add("(min-width: 768px)", () => {
|
||||
animate(desktopItemRefs.value);
|
||||
});
|
||||
|
||||
mm.add("(max-width: 767px)", () => {
|
||||
animate(mobileItemRefs.value);
|
||||
});
|
||||
}, gallerySectionRef.value ?? undefined);
|
||||
|
||||
window.addEventListener("keydown", onKey);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
mm?.revert();
|
||||
ctx?.revert();
|
||||
window.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = "";
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<section class="py-16">
|
||||
<div class="container mx-auto px-4">
|
||||
<h2 class="mb-8 text-center text-3xl font-bold md:text-4xl">
|
||||
{{ t("Featured Dishes") }}
|
||||
</h2>
|
||||
|
||||
<div class="grid gap-8 md:grid-cols-3">
|
||||
<div
|
||||
v-for="item in featuredItems"
|
||||
:key="item.name"
|
||||
class="group relative overflow-hidden"
|
||||
>
|
||||
<img
|
||||
:src="item.image"
|
||||
:alt="item.name"
|
||||
class="h-64 w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="absolute inset-0 flex flex-col items-center justify-center bg-black/50 p-4 text-center opacity-0 transition-opacity duration-300 group-hover:opacity-100"
|
||||
>
|
||||
<h3 class="mb-2 text-xl font-bold text-white">
|
||||
{{ item.name }}
|
||||
</h3>
|
||||
|
||||
<p class="text-sm text-white">
|
||||
{{ item.desc }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const featuredItems = [
|
||||
{
|
||||
name: "Müco Burger",
|
||||
desc: "Bol lezzetli özel burger.",
|
||||
image: "/images/featured/muco-burger.jpg",
|
||||
},
|
||||
{
|
||||
name: "Tavuk Menü",
|
||||
desc: "Doyurucu ve çıtır tavuk lezzeti.",
|
||||
image: "/images/featured/chicken-menu.jpg",
|
||||
},
|
||||
{
|
||||
name: "Patates Kızartması",
|
||||
desc: "Sıcak, çıtır ve paylaşmalık.",
|
||||
image: "/images/featured/fries.jpg",
|
||||
},
|
||||
];
|
||||
</script>
|
||||
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<section id="about" class="bg-[#fff] py-20 md:py-28">
|
||||
<div class="max-w-7xl mx-auto px-4 grid items-start gap-12 lg:grid-cols-2">
|
||||
<div>
|
||||
<img
|
||||
src="/images/mucahit-uslu.webp"
|
||||
alt="Mücahit Uslu - Müco Mutfak ve Kahve"
|
||||
class="w-full md:w-2/5 lg:w-full h-full max-h-[768px] rounded-bl-4xl rounded-tr-4xl object-cover mx-auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="md:pl-8">
|
||||
<p class="mb-4 text-sm font-medium text-highlight">
|
||||
{{ t("Who we are") }}
|
||||
</p>
|
||||
|
||||
<h2
|
||||
class="text-2xl sm:text-5xl font-bold tracking-tight text-dark md:mb-6"
|
||||
>
|
||||
{{ t("About") }}
|
||||
</h2>
|
||||
|
||||
<div
|
||||
class="markdown mb-6 max-w-xl text-dark/80 [&_p]:mb-4 [&_p:last-child]:mb-0 [&_strong]:font-bold [&_em]:italic"
|
||||
v-html="overviewHtml"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import MarkdownIt from "markdown-it";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const md = new MarkdownIt({
|
||||
html: false, // raw HTML kapalı → XSS protection
|
||||
breaks: false,
|
||||
linkify: true,
|
||||
});
|
||||
|
||||
const overviewHtml = computed(() => {
|
||||
const value = t("Overview Text");
|
||||
if (!value) return "";
|
||||
return md.render(value);
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<section
|
||||
ref="parallaxSectionRef"
|
||||
class="relative w-screen overflow-hidden bg-dark h-[628px] md:h-screen"
|
||||
>
|
||||
<div class="absolute -inset-[10%] overflow-hidden">
|
||||
<img
|
||||
ref="imageRef"
|
||||
src="/images/banner/parallax.webp"
|
||||
alt=""
|
||||
class="absolute inset-0 h-[120%] w-full origin-center object-cover saturate-[1.08] contrast-[1.05] will-change-transform"
|
||||
draggable="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute inset-0 z-[2] bg-[radial-gradient(circle_at_50%_42%,rgb(0_0_0_/_0.08),rgb(0_0_0_/_0.54)_70%),linear-gradient(90deg,rgb(0_0_0_/_0.56),rgb(0_0_0_/_0.08),rgb(0_0_0_/_0.56))]"
|
||||
></div>
|
||||
|
||||
<div class="relative z-[4] grid h-full place-items-center px-6 text-center">
|
||||
<h2
|
||||
ref="titleRef"
|
||||
class="m-0 max-w-[920px] text-balance text-[clamp(36px,5vw,64px)] font-black uppercase leading-[0.96] tracking-[-0.055em] text-white [perspective:900px] [text-shadow:0_22px_46px_rgb(0_0_0_/_0.44)] will-change-transform max-md:text-[clamp(34px,11vw,54px)] max-md:leading-[0.94] max-md:tracking-[-0.05em]"
|
||||
>
|
||||
<span
|
||||
v-for="line in titleLines"
|
||||
:key="line"
|
||||
ref="titleLineRefs"
|
||||
class="block origin-bottom will-change-[transform,opacity,filter]"
|
||||
>
|
||||
{{ line }}
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import gsap from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const parallaxSectionRef = ref<HTMLElement | null>(null);
|
||||
const imageRef = ref<HTMLImageElement | null>(null);
|
||||
const titleRef = ref<HTMLElement | null>(null);
|
||||
const titleLineRefs = ref<HTMLElement[]>([]);
|
||||
|
||||
let ctx: gsap.Context | null = null;
|
||||
|
||||
const titleLines = [
|
||||
t("Unlimited Breakfast"),
|
||||
t("As you finish"),
|
||||
t("Ask For More"),
|
||||
];
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
|
||||
if (!parallaxSectionRef.value || !imageRef.value || !titleRef.value) return;
|
||||
|
||||
const section = parallaxSectionRef.value;
|
||||
const image = imageRef.value;
|
||||
const title = titleRef.value;
|
||||
const titleLines = titleLineRefs.value;
|
||||
|
||||
ctx = gsap.context(() => {
|
||||
gsap.fromTo(
|
||||
image,
|
||||
{
|
||||
yPercent: -10,
|
||||
scale: 1.14,
|
||||
},
|
||||
{
|
||||
yPercent: 10,
|
||||
scale: 1.04,
|
||||
ease: "none",
|
||||
scrollTrigger: {
|
||||
trigger: section,
|
||||
start: "top bottom",
|
||||
end: "bottom top",
|
||||
scrub: 1.8,
|
||||
invalidateOnRefresh: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const titleTl = gsap.timeline({
|
||||
scrollTrigger: {
|
||||
trigger: section,
|
||||
start: "top 72%",
|
||||
end: "center 45%",
|
||||
scrub: 1.4,
|
||||
invalidateOnRefresh: true,
|
||||
},
|
||||
});
|
||||
|
||||
titleTl.fromTo(
|
||||
titleLines,
|
||||
{
|
||||
y: 72,
|
||||
opacity: 0,
|
||||
rotateX: 38,
|
||||
scale: 0.92,
|
||||
filter: "blur(10px)",
|
||||
},
|
||||
{
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
rotateX: 0,
|
||||
scale: 1,
|
||||
filter: "blur(0px)",
|
||||
stagger: 0.28,
|
||||
ease: "power4.out",
|
||||
},
|
||||
);
|
||||
|
||||
gsap.to(title, {
|
||||
y: -26,
|
||||
scale: 1.015,
|
||||
ease: "none",
|
||||
scrollTrigger: {
|
||||
trigger: section,
|
||||
start: "center center",
|
||||
end: "bottom top",
|
||||
scrub: 1.6,
|
||||
invalidateOnRefresh: true,
|
||||
},
|
||||
});
|
||||
}, section);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
ctx?.revert();
|
||||
ctx = null;
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<section id="testimonials" ref="testimonialsSectionRef" class="pt-28 pb-10">
|
||||
<div class="container mx-auto max-w-5xl px-4">
|
||||
<h2
|
||||
class="text-2xl sm:text-5xl font-bold tracking-tight text-dark mb-16 text-center"
|
||||
>
|
||||
{{ section.title }}
|
||||
</h2>
|
||||
|
||||
<div
|
||||
class="-mx-4 flex snap-x snap-mandatory gap-4 overflow-x-auto px-4 pb-4 md:hidden"
|
||||
>
|
||||
<article
|
||||
v-for="review in section.reviews ?? []"
|
||||
:key="`mobile-${review.url}`"
|
||||
ref="mobileCardRefs"
|
||||
class="shrink-0 snap-center w-[78vw] max-w-[320px] rounded-2xl border border-black/10 bg-white p-5 pb-8! rounded-[100px_0px_623px_77px/0px_50px_128px_58px]"
|
||||
>
|
||||
<div class="mb-3 flex text-[#f39c12]">
|
||||
<span v-for="star in 5" :key="star">
|
||||
{{ star <= review.rating ? "★" : "☆" }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mb-5 text-sm leading-relaxed text-black/70 [&_p]:mb-3 [&_p:last-child]:mb-0 [&_strong]:font-bold [&_em]:italic"
|
||||
v-html="renderMarkdown(`“${review.comment}”`)"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="font-semibold text-black">{{ review.name }}</p>
|
||||
<a
|
||||
:href="review.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex gap-x-2 text-xs font-bold text-highlight hover:underline"
|
||||
>
|
||||
{{ t("View") }}
|
||||
<component :is="icons.arrowRight" class="w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="hidden gap-y-12 gap-x-6 md:grid md:grid-cols-2 lg:grid-cols-3"
|
||||
>
|
||||
<article
|
||||
v-for="review in section.reviews ?? []"
|
||||
:key="`desktop-${review.url}`"
|
||||
ref="desktopCardRefs"
|
||||
class="rounded-2xl border rounded-[100px_0px_623px_77px/0px_50px_128px_58px] border-black/10 bg-white p-5 pb-8! transition hover:-translate-y-1 hover:shadow-md"
|
||||
>
|
||||
<div class="mb-3 flex text-[#f39c12]">
|
||||
<span v-for="star in 5" :key="star">
|
||||
{{ star <= review.rating ? "★" : "☆" }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<div
|
||||
class="mb-5 text-sm leading-relaxed text-black/70 [&_p]:mb-3 [&_p:last-child]:mb-0 [&_strong]:font-bold [&_em]:italic"
|
||||
v-html="renderMarkdown(`“${review.comment}”`)"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="font-semibold text-black">{{ review.name }}</p>
|
||||
<a
|
||||
:href="review.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex gap-x-2 text-xs font-bold text-highlight hover:underline"
|
||||
>
|
||||
{{ t("View") }}
|
||||
<component :is="icons.arrowRight" class="w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="mt-16 lg:mt-24 text-center">
|
||||
<Button
|
||||
:label="section.ctaText"
|
||||
:href="section.googleReviewsUrl ?? '#'"
|
||||
external
|
||||
variant="outlined-highlight"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useIcons } from "@/composables/useIcons";
|
||||
import MarkdownIt from "markdown-it";
|
||||
import gsap from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
const icons = useIcons();
|
||||
const { t } = useI18n();
|
||||
|
||||
type Review = {
|
||||
name: string;
|
||||
rating: number;
|
||||
label: string;
|
||||
comment: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
type TestimonialsSection = {
|
||||
__component: "sections.testimonials";
|
||||
enabled: boolean;
|
||||
title?: string;
|
||||
ctaText?: string;
|
||||
googleReviewsUrl?: string;
|
||||
reviews?: Review[];
|
||||
};
|
||||
|
||||
const { section } = defineProps<{
|
||||
section: TestimonialsSection;
|
||||
}>();
|
||||
|
||||
const md = new MarkdownIt({
|
||||
html: false,
|
||||
breaks: false,
|
||||
linkify: true,
|
||||
});
|
||||
|
||||
const renderMarkdown = (value?: string) => {
|
||||
if (!value) return "";
|
||||
return md.render(value);
|
||||
};
|
||||
|
||||
const testimonialsSectionRef = ref<HTMLElement | null>(null);
|
||||
const mobileCardRefs = ref<HTMLElement[]>([]);
|
||||
const desktopCardRefs = ref<HTMLElement[]>([]);
|
||||
|
||||
let ctx: gsap.Context | null = null;
|
||||
let mm: gsap.MatchMedia | null = null;
|
||||
|
||||
const createCardsAnimation = (items: HTMLElement[], isDesktop: boolean) => {
|
||||
if (!testimonialsSectionRef.value || !items.length) return;
|
||||
|
||||
gsap.set(items, {
|
||||
y: isDesktop ? 36 : 20,
|
||||
opacity: 0,
|
||||
scale: isDesktop ? 0.96 : 0.9,
|
||||
});
|
||||
|
||||
gsap.to(items, {
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
ease: isDesktop ? "power3.out" : "back.out(2.2)",
|
||||
stagger: {
|
||||
each: isDesktop ? 0.08 : 0.06,
|
||||
from: isDesktop ? "start" : "random",
|
||||
},
|
||||
scrollTrigger: {
|
||||
trigger: testimonialsSectionRef.value,
|
||||
start: "top 75%",
|
||||
end: "top 20%",
|
||||
scrub: 1,
|
||||
invalidateOnRefresh: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
|
||||
if (!testimonialsSectionRef.value) return;
|
||||
|
||||
ctx = gsap.context(() => {
|
||||
mm = gsap.matchMedia();
|
||||
|
||||
mm.add("(min-width: 768px)", () => {
|
||||
createCardsAnimation(desktopCardRefs.value, true);
|
||||
});
|
||||
|
||||
mm.add("(max-width: 767px)", () => {
|
||||
createCardsAnimation(mobileCardRefs.value, false);
|
||||
});
|
||||
}, testimonialsSectionRef.value);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
mm?.revert();
|
||||
ctx?.revert();
|
||||
|
||||
mm = null;
|
||||
ctx = null;
|
||||
});
|
||||
</script>
|
||||
Reference in New Issue
Block a user