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>
196 lines
7.8 KiB
Vue
196 lines
7.8 KiB
Vue
<script setup lang="ts">
|
||
import { Button as AButton, Form as AForm, FormItem as AFormItem, Input as AInput, Textarea as ATextarea } from "ant-design-vue";
|
||
import type { Rule } from "ant-design-vue/es/form";
|
||
import {
|
||
CheckCircle2,
|
||
Clock,
|
||
Loader2,
|
||
Mail,
|
||
MapPin,
|
||
Phone,
|
||
Send,
|
||
Siren,
|
||
} from "lucide-vue-next";
|
||
import Lines from "~/components/ui/Lines.vue";
|
||
import Reveal from "~/components/ui/Reveal.vue";
|
||
import SectionHead from "~/components/ui/SectionHead.vue";
|
||
|
||
const site = useSiteStore();
|
||
const content = computed(() => site.sections.contact);
|
||
|
||
const INFO = computed(() => {
|
||
const s = site.settings;
|
||
return [
|
||
{ icon: MapPin, label: "نشانی", value: `${s.address.locality}، ${s.address.street}` },
|
||
{ icon: Phone, label: "تلفن تماس", value: s.phoneDisplay, ltr: true, href: `tel:${s.phone}` },
|
||
{ icon: Mail, label: "ایمیل", value: s.email, ltr: true, href: `mailto:${s.email}` },
|
||
{ icon: Clock, label: "ساعات کاری", value: s.workingHours },
|
||
];
|
||
});
|
||
|
||
const required = (message: string): Rule[] => [
|
||
{ required: true, whitespace: true, message, trigger: "blur" },
|
||
];
|
||
|
||
const rules: Record<string, Rule[]> = {
|
||
name: required("لطفاً نام و نام خانوادگی را وارد کنید"),
|
||
org: required("لطفاً نام سازمان را وارد کنید"),
|
||
phone: required("لطفاً شماره تماس را وارد کنید"),
|
||
};
|
||
|
||
const contact = useContactStore();
|
||
</script>
|
||
|
||
<template>
|
||
<section
|
||
id="contact"
|
||
aria-labelledby="contact-title"
|
||
class="relative overflow-hidden border-t border-line py-24 md:py-32"
|
||
>
|
||
<div class="absolute inset-0 bg-grid-fine opacity-40" />
|
||
|
||
<div class="relative mx-auto max-w-[1480px] px-5 md:px-8">
|
||
<div class="grid gap-14 lg:grid-cols-2">
|
||
<!-- ---- info ---- -->
|
||
<div>
|
||
<SectionHead heading-id="contact-title" v-bind="content.head" />
|
||
|
||
<div class="mt-10 space-y-1">
|
||
<Reveal v-for="(item, i) in INFO" :key="item.label" :delay="0.15 + i * 0.06">
|
||
<div class="group flex items-center gap-4 border-b border-line/70 py-4">
|
||
<span
|
||
class="flex size-11 shrink-0 items-center justify-center rounded-lg border border-line text-accent transition-colors duration-300 group-hover:border-accent/50"
|
||
>
|
||
<component :is="item.icon" class="size-5" />
|
||
</span>
|
||
<div class="min-w-0">
|
||
<div class="text-xs text-muted">{{ item.label }}</div>
|
||
<component
|
||
:is="item.href ? 'a' : 'div'"
|
||
:href="item.href"
|
||
:class="`mt-1 block text-sm font-medium ${item.ltr ? 'font-mono tracking-wider' : ''}`"
|
||
:dir="item.ltr ? 'ltr' : 'rtl'"
|
||
>
|
||
{{ item.value }}
|
||
</component>
|
||
</div>
|
||
</div>
|
||
</Reveal>
|
||
</div>
|
||
|
||
<Reveal :delay="0.45">
|
||
<div class="mt-8 flex items-start gap-4 rounded-xl border border-hazard/30 bg-hazard/5 p-5">
|
||
<Siren class="size-6 shrink-0 text-hazard" />
|
||
<div>
|
||
<div class="font-bold text-hazard">{{ content.emergencyTitle }}</div>
|
||
<p class="mt-1.5 text-sm leading-7 text-muted">
|
||
{{ content.emergencyText }}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</Reveal>
|
||
</div>
|
||
|
||
<!-- ---- form ---- -->
|
||
<Reveal :delay="0.15">
|
||
<div class="rounded-2xl border border-line bg-panel/60 p-6 backdrop-blur md:p-9">
|
||
<div v-if="content.formTag" class="font-mono text-xs tracking-[0.25em] text-accent">
|
||
{{ content.formTag }}
|
||
</div>
|
||
<h3 class="mt-3 text-2xl font-black leading-snug">
|
||
<Lines :text="content.formTitle" />
|
||
</h3>
|
||
|
||
<div
|
||
v-if="contact.status === 'success'"
|
||
class="mt-10 flex flex-col items-center rounded-xl border border-emerald-500/30 bg-emerald-500/5 p-10 text-center"
|
||
>
|
||
<CheckCircle2 class="size-14 text-emerald-400" />
|
||
<div class="mt-5 text-xl font-black">{{ content.successTitle }}</div>
|
||
<p class="mt-2.5 max-w-sm text-sm leading-7 text-muted">
|
||
{{ content.successText }}
|
||
</p>
|
||
<button
|
||
type="button"
|
||
class="mt-7 rounded-full border border-line px-7 py-3 text-sm transition-colors hover:border-accent hover:text-accent"
|
||
@click="contact.reset"
|
||
>
|
||
ثبت درخواست جدید
|
||
</button>
|
||
</div>
|
||
|
||
<AForm
|
||
v-else
|
||
class="contact-form"
|
||
layout="vertical"
|
||
:model="contact.form"
|
||
:rules="rules"
|
||
hide-required-mark
|
||
@finish="contact.submit"
|
||
>
|
||
<!-- honeypot: hidden from people, bots tend to fill it -->
|
||
<div class="absolute -start-[9999px] h-px w-px overflow-hidden" aria-hidden="true">
|
||
<label for="f-website">وبسایت</label>
|
||
<input id="f-website" v-model="contact.form.website" type="text" tabindex="-1" autocomplete="off" />
|
||
</div>
|
||
<div class="mt-8 grid gap-5 sm:grid-cols-2">
|
||
<AFormItem name="name" label="نام و نام خانوادگی *" html-for="f-name">
|
||
<AInput id="f-name" v-model:value="contact.form.name" placeholder="مثلاً: علی رضایی" />
|
||
</AFormItem>
|
||
<AFormItem name="org" label="نام سازمان *" html-for="f-org">
|
||
<AInput
|
||
id="f-org"
|
||
v-model:value="contact.form.org"
|
||
placeholder="مثلاً: شرکت پتروشیمی خلیج"
|
||
/>
|
||
</AFormItem>
|
||
<AFormItem class="sm:col-span-2" name="phone" label="شماره تماس *" html-for="f-phone">
|
||
<AInput
|
||
id="f-phone"
|
||
v-model:value="contact.form.phone"
|
||
type="tel"
|
||
dir="ltr"
|
||
placeholder="0912 000 0000"
|
||
/>
|
||
</AFormItem>
|
||
<AFormItem class="sm:col-span-2" name="message" label="شرح درخواست" html-for="f-msg">
|
||
<ATextarea
|
||
id="f-msg"
|
||
v-model:value="contact.form.message"
|
||
:rows="4"
|
||
placeholder="تجهیزات موردنیاز، تعداد پرسنل یا هر نکتهای که لازم است بدانیم..."
|
||
/>
|
||
</AFormItem>
|
||
<p
|
||
v-if="contact.status === 'error'"
|
||
class="rounded-lg border border-accent/40 bg-accent/10 px-4 py-3 text-sm leading-7 text-accent sm:col-span-2"
|
||
role="alert"
|
||
>
|
||
{{ contact.error }}
|
||
</p>
|
||
<div class="sm:col-span-2">
|
||
<AButton
|
||
type="primary"
|
||
html-type="submit"
|
||
class="contact-submit"
|
||
:disabled="contact.status === 'loading'"
|
||
>
|
||
<template v-if="contact.status === 'loading'">
|
||
<Loader2 class="size-5 animate-spin" />
|
||
در حال ارسال...
|
||
</template>
|
||
<template v-else>
|
||
<Send class="size-5" />
|
||
ارسال درخواست مشاوره
|
||
</template>
|
||
</AButton>
|
||
</div>
|
||
</div>
|
||
</AForm>
|
||
</div>
|
||
</Reveal>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</template>
|