Files
imen_pishghaman/apps/web/app/components/sections/Contact.vue
T
S. EsmaeiliandClaude Opus 5.5 789d224ea0 feat: monorepo with NestJS API, admin panel and DB-driven website
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>
2026-10-21 20:31:05 +03:30

196 lines
7.8 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>