feat: initial commit — site + admin panel + Postgres content pipeline
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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>
|
||||
Reference in New Issue
Block a user