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,5 @@
|
||||
<template>
|
||||
<main>
|
||||
<router-view />
|
||||
</main>
|
||||
</template>
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,294 @@
|
||||
<template>
|
||||
<component
|
||||
:is="tag"
|
||||
v-if="isVisible"
|
||||
v-bind="componentAttrs"
|
||||
:aria-label="computedAriaLabel"
|
||||
:disabled="isNativeButton ? disabled : undefined"
|
||||
class="inline-flex items-center justify-center gap-2 font-bold transition-all duration-200 focus-visible:outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-30"
|
||||
:class="[
|
||||
sizeClasses,
|
||||
variantClasses,
|
||||
radiusClasses,
|
||||
widthClasses,
|
||||
extraClass,
|
||||
]"
|
||||
>
|
||||
<span
|
||||
v-if="hasIcon && iconPosition === 'left'"
|
||||
aria-hidden="true"
|
||||
class="inline-flex shrink-0"
|
||||
>
|
||||
<component :is="resolvedIcon" :class="iconSizeClasses" />
|
||||
</span>
|
||||
|
||||
<span v-if="iconPosition !== 'center'">
|
||||
<slot>{{ label }}</slot>
|
||||
</span>
|
||||
|
||||
<span v-else class="sr-only">
|
||||
<slot>{{ label }}</slot>
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="hasIcon && iconPosition === 'right'"
|
||||
aria-hidden="true"
|
||||
class="inline-flex shrink-0"
|
||||
>
|
||||
<component :is="resolvedIcon" :class="iconSizeClasses" />
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="hasIcon && iconPosition === 'center'"
|
||||
aria-hidden="true"
|
||||
class="inline-flex shrink-0"
|
||||
>
|
||||
<component :is="resolvedIcon" :class="iconSizeClasses" />
|
||||
</span>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useIcons } from "@/composables/useIcons";
|
||||
import { useLocale } from "@/composables/useLocale";
|
||||
|
||||
const { path } = useLocale();
|
||||
const icons = useIcons();
|
||||
const { t, te } = useI18n();
|
||||
|
||||
type IconName =
|
||||
| "mail"
|
||||
| "document"
|
||||
| "code"
|
||||
| "external"
|
||||
| "arrowRight"
|
||||
| "arrowUp"
|
||||
| "none";
|
||||
|
||||
type IconPosition = "left" | "right" | "center";
|
||||
|
||||
type ButtonVariant =
|
||||
| "default"
|
||||
| "highlight"
|
||||
| "dark"
|
||||
| "white"
|
||||
| "pink"
|
||||
| "outlined-white"
|
||||
| "outlined-dark"
|
||||
| "outlined-pink"
|
||||
| "outlined-highlight"
|
||||
| "ghost-highlight"
|
||||
| "ghost-pink";
|
||||
|
||||
type ButtonSize = "xs" | "sm" | "md" | "lg";
|
||||
type ButtonRadius = "none" | "sm" | "md" | "lg" | "xl" | "2xl" | "full";
|
||||
|
||||
interface ButtonIcon {
|
||||
name?: IconName;
|
||||
position?: IconPosition;
|
||||
}
|
||||
|
||||
export type ButtonConfig = {
|
||||
label: string;
|
||||
url?: string;
|
||||
external?: boolean;
|
||||
enabled?: boolean;
|
||||
color?: ButtonVariant | null;
|
||||
icon?: ButtonIcon;
|
||||
};
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
button?: ButtonConfig;
|
||||
|
||||
label?: string;
|
||||
href?: string;
|
||||
external?: boolean;
|
||||
disabled?: boolean;
|
||||
type?: "button" | "submit" | "reset";
|
||||
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
radius?: ButtonRadius;
|
||||
fullWidth?: boolean;
|
||||
icon?: IconName;
|
||||
iconPosition?: IconPosition;
|
||||
|
||||
ariaLabel?: string;
|
||||
extraClass?: string;
|
||||
}>(),
|
||||
{
|
||||
button: undefined,
|
||||
label: "",
|
||||
href: "",
|
||||
external: false,
|
||||
disabled: false,
|
||||
type: "button",
|
||||
variant: "default",
|
||||
size: "md",
|
||||
radius: "none",
|
||||
fullWidth: false,
|
||||
icon: "none",
|
||||
iconPosition: "left",
|
||||
ariaLabel: "",
|
||||
extraClass: "",
|
||||
},
|
||||
);
|
||||
|
||||
const isVisible = computed(() => props.button?.enabled ?? true);
|
||||
|
||||
const label = computed(() => props.button?.label ?? props.label);
|
||||
|
||||
const href = computed(() => {
|
||||
const rawUrl = props.button?.url ?? props.href;
|
||||
|
||||
if (!rawUrl) return "";
|
||||
|
||||
const isExternal = props.button?.external ?? props.external;
|
||||
|
||||
if (isExternal) return rawUrl;
|
||||
|
||||
return `${path}${rawUrl}`.replace(/\/{2,}/g, "/");
|
||||
});
|
||||
|
||||
const isLink = computed(() => !!href.value);
|
||||
const isNativeButton = computed(() => !isLink.value);
|
||||
const tag = computed(() => (isLink.value ? "a" : "button"));
|
||||
|
||||
const componentAttrs = computed(() => {
|
||||
if (isLink.value) {
|
||||
return {
|
||||
href: href.value,
|
||||
target: (props.button?.external ?? props.external) ? "_blank" : "_self",
|
||||
rel:
|
||||
(props.button?.external ?? props.external)
|
||||
? "noopener noreferrer"
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: props.type,
|
||||
};
|
||||
});
|
||||
|
||||
const variant = computed(() => props.button?.color ?? props.variant);
|
||||
|
||||
const variantClasses = computed(() => {
|
||||
switch (variant.value) {
|
||||
case "highlight":
|
||||
return "bg-highlight text-white hover:bg-softlight focus-visible:ring-[#f39c12]/50";
|
||||
|
||||
case "dark":
|
||||
return "bg-black text-white hover:bg-black/80 focus-visible:ring-black/40";
|
||||
|
||||
case "white":
|
||||
return "bg-white text-[#d2024e] hover:bg-white/85 focus-visible:ring-white/40";
|
||||
|
||||
case "pink":
|
||||
return "bg-[#d2024e] text-white hover:bg-[#d2024e]/80 focus-visible:ring-[#d2024e]/40";
|
||||
|
||||
case "outlined-white":
|
||||
return "border border-white text-white hover:bg-white hover:text-black focus-visible:ring-white/40";
|
||||
|
||||
case "outlined-dark":
|
||||
return "border border-black text-black hover:bg-black hover:text-white focus-visible:ring-black/40";
|
||||
|
||||
case "outlined-pink":
|
||||
return "border border-[#d2024e] text-[#d2024e] hover:bg-[#d2024e] hover:text-white focus-visible:ring-[#d2024e]/40";
|
||||
|
||||
case "outlined-highlight":
|
||||
return "border border-highlight text-highlight hover:bg-highlight hover:text-white focus-visible:ring-[#f39c12]/40";
|
||||
|
||||
case "ghost-highlight":
|
||||
return "text-highlight hover:bg-highlight/10 focus-visible:ring-[#f39c12]/30";
|
||||
|
||||
case "ghost-pink":
|
||||
return "text-[#d2024e] hover:bg-[#d2024e]/10 focus-visible:ring-[#d2024e]/30";
|
||||
|
||||
default:
|
||||
return "border border-highlight/40 text-highlight hover:bg-highlight/10 focus-visible:ring-[#f39c12]/30";
|
||||
}
|
||||
});
|
||||
|
||||
const sizeClasses = computed(() => {
|
||||
switch (props.size) {
|
||||
case "xs":
|
||||
return "px-3 py-2 text-xs";
|
||||
|
||||
case "sm":
|
||||
return "px-3.5 py-2.5 text-sm";
|
||||
|
||||
case "lg":
|
||||
return "px-8 py-3 text-base md:text-lg";
|
||||
|
||||
default:
|
||||
return "px-5 py-3 text-sm";
|
||||
}
|
||||
});
|
||||
|
||||
const radiusClasses = computed(() => {
|
||||
switch (props.radius) {
|
||||
case "none":
|
||||
return "rounded-none";
|
||||
case "sm":
|
||||
return "rounded-sm";
|
||||
case "md":
|
||||
return "rounded-md";
|
||||
case "lg":
|
||||
return "rounded-lg";
|
||||
case "xl":
|
||||
return "rounded-xl";
|
||||
case "2xl":
|
||||
return "rounded-2xl";
|
||||
case "full":
|
||||
return "rounded-full";
|
||||
default:
|
||||
return "rounded-none";
|
||||
}
|
||||
});
|
||||
|
||||
const widthClasses = computed(() => {
|
||||
return props.fullWidth ? "w-full" : "";
|
||||
});
|
||||
|
||||
const iconName = computed(() => props.button?.icon?.name ?? props.icon);
|
||||
const iconPosition = computed(
|
||||
() => props.button?.icon?.position ?? props.iconPosition,
|
||||
);
|
||||
|
||||
const resolvedIcon = computed(() => {
|
||||
if (!iconName.value || iconName.value === "none") return null;
|
||||
return icons[iconName.value as keyof typeof icons] ?? null;
|
||||
});
|
||||
|
||||
const hasIcon = computed(() => !!resolvedIcon.value);
|
||||
|
||||
const iconSizeClasses = computed(() => {
|
||||
switch (props.size) {
|
||||
case "xs":
|
||||
return "size-3.5";
|
||||
case "lg":
|
||||
return "size-5";
|
||||
default:
|
||||
return "size-4";
|
||||
}
|
||||
});
|
||||
|
||||
const computedAriaLabel = computed(() => {
|
||||
if (props.ariaLabel) return props.ariaLabel;
|
||||
|
||||
const base = label.value || "";
|
||||
|
||||
const external = props.button?.external ?? props.external;
|
||||
|
||||
if (external) {
|
||||
const suffix = te("opensInNewTab") ? t("opensInNewTab") : "";
|
||||
return suffix ? `${base} (${suffix})` : base;
|
||||
}
|
||||
|
||||
return base;
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,202 @@
|
||||
<template>
|
||||
<footer class="relative bg-light text-dark pt-28 overflow-hidden">
|
||||
<!-- top curve -->
|
||||
<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="max-w-7xl mx-auto px-4 md:py-10">
|
||||
<div class="w-full pb-10 md:pb-20 px-4">
|
||||
<img
|
||||
class="h-20 object-contain mx-auto"
|
||||
src="/images/payment-methods.webp"
|
||||
:alt="t('Payment Methods')"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid gap-8 md:grid-cols-4">
|
||||
<!-- BRAND -->
|
||||
<div class="space-y-4 text-center md:text-left">
|
||||
<img
|
||||
src="/muco-logo.svg"
|
||||
alt="Müco Logo"
|
||||
class="h-24 mx-auto md:mx-0"
|
||||
/>
|
||||
|
||||
<p class="text-sm opacity-70">
|
||||
{{ t("Slogan") }}
|
||||
</p>
|
||||
|
||||
<div class="flex justify-center md:justify-start gap-3 mb-4">
|
||||
<a
|
||||
href="https://www.instagram.com/mucomutfak/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="hover:text-highlight"
|
||||
aria-label="Instagram"
|
||||
>
|
||||
<component :is="icons.instagram" class="w-7 h-7" />
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://www.tiktok.com/@mucomutfak/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="hover:text-highlight"
|
||||
aria-label="Tiktok"
|
||||
>
|
||||
<component :is="icons.tiktok" class="w-7 h-7" />
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://www.facebook.com/mucomutfakvekahve/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="hover:text-highlight"
|
||||
aria-label="Facebook"
|
||||
>
|
||||
<component :is="icons.facebook" class="w-7 h-7" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LINKS -->
|
||||
<div>
|
||||
<h3 class="font-bold text-black mb-3">{{ t("Navigation") }}</h3>
|
||||
|
||||
<nav class="flex flex-col gap-2 text-sm opacity-80">
|
||||
<a :href="`${path}#about`" class="hover:text-highlight">
|
||||
{{ t("Who we are") }}
|
||||
</a>
|
||||
<a :href="`${path}menu`" class="hover:text-highlight">
|
||||
{{ t("Menu") }}
|
||||
</a>
|
||||
<a :href="`${path}#contact`" class="hover:text-highlight">
|
||||
{{ t("Contact") }}
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- CONTACT -->
|
||||
<div>
|
||||
<h3 class="font-bold text-black mb-3">{{ t("Contact") }}</h3>
|
||||
|
||||
<div class="space-y-2 text-sm opacity-80">
|
||||
<p>{{ t("Opening Hours") }}</p>
|
||||
|
||||
<div class="space-y-2 text-sm">
|
||||
<a
|
||||
href="mailto:info@mucomutfak.com"
|
||||
class="flex items-start gap-2 hover:text-highlight"
|
||||
>
|
||||
<component :is="icons.mail" class="w-4 h-4" />
|
||||
info@mucomutfak.com
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="tel:+902522120777"
|
||||
class="flex items-start gap-2 hover:text-highlight"
|
||||
>
|
||||
<component :is="icons.phone" class="w-4 h-4" />
|
||||
+90 (252) 212 07 77
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://maps.app.goo.gl/s8XV5Ap48Urd6Hk26"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-start gap-2 hover:text-highlight"
|
||||
>
|
||||
<component :is="icons.location" class="w-4 h-4" />
|
||||
Emirbeyazıt, Hasan Ercan Cd. No:23, Muğla
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SOCIAL + INSTAGRAM -->
|
||||
<div>
|
||||
<h3 class="font-bold text-black mb-3">Instagram</h3>
|
||||
|
||||
<!-- Instagram feed placeholder -->
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<a
|
||||
v-for="item in instagramFeed"
|
||||
:key="item"
|
||||
href="https://www.instagram.com/mucomutfak/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="aspect-square rounded bg-dark/10 overflow-hidden"
|
||||
>
|
||||
<img
|
||||
:src="item"
|
||||
alt="Müco Instagram"
|
||||
class="w-full h-full object-cover hover:scale-105 transition"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- BOTTOM -->
|
||||
<div
|
||||
class="mt-10 py-5 border-t border-dark/10 flex flex-col sm:flex-row justify-between gap-3 text-sm opacity-70"
|
||||
>
|
||||
<a :href="`${path}legal`" class="hover:text-highlight underline">
|
||||
{{ t("Legal Texts") }}
|
||||
</a>
|
||||
|
||||
<span class="block">
|
||||
2014 - {{ year }} © Müco - {{ t("All rights reserved.") }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- BACK TO TOP -->
|
||||
<button
|
||||
v-show="showTop"
|
||||
@click="scrollToTop"
|
||||
aria-label="Back to top"
|
||||
class="fixed bottom-4 right-4 z-50 rounded-full bg-dark/10 p-3 backdrop-blur hover:bg-highlight/30"
|
||||
>
|
||||
<component :is="icons.arrowUp" class="w-5 h-5" />
|
||||
</button>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useIcons } from "@/composables/useIcons";
|
||||
import { useLocale } from "@/composables/useLocale";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { path } = useLocale();
|
||||
const icons = useIcons();
|
||||
|
||||
const year = new Date().getFullYear();
|
||||
const showTop = ref(false);
|
||||
|
||||
const instagramFeed = [
|
||||
"/images/instagram/1.webp",
|
||||
"/images/instagram/2.webp",
|
||||
"/images/instagram/3.webp",
|
||||
"/images/instagram/4.webp",
|
||||
"/images/instagram/5.webp",
|
||||
"/images/instagram/6.webp",
|
||||
];
|
||||
|
||||
const onScroll = () => {
|
||||
showTop.value = window.scrollY > 200;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("scroll", onScroll);
|
||||
});
|
||||
|
||||
const scrollToTop = () => {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<header
|
||||
:class="[
|
||||
'fixed w-full z-40 transition-all',
|
||||
isScrolled
|
||||
? 'bg-[#fff]/60 backdrop-blur-2xl top-7 py-2'
|
||||
: 'py-2.5 md:py-4',
|
||||
isMenuOpen ? 'bg-white/60 backdrop-blur-2xl' : '',
|
||||
]"
|
||||
>
|
||||
<div class="max-w-7xl mx-auto px-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<a :href="path">
|
||||
<img
|
||||
src="/muco-logo.svg"
|
||||
alt="Müco Logo"
|
||||
class="transition-all duration-300"
|
||||
:class="isScrolled ? 'h-10' : 'h-12 md:h-18'"
|
||||
/>
|
||||
</a>
|
||||
|
||||
<nav class="hidden md:flex gap-6">
|
||||
<a :href="`${path}#about`" class="hover:text-highlight">
|
||||
{{ t("Who we are") }}
|
||||
</a>
|
||||
<a :href="`${path}#testimonials`" class="hover:text-highlight">
|
||||
{{ t("Testimonials") }}
|
||||
</a>
|
||||
<a :href="`${path}#contact`" class="hover:text-highlight">
|
||||
{{ t("Contact") }}
|
||||
</a>
|
||||
<a :href="`${path}menu`" class="hover:text-highlight">
|
||||
{{ t("Menu") }}
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
:label="t('Order')"
|
||||
href="https://yemek.go.link/3PDtu"
|
||||
external
|
||||
variant="pink"
|
||||
:class="isScrolled ? 'px-3.5! py-2.5! text-sm!' : ''"
|
||||
/>
|
||||
|
||||
<button class="md:hidden py-2 px-3" @click="isMenuOpen = !isMenuOpen">
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
v-if="!isMenuOpen"
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path
|
||||
v-else
|
||||
d="M6 6l12 12M18 6L6 18"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MOBILE MENU -->
|
||||
<transition
|
||||
enter-active-class="transition duration-200 ease-out"
|
||||
enter-from-class="opacity-0 -translate-y-2"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
leave-active-class="transition duration-150 ease-in"
|
||||
leave-from-class="opacity-100 translate-y-0"
|
||||
leave-to-class="opacity-0 -translate-y-2"
|
||||
>
|
||||
<nav
|
||||
v-if="isMenuOpen"
|
||||
class="md:hidden mt-4 flex flex-col px-1 py-4 text-center"
|
||||
>
|
||||
<a
|
||||
:href="`${path}#about`"
|
||||
@click="isMenuOpen = false"
|
||||
class="text-lg py-2"
|
||||
>
|
||||
{{ t("Who we are") }}
|
||||
</a>
|
||||
<a
|
||||
:href="`${path}#testimonials`"
|
||||
@click="isMenuOpen = false"
|
||||
class="text-lg py-2"
|
||||
>
|
||||
{{ t("Testimonials") }}
|
||||
</a>
|
||||
<a
|
||||
:href="`${path}menu`"
|
||||
@click="isMenuOpen = false"
|
||||
class="text-lg py-2"
|
||||
>
|
||||
{{ t("Menu") }}
|
||||
</a>
|
||||
<a
|
||||
:href="`${path}#contact`"
|
||||
@click="isMenuOpen = false"
|
||||
class="text-lg py-2"
|
||||
>
|
||||
{{ t("Contact") }}
|
||||
</a>
|
||||
</nav>
|
||||
</transition>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { ref, onMounted, onUnmounted } from "vue";
|
||||
import { useLocale } from "@/composables/useLocale";
|
||||
|
||||
const { path } = useLocale();
|
||||
const { t } = useI18n();
|
||||
|
||||
const isScrolled = ref(false);
|
||||
const isMenuOpen = ref(false);
|
||||
|
||||
const handleScroll = () => {
|
||||
isScrolled.value = window.scrollY > 20;
|
||||
};
|
||||
|
||||
onMounted(() => window.addEventListener("scroll", handleScroll));
|
||||
onUnmounted(() => window.removeEventListener("scroll", handleScroll));
|
||||
</script>
|
||||
@@ -0,0 +1,860 @@
|
||||
<template>
|
||||
<section
|
||||
ref="promoSectionRef"
|
||||
:class="{ 'is-promo-ready': isPromoReady }"
|
||||
class="promo-section relative h-[580px] sm:h-[640px] md:h-[720px] xl:h-[960px] w-screen overflow-hidden bg-white"
|
||||
@mousemove="handleMouseMove"
|
||||
>
|
||||
<div class="relative h-full w-full overflow-hidden">
|
||||
<div
|
||||
class="pointer-events-none absolute left-1/2 top-1/2 z-30 w-[min(88vw,760px)] -translate-x-1/2 -translate-y-1/2 text-center"
|
||||
>
|
||||
<h1
|
||||
class="hero-title relative -top-28 m-0 text-[clamp(34px,10vw,48px)] font-thin leading-[0.9] text-dark md:-top-24 md:text-[clamp(44px,7vw,70px)]"
|
||||
v-html="section.title"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="promo-plates-layer pointer-events-none absolute inset-0 z-10" aria-hidden="true">
|
||||
<picture
|
||||
v-for="(plate, index) in section.plates"
|
||||
:key="`${plate.src}-${index}`"
|
||||
>
|
||||
<source
|
||||
:srcset="plate.src.replace(/\.(png|jpg|jpeg)$/i, '.avif')"
|
||||
type="image/avif"
|
||||
/>
|
||||
<source
|
||||
:srcset="plate.src.replace(/\.(png|jpg|jpeg)$/i, '.webp')"
|
||||
type="image/webp"
|
||||
/>
|
||||
<img
|
||||
:src="plate.src"
|
||||
class="promo-plate absolute left-1/2 top-1/2 h-auto select-none object-contain"
|
||||
:class="`promo-plate-${index + 1}`"
|
||||
draggable="false"
|
||||
:alt="plate.alt ?? ''"
|
||||
width="1024"
|
||||
height="1024"
|
||||
decoding="async"
|
||||
fetchpriority="high"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</picture>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, nextTick } from "vue";
|
||||
import gsap from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
type SectionButtonIconName =
|
||||
| "mail"
|
||||
| "document"
|
||||
| "code"
|
||||
| "external"
|
||||
| "arrowRight"
|
||||
| "arrowUp"
|
||||
| "none";
|
||||
|
||||
type SectionButtonIconPosition = "left" | "right" | "center";
|
||||
|
||||
interface SectionButton {
|
||||
enabled: boolean;
|
||||
label: string;
|
||||
url: string;
|
||||
external: boolean;
|
||||
color?: string | null;
|
||||
icon?: {
|
||||
name?: SectionButtonIconName;
|
||||
position?: SectionButtonIconPosition;
|
||||
};
|
||||
}
|
||||
|
||||
interface PromoPlate {
|
||||
src: string;
|
||||
alt?: string;
|
||||
}
|
||||
|
||||
interface SectionProps {
|
||||
title: string;
|
||||
description: string;
|
||||
button: SectionButton;
|
||||
plates: PromoPlate[];
|
||||
}
|
||||
|
||||
interface PlateLayout {
|
||||
x: number;
|
||||
y: number;
|
||||
size: number;
|
||||
r: number;
|
||||
fromX: number;
|
||||
fromY: number;
|
||||
sx: number;
|
||||
sy: number;
|
||||
sr: number;
|
||||
}
|
||||
|
||||
type Range = "mobileSmall" | "mobileLarge" | "tablet" | "desktop";
|
||||
|
||||
defineProps<{
|
||||
section: SectionProps;
|
||||
}>();
|
||||
|
||||
const promoSectionRef = ref<HTMLElement | null>(null);
|
||||
const isPromoReady = ref(false);
|
||||
|
||||
let ctx: gsap.Context | undefined;
|
||||
let resizeTimer: number | undefined;
|
||||
let mouseMoveFrame: number | undefined;
|
||||
let latestMouseEvent: MouseEvent | null = null;
|
||||
|
||||
const getRange = (): Range => {
|
||||
const w = window.innerWidth;
|
||||
|
||||
if (w <= 425) return "mobileSmall";
|
||||
if (w <= 768) return "mobileLarge";
|
||||
if (w <= 1140) return "tablet";
|
||||
|
||||
return "desktop";
|
||||
};
|
||||
|
||||
const isTouchLayout = (): boolean =>
|
||||
getRange() === "mobileSmall" || getRange() === "mobileLarge";
|
||||
|
||||
const getPlateElements = (): HTMLElement[] => {
|
||||
if (!promoSectionRef.value) return [];
|
||||
|
||||
return gsap.utils.toArray<HTMLElement>(
|
||||
promoSectionRef.value.querySelectorAll(".promo-plate"),
|
||||
);
|
||||
};
|
||||
|
||||
const applyMouseMove = (e: MouseEvent): void => {
|
||||
if (isTouchLayout()) return;
|
||||
if (!promoSectionRef.value) return;
|
||||
|
||||
const rect = promoSectionRef.value.getBoundingClientRect();
|
||||
|
||||
const x = (e.clientX - rect.left) / rect.width - 0.5;
|
||||
const y = (e.clientY - rect.top) / rect.height - 0.5;
|
||||
|
||||
const plateEls = getPlateElements();
|
||||
|
||||
gsap.to(plateEls, {
|
||||
"--tilt-x": `${x * 6}deg`,
|
||||
"--tilt-y": `${y * -6}deg`,
|
||||
"--tilt-move-x": `${x * 2}vw`,
|
||||
"--tilt-move-y": `${y * 2}vw`,
|
||||
duration: 0.1,
|
||||
ease: "power2.out",
|
||||
overwrite: "auto",
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: MouseEvent): void => {
|
||||
if (isTouchLayout()) return;
|
||||
|
||||
latestMouseEvent = e;
|
||||
|
||||
if (mouseMoveFrame) return;
|
||||
|
||||
mouseMoveFrame = window.requestAnimationFrame(() => {
|
||||
mouseMoveFrame = undefined;
|
||||
|
||||
if (!latestMouseEvent) return;
|
||||
|
||||
applyMouseMove(latestMouseEvent);
|
||||
});
|
||||
};
|
||||
|
||||
const desktopLayout: PlateLayout[] = [
|
||||
{
|
||||
x: -43,
|
||||
y: -16,
|
||||
size: 20,
|
||||
r: -13,
|
||||
fromX: -90,
|
||||
fromY: -22,
|
||||
sx: -3.5,
|
||||
sy: 2.5,
|
||||
sr: -1.5,
|
||||
},
|
||||
{
|
||||
x: -34,
|
||||
y: 25,
|
||||
size: 20,
|
||||
r: 10,
|
||||
fromX: -58,
|
||||
fromY: 62,
|
||||
sx: 2.5,
|
||||
sy: -2,
|
||||
sr: 1.2,
|
||||
},
|
||||
{
|
||||
x: 6,
|
||||
y: 20,
|
||||
size: 30,
|
||||
r: -8,
|
||||
fromX: 18,
|
||||
fromY: 68,
|
||||
sx: -2.5,
|
||||
sy: -2,
|
||||
sr: -1.2,
|
||||
},
|
||||
{
|
||||
x: 44,
|
||||
y: -10,
|
||||
size: 20,
|
||||
r: 12,
|
||||
fromX: 92,
|
||||
fromY: -24,
|
||||
sx: 3.5,
|
||||
sy: 2.5,
|
||||
sr: 1.5,
|
||||
},
|
||||
{
|
||||
x: -47,
|
||||
y: 11,
|
||||
size: 25,
|
||||
r: 9,
|
||||
fromX: -92,
|
||||
fromY: 8,
|
||||
sx: 3,
|
||||
sy: -2,
|
||||
sr: 1.2,
|
||||
},
|
||||
{
|
||||
x: -18,
|
||||
y: 10,
|
||||
size: 22,
|
||||
r: -11,
|
||||
fromX: -42,
|
||||
fromY: 42,
|
||||
sx: -3,
|
||||
sy: -2.5,
|
||||
sr: -1.2,
|
||||
},
|
||||
{
|
||||
x: 18,
|
||||
y: 6,
|
||||
size: 17,
|
||||
r: 13,
|
||||
fromX: 42,
|
||||
fromY: 30,
|
||||
sx: 3,
|
||||
sy: -2,
|
||||
sr: 1.2,
|
||||
},
|
||||
{
|
||||
x: 47,
|
||||
y: 13,
|
||||
size: 14,
|
||||
r: -9,
|
||||
fromX: 92,
|
||||
fromY: 12,
|
||||
sx: -3,
|
||||
sy: -2,
|
||||
sr: -1.2,
|
||||
},
|
||||
{
|
||||
x: -35,
|
||||
y: -5,
|
||||
size: 13,
|
||||
r: 14,
|
||||
fromX: -52,
|
||||
fromY: 40,
|
||||
sx: 2,
|
||||
sy: -3,
|
||||
sr: 1.2,
|
||||
},
|
||||
{
|
||||
x: 32,
|
||||
y: 20,
|
||||
size: 21,
|
||||
r: -12,
|
||||
fromX: 54,
|
||||
fromY: 62,
|
||||
sx: -2,
|
||||
sy: -3,
|
||||
sr: -1.2,
|
||||
},
|
||||
];
|
||||
|
||||
const tabletLayout: PlateLayout[] = [
|
||||
{
|
||||
x: -41,
|
||||
y: -15,
|
||||
size: 18,
|
||||
r: -13,
|
||||
fromX: -78,
|
||||
fromY: -22,
|
||||
sx: -2.2,
|
||||
sy: 1.5,
|
||||
sr: -0.9,
|
||||
},
|
||||
{
|
||||
x: -34,
|
||||
y: 23,
|
||||
size: 18,
|
||||
r: 10,
|
||||
fromX: -56,
|
||||
fromY: 54,
|
||||
sx: 1.6,
|
||||
sy: -1.3,
|
||||
sr: 0.8,
|
||||
},
|
||||
{
|
||||
x: 4,
|
||||
y: 22,
|
||||
size: 24,
|
||||
r: -8,
|
||||
fromX: 16,
|
||||
fromY: 56,
|
||||
sx: -1.6,
|
||||
sy: -1.3,
|
||||
sr: -0.8,
|
||||
},
|
||||
{
|
||||
x: 41,
|
||||
y: -10,
|
||||
size: 18,
|
||||
r: 12,
|
||||
fromX: 78,
|
||||
fromY: -22,
|
||||
sx: 2.2,
|
||||
sy: 1.5,
|
||||
sr: 0.9,
|
||||
},
|
||||
{
|
||||
x: -44,
|
||||
y: 7,
|
||||
size: 21,
|
||||
r: 9,
|
||||
fromX: -78,
|
||||
fromY: 8,
|
||||
sx: 1.8,
|
||||
sy: -1.2,
|
||||
sr: 0.8,
|
||||
},
|
||||
{
|
||||
x: -18,
|
||||
y: 8,
|
||||
size: 19,
|
||||
r: -11,
|
||||
fromX: -40,
|
||||
fromY: 38,
|
||||
sx: -1.8,
|
||||
sy: -1.4,
|
||||
sr: -0.8,
|
||||
},
|
||||
{
|
||||
x: 18,
|
||||
y: 4,
|
||||
size: 16,
|
||||
r: 13,
|
||||
fromX: 40,
|
||||
fromY: 30,
|
||||
sx: 1.8,
|
||||
sy: -1.2,
|
||||
sr: 0.8,
|
||||
},
|
||||
{
|
||||
x: 43,
|
||||
y: 10,
|
||||
size: 14,
|
||||
r: -9,
|
||||
fromX: 78,
|
||||
fromY: 12,
|
||||
sx: -1.8,
|
||||
sy: -1.2,
|
||||
sr: -0.8,
|
||||
},
|
||||
{
|
||||
x: -33,
|
||||
y: -4,
|
||||
size: 13,
|
||||
r: 14,
|
||||
fromX: -50,
|
||||
fromY: 36,
|
||||
sx: 1.2,
|
||||
sy: -1.8,
|
||||
sr: 0.8,
|
||||
},
|
||||
{
|
||||
x: 31,
|
||||
y: 20,
|
||||
size: 18,
|
||||
r: -12,
|
||||
fromX: 50,
|
||||
fromY: 52,
|
||||
sx: -1.2,
|
||||
sy: -1.8,
|
||||
sr: -0.8,
|
||||
},
|
||||
];
|
||||
|
||||
const mobileLargeLayout: PlateLayout[] = [
|
||||
{
|
||||
x: -40,
|
||||
y: -25,
|
||||
size: 35,
|
||||
r: -13,
|
||||
fromX: -70,
|
||||
fromY: -24,
|
||||
sx: -1,
|
||||
sy: 0.7,
|
||||
sr: -0.5,
|
||||
},
|
||||
{
|
||||
x: -30,
|
||||
y: 30,
|
||||
size: 25,
|
||||
r: 10,
|
||||
fromX: -62,
|
||||
fromY: 48,
|
||||
sx: 0.9,
|
||||
sy: -0.7,
|
||||
sr: 0.5,
|
||||
},
|
||||
{
|
||||
x: -2,
|
||||
y: 33,
|
||||
size: 32,
|
||||
r: -8,
|
||||
fromX: 12,
|
||||
fromY: 50,
|
||||
sx: -0.9,
|
||||
sy: -0.7,
|
||||
sr: -0.5,
|
||||
},
|
||||
{
|
||||
x: 40,
|
||||
y: -20,
|
||||
size: 34,
|
||||
r: 12,
|
||||
fromX: 70,
|
||||
fromY: -24,
|
||||
sx: 1,
|
||||
sy: 0.7,
|
||||
sr: 0.5,
|
||||
},
|
||||
{
|
||||
x: -44,
|
||||
y: 6,
|
||||
size: 32,
|
||||
r: 9,
|
||||
fromX: -72,
|
||||
fromY: 6,
|
||||
sx: 0.8,
|
||||
sy: -0.6,
|
||||
sr: 0.5,
|
||||
},
|
||||
{
|
||||
x: -10,
|
||||
y: 6,
|
||||
size: 33,
|
||||
r: -11,
|
||||
fromX: -38,
|
||||
fromY: 36,
|
||||
sx: -0.8,
|
||||
sy: -0.6,
|
||||
sr: -0.5,
|
||||
},
|
||||
{
|
||||
x: 19,
|
||||
y: 5,
|
||||
size: 28,
|
||||
r: 13,
|
||||
fromX: 38,
|
||||
fromY: 30,
|
||||
sx: 0.8,
|
||||
sy: -0.6,
|
||||
sr: 0.5,
|
||||
},
|
||||
{
|
||||
x: 44,
|
||||
y: 8,
|
||||
size: 20,
|
||||
r: -9,
|
||||
fromX: 72,
|
||||
fromY: 10,
|
||||
sx: -0.8,
|
||||
sy: -0.6,
|
||||
sr: -0.5,
|
||||
},
|
||||
{
|
||||
x: -28,
|
||||
y: -5,
|
||||
size: 22,
|
||||
r: 14,
|
||||
fromX: -50,
|
||||
fromY: 32,
|
||||
sx: 0.7,
|
||||
sy: -0.8,
|
||||
sr: 0.5,
|
||||
},
|
||||
{
|
||||
x: 32,
|
||||
y: 30,
|
||||
size: 34,
|
||||
r: -12,
|
||||
fromX: 52,
|
||||
fromY: 48,
|
||||
sx: -0.7,
|
||||
sy: -0.8,
|
||||
sr: -0.5,
|
||||
},
|
||||
];
|
||||
|
||||
const mobileSmallLayout: PlateLayout[] = [
|
||||
{
|
||||
x: -38,
|
||||
y: -30,
|
||||
size: 35,
|
||||
r: -13,
|
||||
fromX: -68,
|
||||
fromY: -24,
|
||||
sx: -0.8,
|
||||
sy: 0.5,
|
||||
sr: -0.4,
|
||||
},
|
||||
{
|
||||
x: -34,
|
||||
y: 43,
|
||||
size: 25,
|
||||
r: 10,
|
||||
fromX: -62,
|
||||
fromY: 48,
|
||||
sx: 0.7,
|
||||
sy: -0.5,
|
||||
sr: 0.4,
|
||||
},
|
||||
{
|
||||
x: 0,
|
||||
y: 40,
|
||||
size: 35,
|
||||
r: -8,
|
||||
fromX: 12,
|
||||
fromY: 52,
|
||||
sx: -0.7,
|
||||
sy: -0.5,
|
||||
sr: -0.4,
|
||||
},
|
||||
{
|
||||
x: 38,
|
||||
y: -20,
|
||||
size: 30,
|
||||
r: 12,
|
||||
fromX: 68,
|
||||
fromY: -24,
|
||||
sx: 0.8,
|
||||
sy: 0.5,
|
||||
sr: 0.4,
|
||||
},
|
||||
{
|
||||
x: -43,
|
||||
y: 18,
|
||||
size: 30,
|
||||
r: 9,
|
||||
fromX: -70,
|
||||
fromY: 6,
|
||||
sx: 0.6,
|
||||
sy: -0.4,
|
||||
sr: 0.4,
|
||||
},
|
||||
{
|
||||
x: -2,
|
||||
y: 3,
|
||||
size: 40,
|
||||
r: -11,
|
||||
fromX: -38,
|
||||
fromY: 36,
|
||||
sx: -0.6,
|
||||
sy: -0.4,
|
||||
sr: -0.4,
|
||||
},
|
||||
{
|
||||
x: 23,
|
||||
y: 20,
|
||||
size: 25,
|
||||
r: 13,
|
||||
fromX: 38,
|
||||
fromY: 30,
|
||||
sx: 0.6,
|
||||
sy: -0.4,
|
||||
sr: 0.4,
|
||||
},
|
||||
{
|
||||
x: 45,
|
||||
y: 5,
|
||||
size: 19,
|
||||
r: -9,
|
||||
fromX: 70,
|
||||
fromY: 10,
|
||||
sx: -0.6,
|
||||
sy: -0.4,
|
||||
sr: -0.4,
|
||||
},
|
||||
{
|
||||
x: -33,
|
||||
y: -5,
|
||||
size: 23,
|
||||
r: 14,
|
||||
fromX: -48,
|
||||
fromY: 32,
|
||||
sx: 0.5,
|
||||
sy: -0.6,
|
||||
sr: 0.4,
|
||||
},
|
||||
{
|
||||
x: 40,
|
||||
y: 40,
|
||||
size: 28,
|
||||
r: -12,
|
||||
fromX: 52,
|
||||
fromY: 48,
|
||||
sx: -0.5,
|
||||
sy: -0.6,
|
||||
sr: -0.4,
|
||||
},
|
||||
];
|
||||
|
||||
const getLayout = (): PlateLayout[] => {
|
||||
const range = getRange();
|
||||
|
||||
if (range === "mobileSmall") return mobileSmallLayout;
|
||||
if (range === "mobileLarge") return mobileLargeLayout;
|
||||
if (range === "tablet") return tabletLayout;
|
||||
|
||||
return desktopLayout;
|
||||
};
|
||||
|
||||
const getLayoutOffsetY = (): number => {
|
||||
const range = getRange();
|
||||
|
||||
if (range === "mobileSmall") return 110;
|
||||
if (range === "mobileLarge") return 98;
|
||||
if (range === "tablet") return 86;
|
||||
|
||||
return 70;
|
||||
};
|
||||
|
||||
const getPlateWidth = (p: PlateLayout): string => {
|
||||
const range = getRange();
|
||||
|
||||
if (range === "mobileSmall") {
|
||||
return `clamp(${p.size * 4.1}px, ${p.size}vw, ${p.size * 5.4}px)`;
|
||||
}
|
||||
|
||||
if (range === "mobileLarge") {
|
||||
return `clamp(${p.size * 4.6}px, ${p.size}vw, ${p.size * 7}px)`;
|
||||
}
|
||||
|
||||
if (range === "tablet") {
|
||||
return `clamp(${p.size * 6.2}px, ${p.size}vw, ${p.size * 10.5}px)`;
|
||||
}
|
||||
|
||||
return `clamp(${p.size * 8}px, ${p.size}vw, ${p.size * 18}px)`;
|
||||
};
|
||||
|
||||
const waitForPromoImages = async (): Promise<void> => {
|
||||
if (!promoSectionRef.value) return;
|
||||
|
||||
const images = Array.from(
|
||||
promoSectionRef.value.querySelectorAll<HTMLImageElement>(".promo-plate"),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
images.map(async (img) => {
|
||||
if (!img.complete || img.naturalWidth === 0) {
|
||||
await new Promise<void>((resolve) => {
|
||||
img.addEventListener("load", () => resolve(), { once: true });
|
||||
img.addEventListener("error", () => resolve(), { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await img.decode();
|
||||
} catch {}
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const initHero = (): void => {
|
||||
isPromoReady.value = false;
|
||||
|
||||
ctx?.revert();
|
||||
|
||||
if (!promoSectionRef.value) return;
|
||||
|
||||
const section = promoSectionRef.value;
|
||||
|
||||
ctx = gsap.context(() => {
|
||||
const layout = getLayout();
|
||||
const layoutOffsetY = getLayoutOffsetY();
|
||||
|
||||
const plateEls = gsap.utils.toArray<HTMLElement>(
|
||||
section.querySelectorAll(".promo-plate"),
|
||||
);
|
||||
|
||||
const introTl = gsap.timeline({ paused: true });
|
||||
|
||||
plateEls.forEach((plate, i) => {
|
||||
const p = layout[i];
|
||||
|
||||
if (!p) {
|
||||
gsap.set(plate, {
|
||||
visibility: "visible",
|
||||
opacity: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
gsap.set(plate, {
|
||||
opacity: 1,
|
||||
display: "block",
|
||||
width: getPlateWidth(p),
|
||||
"--layout-offset-y": `${layoutOffsetY}px`,
|
||||
"--base-x": `${p.x}vw`,
|
||||
"--base-y": `${p.y}vw`,
|
||||
"--base-r": `${p.r}deg`,
|
||||
"--intro-x": `${p.fromX - p.x}vw`,
|
||||
"--intro-y": `${p.fromY - p.y}vw`,
|
||||
"--intro-r": `${p.r * 0.4}deg`,
|
||||
"--parallax-x": "0vw",
|
||||
"--parallax-y": "0vw",
|
||||
"--parallax-r": "0deg",
|
||||
"--tilt-x": "0deg",
|
||||
"--tilt-y": "0deg",
|
||||
"--tilt-move-x": "0vw",
|
||||
"--tilt-move-y": "0vw",
|
||||
"--plate-scale": 0.9,
|
||||
});
|
||||
|
||||
introTl.to(
|
||||
plate,
|
||||
{
|
||||
"--intro-x": "0vw",
|
||||
"--intro-y": "0vw",
|
||||
"--intro-r": "0deg",
|
||||
"--plate-scale": 1,
|
||||
opacity: 1,
|
||||
duration: 1,
|
||||
ease: "power3.out",
|
||||
},
|
||||
i * 0.045,
|
||||
);
|
||||
});
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
isPromoReady.value = true;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
introTl.play(0);
|
||||
});
|
||||
});
|
||||
|
||||
const scrollTl = gsap.timeline({
|
||||
scrollTrigger: {
|
||||
trigger: section,
|
||||
start: "top 85%",
|
||||
end: "bottom 20%",
|
||||
scrub: 1.1,
|
||||
invalidateOnRefresh: true,
|
||||
},
|
||||
});
|
||||
|
||||
plateEls.forEach((plate, i) => {
|
||||
const p = layout[i];
|
||||
if (!p) return;
|
||||
|
||||
scrollTl.to(
|
||||
plate,
|
||||
{
|
||||
"--parallax-x": `${p.sx}vw`,
|
||||
"--parallax-y": `${p.sy}vw`,
|
||||
"--parallax-r": `${p.sr}deg`,
|
||||
ease: "none",
|
||||
},
|
||||
0,
|
||||
);
|
||||
});
|
||||
}, section);
|
||||
};
|
||||
|
||||
const handleResize = (): void => {
|
||||
if (resizeTimer) {
|
||||
window.clearTimeout(resizeTimer);
|
||||
}
|
||||
|
||||
resizeTimer = window.setTimeout(() => {
|
||||
initHero();
|
||||
}, 180);
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
await waitForPromoImages();
|
||||
|
||||
initHero();
|
||||
|
||||
window.addEventListener("resize", handleResize, { passive: true });
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
|
||||
if (resizeTimer) {
|
||||
window.clearTimeout(resizeTimer);
|
||||
}
|
||||
|
||||
if (mouseMoveFrame) {
|
||||
window.cancelAnimationFrame(mouseMoveFrame);
|
||||
}
|
||||
|
||||
ctx?.revert();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.promo-plates-layer {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.is-promo-ready .promo-plates-layer {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.promo-plate {
|
||||
transform: translate(-50%, -50%)
|
||||
translate(var(--base-x), calc(var(--base-y) + var(--layout-offset-y)))
|
||||
translate(var(--intro-x), var(--intro-y))
|
||||
translate(var(--parallax-x), var(--parallax-y))
|
||||
translate(var(--tilt-move-x), var(--tilt-move-y))
|
||||
rotate(calc(var(--base-r) + var(--intro-r) + var(--parallax-r)))
|
||||
rotateX(var(--tilt-y)) rotateY(var(--tilt-x)) scale(var(--plate-scale));
|
||||
|
||||
transform-style: preserve-3d;
|
||||
will-change: transform, opacity;
|
||||
contain: layout paint;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.promo-plate {
|
||||
transform: translate(-50%, -50%)
|
||||
translate(var(--base-x), calc(var(--base-y) + var(--layout-offset-y)))
|
||||
translate(var(--intro-x), var(--intro-y))
|
||||
translate(var(--parallax-x), var(--parallax-y))
|
||||
rotate(calc(var(--base-r) + var(--intro-r) + var(--parallax-r)))
|
||||
scale(var(--plate-scale));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-1 text-sm">
|
||||
<button
|
||||
@click="switchLang('tr')"
|
||||
:class="
|
||||
currentLang === 'tr' ? 'text-dark' : 'text-dark/40 hover:text-dark'
|
||||
"
|
||||
>
|
||||
TR
|
||||
</button>
|
||||
|
||||
<span class="text-dark/20">/</span>
|
||||
|
||||
<button
|
||||
@click="switchLang('en')"
|
||||
:class="
|
||||
currentLang === 'en' ? 'text-dark' : 'text-dark/40 hover:text-dark'
|
||||
"
|
||||
>
|
||||
EN
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, Ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useRouter, useRoute } from "vue-router";
|
||||
|
||||
const { locale } = useI18n();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
// aktif dil
|
||||
const currentLang = computed(() => locale.value);
|
||||
|
||||
// dil değiştir
|
||||
const switchLang = (lang: string) => {
|
||||
(locale as Ref<string>).value = lang;
|
||||
|
||||
try {
|
||||
localStorage.setItem("lang", lang);
|
||||
} catch {}
|
||||
|
||||
if (typeof document !== "undefined") {
|
||||
document.documentElement.lang = lang;
|
||||
}
|
||||
|
||||
// URL prefix yönetimi
|
||||
const currentPath = route.path || "/";
|
||||
|
||||
const withEn = (p: string) => {
|
||||
if (p === "/en") return "/en";
|
||||
return p.startsWith("/en/") ? p : p === "/" ? "/en" : `/en${p}`;
|
||||
};
|
||||
|
||||
const withoutEn = (p: string) => {
|
||||
if (p === "/en") return "/";
|
||||
return p.startsWith("/en/") ? p.slice(3) : p;
|
||||
};
|
||||
|
||||
const targetPath =
|
||||
lang === "en" ? withEn(currentPath) : withoutEn(currentPath);
|
||||
|
||||
if (targetPath !== currentPath) {
|
||||
router
|
||||
.replace({ path: targetPath, query: route.query, hash: route.hash })
|
||||
.catch(() => {});
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,337 @@
|
||||
<template>
|
||||
<section class="min-h-screen px-5 pt-20 pb-10 lg:px-8 bg-[#fff]">
|
||||
<div class="mx-auto w-full max-w-3xl">
|
||||
<button
|
||||
type="button"
|
||||
class="mb-8 flex items-center justify-center gap-x-1.5 text-sm text-dark/60 transition hover:text-dark"
|
||||
@click="$emit('back')"
|
||||
>
|
||||
<component :is="icons.arrowLeft" class="w-4 h-4" />
|
||||
<span>{{ t("Back") }}</span>
|
||||
</button>
|
||||
|
||||
<h1
|
||||
class="text-4xl font-medium tracking-tight text-dark text-center py-5 mb-5"
|
||||
>
|
||||
müco <span class="font-handwritten">{{ t("Campaigns") }}</span>
|
||||
</h1>
|
||||
|
||||
<div
|
||||
v-if="campaigns.length"
|
||||
class="mx-auto grid max-w-5xl grid-cols-1 gap-5 md:grid-cols-2 mt-12"
|
||||
>
|
||||
<button
|
||||
v-for="campaign in campaigns"
|
||||
:key="campaign.title"
|
||||
type="button"
|
||||
class="group overflow-hidden rounded-[1.75rem] border border-dark/20 bg-[#fff] p-3 text-left transition duration-300 hover:-translate-y-0.5 hover:shadow-lg"
|
||||
@click="selectedCampaign = campaign"
|
||||
>
|
||||
<div class="relative overflow-hidden rounded-[1.35rem] bg-light">
|
||||
<div class="aspect-video">
|
||||
<img
|
||||
:src="campaign.image.src"
|
||||
:alt="campaign.image.alt"
|
||||
class="h-full w-full object-cover transition duration-500 group-hover:scale-105"
|
||||
@error="setPlaceholderImage"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="absolute left-3 top-3">
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-2xl bg-white/95 px-3 py-2 text-dark shadow-lg backdrop-blur"
|
||||
:class="isExpired(campaign) ? 'grayscale' : ''"
|
||||
>
|
||||
<span
|
||||
class="flex h-10 w-10 items-center justify-center rounded-xl bg-rose-500 text-lg font-black text-white"
|
||||
>
|
||||
{{ isExpired(campaign) ? "!" : campaign.discount }}
|
||||
</span>
|
||||
|
||||
<span class="text-left leading-none">
|
||||
<strong
|
||||
v-if="!isExpired(campaign)"
|
||||
class="mt-1.5 block text-xl font-handwritten uppercase tracking-wide text-dark"
|
||||
>
|
||||
{{ t("Discount") }}
|
||||
</strong>
|
||||
|
||||
<span
|
||||
v-if="isExpired(campaign)"
|
||||
class="mt-1 inline-flex rounded-full bg-rose-100 px-2 py-1 text-[10px] font-bold text-rose-600"
|
||||
>
|
||||
{{ t("Ended") }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-2 pb-2 pt-4">
|
||||
<div class="mb-2 flex flex-wrap gap-1.5">
|
||||
<span
|
||||
v-for="tag in campaign.tags"
|
||||
:key="tag"
|
||||
class="rounded-full bg-dark/5 px-2.5 py-1 text-[10px] font-medium text-dark/70"
|
||||
>
|
||||
{{ tag }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h2
|
||||
class="text-xl font-semibold leading-tight tracking-tight text-dark"
|
||||
v-html="campaign.title"
|
||||
/>
|
||||
|
||||
<p class="mt-2 line-clamp-2 text-sm leading-relaxed text-dark/70">
|
||||
{{ campaign.excerpt }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="mt-4 flex flex-wrap md:flex-row items-center justify-between gap-3"
|
||||
>
|
||||
<span
|
||||
class="text-xs font-medium"
|
||||
:class="isExpired(campaign) ? 'text-rose-600' : 'text-dark/70'"
|
||||
>
|
||||
{{
|
||||
isExpired(campaign)
|
||||
? t("Campaign ended")
|
||||
: `${formatDate(campaign.endDate)} ${t("until")}`
|
||||
}}
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="rounded-full bg-dark px-3 py-1.5 text-xs font-medium text-white"
|
||||
>
|
||||
{{ t("View Details") }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="rounded-3xl bg-light/60 p-10 text-center">
|
||||
<p class="text-base font-medium text-dark">
|
||||
{{ t("No Active Campaign") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="selectedCampaign"
|
||||
class="fixed inset-0 z-50 flex items-end justify-center bg-black/55 p-3 backdrop-blur-sm md:items-center md:p-6"
|
||||
@click.self="selectedCampaign = null"
|
||||
>
|
||||
<Transition name="modal">
|
||||
<div
|
||||
class="flex max-h-[92dvh] w-full max-w-xl flex-col overflow-hidden rounded-t-4xl bg-white shadow-2xl md:max-h-[88dvh] md:rounded-4xl"
|
||||
>
|
||||
<div class="relative shrink-0 bg-[#fff]">
|
||||
<div class="aspect-video">
|
||||
<img
|
||||
:src="selectedCampaign.image.src"
|
||||
:alt="selectedCampaign.image.alt"
|
||||
class="h-full w-full object-cover"
|
||||
@error="setPlaceholderImage"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute inset-0 bg-linear-to-t from-black/75 via-black/15 to-transparent"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-4 top-4 flex h-10 w-10 items-center justify-center rounded-full bg-white/70 text-dark backdrop-blur transition hover:bg-white hover:text-dark"
|
||||
@click="selectedCampaign = null"
|
||||
>
|
||||
<span>✕</span>
|
||||
</button>
|
||||
|
||||
<div class="absolute left-5 top-5">
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-2xl bg-white/95 px-3 py-2 text-dark shadow-lg backdrop-blur"
|
||||
:class="isExpired(selectedCampaign) ? 'grayscale' : ''"
|
||||
>
|
||||
<span
|
||||
class="flex h-12 w-12 items-center justify-center rounded-xl bg-rose-500 text-xl font-black text-white"
|
||||
>
|
||||
{{
|
||||
isExpired(selectedCampaign)
|
||||
? "!"
|
||||
: selectedCampaign.discount
|
||||
}}
|
||||
</span>
|
||||
|
||||
<span class="text-left leading-none">
|
||||
<strong
|
||||
v-if="!isExpired(selectedCampaign)"
|
||||
class="mt-1 block text-2xl font-handwritten uppercase tracking-wide text-dark"
|
||||
>
|
||||
{{ t("Discount") }}
|
||||
</strong>
|
||||
|
||||
<span
|
||||
v-if="isExpired(selectedCampaign)"
|
||||
class="mt-1 inline-flex rounded-full bg-rose-100 px-2 py-1 text-[10px] font-bold text-rose-600"
|
||||
>
|
||||
{{ t("Ended") }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="min-h-0 flex-1 overflow-y-auto overscroll-contain p-7 md:p-10"
|
||||
>
|
||||
<div class="space-y-5">
|
||||
<h3
|
||||
class="text-3xl font-black uppercase leading-none tracking-tight"
|
||||
v-html="selectedCampaign.title"
|
||||
/>
|
||||
|
||||
<p
|
||||
v-if="isExpired(selectedCampaign)"
|
||||
class="rounded-2xl bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-600"
|
||||
>
|
||||
{{ t("Kampanyamız bitmiştir") }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
class="whitespace-pre-line text-base leading-6 text-dark/70"
|
||||
>
|
||||
{{ selectedCampaign.description }}
|
||||
</p>
|
||||
|
||||
<div v-if="selectedCampaign.rules?.length">
|
||||
<h4
|
||||
class="mb-4 text-lg font-extrabold tracking-tight text-dark"
|
||||
>
|
||||
{{ t("Campaign Rules") }}
|
||||
</h4>
|
||||
|
||||
<ul class="space-y-3">
|
||||
<li
|
||||
v-for="rule in selectedCampaign.rules"
|
||||
:key="rule"
|
||||
class="flex items-start gap-3 text-sm leading-relaxed text-dark"
|
||||
>
|
||||
<span
|
||||
class="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-highlight"
|
||||
/>
|
||||
<span>{{ rule }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-3 mt-2">
|
||||
<div class="rounded-2xl bg-white p-4">
|
||||
<p class="text-sm text-dark font-extrabold">
|
||||
{{ t("End Date") }}
|
||||
</p>
|
||||
<p class="mt-1 text-base font-medium text-dark">
|
||||
{{ formatDate(selectedCampaign.endDate) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useIcons } from "@/composables/useIcons";
|
||||
|
||||
const icons = useIcons();
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
type Campaign = {
|
||||
title: string;
|
||||
excerpt: string;
|
||||
description: string;
|
||||
image: {
|
||||
src: string;
|
||||
alt: string;
|
||||
};
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
tags: string[];
|
||||
discount: string;
|
||||
rules?: string[];
|
||||
};
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
campaigns: Campaign[];
|
||||
fallbackImage?: string;
|
||||
}>(),
|
||||
{
|
||||
fallbackImage: "/images/general-img-square.webp",
|
||||
},
|
||||
);
|
||||
|
||||
defineEmits<{
|
||||
back: [];
|
||||
}>();
|
||||
|
||||
const selectedCampaign = ref<Campaign | null>(null);
|
||||
|
||||
function isExpired(campaign: Campaign) {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const endDate = new Date(campaign.endDate);
|
||||
endDate.setHours(23, 59, 59, 999);
|
||||
|
||||
return endDate < today;
|
||||
}
|
||||
|
||||
function formatDate(date: string) {
|
||||
return new Intl.DateTimeFormat(locale.value === "en" ? "en-US" : "tr-TR", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}).format(new Date(date));
|
||||
}
|
||||
|
||||
function setPlaceholderImage(event: Event) {
|
||||
const image = event.target as HTMLImageElement;
|
||||
image.src = props.fallbackImage;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition:
|
||||
transform 0.25s ease,
|
||||
opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.modal-enter-from,
|
||||
.modal-leave-to {
|
||||
transform: translateY(18px) scale(0.98);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<section class="min-h-screen px-5 pt-20 pb-10 lg:px-8">
|
||||
<div class="mx-auto w-full max-w-3xl">
|
||||
<button
|
||||
type="button"
|
||||
class="mb-8 text-sm text-dark transition hover:text-dark flex items-center justify-center gap-x-1.5"
|
||||
@click="$emit('back')"
|
||||
>
|
||||
<component :is="icons.arrowLeft" class="w-4 h-4" />
|
||||
<span>{{ t("Back") }}</span>
|
||||
</button>
|
||||
|
||||
<h1
|
||||
class="text-4xl font-medium tracking-tight text-dark text-center py-5 mb-5"
|
||||
>
|
||||
müco <span class="font-handwritten">{{ t("Job Application") }}</span>
|
||||
</h1>
|
||||
|
||||
<SectionsContact
|
||||
:section="contactSection"
|
||||
default-subject="job-application"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useIcons } from "@/composables/useIcons";
|
||||
|
||||
const icons = useIcons();
|
||||
|
||||
defineEmits<{
|
||||
back: [];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const contactSection = computed(() => ({
|
||||
__component: "sections.contact",
|
||||
enabled: true,
|
||||
title: null,
|
||||
description: null,
|
||||
}));
|
||||
</script>
|
||||
@@ -0,0 +1,296 @@
|
||||
<template>
|
||||
<section class="hidden lg:block max-w-7xl mx-auto px-8 pt-24 pb-16">
|
||||
<div class="mb-12 text-center">
|
||||
<h1 class="text-4xl font-medium text-dark tracking-tight">Menü</h1>
|
||||
</div>
|
||||
|
||||
<!-- PARENT GRID -->
|
||||
<div v-if="!selectedParentId" class="grid grid-cols-3 gap-10">
|
||||
<button
|
||||
v-for="parent in parents"
|
||||
:key="parent.id"
|
||||
type="button"
|
||||
class="group text-left"
|
||||
@click="selectParent(parent.id)"
|
||||
>
|
||||
<div class="aspect-square overflow-hidden rounded-2xl bg-white">
|
||||
<img
|
||||
:src="parent.image?.src || fallbackImage"
|
||||
:alt="parent.image?.alt || parent.title"
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition"
|
||||
@error="setFallbackImage"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<h2 class="text-dark text-lg font-medium">
|
||||
{{ parent.title }}
|
||||
</h2>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- CATEGORY GRID -->
|
||||
<div v-else-if="!selectedCategoryTitle">
|
||||
<div class="mb-10 flex items-center justify-between gap-6">
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
class="mb-3 text-sm text-dark/50 hover:text-dark transition"
|
||||
@click="goBackToParents"
|
||||
>
|
||||
← Ana kategoriler
|
||||
</button>
|
||||
|
||||
<h2 class="text-3xl font-medium text-dark tracking-tight">
|
||||
{{ selectedParent?.title }}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-10">
|
||||
<button
|
||||
v-for="category in childCategories"
|
||||
:key="category.title"
|
||||
type="button"
|
||||
class="group text-left"
|
||||
@click="selectCategory(category.title)"
|
||||
>
|
||||
<div class="aspect-square overflow-hidden rounded-2xl bg-white">
|
||||
<img
|
||||
:src="category.image?.src || fallbackImage"
|
||||
:alt="category.image?.alt || category.title"
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition"
|
||||
@error="setFallbackImage"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<h3 class="text-dark text-lg font-medium">
|
||||
{{ category.title }}
|
||||
</h3>
|
||||
|
||||
<p v-if="category.description" class="text-dark/50 text-sm mt-1">
|
||||
{{ category.description }}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PRODUCT DETAIL -->
|
||||
<div v-else>
|
||||
<div class="mb-10">
|
||||
<button
|
||||
type="button"
|
||||
class="mb-3 text-sm text-dark/50 hover:text-dark transition"
|
||||
@click="goBackToCategories"
|
||||
>
|
||||
← {{ selectedParent?.title }}
|
||||
</button>
|
||||
|
||||
<h2 class="text-3xl font-medium text-dark tracking-tight">
|
||||
{{ selectedCategory?.title }}
|
||||
</h2>
|
||||
|
||||
<p
|
||||
v-if="selectedCategory?.description"
|
||||
class="text-dark/50 text-sm mt-2 max-w-2xl"
|
||||
>
|
||||
{{ selectedCategory.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- CATEGORY TABS -->
|
||||
<div class="flex gap-4 overflow-x-auto no-scrollbar mb-12">
|
||||
<button
|
||||
v-for="category in childCategories"
|
||||
:key="category.title"
|
||||
type="button"
|
||||
class="w-40 shrink-0 text-left"
|
||||
@click="selectCategory(category.title)"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'rounded-2xl overflow-hidden transition',
|
||||
selectedCategoryTitle === category.title
|
||||
? 'opacity-100 scale-100'
|
||||
: 'opacity-60 scale-95',
|
||||
]"
|
||||
>
|
||||
<div class="aspect-square bg-white">
|
||||
<img
|
||||
:src="category.image?.src || fallbackImage"
|
||||
:alt="category.image?.alt || category.title"
|
||||
class="w-full h-full object-cover"
|
||||
@error="setFallbackImage"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="p-3 text-sm"
|
||||
:class="
|
||||
selectedCategoryTitle === category.title
|
||||
? 'text-dark'
|
||||
: 'text-dark/50'
|
||||
"
|
||||
>
|
||||
{{ category.title }}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ITEMS -->
|
||||
<div class="grid grid-cols-2 gap-x-16 gap-y-10">
|
||||
<div
|
||||
v-for="item in currentItems"
|
||||
:key="item.title"
|
||||
class="flex gap-5"
|
||||
>
|
||||
<div class="w-28 h-28 rounded-xl overflow-hidden bg-white shrink-0">
|
||||
<img
|
||||
:src="item.image?.src || fallbackImage"
|
||||
:alt="item.image?.alt || item.title"
|
||||
class="w-full h-full object-cover"
|
||||
@error="setFallbackImage"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<div class="flex justify-between gap-6">
|
||||
<h3 class="text-dark font-medium">
|
||||
{{ item.title }}
|
||||
</h3>
|
||||
|
||||
<span v-if="item.price" class="text-dark font-medium shrink-0">
|
||||
{{ item.price }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p v-if="item.description" class="text-dark/50 text-sm mt-2">
|
||||
{{ item.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
type Image = {
|
||||
src?: string;
|
||||
alt?: string;
|
||||
};
|
||||
|
||||
type MenuParent = {
|
||||
id: number;
|
||||
title: string;
|
||||
image?: Image;
|
||||
};
|
||||
|
||||
type MenuItem = {
|
||||
title: string;
|
||||
description?: string;
|
||||
image?: Image;
|
||||
price?: string;
|
||||
};
|
||||
|
||||
type MenuCategory = {
|
||||
parent?: MenuParent;
|
||||
title: string;
|
||||
description?: string;
|
||||
image?: Image;
|
||||
items?: MenuItem[];
|
||||
};
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
categories: MenuCategory[];
|
||||
fallbackImage?: string;
|
||||
}>(),
|
||||
{
|
||||
fallbackImage: "/images/general-img-square.webp",
|
||||
},
|
||||
);
|
||||
|
||||
const selectedParentId = ref<number | null>(null);
|
||||
const selectedCategoryTitle = ref<string | null>(null);
|
||||
|
||||
const parents = computed<MenuParent[]>(() => {
|
||||
const map = new Map<number, MenuParent>();
|
||||
|
||||
props.categories.forEach((category) => {
|
||||
if (!category.parent) return;
|
||||
|
||||
if (!map.has(category.parent.id)) {
|
||||
map.set(category.parent.id, category.parent);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(map.values()).sort((a, b) => a.id - b.id);
|
||||
});
|
||||
|
||||
const selectedParent = computed<MenuParent | undefined>(() => {
|
||||
return parents.value.find((parent) => parent.id === selectedParentId.value);
|
||||
});
|
||||
|
||||
const childCategories = computed<MenuCategory[]>(() => {
|
||||
if (!selectedParentId.value) return [];
|
||||
|
||||
return props.categories.filter((category) => {
|
||||
return category.parent?.id === selectedParentId.value;
|
||||
});
|
||||
});
|
||||
|
||||
const selectedCategory = computed<MenuCategory | undefined>(() => {
|
||||
return childCategories.value.find((category) => {
|
||||
return category.title === selectedCategoryTitle.value;
|
||||
});
|
||||
});
|
||||
|
||||
const currentItems = computed<MenuItem[]>(() => {
|
||||
return selectedCategory.value?.items ?? [];
|
||||
});
|
||||
|
||||
function selectParent(parentId: number) {
|
||||
selectedParentId.value = parentId;
|
||||
selectedCategoryTitle.value = null;
|
||||
}
|
||||
|
||||
function selectCategory(title: string) {
|
||||
selectedCategoryTitle.value = title;
|
||||
}
|
||||
|
||||
function goBackToParents() {
|
||||
selectedParentId.value = null;
|
||||
selectedCategoryTitle.value = null;
|
||||
}
|
||||
|
||||
function goBackToCategories() {
|
||||
selectedCategoryTitle.value = null;
|
||||
}
|
||||
|
||||
function setFallbackImage(event: Event) {
|
||||
const image = event.target as HTMLImageElement;
|
||||
image.src = props.fallbackImage;
|
||||
}
|
||||
|
||||
watch([selectedParentId, selectedCategoryTitle], () => {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,463 @@
|
||||
<template>
|
||||
<section class="hidden lg:block max-w-6xl mx-auto px-8 pt-20 pb-28">
|
||||
<!-- TITLE -->
|
||||
<div v-if="currentStep === 'parents'" class="mb-12 text-center">
|
||||
<h1 class="text-5xl font-medium text-dark tracking-tight">
|
||||
müco <span class="font-handwritten">{{ t("Menu") }}</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- BREADCRUMB -->
|
||||
<nav class="mb-10 flex items-center gap-2 overflow-hidden text-xl">
|
||||
<button
|
||||
type="button"
|
||||
class="flex shrink-0 items-center justify-center text-dark/60 transition hover:text-dark"
|
||||
@click="goHome"
|
||||
>
|
||||
<component :is="icons.home" class="h-6 w-6" />
|
||||
</button>
|
||||
|
||||
<span class="text-dark/30">/</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 transition hover:text-dark"
|
||||
:class="currentStep === 'parents' ? 'text-dark' : 'text-dark/55'"
|
||||
@click="goMenuRoot"
|
||||
>
|
||||
{{ t("Main Menu") }}
|
||||
</button>
|
||||
|
||||
<template v-if="selectedParent">
|
||||
<span class="text-dark/25">/</span>
|
||||
|
||||
<button
|
||||
v-if="hasMultipleChildCategories"
|
||||
type="button"
|
||||
class="truncate transition hover:text-dark"
|
||||
:class="currentStep === 'categories' ? 'text-dark' : 'text-dark/55'"
|
||||
@click="goToParent"
|
||||
>
|
||||
{{ selectedParent.title }}
|
||||
</button>
|
||||
|
||||
<span v-else class="truncate text-dark">
|
||||
{{ selectedParent.title }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template
|
||||
v-if="
|
||||
selectedCategory &&
|
||||
currentStep === 'items' &&
|
||||
hasMultipleChildCategories
|
||||
"
|
||||
>
|
||||
<span class="text-dark/25">/</span>
|
||||
|
||||
<span class="truncate text-dark">
|
||||
{{ selectedCategory.title }}
|
||||
</span>
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<!-- PARENT GRID -->
|
||||
<div v-if="currentStep === 'parents'" class="grid grid-cols-3 gap-10">
|
||||
<button
|
||||
v-for="parent in parents"
|
||||
:key="parent.id"
|
||||
type="button"
|
||||
class="group text-left"
|
||||
@click="selectParent(parent.id)"
|
||||
>
|
||||
<div
|
||||
class="relative aspect-square overflow-hidden rounded-4xl bg-light"
|
||||
>
|
||||
<img
|
||||
:src="getImageSrc(parent.image)"
|
||||
:alt="getImageAlt(parent.image, parent.title)"
|
||||
class="absolute inset-0 h-full w-full object-cover transition duration-700 group-hover:scale-105"
|
||||
@error="setFallbackImage"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="absolute inset-0 bg-linear-to-t from-black/80 via-black/10 to-transparent"
|
||||
/>
|
||||
|
||||
<div class="absolute inset-x-0 bottom-0 p-6 text-white">
|
||||
<div
|
||||
class="absolute -top-10 flex h-11 w-11 items-center justify-center rounded-full bg-white/15 backdrop-blur transition group-hover:bg-white group-hover:text-dark"
|
||||
>
|
||||
<component :is="icons.arrowRight" class="h-5 w-5" />
|
||||
</div>
|
||||
|
||||
<h2 class="text-2xl font-medium tracking-tight">
|
||||
{{ parent.title }}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- CATEGORY GRID -->
|
||||
<div v-else-if="currentStep === 'categories'">
|
||||
<div class="mb-10">
|
||||
<h2 class="text-4xl font-medium tracking-tight text-dark">
|
||||
{{ selectedParent?.title }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 xl:grid-cols-3 gap-10">
|
||||
<button
|
||||
v-for="category in childCategories"
|
||||
:key="category.title"
|
||||
type="button"
|
||||
class="group text-left"
|
||||
@click="selectCategory(category.title)"
|
||||
>
|
||||
<div
|
||||
class="relative aspect-square overflow-hidden rounded-4xl bg-light"
|
||||
>
|
||||
<img
|
||||
:src="getImageSrc(category.image)"
|
||||
:alt="getImageAlt(category.image, category.title)"
|
||||
class="absolute inset-0 h-full w-full object-cover transition duration-700 group-hover:scale-105"
|
||||
@error="setFallbackImage"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="absolute inset-0 bg-linear-to-t from-black/80 via-black/10 to-transparent"
|
||||
/>
|
||||
|
||||
<div class="absolute inset-x-0 bottom-0 p-6 text-white">
|
||||
<div
|
||||
class="absolute -top-10 flex h-11 w-11 items-center justify-center rounded-full bg-white/15 backdrop-blur transition group-hover:bg-white group-hover:text-dark"
|
||||
>
|
||||
<component :is="icons.arrowRight" class="h-5 w-5" />
|
||||
</div>
|
||||
|
||||
<h3 class="text-2xl font-medium tracking-tight">
|
||||
{{ category.title }}
|
||||
</h3>
|
||||
|
||||
<p
|
||||
v-if="category.description"
|
||||
class="mt-2 max-w-xs text-sm leading-relaxed text-white/70"
|
||||
>
|
||||
{{ category.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PRODUCT DETAIL -->
|
||||
<div v-else>
|
||||
<div class="mb-12 grid grid-cols-[1.1fr_0.9fr] gap-10 items-end">
|
||||
<!-- HERO -->
|
||||
<div class="overflow-hidden rounded-[2.5rem] bg-dark">
|
||||
<div class="relative aspect-video">
|
||||
<img
|
||||
:src="
|
||||
getImageSrc(selectedCategory?.image || selectedParent?.image)
|
||||
"
|
||||
:alt="
|
||||
getImageAlt(
|
||||
selectedCategory?.image || selectedParent?.image,
|
||||
selectedCategory?.title || selectedParent?.title || t('Menu'),
|
||||
)
|
||||
"
|
||||
class="absolute inset-0 h-full w-full object-cover opacity-85"
|
||||
@error="setFallbackImage"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="absolute inset-0 bg-linear-to-t from-black/80 via-black/20 to-transparent"
|
||||
/>
|
||||
|
||||
<div class="absolute inset-x-0 bottom-0 p-8 text-white">
|
||||
<p
|
||||
v-if="selectedParent?.title"
|
||||
class="mb-2 text-xs font-medium uppercase tracking-[0.24em] text-white/60"
|
||||
>
|
||||
{{ selectedParent.title }}
|
||||
</p>
|
||||
|
||||
<h2
|
||||
class="text-5xl font-black uppercase leading-none tracking-tight"
|
||||
>
|
||||
{{ selectedCategory?.title || selectedParent?.title }}
|
||||
</h2>
|
||||
|
||||
<p
|
||||
v-if="selectedCategory?.description"
|
||||
class="max-w-xl text-sm leading-relaxed text-white/70"
|
||||
>
|
||||
{{ selectedCategory.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- VISUAL TABS -->
|
||||
<div
|
||||
v-if="hasMultipleChildCategories"
|
||||
class="overflow-x-auto no-scrollbar"
|
||||
>
|
||||
<div class="flex gap-4 pb-2">
|
||||
<button
|
||||
v-for="category in childCategories"
|
||||
:key="category.title"
|
||||
type="button"
|
||||
class="group w-44 shrink-0 text-left"
|
||||
@click="selectCategory(category.title)"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'overflow-hidden rounded-2xl transition-all duration-300',
|
||||
selectedCategoryTitle === category.title
|
||||
? 'scale-100 opacity-100'
|
||||
: 'scale-[0.96] opacity-50 hover:opacity-80',
|
||||
]"
|
||||
>
|
||||
<div class="relative aspect-square bg-light">
|
||||
<img
|
||||
:src="getImageSrc(category.image)"
|
||||
:alt="getImageAlt(category.image, category.title)"
|
||||
class="absolute inset-0 h-full w-full object-cover transition duration-500 group-hover:scale-105"
|
||||
@error="setFallbackImage"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="absolute inset-0 bg-linear-to-t from-black/80 via-black/10 to-transparent"
|
||||
/>
|
||||
|
||||
<div class="absolute inset-x-0 bottom-0 p-4 text-white">
|
||||
<p class="text-sbase font-medium leading-tight tracking-tight">
|
||||
{{ category.title }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ITEMS -->
|
||||
<div class="grid grid-cols-2 gap-x-16 gap-y-5">
|
||||
<div
|
||||
v-for="(item, index) in currentItems"
|
||||
:key="item.title"
|
||||
:class="[
|
||||
'flex items-start justify-between gap-6 pb-3',
|
||||
index !== currentItems.length - 1 ? 'border-b border-dark/10' : '',
|
||||
]"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="text-lg font-medium text-dark">
|
||||
{{ item.title }}
|
||||
</h3>
|
||||
|
||||
<p
|
||||
v-if="item.description"
|
||||
class="mt-2 text-sm leading-relaxed text-dark/50"
|
||||
>
|
||||
{{ item.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span
|
||||
v-if="item.price"
|
||||
class="shrink-0 text-base font-semibold text-dark"
|
||||
>
|
||||
{{ item.price }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="!currentItems.length"
|
||||
class="py-16 text-center text-sm text-dark/40"
|
||||
>
|
||||
{{ t("No items found") }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useIcons } from "@/composables/useIcons";
|
||||
|
||||
const icons = useIcons();
|
||||
|
||||
type Image = {
|
||||
src?: string;
|
||||
alt?: string;
|
||||
};
|
||||
|
||||
type MenuParent = {
|
||||
id: number;
|
||||
title: string;
|
||||
image?: Image;
|
||||
};
|
||||
|
||||
type MenuItem = {
|
||||
title: string;
|
||||
description?: string;
|
||||
image?: Image;
|
||||
price?: string;
|
||||
};
|
||||
|
||||
type MenuCategory = {
|
||||
parent?: MenuParent;
|
||||
title: string;
|
||||
description?: string;
|
||||
image?: Image;
|
||||
items?: MenuItem[];
|
||||
};
|
||||
|
||||
type Step = "parents" | "categories" | "items";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
categories: MenuCategory[];
|
||||
fallbackImage?: string;
|
||||
}>(),
|
||||
{
|
||||
fallbackImage: "/images/general-img-square.webp",
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
back: [];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const selectedParentId = ref<number | null>(null);
|
||||
const selectedCategoryTitle = ref<string | null>(null);
|
||||
|
||||
const parents = computed<MenuParent[]>(() => {
|
||||
const map = new Map<number, MenuParent>();
|
||||
|
||||
props.categories.forEach((category) => {
|
||||
if (!category.parent) return;
|
||||
|
||||
if (!map.has(category.parent.id)) {
|
||||
map.set(category.parent.id, category.parent);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(map.values()).sort((a, b) => a.id - b.id);
|
||||
});
|
||||
|
||||
const selectedParent = computed<MenuParent | undefined>(() => {
|
||||
return parents.value.find((parent) => parent.id === selectedParentId.value);
|
||||
});
|
||||
|
||||
const childCategories = computed<MenuCategory[]>(() => {
|
||||
if (!selectedParentId.value) return [];
|
||||
|
||||
return props.categories.filter((category) => {
|
||||
return category.parent?.id === selectedParentId.value;
|
||||
});
|
||||
});
|
||||
|
||||
const hasMultipleChildCategories = computed(() => {
|
||||
return childCategories.value.length > 1;
|
||||
});
|
||||
|
||||
const selectedCategory = computed<MenuCategory | undefined>(() => {
|
||||
return childCategories.value.find((category) => {
|
||||
return category.title === selectedCategoryTitle.value;
|
||||
});
|
||||
});
|
||||
|
||||
const currentItems = computed<MenuItem[]>(() => {
|
||||
return selectedCategory.value?.items ?? [];
|
||||
});
|
||||
|
||||
const currentStep = computed<Step>(() => {
|
||||
if (!selectedParentId.value) return "parents";
|
||||
|
||||
if (hasMultipleChildCategories.value && !selectedCategoryTitle.value) {
|
||||
return "categories";
|
||||
}
|
||||
|
||||
return "items";
|
||||
});
|
||||
|
||||
function getImageSrc(image?: Image) {
|
||||
return image?.src || props.fallbackImage;
|
||||
}
|
||||
|
||||
function getImageAlt(image: Image | undefined, fallback: string) {
|
||||
return image?.alt || fallback;
|
||||
}
|
||||
|
||||
function selectParent(parentId: number) {
|
||||
selectedParentId.value = parentId;
|
||||
|
||||
const children = props.categories.filter((category) => {
|
||||
return category.parent?.id === parentId;
|
||||
});
|
||||
|
||||
if (children.length === 1) {
|
||||
selectedCategoryTitle.value = children[0].title;
|
||||
return;
|
||||
}
|
||||
|
||||
selectedCategoryTitle.value = null;
|
||||
}
|
||||
|
||||
function selectCategory(title: string) {
|
||||
selectedCategoryTitle.value = title;
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
emit("back");
|
||||
}
|
||||
|
||||
function goMenuRoot() {
|
||||
selectedParentId.value = null;
|
||||
selectedCategoryTitle.value = null;
|
||||
}
|
||||
|
||||
function goToParent() {
|
||||
if (!selectedParentId.value) return;
|
||||
|
||||
if (!hasMultipleChildCategories.value) {
|
||||
goMenuRoot();
|
||||
return;
|
||||
}
|
||||
|
||||
selectedCategoryTitle.value = null;
|
||||
}
|
||||
|
||||
function setFallbackImage(event: Event) {
|
||||
const image = event.target as HTMLImageElement;
|
||||
image.src = props.fallbackImage;
|
||||
}
|
||||
|
||||
watch([selectedParentId, selectedCategoryTitle], () => {
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: "smooth",
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<section class="min-h-screen px-5 pt-24 pb-10 lg:px-8 lg:pt-32">
|
||||
<div
|
||||
class="mx-auto flex min-h-[calc(100vh-9rem)] w-full max-w-5xl items-center"
|
||||
>
|
||||
<div
|
||||
class="grid w-full gap-10 lg:grid-cols-[0.9fr_1.1fr] lg:items-center"
|
||||
>
|
||||
<div class="text-center lg:text-left">
|
||||
<p
|
||||
class="mb-3 text-xs font-medium uppercase tracking-[0.24em] text-highlight"
|
||||
>
|
||||
{{ t("Title") }}
|
||||
</p>
|
||||
|
||||
<h1
|
||||
class="text-4xl font-medium tracking-[-0.04em] text-dark lg:text-6xl"
|
||||
>
|
||||
{{ t("Welcome") }}
|
||||
</h1>
|
||||
|
||||
<p
|
||||
class="mx-auto mt-4 max-w-sm text-sm leading-normal lg:mx-0 text-dark/70"
|
||||
>
|
||||
{{ t("Menu Intro") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto w-full max-w-md lg:max-w-none">
|
||||
<div class="grid gap-2.5 lg:grid-cols-2">
|
||||
<button
|
||||
v-for="action in actions"
|
||||
:key="action.key"
|
||||
type="button"
|
||||
class="group flex min-h-28 h-full items-center justify-between gap-5 rounded-3xl border border-dark/20 bg-[#fff] px-5 py-5 text-left text-dark backdrop-blur transition hover:-translate-y-0.5 hover:shadow-md lg:min-h-36 lg:flex-col lg:items-start lg:p-6 first:border-highlight/70 first:text-highlight/90"
|
||||
@click="handleAction(action.key)"
|
||||
>
|
||||
<span>
|
||||
<span
|
||||
class="block text-xl font-medium tracking-tight lg:text-2xl"
|
||||
>
|
||||
{{ t(action.labelKey) }}
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="action.descriptionKey"
|
||||
class="mt-1.5 block text-sm leading-4 text-dark/45"
|
||||
>
|
||||
{{ t(action.descriptionKey) }}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-dark/4 text-xl text-dark/45 transition group-hover:bg-dark group-hover:text-white lg:mt-auto"
|
||||
>
|
||||
→
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
type ActionKey = "menu" | "campaigns" | "contact" | "survey";
|
||||
|
||||
type Action = {
|
||||
key: ActionKey;
|
||||
labelKey: string;
|
||||
descriptionKey?: string;
|
||||
};
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const emit = defineEmits<{
|
||||
changeView: [view: "home" | "menu" | "campaigns" | "contact"];
|
||||
}>();
|
||||
|
||||
const actions: Action[] = [
|
||||
{
|
||||
key: "menu",
|
||||
labelKey: "Menu",
|
||||
descriptionKey: "Explore our food and drinks",
|
||||
},
|
||||
{
|
||||
key: "campaigns",
|
||||
labelKey: "Campaigns",
|
||||
descriptionKey: "View current offers",
|
||||
},
|
||||
{
|
||||
key: "contact",
|
||||
labelKey: "Job Application",
|
||||
descriptionKey: "Apply to join our team",
|
||||
},
|
||||
{
|
||||
key: "survey",
|
||||
labelKey: "Satisfaction Survey",
|
||||
descriptionKey: "Share your experience",
|
||||
},
|
||||
];
|
||||
|
||||
function handleAction(key: ActionKey) {
|
||||
if (key === "survey") {
|
||||
window.open(
|
||||
"https://forms.gle/C8dd65PM9jLftv4g9",
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
emit("changeView", key);
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<MenuHome v-if="activeView === 'home'" @change-view="changeView" />
|
||||
|
||||
<MenuWrapper
|
||||
v-else-if="activeView === 'menu'"
|
||||
:categories="categories"
|
||||
:fallback-image="fallbackImage"
|
||||
@back="goHome"
|
||||
/>
|
||||
|
||||
<MenuCampaings
|
||||
v-else-if="activeView === 'campaigns'"
|
||||
:campaigns="campaigns"
|
||||
:fallback-image="fallbackImage"
|
||||
@back="goHome"
|
||||
/>
|
||||
|
||||
<MenuContact v-else-if="activeView === 'contact'" @back="goHome" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from "vue";
|
||||
|
||||
type Image = {
|
||||
src?: string;
|
||||
alt?: string;
|
||||
};
|
||||
|
||||
type MenuParent = {
|
||||
id: number;
|
||||
title: string;
|
||||
image?: Image;
|
||||
};
|
||||
|
||||
type MenuItem = {
|
||||
title: string;
|
||||
description?: string;
|
||||
image?: Image;
|
||||
price?: string;
|
||||
};
|
||||
|
||||
type MenuCategory = {
|
||||
parent?: MenuParent;
|
||||
title: string;
|
||||
description?: string;
|
||||
image?: Image;
|
||||
items?: MenuItem[];
|
||||
};
|
||||
|
||||
type Campaign = {
|
||||
title: string;
|
||||
excerpt: string;
|
||||
description: string;
|
||||
image: {
|
||||
src: string;
|
||||
alt: string;
|
||||
};
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
tags: string[];
|
||||
discount: string;
|
||||
};
|
||||
|
||||
type ActiveView = "home" | "menu" | "campaigns" | "contact";
|
||||
|
||||
defineProps<{
|
||||
categories: MenuCategory[];
|
||||
campaigns: Campaign[];
|
||||
fallbackImage?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
viewChange: [view: ActiveView];
|
||||
}>();
|
||||
|
||||
const activeView = ref<ActiveView>("home");
|
||||
|
||||
function isActiveView(value: string): value is ActiveView {
|
||||
return ["home", "menu", "campaigns", "contact"].includes(value);
|
||||
}
|
||||
|
||||
function getViewFromHash(): ActiveView {
|
||||
const hash = window.location.hash.replace("#", "");
|
||||
const view = hash.split("/")[0];
|
||||
|
||||
return isActiveView(view) ? view : "home";
|
||||
}
|
||||
|
||||
function updateHash(view: ActiveView) {
|
||||
const path = window.location.pathname;
|
||||
const hash = view === "home" ? "" : `#${view}`;
|
||||
|
||||
window.history.pushState({ view }, "", `${path}${hash}`);
|
||||
}
|
||||
|
||||
function setView(view: ActiveView, push = true) {
|
||||
activeView.value = view;
|
||||
emit("viewChange", view);
|
||||
|
||||
if (push) {
|
||||
updateHash(view);
|
||||
}
|
||||
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
|
||||
function changeView(view: ActiveView) {
|
||||
setView(view, true);
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
setView("home", true);
|
||||
}
|
||||
|
||||
function handlePopState() {
|
||||
setView(getViewFromHash(), false);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const initialView = getViewFromHash();
|
||||
|
||||
activeView.value = initialView;
|
||||
emit("viewChange", initialView);
|
||||
|
||||
window.addEventListener("popstate", handlePopState);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("popstate", handlePopState);
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div>
|
||||
<MenuDesktop
|
||||
v-if="isDesktop"
|
||||
:categories="categories"
|
||||
:fallback-image="fallbackImage"
|
||||
@back="$emit('back')"
|
||||
/>
|
||||
|
||||
<MenuMobile
|
||||
v-else
|
||||
:categories="categories"
|
||||
:fallback-image="fallbackImage"
|
||||
@back="$emit('back')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from "vue";
|
||||
|
||||
import MenuDesktop from "@/components/Menu/Desktop.vue";
|
||||
import MenuMobile from "@/components/Menu/Mobile.vue";
|
||||
|
||||
type Image = {
|
||||
src?: string;
|
||||
alt?: string;
|
||||
};
|
||||
|
||||
type MenuParent = {
|
||||
id: number;
|
||||
title: string;
|
||||
image?: Image;
|
||||
};
|
||||
|
||||
type MenuItem = {
|
||||
title: string;
|
||||
description?: string;
|
||||
image?: Image;
|
||||
price?: string;
|
||||
};
|
||||
|
||||
type MenuCategory = {
|
||||
parent?: MenuParent;
|
||||
title: string;
|
||||
description?: string;
|
||||
image?: Image;
|
||||
items?: MenuItem[];
|
||||
};
|
||||
|
||||
defineProps<{
|
||||
categories: MenuCategory[];
|
||||
fallbackImage?: string;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
back: [];
|
||||
}>();
|
||||
|
||||
const isDesktop = ref(false);
|
||||
|
||||
function handleResize() {
|
||||
isDesktop.value = window.innerWidth >= 1024;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
handleResize();
|
||||
window.addEventListener("resize", handleResize);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,577 @@
|
||||
<template>
|
||||
<section class="block min-h-screen pb-28 lg:hidden bg-[#fff]">
|
||||
<div class="mx-auto w-full max-w-7xl">
|
||||
<!-- BREADCRUMB -->
|
||||
<nav class="relative px-4 py-2.5 bg-light/80 mb-3 md:px-10">
|
||||
<div class="flex items-center gap-1 overflow-hidden text-sm">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center rounded-full text-dark/60 transition hover:bg-dark hover:text-white"
|
||||
@click="goHome"
|
||||
>
|
||||
<component :is="icons.home" class="h-5 w-5 sm:h-7 sm:w-7 -mt-1" />
|
||||
</button>
|
||||
|
||||
<span class="text-dark/40">/</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="truncate px-1 transition hover:text-dark"
|
||||
:class="currentStep === 'parents' ? 'text-dark' : 'text-dark/60'"
|
||||
@click="goMenuRoot"
|
||||
>
|
||||
{{ t("Main Menu") }}
|
||||
</button>
|
||||
|
||||
<template v-if="selectedParent">
|
||||
<span class="text-dark/25">/</span>
|
||||
|
||||
<button
|
||||
v-if="hasMultipleChildCategories"
|
||||
type="button"
|
||||
class="truncate px-1 transition hover:text-dark"
|
||||
:class="
|
||||
currentStep === 'categories' ? 'text-dark/80' : 'text-dark/50'
|
||||
"
|
||||
@click="goToParent"
|
||||
>
|
||||
{{ selectedParent.title }}
|
||||
</button>
|
||||
|
||||
<span v-else class="truncate px-1 text-dark">
|
||||
{{ selectedParent.title }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template
|
||||
v-if="
|
||||
selectedCategory &&
|
||||
currentStep === 'items' &&
|
||||
hasMultipleChildCategories
|
||||
"
|
||||
>
|
||||
<span class="text-dark/25">/</span>
|
||||
|
||||
<span class="truncate px-1 text-dark">
|
||||
{{ selectedCategory.title }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- ROOT TITLE -->
|
||||
<h1
|
||||
v-if="currentStep === 'parents'"
|
||||
class="text-4xl font-medium tracking-tight text-dark text-center py-5 mb-2"
|
||||
>
|
||||
müco <span class="font-handwritten">{{ t("Menu") }}</span>
|
||||
</h1>
|
||||
|
||||
<!-- PARENTS -->
|
||||
<!-- PARENTS -->
|
||||
<div
|
||||
v-if="currentStep === 'parents'"
|
||||
class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3 md:gap-5 px-3 sm:px-10"
|
||||
>
|
||||
<!-- CAMPAIGNS BANNER -->
|
||||
<button
|
||||
type="button"
|
||||
class="group relative col-span-2 sm:col-span-3 md:col-span-4 overflow-hidden rounded-[2rem] bg-dark text-left text-white min-h-[140px]"
|
||||
@click="goCampaigns"
|
||||
>
|
||||
<img
|
||||
src="/images/campaigns/icecekler-tatlilar-indirim.webp"
|
||||
:alt="t('Menu Campaigns')"
|
||||
class="absolute inset-0 h-full w-full object-cover opacity-40 transition duration-700 group-hover:scale-105"
|
||||
@error="setPlaceholderImage"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="absolute inset-0 bg-linear-to-br from-softlight via-softlight/55 to-black/20"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="relative flex h-full min-h-[140px] items-center justify-between gap-5 p-6"
|
||||
>
|
||||
<div class="max-w-[75%]">
|
||||
<p
|
||||
class="mb-2 text-[11px] font-medium uppercase tracking-[0.25em] text-white"
|
||||
>
|
||||
{{ t("Campaigns") }}
|
||||
</p>
|
||||
|
||||
<h2
|
||||
class="text-xl font-black uppercase leading-none tracking-tight"
|
||||
>
|
||||
{{ t("Browse our menu campaigns") }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-white text-dark transition duration-300 group-hover:scale-110"
|
||||
>
|
||||
<component :is="icons.arrowRight" class="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- CATEGORY CARDS -->
|
||||
<button
|
||||
v-for="parent in parents"
|
||||
:key="parent.id"
|
||||
type="button"
|
||||
class="group relative overflow-hidden rounded-2xl bg-dark text-left text-white aspect-square"
|
||||
@click="selectParent(parent.id)"
|
||||
>
|
||||
<img
|
||||
:src="getImageSrc(parent.image)"
|
||||
:alt="getImageAlt(parent.image, parent.title)"
|
||||
class="absolute inset-0 h-full w-full object-cover opacity-75 transition duration-500 group-hover:scale-105"
|
||||
@error="setPlaceholderImage"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="absolute inset-0 bg-linear-to-t from-dark/40 via-black/0 to-transparent"
|
||||
/>
|
||||
|
||||
<div class="relative flex h-full flex-col justify-between p-4">
|
||||
<div
|
||||
class="ml-auto flex h-9 w-9 items-center justify-center rounded-full bg-white/15 text-lg backdrop-blur transition group-hover:bg-white group-hover:text-dark"
|
||||
>
|
||||
<component :is="icons.arrowRight" class="h-4 w-4" />
|
||||
</div>
|
||||
|
||||
<span class="block text-lg font-bold leading-tight tracking-tight">
|
||||
{{ parent.title }}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- CATEGORIES -->
|
||||
<div
|
||||
v-else-if="currentStep === 'categories'"
|
||||
class="space-y-3 px-4 grid md:grid-cols-2"
|
||||
>
|
||||
<button
|
||||
v-for="category in childCategories"
|
||||
:key="category.title"
|
||||
type="button"
|
||||
class="group flex items-center gap-4 rounded-2xl border border-dark/10 bg-[#fff] p-3 text-left transition hover:bg-white"
|
||||
@click="selectCategory(category.title)"
|
||||
>
|
||||
<div
|
||||
class="aspect-square w-24 shrink-0 overflow-hidden rounded-xl bg-light"
|
||||
>
|
||||
<img
|
||||
:src="getImageSrc(category.image)"
|
||||
:alt="getImageAlt(category.image, category.title)"
|
||||
class="h-full w-full object-cover transition duration-500 group-hover:scale-105"
|
||||
@error="setPlaceholderImage"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="text-base font-medium leading-snug text-dark">
|
||||
{{ category.title }}
|
||||
</h2>
|
||||
|
||||
<p
|
||||
v-if="category.description"
|
||||
class="mt-1 line-clamp-3 text-sm leading-relaxed text-dark/45"
|
||||
>
|
||||
{{ category.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span class="shrink-0 text-xl text-dark/35">
|
||||
<component :is="icons.arrowRight" class="h-4 w-4"
|
||||
/></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ITEMS -->
|
||||
<div v-else>
|
||||
<!-- HERO -->
|
||||
<div class="bg-light/40 pb-4">
|
||||
<!-- TABS -->
|
||||
<div
|
||||
v-if="hasMultipleChildCategories"
|
||||
class="sticky top-0 z-20 bg-[#fff] px-4 pb-3 backdrop-blur-xl"
|
||||
>
|
||||
<div class="flex gap-2 overflow-x-auto no-scrollbar">
|
||||
<button
|
||||
v-for="category in childCategories"
|
||||
:key="category.title"
|
||||
type="button"
|
||||
class="shrink-0 rounded-full px-4 py-2 text-xs font-medium transition"
|
||||
:class="
|
||||
selectedCategoryTitle === category.title
|
||||
? 'bg-dark text-white'
|
||||
: 'bg-white text-dark/55'
|
||||
"
|
||||
@click="selectCategory(category.title)"
|
||||
>
|
||||
{{ category.title }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- IMAGE + TITLE -->
|
||||
<div class="px-4 pt-3">
|
||||
<div class="overflow-hidden rounded-[1.75rem] bg-dark">
|
||||
<div class="relative aspect-video">
|
||||
<img
|
||||
:src="
|
||||
getImageSrc(
|
||||
selectedCategory?.image || selectedParent?.image,
|
||||
)
|
||||
"
|
||||
:alt="
|
||||
getImageAlt(
|
||||
selectedCategory?.image || selectedParent?.image,
|
||||
selectedCategory?.title ||
|
||||
selectedParent?.title ||
|
||||
t('Menu'),
|
||||
)
|
||||
"
|
||||
class="absolute inset-0 h-full w-full object-cover opacity-80"
|
||||
@error="setPlaceholderImage"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="absolute inset-0 bg-linear-to-t from-black/80 via-black/20 to-transparent"
|
||||
/>
|
||||
|
||||
<div class="absolute inset-x-0 bottom-0 p-5 text-white">
|
||||
<p
|
||||
v-if="selectedParent?.title"
|
||||
class="mb-1 text-xs font-medium uppercase tracking-[0.2em] text-white/60"
|
||||
>
|
||||
{{ selectedParent.title }}
|
||||
</p>
|
||||
|
||||
<h2
|
||||
class="text-3xl font-black uppercase leading-none tracking-tight"
|
||||
>
|
||||
{{ selectedCategory?.title || selectedParent?.title }}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PRODUCT LIST -->
|
||||
<div class="px-4 pt-5">
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="item in currentItems"
|
||||
:key="item.title"
|
||||
class="flex items-start justify-between gap-5 border-b border-dark/15 pb-2.5"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="text-sm font-medium leading-tight text-dark">
|
||||
{{ item.title }}
|
||||
</h3>
|
||||
|
||||
<p
|
||||
v-if="item.description"
|
||||
class="mt-1 text-xs leading-relaxed text-dark/45"
|
||||
>
|
||||
{{ item.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span
|
||||
v-if="item.price"
|
||||
class="shrink-0 text-sm font-semibold text-dark"
|
||||
>
|
||||
{{ item.price }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="!currentItems.length"
|
||||
class="py-10 text-center text-sm text-dark/40"
|
||||
>
|
||||
{{ t("No items found") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useIcons } from "@/composables/useIcons";
|
||||
|
||||
const icons = useIcons();
|
||||
|
||||
type Image = {
|
||||
src?: string;
|
||||
alt?: string;
|
||||
};
|
||||
|
||||
type MenuParent = {
|
||||
id: number;
|
||||
title: string;
|
||||
image?: Image;
|
||||
};
|
||||
|
||||
type MenuItem = {
|
||||
title: string;
|
||||
description?: string;
|
||||
image?: Image;
|
||||
price?: string;
|
||||
};
|
||||
|
||||
type MenuCategory = {
|
||||
parent?: MenuParent;
|
||||
title: string;
|
||||
description?: string;
|
||||
image?: Image;
|
||||
items?: MenuItem[];
|
||||
};
|
||||
|
||||
type Step = "parents" | "categories" | "items";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
categories: MenuCategory[];
|
||||
fallbackImage?: string;
|
||||
}>(),
|
||||
{
|
||||
fallbackImage: "/images/general-img-square.webp",
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
back: [];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const placeholderImage = "/images/general-img-square.webp";
|
||||
|
||||
const selectedParentId = ref<number | null>(null);
|
||||
const selectedCategoryTitle = ref<string | null>(null);
|
||||
|
||||
const parents = computed<MenuParent[]>(() => {
|
||||
const map = new Map<number, MenuParent>();
|
||||
|
||||
props.categories.forEach((category) => {
|
||||
if (!category.parent) return;
|
||||
|
||||
if (!map.has(category.parent.id)) {
|
||||
map.set(category.parent.id, {
|
||||
...category.parent,
|
||||
image: category.parent.image || {
|
||||
src: placeholderImage,
|
||||
alt: category.parent.title,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(map.values()).sort((a, b) => a.id - b.id);
|
||||
});
|
||||
|
||||
const selectedParent = computed(() => {
|
||||
return parents.value.find((parent) => parent.id === selectedParentId.value);
|
||||
});
|
||||
|
||||
const childCategories = computed<MenuCategory[]>(() => {
|
||||
if (!selectedParentId.value) return [];
|
||||
|
||||
return props.categories.filter((category) => {
|
||||
return category.parent?.id === selectedParentId.value;
|
||||
});
|
||||
});
|
||||
|
||||
const hasMultipleChildCategories = computed(() => {
|
||||
return childCategories.value.length > 1;
|
||||
});
|
||||
|
||||
const selectedCategory = computed<MenuCategory | undefined>(() => {
|
||||
return childCategories.value.find((category) => {
|
||||
return category.title === selectedCategoryTitle.value;
|
||||
});
|
||||
});
|
||||
|
||||
const currentItems = computed<MenuItem[]>(() => {
|
||||
return selectedCategory.value?.items ?? [];
|
||||
});
|
||||
|
||||
const currentStep = computed<Step>(() => {
|
||||
if (!selectedParentId.value) return "parents";
|
||||
|
||||
if (hasMultipleChildCategories.value && !selectedCategoryTitle.value) {
|
||||
return "categories";
|
||||
}
|
||||
|
||||
return "items";
|
||||
});
|
||||
|
||||
function goCampaigns() {
|
||||
window.location.href = "/menu#campaigns";
|
||||
}
|
||||
|
||||
function getImageSrc(image?: Image) {
|
||||
return image?.src || props.fallbackImage || placeholderImage;
|
||||
}
|
||||
|
||||
function getImageAlt(image: Image | undefined, fallback: string) {
|
||||
return image?.alt || fallback;
|
||||
}
|
||||
|
||||
function buildHash(parentId?: number | null, categoryTitle?: string | null) {
|
||||
if (!parentId) return "#menu";
|
||||
|
||||
if (!categoryTitle) return `#menu/p/${parentId}`;
|
||||
|
||||
return `#menu/p/${parentId}/c/${encodeURIComponent(categoryTitle)}`;
|
||||
}
|
||||
|
||||
function pushMenuHash(parentId?: number | null, categoryTitle?: string | null) {
|
||||
window.history.pushState(
|
||||
{},
|
||||
"",
|
||||
`${window.location.pathname}${buildHash(parentId, categoryTitle)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function applyStateFromHash() {
|
||||
const hash = window.location.hash.replace("#", "");
|
||||
const parts = hash.split("/");
|
||||
|
||||
if (parts[0] !== "menu") return;
|
||||
|
||||
const parentIndex = parts.indexOf("p");
|
||||
const categoryIndex = parts.indexOf("c");
|
||||
|
||||
const parentId =
|
||||
parentIndex >= 0 && parts[parentIndex + 1]
|
||||
? Number(parts[parentIndex + 1])
|
||||
: null;
|
||||
|
||||
const categoryTitle =
|
||||
categoryIndex >= 0 && parts[categoryIndex + 1]
|
||||
? decodeURIComponent(parts[categoryIndex + 1])
|
||||
: null;
|
||||
|
||||
if (!parentId || !parents.value.some((parent) => parent.id === parentId)) {
|
||||
selectedParentId.value = null;
|
||||
selectedCategoryTitle.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
selectedParentId.value = parentId;
|
||||
|
||||
const children = props.categories.filter((category) => {
|
||||
return category.parent?.id === parentId;
|
||||
});
|
||||
|
||||
if (children.length === 1) {
|
||||
selectedCategoryTitle.value = children[0].title;
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
categoryTitle &&
|
||||
children.some((category) => category.title === categoryTitle)
|
||||
) {
|
||||
selectedCategoryTitle.value = categoryTitle;
|
||||
} else {
|
||||
selectedCategoryTitle.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function selectParent(parentId: number) {
|
||||
selectedParentId.value = parentId;
|
||||
|
||||
const children = props.categories.filter((category) => {
|
||||
return category.parent?.id === parentId;
|
||||
});
|
||||
|
||||
if (children.length === 1) {
|
||||
selectedCategoryTitle.value = children[0].title;
|
||||
pushMenuHash(parentId);
|
||||
} else {
|
||||
selectedCategoryTitle.value = null;
|
||||
pushMenuHash(parentId);
|
||||
}
|
||||
|
||||
scrollTop();
|
||||
}
|
||||
|
||||
function selectCategory(title: string) {
|
||||
selectedCategoryTitle.value = title;
|
||||
pushMenuHash(selectedParentId.value, title);
|
||||
scrollTop();
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
emit("back");
|
||||
}
|
||||
|
||||
function goMenuRoot() {
|
||||
selectedParentId.value = null;
|
||||
selectedCategoryTitle.value = null;
|
||||
pushMenuHash();
|
||||
scrollTop();
|
||||
}
|
||||
|
||||
function goToParent() {
|
||||
if (!selectedParentId.value) return;
|
||||
|
||||
if (!hasMultipleChildCategories.value) {
|
||||
goMenuRoot();
|
||||
return;
|
||||
}
|
||||
|
||||
selectedCategoryTitle.value = null;
|
||||
pushMenuHash(selectedParentId.value);
|
||||
scrollTop();
|
||||
}
|
||||
|
||||
function handlePopState() {
|
||||
applyStateFromHash();
|
||||
scrollTop();
|
||||
}
|
||||
|
||||
function scrollTop() {
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
|
||||
function setPlaceholderImage(event: Event) {
|
||||
const image = event.target as HTMLImageElement;
|
||||
image.src = props.fallbackImage || placeholderImage;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
applyStateFromHash();
|
||||
window.addEventListener("popstate", handlePopState);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("popstate", handlePopState);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,473 @@
|
||||
<template>
|
||||
<section class="block min-h-screen pb-10 lg:hidden">
|
||||
<div class="mx-auto w-full max-w-md">
|
||||
<!-- BREADCRUMB -->
|
||||
<nav class="relative px-4 py-2 bg-[#fff] mb-3">
|
||||
<div class="flex items-center gap-1 overflow-hidden text-xs">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-dark/50 transition hover:bg-dark hover:text-white"
|
||||
@click="goHome"
|
||||
>
|
||||
<component :is="icons.home" class="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<span class="text-dark/25">/</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="truncate px-1 transition hover:text-dark"
|
||||
:class="currentStep === 'parents' ? 'text-dark/80' : 'text-dark/50'"
|
||||
@click="goMenuRoot"
|
||||
>
|
||||
{{ t("Main Menu") }}
|
||||
</button>
|
||||
|
||||
<template v-if="selectedParent">
|
||||
<span class="text-dark/25">/</span>
|
||||
|
||||
<button
|
||||
v-if="hasMultipleChildCategories"
|
||||
type="button"
|
||||
class="truncate px-1 transition hover:text-dark"
|
||||
:class="
|
||||
currentStep === 'categories' ? 'text-dark/80' : 'text-dark/50'
|
||||
"
|
||||
@click="goToParent"
|
||||
>
|
||||
{{ selectedParent.title }}
|
||||
</button>
|
||||
|
||||
<span v-else class="truncate px-1 text-dark">
|
||||
{{ selectedParent.title }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template
|
||||
v-if="
|
||||
selectedCategory &&
|
||||
currentStep === 'items' &&
|
||||
hasMultipleChildCategories
|
||||
"
|
||||
>
|
||||
<span class="text-dark/25">/</span>
|
||||
|
||||
<span class="truncate px-1 text-dark">
|
||||
{{ selectedCategory.title }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- ROOT TITLE -->
|
||||
<h1
|
||||
v-if="currentStep === 'parents'"
|
||||
class="text-2xl font-medium tracking-tight text-dark text-center mb-3"
|
||||
>
|
||||
{{ t("Main Menu") }}
|
||||
</h1>
|
||||
|
||||
<!-- PARENTS -->
|
||||
<div v-if="currentStep === 'parents'" class="grid grid-cols-2 gap-3 px-4">
|
||||
<button
|
||||
v-for="parent in parents"
|
||||
:key="parent.id"
|
||||
type="button"
|
||||
class="group relative overflow-hidden rounded-xl bg-dark text-left text-white"
|
||||
@click="selectParent(parent.id)"
|
||||
>
|
||||
<img
|
||||
:src="parent.image?.src || placeholderImage"
|
||||
:alt="parent.image?.alt || parent.title"
|
||||
class="absolute inset-0 h-full w-full object-cover opacity-70 transition duration-500 group-hover:scale-105"
|
||||
@error="setPlaceholderImage"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="absolute inset-0 bg-linear-to-t from-black/75 via-black/20 to-transparent"
|
||||
/>
|
||||
|
||||
<div class="relative flex h-full flex-col justify-between p-4">
|
||||
<div
|
||||
class="ml-auto flex h-9 w-9 items-center justify-center rounded-full bg-white/15 text-lg backdrop-blur transition group-hover:bg-white group-hover:text-dark"
|
||||
>
|
||||
→
|
||||
</div>
|
||||
|
||||
<span
|
||||
class="block text-base font-medium leading-tight tracking-tight"
|
||||
>
|
||||
{{ parent.title }}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- CATEGORIES -->
|
||||
<div v-else-if="currentStep === 'categories'" class="space-y-3 px-4">
|
||||
<button
|
||||
v-for="category in childCategories"
|
||||
:key="category.title"
|
||||
type="button"
|
||||
class="group flex w-full items-center gap-4 rounded-xl border border-dark/10 bg-[#fff] p-3 text-left backdrop-blur transition hover:bg-white"
|
||||
@click="selectCategory(category.title)"
|
||||
>
|
||||
<div class="h-24 w-24 shrink-0 overflow-hidden rounded-xl bg-white">
|
||||
<img
|
||||
:src="category.image?.src || placeholderImage"
|
||||
:alt="category.image?.alt || category.title"
|
||||
class="h-full w-full object-cover transition group-hover:scale-105"
|
||||
@error="setPlaceholderImage"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="text-base font-medium leading-snug text-dark">
|
||||
{{ category.title }}
|
||||
</h2>
|
||||
|
||||
<p
|
||||
v-if="category.description"
|
||||
class="mt-1 line-clamp-2 text-sm leading-relaxed text-dark/45"
|
||||
>
|
||||
{{ category.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span class="shrink-0 text-xl text-dark/35">→</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ITEMS -->
|
||||
<div v-else class="space-y-5 px-4">
|
||||
<div
|
||||
v-if="hasMultipleChildCategories"
|
||||
class="sticky top-0 z-20 -mx-4 bg-[#fff] px-4 py-3 backdrop-blur-xl"
|
||||
>
|
||||
<div class="flex gap-2 overflow-x-auto no-scrollbar">
|
||||
<button
|
||||
v-for="category in childCategories"
|
||||
:key="category.title"
|
||||
type="button"
|
||||
class="shrink-0 rounded-full border px-4 py-2 text-sm transition"
|
||||
:class="
|
||||
selectedCategoryTitle === category.title
|
||||
? 'border-highlight bg-[#fff] text-highlight'
|
||||
: 'border-dark/10 bg-[#fff] text-dark/55'
|
||||
"
|
||||
@click="selectCategory(category.title)"
|
||||
>
|
||||
{{ category.title }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="item in currentItems"
|
||||
:key="item.title"
|
||||
class="rounded-xl border border-dark/10 bg-[#fff] p-3"
|
||||
>
|
||||
<div class="flex gap-4">
|
||||
<div class="h-24 w-24 shrink-0 overflow-hidden rounded-xl bg-white">
|
||||
<img
|
||||
:src="item.image?.src || placeholderImage"
|
||||
:alt="item.image?.alt || item.title"
|
||||
class="h-full w-full object-cover"
|
||||
@error="setPlaceholderImage"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<h3 class="text-base font-medium leading-snug text-dark">
|
||||
{{ item.title }}
|
||||
</h3>
|
||||
|
||||
<span
|
||||
v-if="item.price"
|
||||
class="shrink-0 text-sm font-medium text-dark"
|
||||
>
|
||||
{{ item.price }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="item.description"
|
||||
class="mt-2 text-sm leading-relaxed text-dark/45"
|
||||
>
|
||||
{{ item.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="!currentItems.length"
|
||||
class="py-10 text-center text-sm text-dark/40"
|
||||
>
|
||||
{{ t("No items found") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useIcons } from "@/composables/useIcons";
|
||||
|
||||
const icons = useIcons();
|
||||
|
||||
type Image = {
|
||||
src?: string;
|
||||
alt?: string;
|
||||
};
|
||||
|
||||
type MenuParent = {
|
||||
id: number;
|
||||
title: string;
|
||||
image?: Image;
|
||||
};
|
||||
|
||||
type MenuItem = {
|
||||
title: string;
|
||||
description?: string;
|
||||
image?: Image;
|
||||
price?: string;
|
||||
};
|
||||
|
||||
type MenuCategory = {
|
||||
parent?: MenuParent;
|
||||
title: string;
|
||||
description?: string;
|
||||
image?: Image;
|
||||
items?: MenuItem[];
|
||||
};
|
||||
|
||||
type Step = "parents" | "categories" | "items";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
categories: MenuCategory[];
|
||||
fallbackImage?: string;
|
||||
}>(),
|
||||
{
|
||||
fallbackImage: "/images/banner/parallax.webp",
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
back: [];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const placeholderImage = "/images/banner/parallax.webp";
|
||||
|
||||
const selectedParentId = ref<number | null>(null);
|
||||
const selectedCategoryTitle = ref<string | null>(null);
|
||||
|
||||
const parents = computed<MenuParent[]>(() => {
|
||||
const map = new Map<number, MenuParent>();
|
||||
|
||||
props.categories.forEach((category) => {
|
||||
if (!category.parent) return;
|
||||
|
||||
if (!map.has(category.parent.id)) {
|
||||
map.set(category.parent.id, {
|
||||
...category.parent,
|
||||
image: category.parent.image || {
|
||||
src: placeholderImage,
|
||||
alt: category.parent.title,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(map.values()).sort((a, b) => a.id - b.id);
|
||||
});
|
||||
|
||||
const selectedParent = computed(() => {
|
||||
return parents.value.find((parent) => parent.id === selectedParentId.value);
|
||||
});
|
||||
|
||||
const childCategories = computed<MenuCategory[]>(() => {
|
||||
if (!selectedParentId.value) return [];
|
||||
|
||||
return props.categories.filter((category) => {
|
||||
return category.parent?.id === selectedParentId.value;
|
||||
});
|
||||
});
|
||||
|
||||
const hasMultipleChildCategories = computed(() => {
|
||||
return childCategories.value.length > 1;
|
||||
});
|
||||
|
||||
const selectedCategory = computed<MenuCategory | undefined>(() => {
|
||||
return childCategories.value.find((category) => {
|
||||
return category.title === selectedCategoryTitle.value;
|
||||
});
|
||||
});
|
||||
|
||||
const currentItems = computed<MenuItem[]>(() => {
|
||||
return selectedCategory.value?.items ?? [];
|
||||
});
|
||||
|
||||
const currentStep = computed<Step>(() => {
|
||||
if (!selectedParentId.value) return "parents";
|
||||
|
||||
if (hasMultipleChildCategories.value && !selectedCategoryTitle.value) {
|
||||
return "categories";
|
||||
}
|
||||
|
||||
return "items";
|
||||
});
|
||||
|
||||
function buildHash(parentId?: number | null, categoryTitle?: string | null) {
|
||||
if (!parentId) return "#menu";
|
||||
|
||||
if (!categoryTitle) return `#menu/p/${parentId}`;
|
||||
|
||||
return `#menu/p/${parentId}/c/${encodeURIComponent(categoryTitle)}`;
|
||||
}
|
||||
|
||||
function pushMenuHash(parentId?: number | null, categoryTitle?: string | null) {
|
||||
window.history.pushState(
|
||||
{},
|
||||
"",
|
||||
`${window.location.pathname}${buildHash(parentId, categoryTitle)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function applyStateFromHash() {
|
||||
const hash = window.location.hash.replace("#", "");
|
||||
const parts = hash.split("/");
|
||||
|
||||
if (parts[0] !== "menu") return;
|
||||
|
||||
const parentIndex = parts.indexOf("p");
|
||||
const categoryIndex = parts.indexOf("c");
|
||||
|
||||
const parentId =
|
||||
parentIndex >= 0 && parts[parentIndex + 1]
|
||||
? Number(parts[parentIndex + 1])
|
||||
: null;
|
||||
|
||||
const categoryTitle =
|
||||
categoryIndex >= 0 && parts[categoryIndex + 1]
|
||||
? decodeURIComponent(parts[categoryIndex + 1])
|
||||
: null;
|
||||
|
||||
if (!parentId || !parents.value.some((parent) => parent.id === parentId)) {
|
||||
selectedParentId.value = null;
|
||||
selectedCategoryTitle.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
selectedParentId.value = parentId;
|
||||
|
||||
const children = props.categories.filter((category) => {
|
||||
return category.parent?.id === parentId;
|
||||
});
|
||||
|
||||
if (children.length === 1) {
|
||||
selectedCategoryTitle.value = children[0].title;
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
categoryTitle &&
|
||||
children.some((category) => category.title === categoryTitle)
|
||||
) {
|
||||
selectedCategoryTitle.value = categoryTitle;
|
||||
} else {
|
||||
selectedCategoryTitle.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function selectParent(parentId: number) {
|
||||
selectedParentId.value = parentId;
|
||||
|
||||
const children = props.categories.filter((category) => {
|
||||
return category.parent?.id === parentId;
|
||||
});
|
||||
|
||||
if (children.length === 1) {
|
||||
selectedCategoryTitle.value = children[0].title;
|
||||
pushMenuHash(parentId);
|
||||
} else {
|
||||
selectedCategoryTitle.value = null;
|
||||
pushMenuHash(parentId);
|
||||
}
|
||||
|
||||
scrollTop();
|
||||
}
|
||||
|
||||
function selectCategory(title: string) {
|
||||
selectedCategoryTitle.value = title;
|
||||
pushMenuHash(selectedParentId.value, title);
|
||||
scrollTop();
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
emit("back");
|
||||
}
|
||||
|
||||
function goMenuRoot() {
|
||||
selectedParentId.value = null;
|
||||
selectedCategoryTitle.value = null;
|
||||
pushMenuHash();
|
||||
scrollTop();
|
||||
}
|
||||
|
||||
function goToParent() {
|
||||
if (!selectedParentId.value) return;
|
||||
|
||||
if (!hasMultipleChildCategories.value) {
|
||||
goMenuRoot();
|
||||
return;
|
||||
}
|
||||
|
||||
selectedCategoryTitle.value = null;
|
||||
pushMenuHash(selectedParentId.value);
|
||||
scrollTop();
|
||||
}
|
||||
|
||||
function handlePopState() {
|
||||
applyStateFromHash();
|
||||
scrollTop();
|
||||
}
|
||||
|
||||
function scrollTop() {
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
|
||||
function setPlaceholderImage(event: Event) {
|
||||
const image = event.target as HTMLImageElement;
|
||||
image.src = props.fallbackImage || placeholderImage;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
applyStateFromHash();
|
||||
window.addEventListener("popstate", handlePopState);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("popstate", handlePopState);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<section class="min-h-screen px-5 pt-20 pb-10 lg:px-8">
|
||||
<div class="mx-auto w-full max-w-3xl">
|
||||
<button
|
||||
type="button"
|
||||
class="mb-8 text-sm text-dark transition hover:text-dark flex items-center justify-center gap-x-1.5"
|
||||
@click="$emit('back')"
|
||||
>
|
||||
<component :is="icons.arrowLeft" class="w-4 h-4" />
|
||||
<span>{{ t("Back") }}</span>
|
||||
</button>
|
||||
|
||||
<h1 class="text-4xl font-medium tracking-tight text-dark md:text-center">
|
||||
{{ t("Satisfaction Survey") }}
|
||||
</h1>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useIcons } from "@/composables/useIcons";
|
||||
|
||||
const icons = useIcons();
|
||||
|
||||
defineEmits<{
|
||||
back: [];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<div class="bg-light text-sm py-1 sticky top-0 z-50">
|
||||
<div class="max-w-7xl mx-auto px-4 flex justify-between items-center">
|
||||
<div class="flex items-center gap-3">
|
||||
<a
|
||||
href="https://www.instagram.com/mucomutfak/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="hover:text-highlight"
|
||||
>
|
||||
<component :is="icons.instagram" class="w-4.5 h-4.5" />
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://www.tiktok.com/@mucomutfak/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="hover:text-highlight"
|
||||
>
|
||||
<component :is="icons.tiktok" class="w-4.5 h-4.5" />
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://www.facebook.com/mucomutfakvekahve/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="hover:text-highlight"
|
||||
>
|
||||
<component :is="icons.facebook" class="w-4.5 h-4.5" />
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="tel:+902522120777"
|
||||
class="flex items-center gap-2 hover:text-highlight"
|
||||
>
|
||||
+90 (252) 212 07 77
|
||||
</a>
|
||||
</div>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useIcons } from "@/composables/useIcons";
|
||||
|
||||
const { t } = useI18n();
|
||||
const icons = useIcons();
|
||||
</script>
|
||||
@@ -0,0 +1,33 @@
|
||||
import { onMounted, onBeforeUnmount } from "vue";
|
||||
import type { Ref } from "vue";
|
||||
|
||||
/**
|
||||
* useClickOutside composable:
|
||||
* - Detects clicks outside of a given element reference.
|
||||
* - Executes a callback when a click occurs outside the target.
|
||||
* - Automatically registers and cleans up listeners on mount/unmount.
|
||||
*/
|
||||
|
||||
export function useClickOutside(
|
||||
elRef: Ref<HTMLElement | null>,
|
||||
callback: (event: MouseEvent) => void
|
||||
): void {
|
||||
if (!elRef) return;
|
||||
|
||||
// Event handler: invokes callback when click is outside the element
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (elRef.value && !elRef.value.contains(event.target as Node)) {
|
||||
callback(event);
|
||||
}
|
||||
};
|
||||
|
||||
// Register click listener on document when component mounts
|
||||
onMounted(() => {
|
||||
document.addEventListener("click", handleClick);
|
||||
});
|
||||
|
||||
// Remove click listener when component unmounts
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener("click", handleClick);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { h, type Component } from "vue";
|
||||
import { Icon } from "@iconify/vue";
|
||||
|
||||
export const iconNames = {
|
||||
mail: "heroicons-solid:envelope",
|
||||
document: "heroicons-solid:document-text",
|
||||
code: "heroicons-solid:code-bracket",
|
||||
|
||||
chevronRightIcon: "heroicons-solid:chevron-right",
|
||||
chevronLeftIcon: "heroicons-solid:chevron-left",
|
||||
|
||||
arrowRight: "heroicons-solid:arrow-right",
|
||||
arrowLeft: "heroicons-solid:arrow-left",
|
||||
arrowUp: "heroicons-solid:arrow-up",
|
||||
external: "heroicons-solid:arrow-top-right-on-square",
|
||||
phone: "heroicons-solid:phone",
|
||||
location: "heroicons-solid:map-pin",
|
||||
check: "heroicons-solid:check-circle",
|
||||
plus: "heroicons-solid:plus",
|
||||
|
||||
user: "heroicons-solid:user",
|
||||
home: "mdi:home",
|
||||
share: "heroicons-solid:share",
|
||||
clipboard: "heroicons-solid:clipboard-document",
|
||||
link: "heroicons-solid:link",
|
||||
chat: "heroicons-solid:chat-bubble-left-right",
|
||||
close: "heroicons-solid:x-mark",
|
||||
|
||||
instagram: "mdi:instagram",
|
||||
facebook: "mdi:facebook",
|
||||
tiktok: "ic:baseline-tiktok",
|
||||
} as const;
|
||||
|
||||
export type IconName = keyof typeof iconNames;
|
||||
|
||||
export function useIcons(): Record<IconName, Component> {
|
||||
const registry = {} as Record<IconName, Component>;
|
||||
|
||||
for (const name of Object.keys(iconNames) as IconName[]) {
|
||||
registry[name] = {
|
||||
name: `${name}Icon`,
|
||||
render() {
|
||||
return h(Icon, {
|
||||
icon: iconNames[name],
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return registry;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// src/composables/useLocale.ts
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { computed } from "vue";
|
||||
|
||||
/**
|
||||
* useLocale composable:
|
||||
* - Provides the current locale from vue-i18n.
|
||||
* - Returns a computed `path` that prefixes URLs with /en for English locale.
|
||||
* - Helps route components generate localized links.
|
||||
*/
|
||||
|
||||
// Return localized path and current locale reference
|
||||
export function useLocale() {
|
||||
const { locale } = useI18n();
|
||||
const path = computed(() => (locale.value === "en" ? "/en/" : "/"));
|
||||
return { path };
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useHead } from '@vueuse/head'
|
||||
import { computed, unref } from 'vue'
|
||||
/**
|
||||
* useMeta composable:
|
||||
* - Dynamically sets page meta tags and title using @vueuse/head.
|
||||
* - Supports Open Graph, Twitter Card, and standard SEO metadata.
|
||||
* - Localizes `og:locale` automatically based on current locale.
|
||||
*/
|
||||
|
||||
// Interface for supported meta tag fields
|
||||
interface MetaData {
|
||||
title?: string
|
||||
description?: string
|
||||
keywords?: string[]
|
||||
image?: string
|
||||
type?: string
|
||||
author?: string
|
||||
canonical?: string
|
||||
robots?: string
|
||||
structuredData?: Record<string, any>
|
||||
['og:locale']?: string
|
||||
['og:site_name']?: string
|
||||
['twitter:card']?: string
|
||||
['twitter:creator']?: string
|
||||
['twitter:title']?: string
|
||||
['twitter:description']?: string
|
||||
['twitter:image']?: string
|
||||
}
|
||||
|
||||
export function useMeta(data: MetaData | any, locale: string | any) {
|
||||
const d = () => unref(data) as MetaData
|
||||
const loc = () => unref(locale) as string
|
||||
|
||||
const withHost = (u?: string) => {
|
||||
if (!u) return undefined
|
||||
if (/^https?:\/\//i.test(u)) return u
|
||||
if (u.startsWith('//')) return 'https:' + u
|
||||
|
||||
const base = 'https://www.mucomutfak.com'
|
||||
|
||||
if (u.startsWith('/')) return base + u
|
||||
|
||||
return `${base}/${u}`
|
||||
}
|
||||
|
||||
const ogLocale = computed(() => {
|
||||
return d()['og:locale'] || (loc() === 'tr' ? 'tr_TR' : 'en_US')
|
||||
})
|
||||
|
||||
const canonical = computed(() => {
|
||||
return withHost(d().canonical)
|
||||
})
|
||||
|
||||
const structuredData = computed(() => {
|
||||
const schema = d().structuredData
|
||||
|
||||
if (!schema) return undefined
|
||||
|
||||
return JSON.stringify({
|
||||
...schema,
|
||||
url: withHost(schema.url),
|
||||
image: withHost(schema.image),
|
||||
})
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: computed(() => d().title ?? '') as any,
|
||||
|
||||
htmlAttrs: {
|
||||
lang: computed(() => loc() || 'en'),
|
||||
},
|
||||
|
||||
meta: [
|
||||
{ name: 'description', content: computed(() => d().description) },
|
||||
{
|
||||
name: 'keywords',
|
||||
content: computed(() =>
|
||||
d().keywords?.length ? d().keywords!.join(', ') : undefined,
|
||||
),
|
||||
},
|
||||
{ name: 'author', content: computed(() => d().author) },
|
||||
{ name: 'robots', content: computed(() => d().robots) },
|
||||
|
||||
{ property: 'og:title', content: computed(() => d().title) },
|
||||
{ property: 'og:description', content: computed(() => d().description) },
|
||||
{ property: 'og:image', content: computed(() => withHost(d().image)) },
|
||||
{ property: 'og:url', content: canonical },
|
||||
{ property: 'og:type', content: computed(() => d().type) },
|
||||
{ property: 'og:locale', content: ogLocale },
|
||||
{ property: 'og:site_name', content: computed(() => d()['og:site_name']) },
|
||||
|
||||
{ name: 'twitter:card', content: computed(() => d()['twitter:card']) },
|
||||
{ name: 'twitter:creator', content: computed(() => d()['twitter:creator']) },
|
||||
{ name: 'twitter:title', content: computed(() => d()['twitter:title'] || d().title) },
|
||||
{
|
||||
name: 'twitter:description',
|
||||
content: computed(() => d()['twitter:description'] || d().description),
|
||||
},
|
||||
{
|
||||
name: 'twitter:image',
|
||||
content: computed(() => withHost(d()['twitter:image'] || d().image)),
|
||||
},
|
||||
],
|
||||
|
||||
link: [
|
||||
{ rel: 'canonical', href: canonical },
|
||||
],
|
||||
|
||||
script: computed(() =>
|
||||
structuredData.value
|
||||
? [
|
||||
{
|
||||
type: 'application/ld+json',
|
||||
children: structuredData.value,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
) as any,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { shallowRef } from "vue";
|
||||
/**
|
||||
* usePageData composable:
|
||||
* - Loads localized JSON content that was eagerly bundled at build time.
|
||||
* - If the JSON is an array and a `slug` is provided, returns the matching item.
|
||||
* - Otherwise returns the whole JSON payload.
|
||||
* - Exposes the result as a shallowRef for lightweight reactivity.
|
||||
*/
|
||||
// Preload JSON files eagerly (SSG-safe)
|
||||
|
||||
const jsonModules = import.meta.glob("../data/**/*.json", { eager: true });
|
||||
|
||||
// Fetch page data for a locale; optionally filter by slug
|
||||
export function usePageData<T>(page: string, locale: string, slug?: string) {
|
||||
// Result holder (reactive)
|
||||
const data = shallowRef<T | null>(null);
|
||||
// Build module path for the requested page and locale
|
||||
const path = `../data/${page}/${locale}.json`;
|
||||
// Look up the eagerly-imported JSON module
|
||||
const module = jsonModules[path] as any;
|
||||
// Module found: extract its default export
|
||||
if (module?.default) {
|
||||
// JSON payload (object or array)
|
||||
const payload = module?.default ?? null;
|
||||
// If a slug is provided and payload is a list, return the matching item
|
||||
if (slug && Array.isArray(payload)) {
|
||||
// Find item with matching slug
|
||||
const found =
|
||||
(payload as any[]).find((item: any) => item && item.slug === slug) ??
|
||||
null;
|
||||
data.value = (found as T) ?? null;
|
||||
} else {
|
||||
data.value = payload as T;
|
||||
}
|
||||
}
|
||||
// Module missing: warn and set null
|
||||
else {
|
||||
console.warn(`[usePageData] Missing data for ${page}/${locale}.json`);
|
||||
data.value = null;
|
||||
}
|
||||
// Return the shallowRef so consumers can watch/react to it
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
[
|
||||
{
|
||||
"tags": ["coffee", "tea", "beverage", "dessert", "weekday"],
|
||||
"image": {
|
||||
"alt": "25% discount campaign on beverages and desserts",
|
||||
"src": "/images/campaigns/icecekler-tatlilar-indirim.webp"
|
||||
},
|
||||
"rules": [
|
||||
"After ordering any variety of traditional Turkish breakfast platter, a discount card will be provided together with your receipt during payment.",
|
||||
"You must have your discount card with you to benefit from the discount.",
|
||||
"The discount is valid on any weekday during the campaign period.",
|
||||
"Each discount card can only be used once.",
|
||||
"The discount card will be collected by us once the discount is used.",
|
||||
"The campaign cannot be combined with other campaigns.",
|
||||
"The campaign is also valid for Take Away orders.",
|
||||
"The campaign is not valid for online orders."
|
||||
],
|
||||
"title": "Get <span class=\"text-rose-500\">25% Off</span> on All Beverages and Desserts",
|
||||
"endDate": "2026-08-31",
|
||||
"excerpt": "25% off all beverages and desserts on weekdays!",
|
||||
"discount": "%25",
|
||||
"startDate": "2026-05-10",
|
||||
"description": "Guests who order any of our traditional Turkish breakfast platters can enjoy 25% off all beverages and desserts on weekdays! The campaign is valid until the date specified below.\n\nTo benefit from the campaign, you must order any variety of traditional Turkish breakfast platter and collect your discount card from the cashier together with your receipt after payment. During the campaign period, you can receive the discount on all beverages and dessert varieties by presenting your discount card on the same day or another weekday.\n\nEach discount card can only be used once. This campaign cannot be combined with other discounts or promotions. The discount is not valid for online orders.\n\nThe business reserves the right to make changes to the campaign terms and conditions."
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
[
|
||||
{
|
||||
"tags": ["kahve", "çay", "içecek", "tatlı", "hafta içi"],
|
||||
"image": {
|
||||
"alt": "İçecekler ve tatlılarda %25 indirim kampanyası",
|
||||
"src": "/images/campaigns/icecekler-tatlilar-indirim.webp"
|
||||
},
|
||||
"rules": [
|
||||
"Herhangi bir çeşit serpme kahvaltınız sonrası ödeme sırasında fişinize ek olarak indirim kartı verilir.",
|
||||
"İndirimden yararlanmak için indirim kartınız yanınızda olmalıdır.",
|
||||
"İndirim, kampanya süresince hafta içi herhangi bir gün geçerlidir.",
|
||||
"Her indirim kartı yalnızca bir kez kullanılabilir.",
|
||||
"İnidirim kartı indirimden faydalandığınızda bizde kalır.",
|
||||
"Kampanya başka kampanyalarla birleştirilemez.",
|
||||
"Kampanya Gel-Al (Take Away) siparişler için de geçerlidir.",
|
||||
"Kampanya online siparişler için geçerli değildir."
|
||||
],
|
||||
"title": "Tüm içecekler ve tatlılarda <span class=\"text-rose-500\">%25 İndirim</span>",
|
||||
"endDate": "2026-08-31",
|
||||
"excerpt": "Hafta içi tüm içecekler ve tatlılarda %25 indirim!",
|
||||
"discount": "%25",
|
||||
"startDate": "2026-05-10",
|
||||
"description": "Serpme kahvaltı çeşitlerinden sipariş veren misafirlerimiz için hafta içi tüm içecekler ve tatlılarda %25 indirim! Kampanya aşağıda belirtilen tarihe kadar geçerlidir.\n\nKampanyadan yararlanabilmek için herhangi bir çeşit serpme kahvaltı yapmanız ve sonrasında ödeme fişiniz ile birlikte indirim kartını kasadan almanız gerekir. Kampanya süresince; hafta içi olmak şartıyla, aynı gün veya başka bir gün indirim kartınızı göstererek tüm içecekler ve tatlı çeşitlerinde indirimden faydalanabilirsiniz.\n\nHer indirim kartı yalnızca bir kez kullanılabilir. Kampanya başka indirim ve promosyonlarla birleştirilemez. İndirim online siparişlerde geçerli değildir.\n\nİşletme kampanya koşullarında değişiklik yapma hakkını saklı tutar."
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,183 @@
|
||||
{
|
||||
"meta": {
|
||||
"type": "website",
|
||||
"image": "/og-image.jpg",
|
||||
"title": "Müco Kitchen and Coffee | Unlimited Breakfast, Bowls, Croissants, Homestyle Meals & Coffee in Muğla",
|
||||
"author": "Müco",
|
||||
"robots": "index, follow",
|
||||
"keywords": [
|
||||
"Muğla breakfast",
|
||||
"Best breakfast in Muğla",
|
||||
"Unlimited Turkish breakfast",
|
||||
"Muğla brunch",
|
||||
"Muğla coffee shop",
|
||||
"Muğla cafe",
|
||||
"Croissant cafe in Muğla",
|
||||
"Specialty coffee Muğla",
|
||||
"Bowls in Muğla",
|
||||
"Homemade food Muğla",
|
||||
"Turkish breakfast Muğla",
|
||||
"Müco Kitchen and Coffee",
|
||||
"Best cafe in Muğla",
|
||||
"Breakfast cafe in Muğla center",
|
||||
"Coffee and breakfast in Muğla"
|
||||
],
|
||||
"canonical": "https://www.mucomutfak.com/en/",
|
||||
"og:locale": "en_US",
|
||||
"description": "Müco Kitchen and Coffee offers unlimited Turkish breakfast, fresh bowls, croissants, specialty coffee, and homemade dishes in the center of Muğla.",
|
||||
"og:site_name": "Müco Kitchen and Coffee",
|
||||
"twitter:card": "summary_large_image",
|
||||
"twitter:image": "/og-image.jpg",
|
||||
"twitter:title": "Müco Kitchen and Coffee",
|
||||
"structuredData": {
|
||||
"geo": {
|
||||
"@type": "GeoCoordinates",
|
||||
"latitude": 37.213914,
|
||||
"longitude": 28.3590489
|
||||
},
|
||||
"url": "https://www.mucomutfak.com/en",
|
||||
"name": "Müco Kitchen and Coffee",
|
||||
"@type": ["Restaurant", "CafeOrCoffeeShop"],
|
||||
"image": "/og-image.jpg",
|
||||
"sameAs": [
|
||||
"https://www.instagram.com/mucomutfak/",
|
||||
"https://maps.app.goo.gl/RGNZJAoAdkTYmsUz5"
|
||||
],
|
||||
"address": {
|
||||
"@type": "PostalAddress",
|
||||
"postalCode": "48000",
|
||||
"addressRegion": "Muğla",
|
||||
"streetAddress": "Emirbeyazıt, Hasan Ercan Cd. No:23, Muğla",
|
||||
"addressCountry": "TR",
|
||||
"addressLocality": "Muğla"
|
||||
},
|
||||
"@context": "https://schema.org",
|
||||
"telephone": "+90 (252) 212 07 77",
|
||||
"priceRange": "₺₺",
|
||||
"description": "Müco Kitchen and Coffee offers unlimited Turkish breakfast, fresh bowls, croissants, specialty coffee, and homemade dishes in the center of Muğla.",
|
||||
"alternateName": "Müco Mutfak ve Kahve",
|
||||
"servesCuisine": [
|
||||
"Turkish Breakfast",
|
||||
"Breakfast",
|
||||
"Brunch",
|
||||
"Coffee",
|
||||
"Homemade Food",
|
||||
"Bowls",
|
||||
"Croissants"
|
||||
],
|
||||
"openingHoursSpecification": [
|
||||
{
|
||||
"@type": "OpeningHoursSpecification",
|
||||
"opens": "07:30",
|
||||
"closes": "20:00",
|
||||
"dayOfWeek": [
|
||||
"Monday",
|
||||
"Tuesday",
|
||||
"Wednesday",
|
||||
"Thursday",
|
||||
"Friday",
|
||||
"Saturday",
|
||||
"Sunday"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"twitter:creator": "@mucomutfak",
|
||||
"twitter:description": "Müco Kitchen and Coffee offers a delicious start to the day in the center of Muğla with rich breakfast options, carefully prepared coffee varieties, and a pleasant atmosphere."
|
||||
},
|
||||
"locale": "en",
|
||||
"components": [
|
||||
{
|
||||
"title": "<span class=\"font-bold\">New</span> Plates <br>New <span class=\"font-bold\">Flavors</span>",
|
||||
"plates": [
|
||||
{ "alt": "Muğla Simit Bowl - Müco", "src": "/images/promo/1.webp" },
|
||||
{
|
||||
"alt": "Çikolata Bomba Kruvasan - Müco",
|
||||
"src": "/images/promo/2.webp"
|
||||
},
|
||||
{ "alt": "Truffle Burger - Müco", "src": "/images/promo/3.webp" },
|
||||
{
|
||||
"alt": "Avocado and Egg Croissant - Müco",
|
||||
"src": "/images/promo/4.webp"
|
||||
},
|
||||
{ "alt": "Salmon Bowl - Müco", "src": "/images/promo/5.webp" },
|
||||
{ "alt": "Breakfast Bowl - Müco", "src": "/images/promo/6.webp" },
|
||||
{ "alt": "Granola Bowl - Müco", "src": "/images/promo/7.webp" },
|
||||
{ "alt": "Beef Bowl - Müco", "src": "/images/promo/8.webp" },
|
||||
{ "alt": "Chicken Bowl - Müco", "src": "/images/promo/9.webp" },
|
||||
{ "alt": "Sos-Pan-Yum", "src": "/images/promo/10.webp" }
|
||||
],
|
||||
"enabled": true,
|
||||
"__component": "sections.hero"
|
||||
},
|
||||
{ "enabled": true, "__component": "sections.overview" },
|
||||
{ "enabled": true, "__component": "sections.breakfast" },
|
||||
{
|
||||
"title": "Testimonials",
|
||||
"ctaText": "View All Google Reviews",
|
||||
"enabled": true,
|
||||
"reviews": [
|
||||
{
|
||||
"url": "https://maps.app.goo.gl/eXcogKZmT2JNjwKN9",
|
||||
"name": "Erdi Aydın",
|
||||
"rating": 5,
|
||||
"comment": "We tried the unlimited breakfast. The portions are very small, which is a nice touch. The fried dough comes individually, so you can eat it warm. The products were delicious. The staff were a very good team. We were satisfied and would recommend it."
|
||||
},
|
||||
{
|
||||
"url": "https://maps.app.goo.gl/rNc8EjKuKHZ9xk6o8",
|
||||
"name": "Pınar Gönül",
|
||||
"rating": 5,
|
||||
"comment": "They've really raised the bar with the new menu. My absolute favorite among the new additions is the Dubai croissant. The quality and balance of the ingredients are so successful that the taste lingers on your palate for a long time. Both the presentation and the taste are top-notc..."
|
||||
},
|
||||
{
|
||||
"url": "https://maps.app.goo.gl/Z4gHEHwKHBSEbLPk6",
|
||||
"name": "İlker Çelik",
|
||||
"rating": 5,
|
||||
"comment": "We thank Müco for providing quality, friendly, delicious, fast, and affordable service in Muğla. The humble owner also serves, they aren't as uptight as in many places, the waiters are polite and fast, they don't treat customers like they're going to hit them, they're all smiling..."
|
||||
},
|
||||
{
|
||||
"url": "https://maps.app.goo.gl/LAjYxTyTojXkUjqx8",
|
||||
"name": "almıla bayraktar",
|
||||
"rating": 5,
|
||||
"comment": "I would have loved to share photos, but we devoured it as soon as it arrived. We came by chance and had a fantastic experience. Great job!"
|
||||
},
|
||||
{
|
||||
"url": "https://maps.app.goo.gl/t73cZaWhws962MD77",
|
||||
"name": "Yavuz Aydın (Rehber)",
|
||||
"rating": 5,
|
||||
"comment": "🌿 A wonderful stop in Muğla where taste, courtesy, and hygiene meet: Müco Breakfast and Home Cooking! 🌿 If you happen to be in Muğla, you absolutely must visit Müco! Because this is not just a breakfast place, but also an address for hospitality, delicious food, and ..."
|
||||
},
|
||||
{
|
||||
"url": "https://maps.app.goo.gl/WDjGw7FcNx7dWr7w9",
|
||||
"name": "Ece",
|
||||
"rating": 5,
|
||||
"comment": "We came from Istanbul, they open early in the morning. My wife had a village breakfast, it was 310 TL, and I had a hot breakfast, I think it was 360 TL. It was very filling, but the portions were small enough not to waste anything. There's a variety of options for breakfast..."
|
||||
}
|
||||
],
|
||||
"__component": "sections.testimonials",
|
||||
"googleReviewsUrl": "https://www.google.com/maps/place/M%C3%BCco/@37.213914,28.356474,914m/data=!3m1!1e3!4m8!3m7!1s0x14bf727773c18269:0xa1eea8f88ec02944!8m2!3d37.213914!4d28.3590489!9m1!1b1!16s%2Fg%2F11cn9h4t0l?entry=ttu&g_ep=EgoyMDI2MDQyMi4wIKXMDSoASAFQAw%3D%3D"
|
||||
},
|
||||
{ "enabled": true, "__component": "sections.cta" },
|
||||
{ "enabled": true, "__component": "sections.gallery" },
|
||||
{
|
||||
"title": "<i class=\"text-highlight font-handwritten text-5xl lg:text-7xl\">Homestyle Meals</i> <br>Different dishes<br/>Every weekday",
|
||||
"button": {
|
||||
"url": "https://www.instagram.com/mucomutfak/",
|
||||
"icon": { "name": "arrowRight", "position": "right" },
|
||||
"color": "outlined-dark",
|
||||
"label": "Instagram",
|
||||
"enabled": true,
|
||||
"external": true
|
||||
},
|
||||
"enabled": true,
|
||||
"__component": "sections.banner",
|
||||
"description": "Daily menu is on Instagram, stay tuned!"
|
||||
},
|
||||
{
|
||||
"title": "Contact",
|
||||
"enabled": true,
|
||||
"__component": "sections.contact",
|
||||
"description": null
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
{
|
||||
"meta": {
|
||||
"type": "website",
|
||||
"image": "/og-image.jpg",
|
||||
"title": "Müco Mutfak ve Kahve | Muğla Sınırsız Serpme Kahvaltı, Bowls, Kruvasan, Ev Yemekleri ve Kahve",
|
||||
"author": "Müco",
|
||||
"robots": "index, follow",
|
||||
"keywords": [
|
||||
"Muğla kahvaltı",
|
||||
"Muğla serpme kahvaltı",
|
||||
"Muğla sınırsız kahvaltı",
|
||||
"Muğla brunch",
|
||||
"Muğla kahve",
|
||||
"Muğla kruvasan",
|
||||
"Muğla bowl",
|
||||
"Muğla ev yemekleri",
|
||||
"Muğla kafe",
|
||||
"Muğla breakfast",
|
||||
"Muğla coffee shop",
|
||||
"Müco",
|
||||
"Müco Mutfak ve Kahve",
|
||||
"Muğla merkez kahvaltı",
|
||||
"Muğla cafe önerisi"
|
||||
],
|
||||
"canonical": "https://www.mucomutfak.com/",
|
||||
"og:locale": "tr_TR",
|
||||
"description": "Muğla merkezde sınırsız serpme kahvaltı, bowls, kruvasan, kahve ve ev yemekleriyle keyifli bir lezzet deneyimi.",
|
||||
"og:site_name": "Müco Mutfak ve Kahve",
|
||||
"twitter:card": "summary_large_image",
|
||||
"twitter:image": "/og-image.jpg",
|
||||
"twitter:title": "Müco Mutfak ve Kahve",
|
||||
"structuredData": {
|
||||
"geo": {
|
||||
"@type": "GeoCoordinates",
|
||||
"latitude": 37.213914,
|
||||
"longitude": 28.3590489
|
||||
},
|
||||
"url": "https://www.mucomutfak.com/",
|
||||
"name": "Müco Mutfak ve Kahve",
|
||||
"@type": ["Restaurant", "CafeOrCoffeeShop"],
|
||||
"image": "/og-image.jpg",
|
||||
"sameAs": [
|
||||
"https://www.instagram.com/mucomutfak/",
|
||||
"https://maps.app.goo.gl/RGNZJAoAdkTYmsUz5"
|
||||
],
|
||||
"address": {
|
||||
"@type": "PostalAddress",
|
||||
"postalCode": "48000",
|
||||
"addressRegion": "Muğla",
|
||||
"streetAddress": "Emirbeyazıt, Hasan Ercan Cd. No:23, Muğla",
|
||||
"addressCountry": "TR",
|
||||
"addressLocality": "Muğla"
|
||||
},
|
||||
"@context": "https://schema.org",
|
||||
"telephone": "+90 (252) 212 07 77",
|
||||
"priceRange": "₺₺",
|
||||
"description": "Muğla merkezde sınırsız serpme kahvaltı, bowls, kruvasan, kahve ve ev yemekleriyle keyifli bir lezzet deneyimi.",
|
||||
"alternateName": "Müco Kitchen and Coffee",
|
||||
"servesCuisine": [
|
||||
"Kahvaltı",
|
||||
"Serpme Kahvaltı",
|
||||
"Brunch",
|
||||
"Kahve",
|
||||
"Ev Yemekleri",
|
||||
"Bowls",
|
||||
"Kruvasan"
|
||||
],
|
||||
"openingHoursSpecification": [
|
||||
{
|
||||
"@type": "OpeningHoursSpecification",
|
||||
"opens": "07:30",
|
||||
"closes": "20:00",
|
||||
"dayOfWeek": [
|
||||
"Monday",
|
||||
"Tuesday",
|
||||
"Wednesday",
|
||||
"Thursday",
|
||||
"Friday",
|
||||
"Saturday",
|
||||
"Sunday"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"twitter:creator": "@mucomutfak",
|
||||
"twitter:description": "Müco Mutfak ve Kahve, Muğla merkezde zengin kahvaltı seçenekleri, özenle hazırlanan kahve çeşitleri ve keyifli atmosferiyle güne lezzetli bir başlangıç sunar."
|
||||
},
|
||||
"locale": "tr",
|
||||
"components": [
|
||||
{
|
||||
"title": "<span class=\"font-bold\">Yeni</span> Tabaklar <br>Yeni <span class=\"font-bold\">Lezzetler</span>",
|
||||
"plates": [
|
||||
{ "alt": "Muğla Simit Bowl - Müco", "src": "/images/promo/1.webp" },
|
||||
{
|
||||
"alt": "Çikolata Bomba Kruvasan - Müco",
|
||||
"src": "/images/promo/2.webp"
|
||||
},
|
||||
{ "alt": "Trüflü Burger - Müco", "src": "/images/promo/3.webp" },
|
||||
{
|
||||
"alt": "Avokadolu Yumurtalı Kruvasan - Müco",
|
||||
"src": "/images/promo/4.webp"
|
||||
},
|
||||
{ "alt": "Somon Bowl - Müco", "src": "/images/promo/5.webp" },
|
||||
{ "alt": "Kahvaltı Bowl - Müco", "src": "/images/promo/6.webp" },
|
||||
{ "alt": "Granola Bowl - Müco", "src": "/images/promo/7.webp" },
|
||||
{ "alt": "Dana Etli Bowl - Müco", "src": "/images/promo/8.webp" },
|
||||
{ "alt": "Tavuklu Bowl - Müco", "src": "/images/promo/9.webp" },
|
||||
{ "alt": "Sos-Pan-Yum", "src": "/images/promo/10.webp" }
|
||||
],
|
||||
"enabled": true,
|
||||
"__component": "sections.hero"
|
||||
},
|
||||
{ "enabled": true, "__component": "sections.overview" },
|
||||
{ "enabled": true, "__component": "sections.breakfast" },
|
||||
{
|
||||
"title": "Memnuniyet",
|
||||
"ctaText": "Tüm Google Yorumları",
|
||||
"enabled": true,
|
||||
"reviews": [
|
||||
{
|
||||
"url": "https://maps.app.goo.gl/eXcogKZmT2JNjwKN9",
|
||||
"name": "Erdi Aydın",
|
||||
"rating": 5,
|
||||
"comment": "Sınırsız kahvaltı denedik. Porsiyon israf olmayacak kadar az az geliyor oldukça güzel bir hareket. Pişi tek tek geliyor bu sayede sıcak yemis oluyorsunuz. Ürünler lezzetliydi. Çalışan arkadaşlar oldukça iyi bir ekipti. Biz memnun kaldık tavsiye ederiz."
|
||||
},
|
||||
{
|
||||
"url": "https://maps.app.goo.gl/rNc8EjKuKHZ9xk6o8",
|
||||
"name": "Pınar Gönül",
|
||||
"rating": 5,
|
||||
"comment": "Yeni menüyle çıtayı iyice yükseltmişler. Menüdeki yenilikler arasında favorim kesinlikle dubai kruvasan oldu. Malzeme kalitesi ve dengesi o kadar başarılı ki damağınızda uzun süre tadı kalıyor. Hem sunum hem lezzet 10 numara. İşletmecilerin eline sağlık!"
|
||||
},
|
||||
{
|
||||
"url": "https://maps.app.goo.gl/Z4gHEHwKHBSEbLPk6",
|
||||
"name": "İlker Çelik",
|
||||
"rating": 5,
|
||||
"comment": "Muğla’ya kaliteli, güleryüzlü, lezzetli, hızlı ve uygun fiyatlı hizmet sunduğu için Müco’ya teşekkür ediyoruz. Tevazu sahibi Patronu da servis yapıyor, çoğu işyerindeki gibi kasılmıyorlar, garsonlar nazik ve hızlı, müşteriye dövecek gibi davranmıyorlar, güleryüz..."
|
||||
},
|
||||
{
|
||||
"url": "https://maps.app.goo.gl/LAjYxTyTojXkUjqx8",
|
||||
"name": "almıla bayraktar",
|
||||
"rating": 5,
|
||||
"comment": "Fotoğraf paylaşmayı çok isterdim ama geldiği gibi hüplettik. Tesadüfen geldik ve müthiş bir deneyim yaşadık. Elinize sağlık"
|
||||
},
|
||||
{
|
||||
"url": "https://maps.app.goo.gl/t73cZaWhws962MD77",
|
||||
"name": "Yavuz Aydın (Rehber)",
|
||||
"rating": 5,
|
||||
"comment": "🌿 Muğla’da lezzetin, nezaketin ve hijyenin buluştuğu harika bir durak: Müco Kahvaltı ve Ev Yemekleri! 🌿 Eğer yolunuz Muğla’ya düşerse değil, mutlaka yolunuzu Müco’ya düşürün! Çünkü burası sadece bir kahvaltı mekânı değil, aynı zamanda misafirperverliğin..."
|
||||
},
|
||||
{
|
||||
"url": "https://maps.app.goo.gl/WDjGw7FcNx7dWr7w9",
|
||||
"name": "Ece",
|
||||
"rating": 5,
|
||||
"comment": "İstanbul'dan geldik, sabah erken açıyorlar, eşim köy kahvaltısı aldı, 310 TL , ben de sıcak kahvaltı aldım,360 TL idi galiba, çok doyurucu ama israf edilmeyecek şekilde porsiyonlar halinde geldi. Kahvaltı için her fiyata ve porsiyona göre çeşitlilik var. Ayrıca temiz ve güleryüzlü bir..."
|
||||
}
|
||||
],
|
||||
"__component": "sections.testimonials",
|
||||
"googleReviewsUrl": "https://www.google.com/maps/place/M%C3%BCco/@37.213914,28.356474,914m/data=!3m1!1e3!4m8!3m7!1s0x14bf727773c18269:0xa1eea8f88ec02944!8m2!3d37.213914!4d28.3590489!9m1!1b1!16s%2Fg%2F11cn9h4t0l?entry=ttu&g_ep=EgoyMDI2MDQyMi4wIKXMDSoASAFQAw%3D%3D"
|
||||
},
|
||||
{ "enabled": true, "__component": "sections.cta" },
|
||||
{ "enabled": true, "__component": "sections.gallery" },
|
||||
{
|
||||
"title": "<i class=\"text-highlight font-handwritten text-5xl lg:text-7xl\">Ev Yemekleri</i> <br>Haftaiçi her gün<br/>farklı yemekler",
|
||||
"button": {
|
||||
"url": "https://www.instagram.com/mucomutfak/",
|
||||
"icon": { "name": "arrowRight", "position": "right" },
|
||||
"color": "outlined-dark",
|
||||
"label": "Instagram",
|
||||
"enabled": true,
|
||||
"external": true
|
||||
},
|
||||
"enabled": true,
|
||||
"__component": "sections.banner",
|
||||
"description": "Günlük menü Instagram’da, takipte kalın!"
|
||||
},
|
||||
{
|
||||
"title": "İletişim",
|
||||
"enabled": true,
|
||||
"__component": "sections.contact",
|
||||
"description": null
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"meta": {
|
||||
"type": "website",
|
||||
"image": "/og-image.jpg",
|
||||
"title": "Legal Texts - Müco Mutfak ve Kahve",
|
||||
"author": "Müco",
|
||||
"robots": "index, follow",
|
||||
"keywords": [
|
||||
"Breakfast",
|
||||
"Bowls",
|
||||
"Croissants",
|
||||
"Coffee",
|
||||
"Muğla",
|
||||
"Müco",
|
||||
"Müco Mutfak ve Kahve"
|
||||
],
|
||||
"canonical": "https://www.mucomutfak.com/legal",
|
||||
"og:locale": "en_US",
|
||||
"description": "Müco Mutfak ve Kahve, Muğla center with rich breakfast options, bowls, croissants, carefully prepared coffee varieties and pleasant atmosphere provide a delicious start to the day.",
|
||||
"og:site_name": "Müco Mutfak ve Kahve",
|
||||
"twitter:card": "summary_large_image",
|
||||
"twitter:image": "/og-image.jpg",
|
||||
"twitter:title": "Müco Mutfak ve Kahve",
|
||||
"twitter:creator": "@mucomutfak",
|
||||
"twitter:description": "Müco Mutfak ve Kahve, Muğla center with rich breakfast options, bowls, croissants, carefully prepared coffee varieties and pleasant atmosphere provide a delicious start to the day."
|
||||
},
|
||||
"locale": "en"
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"meta": {
|
||||
"type": "website",
|
||||
"image": "/og-image.jpg",
|
||||
"title": "Yasal Metinler - Müco Mutfak ve Kahve",
|
||||
"author": "Müco",
|
||||
"robots": "index, follow",
|
||||
"keywords": [
|
||||
"Kahvaltı",
|
||||
"Bowls",
|
||||
"Kruvasanlar",
|
||||
"Kahve",
|
||||
"Muğla",
|
||||
"Müco",
|
||||
"Müco Mutfak ve Kahve"
|
||||
],
|
||||
"canonical": "https://www.mucomutfak.com/legal",
|
||||
"og:locale": "tr_TR",
|
||||
"description": "Müco Mutfak ve Kahve, Muğla merkezde zengin kahvaltı seçenekleri, bowls, kruvasanlar, özenle hazırlanan kahve çeşitleri ve keyifli atmosferiyle güne lezzetli bir başlangıç sunar.",
|
||||
"og:site_name": "Müco Mutfak ve Kahve",
|
||||
"twitter:card": "summary_large_image",
|
||||
"twitter:image": "/og-image.jpg",
|
||||
"twitter:title": "Müco Mutfak ve Kahve",
|
||||
"twitter:creator": "@mucomutfak",
|
||||
"twitter:description": "Müco Mutfak ve Kahve, Muğla merkezde zengin kahvaltı seçenekleri, özenle hazırlanan kahve çeşitleri ve keyifli atmosferiyle güne lezzetli bir başlangıç sunar."
|
||||
},
|
||||
"locale": "tr"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,122 @@
|
||||
{
|
||||
"meta": {
|
||||
"type": "website",
|
||||
"image": "/og-image.jpg",
|
||||
"title": "Müco Mutfak ve Kahve",
|
||||
"author": "Müco",
|
||||
"robots": "index, follow",
|
||||
"keywords": [
|
||||
"Kahvaltı",
|
||||
"Bowls",
|
||||
"Kruvasanlar",
|
||||
"Kahve",
|
||||
"Muğla",
|
||||
"Müco",
|
||||
"Müco Mutfak ve Kahve"
|
||||
],
|
||||
"canonical": "https://www.mucomutfak.com/",
|
||||
"og:locale": "tr_TR",
|
||||
"description": "Müco Mutfak ve Kahve, Muğla merkezde zengin kahvaltı seçenekleri, bowls, kruvasanlar, özenle hazırlanan kahve çeşitleri ve keyifli atmosferiyle güne lezzetli bir başlangıç sunar.",
|
||||
"og:site_name": "Müco Mutfak ve Kahve",
|
||||
"twitter:card": "summary_large_image",
|
||||
"twitter:image": "/og-image.jpg",
|
||||
"twitter:title": "Müco Mutfak ve Kahve",
|
||||
"twitter:creator": "@mucomutfak",
|
||||
"twitter:description": "Müco Mutfak ve Kahve, Muğla merkezde zengin kahvaltı seçenekleri, özenle hazırlanan kahve çeşitleri ve keyifli atmosferiyle güne lezzetli bir başlangıç sunar."
|
||||
},
|
||||
"locale": "tr",
|
||||
"components": [
|
||||
{
|
||||
"title": "<span class=\"font-bold\">Yeni</span> Tabaklar <br>Yeni <span class=\"font-bold\">Lezzetler</span>",
|
||||
"plates": [
|
||||
{ "alt": "Muğla Simit Bowl - Müco", "src": "/images/promo/1.webp" },
|
||||
{
|
||||
"alt": "Çikolata Bomba Kruvasan - Müco",
|
||||
"src": "/images/promo/2.webp"
|
||||
},
|
||||
{ "alt": "Trüflü Burger - Müco", "src": "/images/promo/3.webp" },
|
||||
{
|
||||
"alt": "Avokadolu Yumurtalı Kruvasan - Müco",
|
||||
"src": "/images/promo/4.webp"
|
||||
},
|
||||
{ "alt": "Somon Bowl - Müco", "src": "/images/promo/5.webp" },
|
||||
{ "alt": "Kahvaltı Bowl - Müco", "src": "/images/promo/6.webp" },
|
||||
{ "alt": "Granola Bowl - Müco", "src": "/images/promo/7.webp" },
|
||||
{ "alt": "Dana Etli Bowl - Müco", "src": "/images/promo/8.webp" },
|
||||
{ "alt": "Tavuklu Bowl - Müco", "src": "/images/promo/9.webp" },
|
||||
{ "alt": "Sos-Pan-Yum", "src": "/images/promo/10.webp" }
|
||||
],
|
||||
"enabled": true,
|
||||
"__component": "sections.hero"
|
||||
},
|
||||
{ "enabled": true, "__component": "sections.overview" },
|
||||
{ "enabled": true, "__component": "sections.breakfast" },
|
||||
{
|
||||
"title": "Memnuniyet",
|
||||
"ctaText": "Tüm Google Yorumları",
|
||||
"enabled": true,
|
||||
"reviews": [
|
||||
{
|
||||
"url": "https://maps.app.goo.gl/eXcogKZmT2JNjwKN9",
|
||||
"name": "Erdi Aydın",
|
||||
"rating": 5,
|
||||
"comment": "Sınırsız kahvaltı denedik. Porsiyon israf olmayacak kadar az az geliyor oldukça güzel bir hareket. Pişi tek tek geliyor bu sayede sıcak yemis oluyorsunuz. Ürünler lezzetliydi. Çalışan arkadaşlar oldukça iyi bir ekipti. Biz memnun kaldık tavsiye ederiz."
|
||||
},
|
||||
{
|
||||
"url": "https://maps.app.goo.gl/rNc8EjKuKHZ9xk6o8",
|
||||
"name": "Pınar Gönül",
|
||||
"rating": 5,
|
||||
"comment": "Yeni menüyle çıtayı iyice yükseltmişler. Menüdeki yenilikler arasında favorim kesinlikle dubai kruvasan oldu. Malzeme kalitesi ve dengesi o kadar başarılı ki damağınızda uzun süre tadı kalıyor. Hem sunum hem lezzet 10 numara. İşletmecilerin eline sağlık!"
|
||||
},
|
||||
{
|
||||
"url": "https://maps.app.goo.gl/Z4gHEHwKHBSEbLPk6",
|
||||
"name": "İlker Çelik",
|
||||
"rating": 5,
|
||||
"comment": "Muğla’ya kaliteli, güleryüzlü, lezzetli, hızlı ve uygun fiyatlı hizmet sunduğu için Müco’ya teşekkür ediyoruz. Tevazu sahibi Patronu da servis yapıyor, çoğu işyerindeki gibi kasılmıyorlar, garsonlar nazik ve hızlı, müşteriye dövecek gibi davranmıyorlar, güleryüz..."
|
||||
},
|
||||
{
|
||||
"url": "https://maps.app.goo.gl/LAjYxTyTojXkUjqx8",
|
||||
"name": "almıla bayraktar",
|
||||
"rating": 5,
|
||||
"comment": "Fotoğraf paylaşmayı çok isterdim ama geldiği gibi hüplettik. Tesadüfen geldik ve müthiş bir deneyim yaşadık. Elinize sağlık"
|
||||
},
|
||||
{
|
||||
"url": "https://maps.app.goo.gl/t73cZaWhws962MD77",
|
||||
"name": "Yavuz Aydın (Rehber)",
|
||||
"rating": 5,
|
||||
"comment": "🌿 Muğla’da lezzetin, nezaketin ve hijyenin buluştuğu harika bir durak: Müco Kahvaltı ve Ev Yemekleri! 🌿 Eğer yolunuz Muğla’ya düşerse değil, mutlaka yolunuzu Müco’ya düşürün! Çünkü burası sadece bir kahvaltı mekânı değil, aynı zamanda misafirperverliğin..."
|
||||
},
|
||||
{
|
||||
"url": "https://maps.app.goo.gl/WDjGw7FcNx7dWr7w9",
|
||||
"name": "Ece",
|
||||
"rating": 5,
|
||||
"comment": "İstanbul'dan geldik, sabah erken açıyorlar, eşim köy kahvaltısı aldı, 310 TL , ben de sıcak kahvaltı aldım,360 TL idi galiba, çok doyurucu ama israf edilmeyecek şekilde porsiyonlar halinde geldi. Kahvaltı için her fiyata ve porsiyona göre çeşitlilik var. Ayrıca temiz ve güleryüzlü bir..."
|
||||
}
|
||||
],
|
||||
"__component": "sections.testimonials",
|
||||
"googleReviewsUrl": "https://www.google.com/maps/place/M%C3%BCco/@37.213914,28.356474,914m/data=!3m1!1e3!4m8!3m7!1s0x14bf727773c18269:0xa1eea8f88ec02944!8m2!3d37.213914!4d28.3590489!9m1!1b1!16s%2Fg%2F11cn9h4t0l?entry=ttu&g_ep=EgoyMDI2MDQyMi4wIKXMDSoASAFQAw%3D%3D"
|
||||
},
|
||||
{ "enabled": true, "__component": "sections.cta" },
|
||||
{ "enabled": true, "__component": "sections.gallery" },
|
||||
{
|
||||
"title": "<i class=\"text-highlight font-handwritten text-5xl lg:text-7xl\">Ev Yemekleri</i> <br>Haftaiçi her gün<br/>farklı yemekler",
|
||||
"button": {
|
||||
"url": "https://www.instagram.com/mucomutfak/",
|
||||
"icon": { "name": "arrowRight", "position": "right" },
|
||||
"color": "outlined-dark",
|
||||
"label": "Instagram",
|
||||
"enabled": true,
|
||||
"external": true
|
||||
},
|
||||
"enabled": true,
|
||||
"__component": "sections.banner",
|
||||
"description": "Günlük menü Instagram’da, takipte kalın!"
|
||||
},
|
||||
{
|
||||
"title": "İletişim",
|
||||
"enabled": true,
|
||||
"__component": "sections.contact",
|
||||
"description": null
|
||||
}
|
||||
]
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
// src/i18n.ts
|
||||
import { createI18n } from "vue-i18n";
|
||||
|
||||
const modules = import.meta.glob("./locales/*.json", { eager: true });
|
||||
|
||||
const messages: Record<string, any> = {};
|
||||
|
||||
for (const path in modules) {
|
||||
const matched = path.match(/\.\/locales\/(.*)\.json$/);
|
||||
|
||||
if (matched) {
|
||||
const locale = matched[1];
|
||||
messages[locale] = (modules[path] as any).default;
|
||||
}
|
||||
}
|
||||
|
||||
export type MessageSchema = (typeof messages)["tr"];
|
||||
|
||||
const getInitialLocale = (): keyof typeof messages => {
|
||||
try {
|
||||
const storedLang = localStorage.getItem("lang");
|
||||
|
||||
if (storedLang && storedLang in messages) {
|
||||
return storedLang as keyof typeof messages;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return "tr";
|
||||
};
|
||||
|
||||
const currentLocale = getInitialLocale();
|
||||
|
||||
if (typeof document !== "undefined") {
|
||||
document.documentElement.lang = currentLocale;
|
||||
}
|
||||
|
||||
const i18n = createI18n<[MessageSchema], keyof typeof messages>({
|
||||
legacy: false,
|
||||
locale: currentLocale,
|
||||
fallbackLocale: "tr",
|
||||
messages,
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<TopBar />
|
||||
<Header />
|
||||
|
||||
<main>
|
||||
<router-view />
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import TopBar from "@/components/TopBar.vue";
|
||||
import Header from "@/components/Header.vue";
|
||||
import Footer from "@/components/Footer.vue";
|
||||
</script>
|
||||
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<main>
|
||||
<router-view />
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Footer from "@/components/Footer.vue";
|
||||
</script>
|
||||
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"Title": "Müco Kitchen & Coffee",
|
||||
"Welcome": "Welcome",
|
||||
"Explore our food and drinks": "Explore our foods and drinks",
|
||||
"Campaigns": "Campaigns",
|
||||
"Campaign Rules": "Campaign Rules",
|
||||
"Browse our menu campaigns": "Browse our menu campaigns",
|
||||
"No Active Campaign": "There are currently no active campaigns.",
|
||||
"View Details": "View Details",
|
||||
"Start Date": "Start Date",
|
||||
"End Date": "End Date",
|
||||
"Discount": "Discount",
|
||||
"Ended": "Ended",
|
||||
"Campaign ended": "Campaign ended",
|
||||
"Kampanyamız bitmiştir": "Our campaign has ended",
|
||||
"until": "until",
|
||||
"View current offers": "View current offers",
|
||||
"Apply to join our team": "Apply to join our team",
|
||||
"Satisfaction Survey": "Satisfaction Survey",
|
||||
"Share your experience": "Share your experience",
|
||||
"Menu Intro": "Weekday home-style meals service continues between 12:00–14:00. Current menu on Instagram 🌿",
|
||||
"Menu": "Menu",
|
||||
"Main Menu": "Müco Menu",
|
||||
"Phone": "Phone",
|
||||
"Back": "Back",
|
||||
"Payment Methods": "Payment Methods",
|
||||
"Legal Texts": "Legal Texts",
|
||||
"About": "Rooted in the past, living in the present.",
|
||||
"Who we are": "Who We Are",
|
||||
"Contact": "Contact",
|
||||
"Opening Hours": "We are open between 7:30 AM - 8:00 PM.",
|
||||
"Order": "Order",
|
||||
"Online Order": "Online Order",
|
||||
"Testimonials": "Testimonials",
|
||||
"View": "View Testimonial",
|
||||
"View All Google Reviews": "View All Google Reviews",
|
||||
"Featured Dishes": "Featured Dishes",
|
||||
"Photo Gallery": "<i class=\"text-highlight font-handwritten text-3xl lg:text-7xl\">Moments</i> at Müco",
|
||||
"Slogan": "We’re waiting for you for breakfast, delicious meals, coffee, and a good mood :)",
|
||||
"Show on Maps": "Show on Maps",
|
||||
"All rights reserved.": "All rights reserved.",
|
||||
"Müco is at your door with Yemeksepeti!": "Get Müco delivered to your door with Yemeksepeti!",
|
||||
"Unlimited Breakfast": "Unlimited Breakfast",
|
||||
"As you finish": "As you finish",
|
||||
"Ask For More": "Ask For More",
|
||||
"Navigation": "Navigation",
|
||||
"Overview Text": "## Since 2006…\n\nOur journey began in a tiny little shop. Just like we prepared food in our own home, we welcomed our guests with homemade breakfasts and gözleme… And every time we saw the happiness on your faces, our own happiness grew even more.\n\nNot *as if* touched by a mother’s hand, but truly *crafted by one*… The flavors we prepared with the same care and devotion as day one gradually took root and flourished.\nIn 2014, right here, this journey—like a **vine**—grew patiently, lovingly, and together with you into the big Müco family it is today.\n\n**Today, Müco** is a meeting place in the heart of the city where you can find a piece of yourself at any time of day. Whether it’s an energetic start to your morning, a peaceful break, or warm conversations…\n\nOur roots are in the past, our spirit is in the present; we continue to evolve while preserving the sincerity of our very first day.\n\nOur aim is simple: to offer the flavors and atmosphere you dream of, while making you feel right at home.\n\nOur doors are always open, just as warmly as ever. We are here to be part of your most beautiful moments on this journey.\n\n**Stay with Müco—deliciously, joyfully…**\n\n*Mücahit Uslu*",
|
||||
"Home": "Home",
|
||||
"Go to Homepage": "Go to Homepage",
|
||||
"Language Switcher": "Language Switcher",
|
||||
"languageSwitcher": {
|
||||
"changed": "Language has been changed to English"
|
||||
},
|
||||
"opensInNewTab": "opens in a new tab",
|
||||
"Tag": "Tag",
|
||||
"Job Application": "Job Application",
|
||||
"Suggestion": "Suggestion or Complaint",
|
||||
"Something Else": "Something Else",
|
||||
"Select a subject": "Select a subject",
|
||||
"Name": "Name",
|
||||
"Full Name": "Full Name",
|
||||
"Email": "Email",
|
||||
"Subject": "Subject",
|
||||
"Message": "Message",
|
||||
"Send": "Send",
|
||||
"Sending…": "Sending…",
|
||||
"I agree to be contacted regarding my inquiry.": "I agree to be contacted regarding my inquiry.",
|
||||
"Name is required": "Full name is required",
|
||||
"Please specify the subject": "Please specify the subject",
|
||||
"Please enter at least 2 characters": "Please enter at least 2 characters",
|
||||
"Please enter at least 4 characters": "Please enter at least 4 characters",
|
||||
"Email is required": "Email is required",
|
||||
"Please enter a valid email": "Please enter a valid email",
|
||||
"Subject is required": "Subject is required",
|
||||
"Message is required": "Message is required",
|
||||
"Please write at least 25 characters": "Please write at least 25 characters",
|
||||
"Please keep your message under 600 characters": "Please keep your message under 600 characters",
|
||||
"Please confirm you agree to be contacted": "Please confirm that you agree to be contacted",
|
||||
"Please fix the errors above.": "Please fix the errors above.",
|
||||
"We'll get back to you soon.": "We'll get back to you soon.",
|
||||
"Something went wrong. Please try again.": "Something went wrong. Please try again.",
|
||||
"How can I help?": "How can I help?",
|
||||
"Write your message here…": "Write your message here…",
|
||||
"Other Subject": "Other Subject",
|
||||
"Message sent!": "Message sent!",
|
||||
"Message is not receive.": "Something went wrong. Please try again later or contact me directly at info@mucomutfak.com.",
|
||||
"Send another message": "Send another message",
|
||||
"common": {
|
||||
"close": "Close",
|
||||
"goTo": "Go to",
|
||||
"go": "Go",
|
||||
"copyLink": "Copy link",
|
||||
"share": "Share",
|
||||
"coppied": "Copied!"
|
||||
},
|
||||
"errors": {
|
||||
"title": "We couldn’t find the page you were looking for.",
|
||||
"backHome": "Go to homepage",
|
||||
"goBack": "Go back."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"Title": "Müco Mutfak & Kahve",
|
||||
"Welcome": "Hoşgeldiniz",
|
||||
"Explore our food and drinks": "Yiyecek ve içecek menümüzü keşfedin",
|
||||
"Campaigns": "Kampanyalar",
|
||||
"Campaign Rules": "Kampanya Kuralları",
|
||||
"Browse our menu campaigns": "Menü fırsatlarımıza göz atın",
|
||||
"No Active Campaign": "Şu anda aktif bir kampanyamız yoktur.",
|
||||
"View Details": "Detayları Gör",
|
||||
"Start Date": "Başlangıç Tarihi",
|
||||
"End Date": "Bitiş Tarihi",
|
||||
"Discount": "İndirim",
|
||||
"Ended": "Bitti",
|
||||
"Campaign ended": "Kampanyamız bitmiştir.",
|
||||
"Kampanyamız bitmiştir": "Kampanyamız bitmiştir",
|
||||
"until": "tarihine kadar",
|
||||
"View current offers": "Güncel fırsatları görüntüleyin",
|
||||
"Apply to join our team": "Ekibimize katılmak için başvurun",
|
||||
"Satisfaction Survey": "Memnuniyet Anketi",
|
||||
"Share your experience": "Deneyiminizi bizimle paylaşın",
|
||||
"Menu Intro": "Haftaiçi 12.00–14.00 arası ev yemekleri servisimiz devam ediyor. Güncel menü Instagram'da 🌿",
|
||||
"Menu": "Menü",
|
||||
"Main Menu": "Müco Menü",
|
||||
"Phone": "Telefon",
|
||||
"Back": "Geri",
|
||||
"Payment Methods": "Ödeme Yöntemleri",
|
||||
"Legal Texts": "Yasal Metinler",
|
||||
"About": "Kökleri geçmişte, ruhu bugünde.",
|
||||
"Who we are": "Biz Kimiz?",
|
||||
"Contact": "İletişim",
|
||||
"Opening Hours": "7:30-20:00 saatleri arasında açığız.",
|
||||
"Order": "Sipariş Ver",
|
||||
"Online Order": "Online Sipariş",
|
||||
"Testimonials": "Memnuniyet",
|
||||
"View": "Yorumu Gör",
|
||||
"View All Google Reviews": "Tüm Google Yorumlarını Gör",
|
||||
"Featured Dishes": "Öne Çıkan Lezzetler",
|
||||
"Photo Gallery": "Müco'da <i class=\"text-highlight font-handwritten text-3xl lg:text-7xl\">Keyifli Köşeler</i>",
|
||||
"Slogan": "Kahvaltı, leziz yemekler, kahve ve iyi bir ruh hali için bekliyoruz :)",
|
||||
"Show on Maps": "Haritada Göster",
|
||||
"All rights reserved.": "Tüm hakları saklıdır.",
|
||||
"Müco is at your door with Yemeksepeti!": "Müco YemekSepeti'yle kapında!",
|
||||
"Unlimited Breakfast": "SINIRSIZ KAHVALTI",
|
||||
"As you finish": "BİTTİKÇE İSTE",
|
||||
"Ask For More": "GELSİN",
|
||||
"Navigation": "Navigasyon",
|
||||
"Overview Text": "### 2006’dan bu yana…\n\nKüçücük bir dükkânda başladı yolculuğumuz. Evimizde hazırladığımız gibi, gelen misafirlerimize de kahvaltılar ve gözlemeler sunarak… Sizleri ağırlarken yüzünüzdeki mutluluğu gördükçe, bizim mutluluğumuz da büyüdü.\n\nAnne eli değmiş **gibi değil**, gerçekten **anne eli değerek**; ilk günkü hassasiyet ve titizlikle hazırladığımız lezzetler zamanla kök saldı, dallandı…\n2014’te bu yolculuk tam da burada; tıpkı bir **sarmaşık** gibi; sabırla, sevgiyle, sizlerle birlikte kocaman bir Müco ailesine dönüştü.\n\n**Bugün Müco**; günün her anında kendinizden bir parça bulabileceğiniz, şehrin içinde nefes alan bir buluşma noktası. İster güne enerjik bir başlangıç, ister sakin bir mola, ister keyifli sohbetler…\n\nKöklerimiz geçmişte, ruhumuz bugünde; Değişiyoruz, değişirken de o ilk günkü samimiyeti korumaya devam ediyoruz.\n\nAmacımız; hayal ettiğiniz lezzeti ve ortamı sunarken, kendinizi evinizde hissettirmek.\n\nKapımız her zaman olduğu gibi samimiyetle açık. Bu yolculukta en güzel anılarınıza eşlik etmek için buradayız.\n\n**Afiyetle, keyifle, Müco’yla kalın…**\n\n*Mücahit Uslu*",
|
||||
"Home": "Anasayfa",
|
||||
"Go to Homepage": "Anasayfaya git",
|
||||
"Language Switcher": "Dil Değiştir",
|
||||
"languageSwitcher": {
|
||||
"changed": "Dil Türkçe olarak değiştirildi"
|
||||
},
|
||||
"opensInNewTab": "yeni sekmede açılır",
|
||||
"Tag": "Etiket",
|
||||
"Job Application": "İş Başvurusu",
|
||||
"Suggestion": "İstek veya Şikayet",
|
||||
"Something Else": "Diğer",
|
||||
"Select a subject": "Konu seçin",
|
||||
"Name": "İsim",
|
||||
"Full Name": "Ad Soyad",
|
||||
"Email": "E-posta",
|
||||
"Subject": "Konu",
|
||||
"Message": "Mesaj",
|
||||
"Send": "Gönder",
|
||||
"Sending…": "Gönderiliyor…",
|
||||
"I agree to be contacted regarding my inquiry.": "Talebimle ilgili olarak benimle iletişime geçilmesini kabul ediyorum.",
|
||||
"Name is required": "Ad Soyad zorunludur",
|
||||
"Please specify the subject": "Lütfen konuyu belirtin",
|
||||
"Please enter at least 2 characters": "Lütfen en az 2 karakter giriniz",
|
||||
"Please enter at least 4 characters": "Lütfen en az 4 karakter giriniz",
|
||||
"Email is required": "E-posta zorunludur",
|
||||
"Please enter a valid email": "Lütfen geçerli bir e-posta giriniz",
|
||||
"Subject is required": "Konu zorunludur",
|
||||
"Message is required": "Mesaj zorunludur",
|
||||
"Please write at least 25 characters": "Lütfen en az 25 karakter yazınız",
|
||||
"Please keep your message under 600 characters": "Lütfen mesajınızı 600 karakterin altında tutun",
|
||||
"Please confirm you agree to be contacted": "Lütfen iletişime geçilmesini kabul ettiğinizi onaylayın",
|
||||
"Please fix the errors above.": "Lütfen yukarıdaki hataları düzeltin.",
|
||||
"We'll get back to you soon.": "En kısa zamanda iletişime geçeceğiz.",
|
||||
"Something went wrong. Please try again.": "Bir şeyler yanlış gitti. Lütfen tekrar deneyin.",
|
||||
"How can I help?": "Size nasıl yardımcı olabilirim?",
|
||||
"Write your message here…": "Mesajınızı buraya yazın…",
|
||||
"Other Subject": "Diğer Konu",
|
||||
"Message sent!": "Mesajınız yolda!",
|
||||
"Message is not receive.": "Bir şeyler ters gitti. Biraz sonra tekrar dener misiniz? Ya da doğrudan info@mucomutfak.com adresinden bana ulaşabilirsiniz.",
|
||||
"Send another message": "Bir mesaj daha gönder",
|
||||
"common": {
|
||||
"close": "Kapat",
|
||||
"goTo": "Şuraya git",
|
||||
"go": "Git",
|
||||
"copyLink": "Linki Kopyala",
|
||||
"share": "Paylaş",
|
||||
"coppied": "Kopyalandı!"
|
||||
},
|
||||
"errors": {
|
||||
"title": "Aradığınız sayfayı bulamadık.",
|
||||
"backHome": "Anasayfaya git",
|
||||
"goBack": "Geri dön."
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import "./style.css";
|
||||
import App from "./App.vue";
|
||||
import { ViteSSG } from "vite-ssg";
|
||||
import { routes } from "@/router/index"; // NOTE: routes array export is required
|
||||
import i18n from "@/i18n";
|
||||
import gsapPlugin from "@/plugins/gsap";
|
||||
import "locomotive-scroll/dist/locomotive-scroll.css";
|
||||
import type { ViteSSGContext } from "vite-ssg";
|
||||
|
||||
// Type-safe global property for Locomotive Scroll (loaded only on client)
|
||||
type LocomotiveScrollCtor = typeof import("locomotive-scroll").default;
|
||||
|
||||
declare module "@vue/runtime-core" {
|
||||
interface ComponentCustomProperties {
|
||||
$LocomotiveScroll: LocomotiveScrollCtor;
|
||||
}
|
||||
}
|
||||
|
||||
export const createApp = ViteSSG(
|
||||
App,
|
||||
{ routes },
|
||||
async ({ app, isClient }: ViteSSGContext) => {
|
||||
app.use(i18n);
|
||||
app.use(gsapPlugin);
|
||||
|
||||
if (isClient) {
|
||||
const { default: LocomotiveScroll } = await import("locomotive-scroll");
|
||||
// Expose constructor on client only to avoid SSR import errors
|
||||
app.config.globalProperties.$LocomotiveScroll =
|
||||
LocomotiveScroll as LocomotiveScrollCtor;
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,141 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useLocale } from "@/composables/useLocale";
|
||||
|
||||
const { path } = useLocale();
|
||||
const { t } = useI18n();
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const goBack = () => router.back();
|
||||
const url = computed(() => route.fullPath);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="fixed w-full z-40 transition-all py-6">
|
||||
<div class="flex items-center justify-center">
|
||||
<!-- LOGO -->
|
||||
<a href="/">
|
||||
<img
|
||||
src="/muco-logo.svg"
|
||||
alt="Müco Logo"
|
||||
class="transition-all duration-300 h-16"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
<section
|
||||
class="relative flex min-h-screen w-screen items-center justify-center overflow-hidden bg-white px-6 py-28 text-dark"
|
||||
>
|
||||
<div class="not-found-blob not-found-blob-left" />
|
||||
<div class="not-found-blob not-found-blob-right" />
|
||||
|
||||
<div class="relative z-10 mx-auto max-w-5xl text-center">
|
||||
<h1
|
||||
class="font-handwritten text-[clamp(72px,18vw,190px)] font-light leading-none tracking-widest text-dark"
|
||||
>
|
||||
404
|
||||
</h1>
|
||||
|
||||
<p
|
||||
class="mx-auto mt-4 max-w-3xl font-handwritten text-[clamp(42px,8vw,92px)] font-light leading-[0.9] tracking-widest text-dark"
|
||||
>
|
||||
{{ t("errors.title") }}
|
||||
</p>
|
||||
|
||||
<p v-if="url" class="mt-6">
|
||||
<code
|
||||
class="inline-block max-w-full rounded-full border border-dark/10 bg-dark/[0.04] px-4 py-2 text-xs text-dark/55 md:text-sm"
|
||||
>
|
||||
{{ url }}
|
||||
</code>
|
||||
</p>
|
||||
|
||||
<div class="mt-10 flex flex-wrap items-center justify-center gap-4">
|
||||
<RouterLink
|
||||
:to="path"
|
||||
class="inline-flex items-center justify-center rounded-full bg-dark px-7 py-3 text-sm font-medium text-white transition hover:scale-[1.03] active:scale-[0.98]"
|
||||
>
|
||||
{{ t("errors.backHome") }}
|
||||
</RouterLink>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex cursor-pointer items-center justify-center rounded-full border border-dark/15 bg-white px-7 py-3 text-sm font-medium text-dark transition hover:bg-light active:scale-[0.98]"
|
||||
@click="goBack"
|
||||
>
|
||||
{{ t("errors.goBack") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.not-found-blob {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
width: clamp(320px, 42vw, 720px);
|
||||
height: clamp(320px, 42vw, 720px);
|
||||
border-radius: 9999px;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 35% 35%,
|
||||
rgba(245, 231, 204, 0.95),
|
||||
transparent 58%
|
||||
),
|
||||
radial-gradient(circle at 70% 70%, rgba(45, 44, 42, 0.08), transparent 62%);
|
||||
filter: blur(2px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.not-found-blob-left {
|
||||
left: -18vw;
|
||||
bottom: -18vh;
|
||||
animation: floatLeft 9s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.not-found-blob-right {
|
||||
right: -20vw;
|
||||
top: -20vh;
|
||||
transform: scale(0.85);
|
||||
animation: floatRight 11s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes floatLeft {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate3d(0, 0, 0) rotate(0deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate3d(2vw, -2vh, 0) rotate(4deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes floatRight {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate3d(0, 0, 0) scale(0.85) rotate(0deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate3d(-2vw, 2vh, 0) scale(0.9) rotate(-4deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.not-found-blob-left {
|
||||
left: -45vw;
|
||||
bottom: -16vh;
|
||||
}
|
||||
|
||||
.not-found-blob-right {
|
||||
right: -48vw;
|
||||
top: -14vh;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,450 @@
|
||||
<template>
|
||||
<div class="min-h-dvh bg-[#f5f7f8] text-[#3C393D] font-sans">
|
||||
<!-- Login -->
|
||||
<div
|
||||
v-if="!token"
|
||||
class="min-h-dvh flex items-center justify-center px-4"
|
||||
>
|
||||
<form
|
||||
class="w-full max-w-sm bg-white rounded-2xl shadow-lg p-8 space-y-4"
|
||||
@submit.prevent="login"
|
||||
>
|
||||
<h1 class="text-xl font-bold text-center">Müco Admin</h1>
|
||||
<p class="text-sm text-gray-500 text-center">
|
||||
İçerik yönetim paneline giriş yapın
|
||||
</p>
|
||||
<input
|
||||
v-model="password"
|
||||
type="password"
|
||||
placeholder="Şifre"
|
||||
autocomplete="current-password"
|
||||
class="w-full border border-gray-300 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-[#3FA0C7]"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="busy"
|
||||
class="w-full bg-[#3FA0C7] hover:bg-[#3590b5] text-white rounded-lg py-2.5 font-semibold disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{{ busy ? "..." : "Giriş Yap" }}
|
||||
</button>
|
||||
<p v-if="error" class="text-sm text-red-600 text-center">{{ error }}</p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Panel -->
|
||||
<div v-else class="flex min-h-dvh">
|
||||
<!-- Sidebar -->
|
||||
<aside
|
||||
class="w-64 shrink-0 bg-white border-r border-gray-200 flex flex-col"
|
||||
>
|
||||
<div class="px-4 py-4 border-b border-gray-200">
|
||||
<h1 class="font-bold">Müco Admin</h1>
|
||||
<p class="text-xs text-gray-400">İçerik (JSON) Yönetimi</p>
|
||||
</div>
|
||||
<nav class="flex-1 overflow-y-auto p-2">
|
||||
<template v-for="(group, page) in grouped" :key="page">
|
||||
<p
|
||||
class="px-2 pt-3 pb-1 text-[11px] font-semibold uppercase tracking-wide text-gray-400"
|
||||
>
|
||||
{{ page }}
|
||||
</p>
|
||||
<button
|
||||
v-for="ds in group"
|
||||
:key="ds.page + '/' + ds.locale"
|
||||
class="w-full text-left px-2 py-1.5 rounded-md text-sm cursor-pointer flex items-center justify-between"
|
||||
:class="
|
||||
selected &&
|
||||
selected.page === ds.page &&
|
||||
selected.locale === ds.locale
|
||||
? 'bg-[#DFEDEE] text-[#3C393D] font-semibold'
|
||||
: 'hover:bg-gray-100 text-gray-600'
|
||||
"
|
||||
@click="select(ds)"
|
||||
>
|
||||
<span>{{ ds.locale.toUpperCase() }}</span>
|
||||
<span class="text-[10px] text-gray-400">{{
|
||||
formatBytes(ds.bytes)
|
||||
}}</span>
|
||||
</button>
|
||||
</template>
|
||||
</nav>
|
||||
<div class="p-3 border-t border-gray-200 space-y-2">
|
||||
<button
|
||||
class="w-full text-sm bg-[#3FA0C7] hover:bg-[#3590b5] text-white rounded-lg py-2.5 font-bold cursor-pointer disabled:opacity-50"
|
||||
:disabled="publishing"
|
||||
@click="publish"
|
||||
>
|
||||
{{ publishing ? "Yayınlanıyor..." : "🚀 Yayınla" }}
|
||||
</button>
|
||||
<p
|
||||
v-if="publishState && !publishing"
|
||||
class="text-[11px] text-center"
|
||||
:class="publishState.ok ? 'text-emerald-600' : 'text-red-600'"
|
||||
>
|
||||
{{
|
||||
publishState.ok
|
||||
? "Son yayın başarılı ✓"
|
||||
: "Son yayın başarısız — loga bakın"
|
||||
}}
|
||||
</p>
|
||||
<button
|
||||
class="w-full text-sm bg-[#3C393D] hover:bg-black text-white rounded-lg py-2 cursor-pointer disabled:opacity-50"
|
||||
:disabled="busy"
|
||||
@click="exportFiles"
|
||||
>
|
||||
DB → JSON Dosyalarına Aktar
|
||||
</button>
|
||||
<button
|
||||
class="w-full text-sm border border-gray-300 hover:bg-gray-50 rounded-lg py-2 cursor-pointer disabled:opacity-50"
|
||||
:disabled="busy"
|
||||
@click="importFiles"
|
||||
>
|
||||
JSON Dosyalarından Yükle
|
||||
</button>
|
||||
<button
|
||||
class="w-full text-xs text-gray-400 hover:text-gray-600 py-1 cursor-pointer"
|
||||
@click="logout"
|
||||
>
|
||||
Çıkış yap
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Editor -->
|
||||
<main class="flex-1 flex flex-col min-w-0">
|
||||
<div
|
||||
class="flex items-center gap-3 px-5 py-3 bg-white border-b border-gray-200"
|
||||
>
|
||||
<template v-if="selected">
|
||||
<h2 class="font-semibold">
|
||||
{{ selected.page }} /
|
||||
<span class="text-[#3FA0C7]">{{ selected.locale }}</span>
|
||||
</h2>
|
||||
<span v-if="selectedUpdatedAt" class="text-xs text-gray-400">
|
||||
Son güncelleme: {{ formatDate(selectedUpdatedAt) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="dirty"
|
||||
class="text-xs bg-amber-100 text-amber-700 rounded-full px-2 py-0.5"
|
||||
>kaydedilmedi</span
|
||||
>
|
||||
</template>
|
||||
<h2 v-else class="text-gray-400">Soldan bir veri seti seçin</h2>
|
||||
<div class="flex-1"></div>
|
||||
<template v-if="selected">
|
||||
<button
|
||||
class="text-sm border border-gray-300 hover:bg-gray-50 rounded-lg px-3 py-1.5 cursor-pointer"
|
||||
@click="formatJson"
|
||||
>
|
||||
Biçimlendir
|
||||
</button>
|
||||
<button
|
||||
class="text-sm bg-[#3FA0C7] hover:bg-[#3590b5] text-white rounded-lg px-4 py-1.5 font-semibold cursor-pointer disabled:opacity-50"
|
||||
:disabled="busy || !dirty"
|
||||
@click="save"
|
||||
>
|
||||
Kaydet
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="jsonError"
|
||||
class="px-5 py-2 bg-red-50 text-red-700 text-sm border-b border-red-100"
|
||||
>
|
||||
JSON hatası: {{ jsonError }}
|
||||
</div>
|
||||
<div
|
||||
v-if="notice"
|
||||
class="px-5 py-2 bg-emerald-50 text-emerald-700 text-sm border-b border-emerald-100"
|
||||
>
|
||||
{{ notice }}
|
||||
</div>
|
||||
<pre
|
||||
v-if="showPublishLog"
|
||||
class="max-h-48 overflow-y-auto px-5 py-3 bg-[#111] text-[#9fe8a8] text-xs leading-relaxed border-b border-gray-800 whitespace-pre-wrap"
|
||||
>{{ publishState?.log || "..." }}</pre
|
||||
>
|
||||
|
||||
<textarea
|
||||
v-if="selected"
|
||||
v-model="editorText"
|
||||
spellcheck="false"
|
||||
class="flex-1 w-full resize-none font-mono text-[13px] leading-relaxed p-5 bg-[#1e1e2e] text-[#e6e6ef] focus:outline-none"
|
||||
@input="onEdit"
|
||||
></textarea>
|
||||
<div
|
||||
v-else
|
||||
class="flex-1 flex items-center justify-center text-gray-300 text-sm"
|
||||
>
|
||||
İçerik JSON'ları Postgres'te saklanır; "DB → JSON Dosyalarına Aktar"
|
||||
ile siteye yansıtılır.
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { useHead } from "@vueuse/head";
|
||||
|
||||
useHead({
|
||||
title: "Müco Admin",
|
||||
meta: [{ name: "robots", content: "noindex, nofollow" }],
|
||||
});
|
||||
|
||||
type Dataset = {
|
||||
page: string;
|
||||
locale: string;
|
||||
updated_at: string;
|
||||
bytes: number;
|
||||
};
|
||||
|
||||
const TOKEN_KEY = "muco-admin-token";
|
||||
|
||||
const token = ref<string | null>(null);
|
||||
const password = ref("");
|
||||
const busy = ref(false);
|
||||
const error = ref("");
|
||||
const notice = ref("");
|
||||
const jsonError = ref("");
|
||||
|
||||
const datasets = ref<Dataset[]>([]);
|
||||
const selected = ref<Dataset | null>(null);
|
||||
const selectedUpdatedAt = ref<string | null>(null);
|
||||
const editorText = ref("");
|
||||
const savedText = ref("");
|
||||
|
||||
const dirty = computed(() => editorText.value !== savedText.value);
|
||||
|
||||
type PublishState = {
|
||||
running: boolean;
|
||||
startedAt: string | null;
|
||||
finishedAt: string | null;
|
||||
ok: boolean | null;
|
||||
log: string;
|
||||
};
|
||||
|
||||
const publishState = ref<PublishState | null>(null);
|
||||
const publishing = computed(() => !!publishState.value?.running);
|
||||
const showPublishLog = computed(
|
||||
() => publishing.value || publishState.value?.ok === false,
|
||||
);
|
||||
let publishTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const grouped = computed(() => {
|
||||
const g: Record<string, Dataset[]> = {};
|
||||
for (const ds of datasets.value) (g[ds.page] ??= []).push(ds);
|
||||
return g;
|
||||
});
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
return { Authorization: `Bearer ${token.value}` };
|
||||
}
|
||||
|
||||
async function api(path: string, options: RequestInit = {}) {
|
||||
const res = await fetch(`/api/admin${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...authHeaders(),
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
if (res.status === 401 && path !== "/login") {
|
||||
token.value = null;
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
throw new Error("Oturum süresi doldu, tekrar giriş yapın");
|
||||
}
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(body.error || `HTTP ${res.status}`);
|
||||
return body;
|
||||
}
|
||||
|
||||
function flash(msg: string) {
|
||||
notice.value = msg;
|
||||
setTimeout(() => (notice.value = ""), 4000);
|
||||
}
|
||||
|
||||
async function login() {
|
||||
busy.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const res = await fetch("/api/admin/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ password: password.value }),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error("Şifre hatalı");
|
||||
token.value = body.token;
|
||||
localStorage.setItem(TOKEN_KEY, body.token);
|
||||
password.value = "";
|
||||
await loadDatasets();
|
||||
await refreshPublishState();
|
||||
if (publishState.value?.running) {
|
||||
publishTimer ??= setInterval(refreshPublishState, 3000);
|
||||
}
|
||||
} catch (e: any) {
|
||||
error.value = e.message;
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await api("/logout", { method: "POST" });
|
||||
} catch {}
|
||||
token.value = null;
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
selected.value = null;
|
||||
}
|
||||
|
||||
async function loadDatasets() {
|
||||
datasets.value = await api("/datasets");
|
||||
}
|
||||
|
||||
async function select(ds: Dataset) {
|
||||
if (dirty.value && !confirm("Kaydedilmemiş değişiklikler var, devam?")) {
|
||||
return;
|
||||
}
|
||||
busy.value = true;
|
||||
jsonError.value = "";
|
||||
try {
|
||||
const row = await api(`/datasets/${ds.page}/${ds.locale}`);
|
||||
selected.value = ds;
|
||||
selectedUpdatedAt.value = row.updated_at;
|
||||
editorText.value = JSON.stringify(row.content, null, 2);
|
||||
savedText.value = editorText.value;
|
||||
} catch (e: any) {
|
||||
flash(e.message);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onEdit() {
|
||||
try {
|
||||
JSON.parse(editorText.value);
|
||||
jsonError.value = "";
|
||||
} catch (e: any) {
|
||||
jsonError.value = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
function formatJson() {
|
||||
try {
|
||||
editorText.value = JSON.stringify(JSON.parse(editorText.value), null, 2);
|
||||
jsonError.value = "";
|
||||
} catch (e: any) {
|
||||
jsonError.value = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!selected.value) return;
|
||||
let content: unknown;
|
||||
try {
|
||||
content = JSON.parse(editorText.value);
|
||||
} catch (e: any) {
|
||||
jsonError.value = e.message;
|
||||
return;
|
||||
}
|
||||
busy.value = true;
|
||||
try {
|
||||
const row = await api(
|
||||
`/datasets/${selected.value.page}/${selected.value.locale}`,
|
||||
{ method: "PUT", body: JSON.stringify({ content }) },
|
||||
);
|
||||
savedText.value = editorText.value;
|
||||
selectedUpdatedAt.value = row.updated_at;
|
||||
flash("Kaydedildi ✓ (Siteye yansıtmak için 'DB → JSON Dosyalarına Aktar')");
|
||||
await loadDatasets();
|
||||
} catch (e: any) {
|
||||
flash(e.message);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function exportFiles() {
|
||||
busy.value = true;
|
||||
try {
|
||||
const res = await api("/export", { method: "POST" });
|
||||
flash(`${res.written.length} dosya yazıldı: src/data güncellendi ✓`);
|
||||
} catch (e: any) {
|
||||
flash(e.message);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshPublishState() {
|
||||
try {
|
||||
publishState.value = await api("/publish/status");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!publishState.value?.running && publishTimer) {
|
||||
clearInterval(publishTimer);
|
||||
publishTimer = null;
|
||||
if (publishState.value?.ok) flash("Site yayınlandı ✓");
|
||||
}
|
||||
}
|
||||
|
||||
async function publish() {
|
||||
if (publishing.value) return;
|
||||
if (!confirm("Site DB'deki içerikle yeniden build edilip yayınlanacak. Devam?"))
|
||||
return;
|
||||
try {
|
||||
await api("/publish", { method: "POST" });
|
||||
await refreshPublishState();
|
||||
publishTimer ??= setInterval(refreshPublishState, 3000);
|
||||
} catch (e: any) {
|
||||
flash(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function importFiles() {
|
||||
if (!confirm("JSON dosyaları DB'nin üzerine yazılacak. Emin misiniz?"))
|
||||
return;
|
||||
busy.value = true;
|
||||
try {
|
||||
const res = await api("/import", { method: "POST" });
|
||||
flash(`${res.written}/${res.total} veri seti dosyalardan yüklendi ✓`);
|
||||
await loadDatasets();
|
||||
if (selected.value) await select(selected.value);
|
||||
} catch (e: any) {
|
||||
flash(e.message);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(b: number) {
|
||||
if (!b) return "";
|
||||
return b > 1024 ? `${(b / 1024).toFixed(1)} KB` : `${b} B`;
|
||||
}
|
||||
|
||||
function formatDate(d: string) {
|
||||
return new Date(d).toLocaleString("tr-TR");
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const stored = localStorage.getItem(TOKEN_KEY);
|
||||
if (stored) {
|
||||
token.value = stored;
|
||||
try {
|
||||
await loadDatasets();
|
||||
await refreshPublishState();
|
||||
if (publishState.value?.running) {
|
||||
publishTimer ??= setInterval(refreshPublishState, 3000);
|
||||
}
|
||||
} catch {
|
||||
// token invalid — api() already cleared it
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div v-if="data?.components && data.components.length" class="relative">
|
||||
<template
|
||||
v-for="(component, idx) in data.components"
|
||||
:key="component.id || `${component.__component}-${idx}`"
|
||||
>
|
||||
<section :data-section="component.__component">
|
||||
<HeroPromo
|
||||
v-if="component.__component === 'sections.hero'"
|
||||
:section="component"
|
||||
/>
|
||||
<SectionsHighlight
|
||||
v-if="component.__component === 'sections.highlight'"
|
||||
:section="component"
|
||||
/>
|
||||
<SectionsBanner
|
||||
v-if="component.__component === 'sections.banner'"
|
||||
:section="component"
|
||||
/>
|
||||
<SectionsParallax
|
||||
v-if="component.__component === 'sections.parallax'"
|
||||
:section="component"
|
||||
/>
|
||||
<SectionsOverview
|
||||
v-if="component.__component === 'sections.overview'"
|
||||
:section="component"
|
||||
/>
|
||||
<SectionsCTA
|
||||
v-if="component.__component === 'sections.cta'"
|
||||
:section="component"
|
||||
/>
|
||||
<SectionsGallery
|
||||
v-if="component.__component === 'sections.gallery'"
|
||||
:section="component"
|
||||
/>
|
||||
<SectionsBreakfastEn
|
||||
v-if="component.__component === 'sections.breakfast'"
|
||||
:section="component"
|
||||
/>
|
||||
<SectionsTestimonials
|
||||
v-if="component.__component === 'sections.testimonials'"
|
||||
:section="component"
|
||||
/>
|
||||
<SectionsContact
|
||||
v-if="component.__component === 'sections.contact'"
|
||||
:section="component"
|
||||
class="px-6 py-28 lg:h-dvh min-h-fit"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onBeforeUnmount, ref } from "vue";
|
||||
import { usePageData } from "@/composables/usePageData";
|
||||
import { useMeta } from "@/composables/useMeta";
|
||||
|
||||
type ComponentSlice = Record<string, any> & {
|
||||
__component?: string;
|
||||
id?: string | number;
|
||||
};
|
||||
|
||||
type Data = {
|
||||
components?: Array<ComponentSlice>;
|
||||
meta?: Record<string, any>;
|
||||
};
|
||||
|
||||
const data = usePageData<Data>("home", "en");
|
||||
useMeta(data.value?.meta ?? {}, "en");
|
||||
</script>
|
||||
@@ -0,0 +1,185 @@
|
||||
<template>
|
||||
<section
|
||||
id="legal"
|
||||
class="max-w-4xl mx-auto px-6 py-40 text-sm leading-relaxed text-gray-800"
|
||||
>
|
||||
<h1 class="text-3xl font-bold mb-10">Legal Texts</h1>
|
||||
|
||||
<!-- KVKK -->
|
||||
<article class="mb-16">
|
||||
<h2 class="text-2xl font-semibold mb-6">
|
||||
1. Personal Data Protection (KVKK) Disclosure Statement
|
||||
</h2>
|
||||
|
||||
<h3 class="font-semibold mt-6 mb-2">Data Controller</h3>
|
||||
<p>
|
||||
This disclosure statement has been prepared by Müco Mutfak ve Kahve
|
||||
(“Data Controller”) in accordance with the Turkish Personal Data
|
||||
Protection Law No. 6698 (“KVKK”).
|
||||
</p>
|
||||
|
||||
<h3 class="font-semibold mt-6 mb-2">Processed Personal Data</h3>
|
||||
<p>
|
||||
The following personal data is processed through the contact form on our
|
||||
website:
|
||||
</p>
|
||||
<ul class="list-disc pl-6 mt-2 space-y-1">
|
||||
<li>Full name</li>
|
||||
<li>Email address</li>
|
||||
<li>Message content</li>
|
||||
<li>Subject of inquiry</li>
|
||||
<li>IP address and transaction security data</li>
|
||||
</ul>
|
||||
|
||||
<h3 class="font-semibold mt-6 mb-2">
|
||||
Purposes of Processing Personal Data
|
||||
</h3>
|
||||
<p>Your personal data is processed for the following purposes:</p>
|
||||
<ul class="list-disc pl-6 mt-2 space-y-1">
|
||||
<li>Evaluating your requests, suggestions, and inquiries</li>
|
||||
<li>Contacting you</li>
|
||||
<li>Improving service quality</li>
|
||||
<li>Fulfilling legal obligations</li>
|
||||
</ul>
|
||||
|
||||
<h3 class="font-semibold mt-6 mb-2">Legal Basis for Processing</h3>
|
||||
<p>
|
||||
Your personal data is processed in accordance with Article 5 of KVKK
|
||||
based on:
|
||||
</p>
|
||||
<ul class="list-disc pl-6 mt-2 space-y-1">
|
||||
<li>Your explicit consent</li>
|
||||
<li>
|
||||
The necessity of processing data for the establishment, exercise, or
|
||||
protection of a right
|
||||
</li>
|
||||
</ul>
|
||||
<p class="mt-2">legal grounds.</p>
|
||||
|
||||
<h3 class="font-semibold mt-6 mb-2">Transfer of Personal Data</h3>
|
||||
<p>Your personal data may be shared with:</p>
|
||||
<ul class="list-disc pl-6 mt-2 space-y-1">
|
||||
<li>Email service providers (for handling communication requests)</li>
|
||||
<li>
|
||||
Authorized public institutions and organizations (as required by law)
|
||||
</li>
|
||||
</ul>
|
||||
<p class="mt-2">limited to these purposes.</p>
|
||||
|
||||
<h3 class="font-semibold mt-6 mb-2">Data Retention Period</h3>
|
||||
<p>
|
||||
Your personal data will be retained for as long as necessary for the
|
||||
purposes of processing and in accordance with applicable legal
|
||||
requirements. Afterward, it will be deleted, destroyed, or anonymized.
|
||||
</p>
|
||||
|
||||
<h3 class="font-semibold mt-6 mb-2">Your Rights Under KVKK</h3>
|
||||
<p>You have the following rights under Article 11 of KVKK:</p>
|
||||
<ul class="list-disc pl-6 mt-2 space-y-1">
|
||||
<li>To learn whether your personal data is processed</li>
|
||||
<li>To request information if it has been processed</li>
|
||||
<li>
|
||||
To learn the purpose of processing and whether it is used accordingly
|
||||
</li>
|
||||
<li>To know the third parties to whom data is transferred</li>
|
||||
<li>To request correction of incomplete or incorrect data</li>
|
||||
<li>To request deletion or destruction of your data</li>
|
||||
<li>
|
||||
To object to results arising against you from automated data analysis
|
||||
</li>
|
||||
<li>To claim compensation for damages incurred</li>
|
||||
</ul>
|
||||
|
||||
<h3 class="font-semibold mt-6 mb-2">Contact</h3>
|
||||
<p>
|
||||
You may submit your requests under KVKK through the following contact
|
||||
channels:
|
||||
</p>
|
||||
<p class="mt-2">📧 Email: info@mucomutfak.com</p>
|
||||
<p>📍 Address: Hasan Ercan Cad. No: 23/E Menteşe/MUĞLA</p>
|
||||
</article>
|
||||
|
||||
<!-- Privacy -->
|
||||
<article>
|
||||
<h2 class="text-2xl font-semibold mb-6">2. Privacy Policy</h2>
|
||||
|
||||
<p>As Müco Mutfak ve Kahve, we value the privacy of our visitors.</p>
|
||||
|
||||
<h3 class="font-semibold mt-6 mb-2">Information Collected</h3>
|
||||
<p>
|
||||
The following information may be collected when you visit our website:
|
||||
</p>
|
||||
<ul class="list-disc pl-6 mt-2 space-y-1">
|
||||
<li>Browser and device information</li>
|
||||
<li>IP address</li>
|
||||
<li>Website usage data</li>
|
||||
<li>Information you provide through the contact form</li>
|
||||
</ul>
|
||||
|
||||
<h3 class="font-semibold mt-6 mb-2">Use of Information</h3>
|
||||
<p>The collected information is used to:</p>
|
||||
<ul class="list-disc pl-6 mt-2 space-y-1">
|
||||
<li>Ensure the proper functioning of the website</li>
|
||||
<li>Improve user experience</li>
|
||||
<li>Respond to your inquiries</li>
|
||||
</ul>
|
||||
|
||||
<h3 class="font-semibold mt-6 mb-2">Cookies</h3>
|
||||
<p>Our website may use cookies to enhance user experience.</p>
|
||||
<ul class="list-disc pl-6 mt-2 space-y-1">
|
||||
<li>Remember your preferences</li>
|
||||
<li>Analyze site performance</li>
|
||||
</ul>
|
||||
<p class="mt-2">
|
||||
You can manage or delete cookies through your browser settings.
|
||||
</p>
|
||||
|
||||
<h3 class="font-semibold mt-6 mb-2">Third-Party Services</h3>
|
||||
<p>
|
||||
Our website may use third-party services to improve service quality
|
||||
(such as email service providers). These services are subject to their
|
||||
own privacy policies.
|
||||
</p>
|
||||
|
||||
<h3 class="font-semibold mt-6 mb-2">Data Security</h3>
|
||||
<p>
|
||||
Necessary technical and administrative measures are taken to ensure the
|
||||
security of your personal data.
|
||||
</p>
|
||||
|
||||
<h3 class="font-semibold mt-6 mb-2">Changes</h3>
|
||||
<p>
|
||||
This privacy policy may be updated when necessary. The current version
|
||||
will always be published on this page.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<!-- Footer note -->
|
||||
<div class="mt-16 text-xs text-gray-500 border-t pt-6">
|
||||
<ul class="list-disc pl-6 mt-2">
|
||||
<li>Müco Mutfak ve Kahve</li>
|
||||
<li>Mücahit Uslu</li>
|
||||
<li>info@mucomutfak.com</li>
|
||||
<li>Hasan Ercan Cad. No: 23/E Menteşe/MUĞLA</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { usePageData } from "@/composables/usePageData";
|
||||
import { useMeta } from "@/composables/useMeta";
|
||||
|
||||
type ComponentSlice = Record<string, any> & {
|
||||
__component?: string;
|
||||
id?: string | number;
|
||||
};
|
||||
|
||||
type Data = {
|
||||
components?: Array<ComponentSlice>;
|
||||
meta?: Record<string, any>;
|
||||
};
|
||||
|
||||
const data = usePageData<Data>("legal", "en");
|
||||
useMeta(data.value?.meta ?? {}, "en");
|
||||
</script>
|
||||
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<header
|
||||
v-if="currentMenuView !== 'menu'"
|
||||
:class="[
|
||||
'fixed inset-x-0 z-40 transition-all duration-300',
|
||||
isHeaderVisible
|
||||
? 'translate-y-0 opacity-100'
|
||||
: '-translate-y-full opacity-0',
|
||||
isScrolled ? 'bg-[#fff]/60 py-1.5 backdrop-blur-2xl' : 'py-2',
|
||||
]"
|
||||
>
|
||||
<div class="max-w-7xl mx-auto px-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<a :href="basePath">
|
||||
<img
|
||||
src="/muco-logo.svg"
|
||||
alt="Müco Logo"
|
||||
class="transition-all duration-300"
|
||||
:class="isScrolled ? 'h-8 lg:h-18' : 'h-10 lg:h-18'"
|
||||
/>
|
||||
</a>
|
||||
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<MenuLayout
|
||||
:campaigns="campaigns"
|
||||
:categories="categories"
|
||||
:fallback-image="fallbackImage"
|
||||
@view-change="currentMenuView = $event"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
import { usePageData } from "@/composables/usePageData";
|
||||
import { useMeta } from "@/composables/useMeta";
|
||||
|
||||
type Image = {
|
||||
src?: string;
|
||||
alt?: string;
|
||||
};
|
||||
|
||||
type MenuParent = {
|
||||
id: number;
|
||||
title: string;
|
||||
image?: Image;
|
||||
};
|
||||
|
||||
type MenuItem = {
|
||||
title: string;
|
||||
description?: string;
|
||||
image?: Image;
|
||||
price?: string;
|
||||
};
|
||||
|
||||
type MenuCategory = {
|
||||
parent?: MenuParent;
|
||||
title: string;
|
||||
description?: string;
|
||||
image?: Image;
|
||||
items?: MenuItem[];
|
||||
};
|
||||
|
||||
type Campaign = {
|
||||
title: string;
|
||||
excerpt: string;
|
||||
description: string;
|
||||
image: {
|
||||
src: string;
|
||||
alt: string;
|
||||
};
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
tags: string[];
|
||||
discount: string;
|
||||
};
|
||||
|
||||
type MenuData = {
|
||||
categories?: MenuCategory[];
|
||||
meta?: Record<string, any>;
|
||||
};
|
||||
|
||||
type CampaignData = {
|
||||
campaigns?: Campaign[];
|
||||
};
|
||||
|
||||
type ActiveView = "home" | "menu" | "campaigns" | "contact";
|
||||
|
||||
const fallbackImage = "/images/general-img-square.webp";
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const isScrolled = ref(false);
|
||||
const isHeaderVisible = ref(true);
|
||||
const currentMenuView = ref<ActiveView>("home");
|
||||
|
||||
let lastScrollY = 0;
|
||||
|
||||
const currentLang = computed<"en" | "tr">(() => {
|
||||
return route.path.startsWith("/en") ? "en" : "tr";
|
||||
});
|
||||
|
||||
const basePath = computed(() => {
|
||||
return currentLang.value === "en" ? "/en/" : "/";
|
||||
});
|
||||
|
||||
const menuData = usePageData<MenuData>("menu", currentLang.value);
|
||||
const campaignsData = usePageData<Campaign[]>("campaigns", currentLang.value);
|
||||
|
||||
useMeta(menuData.value?.meta ?? {}, currentLang.value);
|
||||
|
||||
const categories = computed<MenuCategory[]>(() => {
|
||||
return menuData.value?.categories ?? [];
|
||||
});
|
||||
|
||||
const campaigns = computed<Campaign[]>(() => {
|
||||
return campaignsData.value ?? [];
|
||||
});
|
||||
|
||||
function handleScroll() {
|
||||
const currentScrollY = window.scrollY;
|
||||
|
||||
isScrolled.value = currentScrollY > 20;
|
||||
|
||||
if (currentScrollY <= 20) {
|
||||
isHeaderVisible.value = true;
|
||||
lastScrollY = currentScrollY;
|
||||
return;
|
||||
}
|
||||
|
||||
isHeaderVisible.value = currentScrollY <= lastScrollY;
|
||||
lastScrollY = currentScrollY;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
lastScrollY = window.scrollY;
|
||||
handleScroll();
|
||||
|
||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div v-if="data?.components && data.components.length" class="relative">
|
||||
<template
|
||||
v-for="(component, idx) in data.components"
|
||||
:key="component.id || `${component.__component}-${idx}`"
|
||||
>
|
||||
<section :data-section="component.__component">
|
||||
<HeroPromo
|
||||
v-if="component.__component === 'sections.hero'"
|
||||
:section="component"
|
||||
/>
|
||||
<SectionsHighlight
|
||||
v-if="component.__component === 'sections.highlight'"
|
||||
:section="component"
|
||||
/>
|
||||
<SectionsBanner
|
||||
v-if="component.__component === 'sections.banner'"
|
||||
:section="component"
|
||||
/>
|
||||
<SectionsParallax
|
||||
v-if="component.__component === 'sections.parallax'"
|
||||
:section="component"
|
||||
/>
|
||||
<SectionsOverview
|
||||
v-if="component.__component === 'sections.overview'"
|
||||
:section="component"
|
||||
/>
|
||||
<SectionsCTA
|
||||
v-if="component.__component === 'sections.cta'"
|
||||
:section="component"
|
||||
/>
|
||||
<SectionsGallery
|
||||
v-if="component.__component === 'sections.gallery'"
|
||||
:section="component"
|
||||
/>
|
||||
<SectionsTestimonials
|
||||
v-if="component.__component === 'sections.testimonials'"
|
||||
:section="component"
|
||||
/>
|
||||
<SectionsBreakfast
|
||||
v-if="component.__component === 'sections.breakfast'"
|
||||
:section="component"
|
||||
/>
|
||||
<SectionsContact
|
||||
v-if="component.__component === 'sections.contact'"
|
||||
:section="component"
|
||||
class="px-6 py-28 lg:h-dvh min-h-fit"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onBeforeUnmount, ref } from "vue";
|
||||
import { usePageData } from "@/composables/usePageData";
|
||||
import { useMeta } from "@/composables/useMeta";
|
||||
|
||||
type ComponentSlice = Record<string, any> & {
|
||||
__component?: string;
|
||||
id?: string | number;
|
||||
};
|
||||
|
||||
type Data = {
|
||||
components?: Array<ComponentSlice>;
|
||||
meta?: Record<string, any>;
|
||||
};
|
||||
|
||||
const data = usePageData<Data>("home", "tr");
|
||||
useMeta(data.value?.meta ?? {}, "tr");
|
||||
</script>
|
||||
@@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<section
|
||||
id="legal"
|
||||
class="max-w-4xl mx-auto px-6 py-40 text-sm leading-relaxed text-gray-800"
|
||||
>
|
||||
<h1 class="text-3xl font-bold mb-10">Yasal Metinler</h1>
|
||||
|
||||
<!-- KVKK -->
|
||||
<article class="mb-16">
|
||||
<h2 class="text-2xl font-semibold mb-6">
|
||||
1. Kişisel Verilerin Korunması (KVKK) Aydınlatma Metni
|
||||
</h2>
|
||||
|
||||
<h3 class="font-semibold mt-6 mb-2">Veri Sorumlusu</h3>
|
||||
<p>
|
||||
Bu aydınlatma metni, Müco Mutfak ve Kahve (“Veri Sorumlusu”) tarafından,
|
||||
6698 sayılı Kişisel Verilerin Korunması Kanunu (“KVKK”) kapsamında
|
||||
hazırlanmıştır.
|
||||
</p>
|
||||
|
||||
<h3 class="font-semibold mt-6 mb-2">İşlenen Kişisel Veriler</h3>
|
||||
<p>
|
||||
Web sitemizde yer alan iletişim formu aracılığıyla aşağıdaki kişisel
|
||||
verileriniz işlenmektedir:
|
||||
</p>
|
||||
<ul class="list-disc pl-6 mt-2 space-y-1">
|
||||
<li>Ad ve soyad</li>
|
||||
<li>E-posta adresi</li>
|
||||
<li>Mesaj içeriği</li>
|
||||
<li>Talep konusu</li>
|
||||
<li>IP adresi ve işlem güvenliği bilgileri</li>
|
||||
</ul>
|
||||
|
||||
<h3 class="font-semibold mt-6 mb-2">
|
||||
Kişisel Verilerin İşlenme Amaçları
|
||||
</h3>
|
||||
<p>Toplanan kişisel verileriniz aşağıdaki amaçlarla işlenmektedir:</p>
|
||||
<ul class="list-disc pl-6 mt-2 space-y-1">
|
||||
<li>
|
||||
Tarafınızdan iletilen talep, öneri ve başvuruların değerlendirilmesi
|
||||
</li>
|
||||
<li>Sizinle iletişime geçilmesi</li>
|
||||
<li>Hizmet kalitesinin artırılması</li>
|
||||
<li>Hukuki yükümlülüklerin yerine getirilmesi</li>
|
||||
</ul>
|
||||
|
||||
<h3 class="font-semibold mt-6 mb-2">Kişisel Verilerin Hukuki Sebebi</h3>
|
||||
<p>Kişisel verileriniz, KVKK’nın 5. maddesi uyarınca:</p>
|
||||
<ul class="list-disc pl-6 mt-2 space-y-1">
|
||||
<li>Açık rızanızın bulunması</li>
|
||||
<li>
|
||||
Bir hakkın tesisi, kullanılması veya korunması için veri işlemenin
|
||||
zorunlu olması
|
||||
</li>
|
||||
</ul>
|
||||
<p class="mt-2">hukuki sebeplerine dayanılarak işlenmektedir.</p>
|
||||
|
||||
<h3 class="font-semibold mt-6 mb-2">Kişisel Verilerin Aktarılması</h3>
|
||||
<p>Kişisel verileriniz:</p>
|
||||
<ul class="list-disc pl-6 mt-2 space-y-1">
|
||||
<li>
|
||||
E-posta hizmet sağlayıcıları (ör. iletişim taleplerinin iletilmesi
|
||||
için)
|
||||
</li>
|
||||
<li>
|
||||
Yetkili kamu kurum ve kuruluşları (yasal yükümlülükler kapsamında)
|
||||
</li>
|
||||
</ul>
|
||||
<p class="mt-2">ile sınırlı olarak paylaşılabilir.</p>
|
||||
|
||||
<h3 class="font-semibold mt-6 mb-2">Kişisel Verilerin Saklanma Süresi</h3>
|
||||
<p>
|
||||
Kişisel verileriniz, işlenme amacının gerektirdiği süre boyunca ve
|
||||
ilgili mevzuatta öngörülen süreler boyunca saklanır. Süre sonunda
|
||||
verileriniz silinir, yok edilir veya anonim hale getirilir.
|
||||
</p>
|
||||
|
||||
<h3 class="font-semibold mt-6 mb-2">KVKK Kapsamındaki Haklarınız</h3>
|
||||
<p>KVKK’nın 11. maddesi kapsamında aşağıdaki haklara sahipsiniz:</p>
|
||||
<ul class="list-disc pl-6 mt-2 space-y-1">
|
||||
<li>Kişisel verilerinizin işlenip işlenmediğini öğrenme</li>
|
||||
<li>İşlenmişse buna ilişkin bilgi talep etme</li>
|
||||
<li>
|
||||
İşlenme amacını ve amacına uygun kullanılıp kullanılmadığını öğrenme
|
||||
</li>
|
||||
<li>Verilerin aktarıldığı üçüncü kişileri bilme</li>
|
||||
<li>Eksik veya yanlış işlenmiş verilerin düzeltilmesini isteme</li>
|
||||
<li>Verilerin silinmesini veya yok edilmesini isteme</li>
|
||||
<li>
|
||||
Otomatik sistemlerle analiz sonucu aleyhinize bir durum oluşmasına
|
||||
itiraz etme
|
||||
</li>
|
||||
<li>Zarara uğramanız hâlinde zararın giderilmesini talep etme</li>
|
||||
</ul>
|
||||
|
||||
<h3 class="font-semibold mt-6 mb-2">İletişim</h3>
|
||||
<p>
|
||||
KVKK kapsamındaki taleplerinizi aşağıdaki iletişim kanalları üzerinden
|
||||
iletebilirsiniz:
|
||||
</p>
|
||||
<p class="mt-2">📧 E-posta: info@mucomutfak.com]</p>
|
||||
<p>📍 Adres: Hasan Ercan Cad. No: 23/E Menteşe/MUĞLA</p>
|
||||
</article>
|
||||
|
||||
<!-- Gizlilik -->
|
||||
<article>
|
||||
<h2 class="text-2xl font-semibold mb-6">2. Gizlilik Politikası</h2>
|
||||
|
||||
<p>
|
||||
Müco Mutfak ve Kahve olarak, ziyaretçilerimizin gizliliğini korumaya
|
||||
önem veriyoruz.
|
||||
</p>
|
||||
|
||||
<h3 class="font-semibold mt-6 mb-2">Toplanan Bilgiler</h3>
|
||||
<p>Web sitemizi ziyaret ettiğinizde aşağıdaki bilgiler toplanabilir:</p>
|
||||
<ul class="list-disc pl-6 mt-2 space-y-1">
|
||||
<li>Tarayıcı ve cihaz bilgileri</li>
|
||||
<li>IP adresi</li>
|
||||
<li>Site kullanım verileri</li>
|
||||
<li>İletişim formu aracılığıyla paylaştığınız bilgiler</li>
|
||||
</ul>
|
||||
|
||||
<h3 class="font-semibold mt-6 mb-2">Bilgilerin Kullanımı</h3>
|
||||
<p>Toplanan bilgiler:</p>
|
||||
<ul class="list-disc pl-6 mt-2 space-y-1">
|
||||
<li>Web sitesinin düzgün çalışmasını sağlamak</li>
|
||||
<li>Kullanıcı deneyimini geliştirmek</li>
|
||||
<li>Taleplerinize yanıt vermek</li>
|
||||
</ul>
|
||||
<p class="mt-2">amacıyla kullanılmaktadır.</p>
|
||||
|
||||
<h3 class="font-semibold mt-6 mb-2">Çerezler (Cookies)</h3>
|
||||
<p>
|
||||
Web sitemiz, kullanıcı deneyimini geliştirmek amacıyla çerezler
|
||||
kullanabilir.
|
||||
</p>
|
||||
<ul class="list-disc pl-6 mt-2 space-y-1">
|
||||
<li>Site tercihlerinizi hatırlamak</li>
|
||||
<li>Site performansını analiz etmek</li>
|
||||
</ul>
|
||||
<p class="mt-2">
|
||||
Tarayıcı ayarlarınızdan çerezleri kontrol edebilir veya silebilirsiniz.
|
||||
</p>
|
||||
|
||||
<h3 class="font-semibold mt-6 mb-2">Üçüncü Taraf Hizmetler</h3>
|
||||
<p>
|
||||
Web sitemiz, hizmet kalitesini artırmak amacıyla üçüncü taraf servisler
|
||||
kullanabilir (örneğin e-posta servis sağlayıcıları). Bu hizmetler kendi
|
||||
gizlilik politikalarına tabidir.
|
||||
</p>
|
||||
|
||||
<h3 class="font-semibold mt-6 mb-2">Veri Güvenliği</h3>
|
||||
<p>
|
||||
Kişisel verilerinizin güvenliği için gerekli teknik ve idari önlemler
|
||||
alınmaktadır.
|
||||
</p>
|
||||
|
||||
<h3 class="font-semibold mt-6 mb-2">Değişiklikler</h3>
|
||||
<p>
|
||||
Bu gizlilik politikası gerektiğinde güncellenebilir. Güncel versiyon her
|
||||
zaman bu sayfada yayınlanır.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<!-- Not -->
|
||||
<div class="mt-16 text-xs text-gray-500 border-t pt-6">
|
||||
<ul class="list-disc pl-6 mt-2">
|
||||
<li>Müco Mutfak ve Kahve</li>
|
||||
<li>Mücahit Uslu</li>
|
||||
<li>info@mucomutfak.com]</li>
|
||||
<li>Hasan Ercan Cad. No: 23/E Menteşe/MUĞLA</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { usePageData } from "@/composables/usePageData";
|
||||
import { useMeta } from "@/composables/useMeta";
|
||||
|
||||
type ComponentSlice = Record<string, any> & {
|
||||
__component?: string;
|
||||
id?: string | number;
|
||||
};
|
||||
|
||||
type Data = {
|
||||
components?: Array<ComponentSlice>;
|
||||
meta?: Record<string, any>;
|
||||
};
|
||||
|
||||
const data = usePageData<Data>("legal", "tr");
|
||||
useMeta(data.value?.meta ?? {}, "tr");
|
||||
</script>
|
||||
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<header
|
||||
v-if="currentMenuView !== 'menu'"
|
||||
:class="[
|
||||
'fixed inset-x-0 z-40 transition-all duration-300',
|
||||
isHeaderVisible
|
||||
? 'translate-y-0 opacity-100'
|
||||
: '-translate-y-full opacity-0',
|
||||
isScrolled ? 'bg-[#fff]/60 py-1.5 backdrop-blur-2xl' : 'py-2',
|
||||
]"
|
||||
>
|
||||
<div class="max-w-7xl mx-auto px-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<a :href="basePath">
|
||||
<img
|
||||
src="/muco-logo.svg"
|
||||
alt="Müco Logo"
|
||||
class="transition-all duration-300"
|
||||
:class="isScrolled ? 'h-8 lg:h-18' : 'h-10 lg:h-18'"
|
||||
/>
|
||||
</a>
|
||||
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<MenuLayout
|
||||
:campaigns="campaigns"
|
||||
:categories="categories"
|
||||
:fallback-image="fallbackImage"
|
||||
@view-change="currentMenuView = $event"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
import { usePageData } from "@/composables/usePageData";
|
||||
import { useMeta } from "@/composables/useMeta";
|
||||
|
||||
type Image = {
|
||||
src?: string;
|
||||
alt?: string;
|
||||
};
|
||||
|
||||
type MenuParent = {
|
||||
id: number;
|
||||
title: string;
|
||||
image?: Image;
|
||||
};
|
||||
|
||||
type MenuItem = {
|
||||
title: string;
|
||||
description?: string;
|
||||
image?: Image;
|
||||
price?: string;
|
||||
};
|
||||
|
||||
type MenuCategory = {
|
||||
parent?: MenuParent;
|
||||
title: string;
|
||||
description?: string;
|
||||
image?: Image;
|
||||
items?: MenuItem[];
|
||||
};
|
||||
|
||||
type Campaign = {
|
||||
title: string;
|
||||
excerpt: string;
|
||||
description: string;
|
||||
image: {
|
||||
src: string;
|
||||
alt: string;
|
||||
};
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
tags: string[];
|
||||
discount: string;
|
||||
};
|
||||
|
||||
type MenuData = {
|
||||
categories?: MenuCategory[];
|
||||
meta?: Record<string, any>;
|
||||
};
|
||||
|
||||
type CampaignData = {
|
||||
campaigns?: Campaign[];
|
||||
};
|
||||
|
||||
type ActiveView = "home" | "menu" | "campaigns" | "contact";
|
||||
|
||||
const fallbackImage = "/images/general-img-square.webp";
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const isScrolled = ref(false);
|
||||
const isHeaderVisible = ref(true);
|
||||
const currentMenuView = ref<ActiveView>("home");
|
||||
|
||||
let lastScrollY = 0;
|
||||
|
||||
const currentLang = computed<"tr" | "en">(() => {
|
||||
return route.path.startsWith("/en") ? "en" : "tr";
|
||||
});
|
||||
|
||||
const basePath = computed(() => {
|
||||
return currentLang.value === "en" ? "/en/" : "/";
|
||||
});
|
||||
|
||||
const menuData = usePageData<MenuData>("menu", currentLang.value);
|
||||
const campaignsData = usePageData<Campaign[]>("campaigns", currentLang.value);
|
||||
|
||||
useMeta(menuData.value?.meta ?? {}, currentLang.value);
|
||||
|
||||
const categories = computed<MenuCategory[]>(() => {
|
||||
return menuData.value?.categories ?? [];
|
||||
});
|
||||
|
||||
const campaigns = computed<Campaign[]>(() => {
|
||||
return campaignsData.value ?? [];
|
||||
});
|
||||
|
||||
function handleScroll() {
|
||||
const currentScrollY = window.scrollY;
|
||||
|
||||
isScrolled.value = currentScrollY > 20;
|
||||
|
||||
if (currentScrollY <= 20) {
|
||||
isHeaderVisible.value = true;
|
||||
lastScrollY = currentScrollY;
|
||||
return;
|
||||
}
|
||||
|
||||
isHeaderVisible.value = currentScrollY <= lastScrollY;
|
||||
lastScrollY = currentScrollY;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
lastScrollY = window.scrollY;
|
||||
handleScroll();
|
||||
|
||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,14 @@
|
||||
// src/plugins/gsap.ts
|
||||
import { App } from "vue";
|
||||
import { gsap } from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
|
||||
export default {
|
||||
install: (app: App) => {
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
(app.config.globalProperties as any).$gsap = gsap;
|
||||
(app.config.globalProperties as any).$ScrollTrigger = ScrollTrigger;
|
||||
}
|
||||
};
|
||||
|
||||
export { gsap, ScrollTrigger };
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
createWebHistory,
|
||||
createMemoryHistory,
|
||||
createRouter,
|
||||
} from "vue-router";
|
||||
|
||||
// Layouts
|
||||
import DefaultLayout from "@/layouts/default.vue";
|
||||
import MenuLayout from "@/layouts/menu.vue";
|
||||
|
||||
// Pages
|
||||
import HomePage from "@/pages/index.vue";
|
||||
import Menu from "@/pages/menu/index.vue";
|
||||
import Legal from "@/pages/legal/index.vue";
|
||||
import HomePageEN from "@/pages/en/index.vue";
|
||||
import MenuEN from "@/pages/en/menu/index.vue";
|
||||
import LegalEN from "@/pages/en/legal/index.vue";
|
||||
|
||||
// Route definitions
|
||||
export const routes = [
|
||||
// TR (default)
|
||||
{
|
||||
path: "/",
|
||||
component: DefaultLayout,
|
||||
children: [
|
||||
{ path: "", name: "home", component: HomePage },
|
||||
{ path: "legal", name: "legal", component: Legal },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/menu",
|
||||
component: MenuLayout,
|
||||
children: [{ path: "", name: "menu", component: Menu }],
|
||||
},
|
||||
|
||||
// EN
|
||||
{
|
||||
path: "/en",
|
||||
component: DefaultLayout,
|
||||
children: [
|
||||
{ path: "", name: "home-en", component: HomePageEN },
|
||||
{ path: "legal", name: "legal-en", component: LegalEN },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/en/menu",
|
||||
component: MenuLayout,
|
||||
children: [{ path: "", name: "menu-en", component: MenuEN }],
|
||||
},
|
||||
|
||||
// Admin (client-only, lazy — not included in SSG routes)
|
||||
{
|
||||
path: "/admin",
|
||||
name: "admin",
|
||||
component: () => import("@/pages/admin/index.vue"),
|
||||
},
|
||||
|
||||
// 404
|
||||
{
|
||||
path: "/:pathMatch(.*)*",
|
||||
name: "not-found",
|
||||
component: () => import("@/pages/404.vue"),
|
||||
},
|
||||
];
|
||||
|
||||
// Router instance
|
||||
export const router = createRouter({
|
||||
history: import.meta.env.SSR
|
||||
? createMemoryHistory(import.meta.env.BASE_URL)
|
||||
: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes,
|
||||
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
if (savedPosition) return savedPosition;
|
||||
if (to.hash) return { el: to.hash };
|
||||
if (to.path !== from.path) return { top: 0 };
|
||||
return false;
|
||||
},
|
||||
});
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@theme {
|
||||
--color-white: #F1F4F5;
|
||||
--color-light: #DFEDEE;
|
||||
--color-highlight: #3FA0C7;
|
||||
--color-softlight: #8FCFE1;
|
||||
--color-dark: #3C393D;
|
||||
--font-sans: "MucoFont", ui-sans-serif, system-ui, -apple-system, Roboto, "Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji";
|
||||
--font-handwritten: "MucoFont Handwritten", ui-sans-serif, system-ui, -apple-system, Roboto, "Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji";
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "MucoFont";
|
||||
src: url("./assets/fonts/mucofont-xlig.ttf") format("truetype");
|
||||
font-weight: 100;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "MucoFont";
|
||||
src: url("./assets/fonts/mucofont-book.ttf") format("truetype");
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "MucoFont";
|
||||
src: url("./assets/fonts/mucofont-medium.ttf") format("truetype");
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "MucoFont";
|
||||
src: url("./assets/fonts/mucofont-demi.ttf") format("truetype");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "MucoFont";
|
||||
src: url("./assets/fonts/mucofont-bold.ttf") format("truetype");
|
||||
font-weight: 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "MucoFont Handwritten";
|
||||
src: url("./assets/fonts/mucofont-handwritten.ttf") format("truetype");
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
@apply text-dark bg-white scroll-smooth;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
@apply transition-colors duration-300;
|
||||
}
|
||||
|
||||
button {
|
||||
@apply cursor-pointer transition-colors duration-300;
|
||||
}
|
||||
|
||||
/* Markdown içerik stilleri */
|
||||
@layer components {
|
||||
.markdown {
|
||||
@apply text-base leading-7 text-dark;
|
||||
}
|
||||
|
||||
/* Başlıklar */
|
||||
.markdown h1,
|
||||
.markdown h2,
|
||||
.markdown h3,
|
||||
.markdown h4,
|
||||
.markdown h5,
|
||||
.markdown h6 {
|
||||
@apply font-bold tracking-tight text-dark scroll-mt-24;
|
||||
}
|
||||
|
||||
.markdown h1 {
|
||||
@apply text-3xl sm:text-4xl my-3;
|
||||
}
|
||||
|
||||
.markdown h2 {
|
||||
@apply text-2xl sm:text-3xl my-3;
|
||||
}
|
||||
|
||||
.markdown h3 {
|
||||
@apply text-xl my-3;
|
||||
}
|
||||
|
||||
.markdown h4 {
|
||||
@apply text-lg my-3;
|
||||
}
|
||||
|
||||
.markdown h5 {
|
||||
@apply text-base my-3;
|
||||
}
|
||||
|
||||
.markdown h6 {
|
||||
@apply text-sm my-3 text-dark;
|
||||
}
|
||||
|
||||
/* Paragraflar */
|
||||
.markdown p {
|
||||
@apply my-4;
|
||||
}
|
||||
|
||||
/* Linkler */
|
||||
.markdown a {
|
||||
@apply underline underline-offset-2 text-highlight hover:text-softlight wrap-break-word;
|
||||
}
|
||||
|
||||
/* Listeler */
|
||||
.markdown ul,
|
||||
.markdown ol {
|
||||
@apply my-4 ml-6;
|
||||
}
|
||||
|
||||
.markdown ul {
|
||||
@apply list-disc;
|
||||
}
|
||||
|
||||
.markdown ol {
|
||||
@apply list-decimal;
|
||||
}
|
||||
|
||||
.markdown li {
|
||||
@apply my-1;
|
||||
}
|
||||
|
||||
.markdown li>p {
|
||||
@apply my-0;
|
||||
}
|
||||
|
||||
/* Blockquote */
|
||||
.markdown blockquote {
|
||||
@apply my-6 border-l-4 border-light pl-4 text-dark;
|
||||
}
|
||||
|
||||
/* Yatay çizgi */
|
||||
.markdown hr {
|
||||
@apply my-8 border-t border-dark;
|
||||
}
|
||||
|
||||
/* Görseller */
|
||||
.markdown img {
|
||||
@apply block max-w-full h-auto my-4 rounded-lg;
|
||||
}
|
||||
|
||||
/* Tablolar */
|
||||
.markdown table {
|
||||
@apply block overflow-x-auto;
|
||||
@apply w-full text-left text-sm my-6 border-collapse;
|
||||
}
|
||||
|
||||
.markdown thead th {
|
||||
@apply border border-dark bg-light px-3 py-2 font-semibold;
|
||||
}
|
||||
|
||||
.markdown tbody td {
|
||||
@apply border border-dark px-3 py-2 align-top;
|
||||
}
|
||||
|
||||
.markdown tbody tr:nth-child(even) td {
|
||||
@apply bg-dark/50;
|
||||
}
|
||||
|
||||
/* Kod satırlarında uzun kelime kırma için yardımcı */
|
||||
.markdown .break-lines pre,
|
||||
.markdown .break-lines code {
|
||||
@apply whitespace-pre-wrap wrap-break-word;
|
||||
}
|
||||
|
||||
/* İçerik genişliği (opsiyonel) */
|
||||
.markdown.prose {
|
||||
@apply max-w-[72ch];
|
||||
}
|
||||
|
||||
.markdown strong {
|
||||
@apply underline
|
||||
}
|
||||
}
|
||||
Vendored
+25
@@ -0,0 +1,25 @@
|
||||
declare module "locomotive-scroll" {
|
||||
export interface LocomotiveScrollOptions {
|
||||
el?: HTMLElement;
|
||||
name?: string;
|
||||
smooth?: boolean;
|
||||
direction?: "vertical" | "horizontal";
|
||||
gestureDirection?: "vertical" | "horizontal";
|
||||
smartphone?: { smooth: boolean };
|
||||
tablet?: { smooth: boolean };
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export default class LocomotiveScroll {
|
||||
constructor(options?: LocomotiveScrollOptions);
|
||||
update(): void;
|
||||
start(): void;
|
||||
stop(): void;
|
||||
scrollTo(
|
||||
target: HTMLElement | string | number,
|
||||
options?: { offset?: number; duration?: number; easing?: [number, number, number, number]; disableLerp?: boolean; callback?: () => void }
|
||||
): void;
|
||||
on(event: string, callback: (...args: any[]) => void): void;
|
||||
destroy(): void;
|
||||
}
|
||||
}
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "swiper/css";
|
||||
declare module "swiper/css/pagination";
|
||||
declare module "swiper/css/navigation";
|
||||
declare module "swiper/css/autoplay";
|
||||
Reference in New Issue
Block a user