464 lines
13 KiB
Vue
464 lines
13 KiB
Vue
<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>
|