feat: initial commit — site + admin panel + Postgres content pipeline

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
AyrisAI
2026-07-21 01:24:47 +03:00
co-authored by Claude Fable 5
commit 3420d32271
188 changed files with 25053 additions and 0 deletions
+115
View File
@@ -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
+160
View File
@@ -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>
+754
View File
@@ -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>
+253
View File
@@ -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>
+59
View File
@@ -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>
+50
View File
@@ -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>
+141
View File
@@ -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>
+199
View File
@@ -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>