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

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