Restructure the repo into a pnpm + Turborepo monorepo: - apps/web: Nuxt 4 site, now server-rendered and reading all content from the API; pages cached with SWR and purged on content changes - apps/api: NestJS 12 + Prisma 7 + PostgreSQL. Auth with short-lived access tokens and rotating httpOnly refresh cookies, roles (ADMIN, EDITOR), catalog, editable site sections, media upload to S3-compatible storage (converted to WebP), contact messages, audit log - apps/admin: Vue 3 admin panel (Ant Design Vue, Pinia, Tailwind) for products, categories, site sections, messages, media and users - packages/shared: Zod schemas and types shared by API and admin Also: - seed script that loads the original 8 categories / 89 products and section content - Docker images for api (with a one-off migrate service), web, admin, and an nginx gateway for both domains - dev and production compose files, daily database backup service - API e2e tests Removes the committed static out/ build and the old root Dockerfile. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
77 lines
2.6 KiB
Vue
77 lines
2.6 KiB
Vue
<script setup lang="ts">
|
|
import { AnimatePresence, motion } from "motion-v";
|
|
import { Quote } from "lucide-vue-next";
|
|
import SectionHead from "~/components/ui/SectionHead.vue";
|
|
|
|
const testimonials = computed(() => useSiteStore().sections.testimonials);
|
|
const QUOTES = computed(() => testimonials.value.items);
|
|
|
|
const idx = ref(0);
|
|
const q = computed(() => QUOTES.value[idx.value]);
|
|
let t: ReturnType<typeof setInterval> | undefined;
|
|
|
|
onMounted(() => {
|
|
t = setInterval(() => (idx.value = (idx.value + 1) % Math.max(1, QUOTES.value.length)), 7000);
|
|
});
|
|
onBeforeUnmount(() => clearInterval(t));
|
|
</script>
|
|
|
|
<template>
|
|
<section
|
|
v-if="QUOTES.length"
|
|
aria-labelledby="testimonials-title"
|
|
class="relative overflow-hidden border-t border-line py-24 md:py-32"
|
|
>
|
|
<div class="absolute inset-0 bg-grid opacity-40" />
|
|
<div
|
|
class="pointer-events-none absolute -top-10 -end-10 select-none font-mono text-[18vw] font-bold leading-none text-outline opacity-50"
|
|
>
|
|
"
|
|
</div>
|
|
|
|
<div class="relative mx-auto max-w-4xl px-5 text-center md:px-8">
|
|
<SectionHead heading-id="testimonials-title" center v-bind="testimonials.head" />
|
|
|
|
<div class="mt-14 flex min-h-[320px] flex-col items-center md:min-h-[280px]">
|
|
<span
|
|
class="flex size-16 items-center justify-center rounded-full border border-accent/30 bg-accent/10"
|
|
>
|
|
<Quote class="size-7 text-accent" />
|
|
</span>
|
|
|
|
<AnimatePresence mode="wait">
|
|
<motion.blockquote
|
|
v-if="q"
|
|
:key="idx"
|
|
:initial="{ opacity: 0, y: 26 }"
|
|
:animate="{ opacity: 1, y: 0 }"
|
|
:exit="{ opacity: 0, y: -26 }"
|
|
:transition="{ duration: 0.55, ease: EASE }"
|
|
class="mt-8"
|
|
>
|
|
<p class="text-xl font-bold leading-[2.1] text-ink/90 md:text-2xl md:leading-[2]">
|
|
«{{ q.text }}»
|
|
</p>
|
|
<footer class="mt-8">
|
|
<div class="text-lg font-black">{{ q.name }}</div>
|
|
<div class="mt-2 font-mono text-xs text-muted">{{ q.role }}</div>
|
|
</footer>
|
|
</motion.blockquote>
|
|
</AnimatePresence>
|
|
</div>
|
|
|
|
<div class="mt-12 flex items-center justify-center gap-3">
|
|
<button
|
|
v-for="(_, i) in QUOTES"
|
|
:key="i"
|
|
:aria-label="`نظر شماره ${faNum(i + 1)}`"
|
|
:class="`h-1.5 rounded-full transition-all duration-500 ${
|
|
i === idx ? 'w-12 bg-accent' : 'w-4 bg-line hover:bg-muted'
|
|
}`"
|
|
@click="idx = i"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</template>
|