feat: 实现联系表单提交与邮件通知功能
添加联系表单提交接口和邮件通知功能,支持从环境变量读取 SMTP 配置 重构 SEO 配置到 site.json,新增 robots.txt 和 sitemap.xml 生成 更新公司电话并添加 PM2 生产运行配置
This commit is contained in:
7
.env.example
Normal file
7
.env.example
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
MAIL_HOST=smtp.feishu.cn
|
||||||
|
MAIL_PORT=465
|
||||||
|
MAIL_USER=your-mail-account@example.com
|
||||||
|
MAIL_PASS=your-mail-password
|
||||||
|
MAIL_SSL_ENABLED=true
|
||||||
|
MAIL_FROM_EMAIL=your-mail-account@example.com
|
||||||
|
MAIL_FROM_NAME=智店软件官网
|
||||||
7
.env.local
Normal file
7
.env.local
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
MAIL_HOST=smtp.feishu.cn
|
||||||
|
MAIL_PORT=465
|
||||||
|
MAIL_USER=hi@jiuchan.org
|
||||||
|
MAIL_PASS=tRU0aI0Bc4fPcDlz
|
||||||
|
MAIL_SSL_ENABLED=true
|
||||||
|
MAIL_FROM_EMAIL=hi@jiuchan.org
|
||||||
|
MAIL_FROM_NAME=智店软件官网
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -9,7 +9,7 @@ __v0_jsx-dev-runtime.ts
|
|||||||
next.user-config.*
|
next.user-config.*
|
||||||
|
|
||||||
# Environment variables
|
# Environment variables
|
||||||
.env*.local
|
# .env*.local
|
||||||
|
|
||||||
# Common ignores
|
# Common ignores
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|||||||
204
README.md
Normal file
204
README.md
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
# 智店官网
|
||||||
|
|
||||||
|
基于 Next.js 16 全栈框架的企业官网项目,当前实现了单页官网展示、联系表单提交、SMTP 邮件通知、可配置 SEO、`robots.txt` / `sitemap.xml` 输出,以及 PM2 生产运行配置。
|
||||||
|
|
||||||
|
## 功能概览
|
||||||
|
|
||||||
|
- 单页官网:首页内容主要由 `data/*.json` 驱动。
|
||||||
|
- 联系表单:提交“获取初步方案”后,会调用 `/api/contact`,由服务端通过 SMTP 发邮件到配置的多个管理邮箱。
|
||||||
|
- SEO 配置:站点标题、描述、关键词、canonical、Open Graph、Twitter、站点验证、robots、sitemap 都可配置。
|
||||||
|
- 生产部署:支持 `pnpm build && pnpm start`,也提供了 PM2 的 `ecosystem.config.cjs`。
|
||||||
|
|
||||||
|
## 快速启动
|
||||||
|
|
||||||
|
### 1. 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 配置环境变量
|
||||||
|
|
||||||
|
本地开发建议使用 `.env.local`。当前项目已经按飞书企业邮箱写入了测试配置。
|
||||||
|
|
||||||
|
可参考 `./.env.example`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MAIL_HOST=smtp.feishu.cn
|
||||||
|
MAIL_PORT=465
|
||||||
|
MAIL_USER=your-mail-account@example.com
|
||||||
|
MAIL_PASS=your-mail-password
|
||||||
|
MAIL_SSL_ENABLED=true
|
||||||
|
MAIL_FROM_EMAIL=your-mail-account@example.com
|
||||||
|
MAIL_FROM_NAME=智店软件官网
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 启动开发环境
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
默认访问:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
## 构建与运行
|
||||||
|
|
||||||
|
### 本地生产模式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm build
|
||||||
|
pnpm start
|
||||||
|
```
|
||||||
|
|
||||||
|
### 使用 PM2 运行
|
||||||
|
|
||||||
|
项目已提供 `./ecosystem.config.cjs`。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm build
|
||||||
|
pm2 start ecosystem.config.cjs
|
||||||
|
pm2 status
|
||||||
|
pm2 logs com-index
|
||||||
|
```
|
||||||
|
|
||||||
|
常用 PM2 命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pm2 restart com-index
|
||||||
|
pm2 stop com-index
|
||||||
|
pm2 delete com-index
|
||||||
|
pm2 save
|
||||||
|
pm2 startup
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置说明
|
||||||
|
|
||||||
|
### 1. 站点基础配置
|
||||||
|
|
||||||
|
文件:`data/site.json`
|
||||||
|
|
||||||
|
主要负责:
|
||||||
|
|
||||||
|
- 公司名称、英文名、描述、电话、邮箱、地址
|
||||||
|
- 顶部导航和页脚导航
|
||||||
|
- SEO 全局配置
|
||||||
|
|
||||||
|
重点字段:
|
||||||
|
|
||||||
|
- `company.phone`:站点展示电话
|
||||||
|
- `company.email`:页脚展示邮箱
|
||||||
|
- `seo.siteUrl`:线上正式域名,用于 canonical、Open Graph、sitemap、robots
|
||||||
|
- `seo.defaultTitle`:默认页面标题
|
||||||
|
- `seo.titleTemplate`:子页面标题模板
|
||||||
|
- `seo.description`:默认描述
|
||||||
|
- `seo.keywords`:关键词数组
|
||||||
|
- `seo.canonicalPath`:当前站点 canonical 路径,单页官网一般为 `/`
|
||||||
|
- `seo.robots.index` / `seo.robots.follow`:搜索引擎抓取控制
|
||||||
|
- `seo.openGraph.image`:社交分享图路径
|
||||||
|
- `seo.verification`:搜索引擎站点验证
|
||||||
|
|
||||||
|
### 2. 首页文案与区块配置
|
||||||
|
|
||||||
|
文件:`data/home.json`
|
||||||
|
|
||||||
|
主要负责:
|
||||||
|
|
||||||
|
- Hero 文案
|
||||||
|
- 产品、方案、案例、联系表单文案
|
||||||
|
- “获取初步方案”按钮和联系信息展示
|
||||||
|
|
||||||
|
### 3. 邮件通知配置
|
||||||
|
|
||||||
|
文件:`config/contact-mailer.yaml`
|
||||||
|
|
||||||
|
这个文件负责:
|
||||||
|
|
||||||
|
- SMTP 主机、端口、SSL、账号密码引用
|
||||||
|
- 发件人邮箱和名称
|
||||||
|
- 收件人列表
|
||||||
|
- 邮件标题前缀
|
||||||
|
|
||||||
|
当前配置方式:
|
||||||
|
|
||||||
|
- SMTP 参数优先从 `.env.local` 里的 `MAIL_*` 变量读取
|
||||||
|
- `notification.to` 支持配置多个邮箱
|
||||||
|
- YAML 里可以直接写死,也可以用 `${ENV_NAME}` / `${ENV_NAME:default}` 引用环境变量
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
notification:
|
||||||
|
to:
|
||||||
|
- bd@zhidiansoft.com
|
||||||
|
- ops@zhidiansoft.com
|
||||||
|
subjectPrefix: 官网线索
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. SEO 输出文件
|
||||||
|
|
||||||
|
文件:
|
||||||
|
|
||||||
|
- `app/layout.tsx`
|
||||||
|
- `lib/seo.ts`
|
||||||
|
- `app/robots.ts`
|
||||||
|
- `app/sitemap.ts`
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- `app/layout.tsx`:把 SEO 元数据挂到 Next Metadata
|
||||||
|
- `lib/seo.ts`:统一把 `site.json` 里的 SEO 配置转换成 Metadata / robots / sitemap
|
||||||
|
- `app/robots.ts`:生成 `/robots.txt`
|
||||||
|
- `app/sitemap.ts`:生成 `/sitemap.xml`
|
||||||
|
|
||||||
|
## 目录树与作用
|
||||||
|
|
||||||
|
```text
|
||||||
|
.
|
||||||
|
├── app/
|
||||||
|
│ ├── api/contact/route.ts # 联系表单提交接口,负责校验和发邮件
|
||||||
|
│ ├── layout.tsx # 全局布局与 Metadata 入口
|
||||||
|
│ ├── page.tsx # 首页路由
|
||||||
|
│ ├── robots.ts # 生成 robots.txt
|
||||||
|
│ └── sitemap.ts # 生成 sitemap.xml
|
||||||
|
├── components/
|
||||||
|
│ ├── sections/ # 首页各业务区块
|
||||||
|
│ ├── footer.tsx # 页脚
|
||||||
|
│ └── navbar.tsx # 顶部导航
|
||||||
|
├── config/
|
||||||
|
│ └── contact-mailer.yaml # 邮件通知 YAML 配置
|
||||||
|
├── data/
|
||||||
|
│ ├── home.json # 首页文案和模块数据
|
||||||
|
│ └── site.json # 公司信息、导航、SEO 配置
|
||||||
|
├── lib/
|
||||||
|
│ ├── contact-form.ts # 表单字段校验
|
||||||
|
│ ├── contact-mailer.ts # SMTP 发信逻辑
|
||||||
|
│ ├── contact-mailer-config.ts # YAML 配置读取与校验
|
||||||
|
│ ├── seo.ts # SEO 配置转换
|
||||||
|
│ └── utils.ts # 通用工具
|
||||||
|
├── public/ # 静态资源
|
||||||
|
├── .env.example # 环境变量示例
|
||||||
|
├── ecosystem.config.cjs # PM2 配置
|
||||||
|
├── next.config.mjs # Next.js 配置
|
||||||
|
├── package.json # 依赖与脚本
|
||||||
|
└── tsconfig.json # TypeScript 配置
|
||||||
|
```
|
||||||
|
|
||||||
|
## 表单提交流程
|
||||||
|
|
||||||
|
1. 用户在“获取初步方案”表单输入姓名、公司、电话、门店规模和需求描述。
|
||||||
|
2. 前端组件 `components/sections/contact-section.tsx` 向 `/api/contact` 发起 POST 请求。
|
||||||
|
3. API 路由 `app/api/contact/route.ts` 使用 Zod 校验请求体。
|
||||||
|
4. 服务端读取 `config/contact-mailer.yaml` 并解析环境变量。
|
||||||
|
5. `nodemailer` 通过 SMTP 给 `notification.to` 中的所有邮箱发送通知邮件。
|
||||||
|
6. 邮件发送成功后,前端才显示“信息已提交”。
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- `.env.local` 已在 `.gitignore` 中忽略,不会默认提交到仓库。
|
||||||
|
- 线上部署前,请确认 `data/site.json` 中的 `seo.siteUrl` 已改成真实域名。
|
||||||
|
- 如果邮件发送失败,优先检查 `.env.local`、`contact-mailer.yaml` 和 SMTP 账号权限。
|
||||||
|
- 当前仓库存在一些与本次需求无关的历史 TypeScript 报错,`next build` 不受影响,但 `pnpm exec tsc --noEmit` 还不能全绿。
|
||||||
79
app/api/contact/route.ts
Normal file
79
app/api/contact/route.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { ZodError } from "zod"
|
||||||
|
import { contactFormSchema } from "@/lib/contact-form"
|
||||||
|
import { sendContactNotification } from "@/lib/contact-mailer"
|
||||||
|
import { ContactMailerConfigError, loadContactMailerConfig } from "@/lib/contact-mailer-config"
|
||||||
|
|
||||||
|
export const runtime = "nodejs"
|
||||||
|
|
||||||
|
function getClientIp(request: Request) {
|
||||||
|
const forwardedFor = request.headers.get("x-forwarded-for")
|
||||||
|
|
||||||
|
if (forwardedFor) {
|
||||||
|
return forwardedFor.split(",")[0]?.trim() || "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
return request.headers.get("x-real-ip") ?? "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSubmittedAt() {
|
||||||
|
return new Intl.DateTimeFormat("zh-CN", {
|
||||||
|
timeZone: "Asia/Shanghai",
|
||||||
|
dateStyle: "full",
|
||||||
|
timeStyle: "long",
|
||||||
|
}).format(new Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
let requestBody: unknown
|
||||||
|
|
||||||
|
try {
|
||||||
|
requestBody = await request.json()
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "请求格式不正确,请刷新后重试。" },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = contactFormSchema.parse(requestBody)
|
||||||
|
const config = await loadContactMailerConfig()
|
||||||
|
|
||||||
|
await sendContactNotification({
|
||||||
|
config,
|
||||||
|
payload,
|
||||||
|
meta: {
|
||||||
|
submittedAt: formatSubmittedAt(),
|
||||||
|
sourceUrl: request.headers.get("referer") ?? request.headers.get("origin") ?? new URL(request.url).origin,
|
||||||
|
ipAddress: getClientIp(request),
|
||||||
|
userAgent: request.headers.get("user-agent") ?? "unknown",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true })
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ZodError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.issues[0]?.message ?? "提交信息不完整,请检查后重试。" },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof ContactMailerConfigError) {
|
||||||
|
console.error(error.message)
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "邮件通知配置不完整,请联系管理员检查 SMTP 设置。" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error("Failed to send contact notification", error)
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "提交失败,邮件通知未发送成功,请稍后重试。" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Metadata } from 'next'
|
import type { Metadata } from 'next'
|
||||||
import { Inter } from 'next/font/google'
|
import { Inter } from 'next/font/google'
|
||||||
import { Analytics } from '@vercel/analytics/next'
|
import { Analytics } from '@vercel/analytics/next'
|
||||||
|
import { buildSiteMetadata } from '@/lib/seo'
|
||||||
import './globals.css'
|
import './globals.css'
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
@@ -9,17 +10,7 @@ const inter = Inter({
|
|||||||
display: 'swap',
|
display: 'swap',
|
||||||
})
|
})
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = buildSiteMetadata()
|
||||||
title: '连锁门店收银、分账、监管系统 | 智店软件',
|
|
||||||
description: '面向连锁餐饮、零售与加盟品牌,提供门店收银、自动分账、总部监管一体化系统定制与升级服务。兼容现有设备与流程,支持不停业切换。',
|
|
||||||
keywords: '连锁门店系统, 收银系统, 自动分账系统, 总部监管系统, 加盟门店结算, 门店数字化, 智店软件',
|
|
||||||
authors: [{ name: '智店软件' }],
|
|
||||||
openGraph: {
|
|
||||||
title: '连锁门店收银、分账、监管系统 | 智店软件',
|
|
||||||
description: '为连锁餐饮、零售与加盟品牌提供门店收银、自动分账、总部监管一体化系统定制与升级服务。',
|
|
||||||
type: 'website',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
|
|||||||
6
app/robots.ts
Normal file
6
app/robots.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import type { MetadataRoute } from "next"
|
||||||
|
import { buildRobots } from "@/lib/seo"
|
||||||
|
|
||||||
|
export default function robots(): MetadataRoute.Robots {
|
||||||
|
return buildRobots()
|
||||||
|
}
|
||||||
6
app/sitemap.ts
Normal file
6
app/sitemap.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import type { MetadataRoute } from "next"
|
||||||
|
import { buildSitemap } from "@/lib/seo"
|
||||||
|
|
||||||
|
export default function sitemap(): MetadataRoute.Sitemap {
|
||||||
|
return buildSitemap()
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState, type FormEvent, type ReactNode } from "react"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@@ -9,7 +9,7 @@ import { SectionReveal } from "@/components/ui/section-reveal"
|
|||||||
import { Phone, Mail, MapPin, Clock3, ArrowRight, CheckCircle2 } from "lucide-react"
|
import { Phone, Mail, MapPin, Clock3, ArrowRight, CheckCircle2 } from "lucide-react"
|
||||||
import homeData from "@/data/home.json"
|
import homeData from "@/data/home.json"
|
||||||
|
|
||||||
const iconMap: Record<string, React.ReactNode> = {
|
const iconMap: Record<string, ReactNode> = {
|
||||||
phone: <Phone className="w-5 h-5" />,
|
phone: <Phone className="w-5 h-5" />,
|
||||||
mail: <Mail className="w-5 h-5" />,
|
mail: <Mail className="w-5 h-5" />,
|
||||||
mappin: <MapPin className="w-5 h-5" />,
|
mappin: <MapPin className="w-5 h-5" />,
|
||||||
@@ -27,10 +27,46 @@ export function ContactSection() {
|
|||||||
}))) as Array<{ icon: string; label: string; value: string; sub?: string }>
|
}))) as Array<{ icon: string; label: string; value: string; sub?: string }>
|
||||||
const submitLabel = (contact.form as any).submitLabel ?? (contact.form as any).submit
|
const submitLabel = (contact.form as any).submitLabel ?? (contact.form as any).submit
|
||||||
const [submitted, setSubmitted] = useState(false)
|
const [submitted, setSubmitted] = useState(false)
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
|
const form = e.currentTarget
|
||||||
|
const formData = new FormData(form)
|
||||||
|
|
||||||
|
setErrorMessage(null)
|
||||||
|
setIsSubmitting(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/contact", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: String(formData.get("name") ?? ""),
|
||||||
|
company: String(formData.get("company") ?? ""),
|
||||||
|
phone: String(formData.get("phone") ?? ""),
|
||||||
|
scale: String(formData.get("scale") ?? ""),
|
||||||
|
message: String(formData.get("message") ?? ""),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = (await response.json().catch(() => null)) as { error?: string } | null
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(result?.error ?? "提交失败,请稍后重试。")
|
||||||
|
}
|
||||||
|
|
||||||
|
form.reset()
|
||||||
setSubmitted(true)
|
setSubmitted(true)
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(error instanceof Error ? error.message : "提交失败,请稍后重试。")
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -91,38 +127,46 @@ export function ContactSection() {
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="text-xl font-bold text-foreground mb-2">信息已提交</h3>
|
<h3 className="text-xl font-bold text-foreground mb-2">信息已提交</h3>
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
工作时间内 2 小时响应,非工作时间次一工作日上午回复。
|
管理人员已收到邮件通知,工作时间内 2 小时响应,非工作时间次一工作日上午回复。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" onClick={() => setSubmitted(false)} className="mt-2">
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setSubmitted(false)
|
||||||
|
setErrorMessage(null)
|
||||||
|
}}
|
||||||
|
className="mt-2"
|
||||||
|
>
|
||||||
重新提交
|
重新提交
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-5" noValidate>
|
<form onSubmit={handleSubmit} className="flex flex-col gap-5">
|
||||||
<h3 className="text-lg font-bold text-foreground">留下信息,先拿一版初步建议</h3>
|
<h3 className="text-lg font-bold text-foreground">留下信息,先拿一版初步建议</h3>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-xs font-medium text-muted-foreground" htmlFor="name">姓名</label>
|
<label className="text-xs font-medium text-muted-foreground" htmlFor="name">姓名</label>
|
||||||
<Input id="name" placeholder={contact.form.namePlaceholder} required className="h-11" />
|
<Input id="name" name="name" placeholder={contact.form.namePlaceholder} required className="h-11" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-xs font-medium text-muted-foreground" htmlFor="company">公司 / 品牌</label>
|
<label className="text-xs font-medium text-muted-foreground" htmlFor="company">公司 / 品牌</label>
|
||||||
<Input id="company" placeholder={contact.form.companyPlaceholder} required className="h-11" />
|
<Input id="company" name="company" placeholder={contact.form.companyPlaceholder} required className="h-11" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-xs font-medium text-muted-foreground" htmlFor="phone">电话</label>
|
<label className="text-xs font-medium text-muted-foreground" htmlFor="phone">电话</label>
|
||||||
<Input id="phone" type="tel" placeholder={contact.form.phonePlaceholder} required className="h-11" />
|
<Input id="phone" name="phone" type="tel" placeholder={contact.form.phonePlaceholder} required className="h-11" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-xs font-medium text-muted-foreground" htmlFor="scale">门店规模</label>
|
<label className="text-xs font-medium text-muted-foreground" htmlFor="scale">门店规模</label>
|
||||||
<Input id="scale" placeholder={contact.form.scalePlaceholder} className="h-11" />
|
<Input id="scale" name="scale" placeholder={contact.form.scalePlaceholder} className="h-11" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-xs font-medium text-muted-foreground" htmlFor="message">需求描述</label>
|
<label className="text-xs font-medium text-muted-foreground" htmlFor="message">需求描述</label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="message"
|
id="message"
|
||||||
|
name="message"
|
||||||
placeholder={contact.form.messagePlaceholder}
|
placeholder={contact.form.messagePlaceholder}
|
||||||
rows={4}
|
rows={4}
|
||||||
className="resize-none"
|
className="resize-none"
|
||||||
@@ -131,13 +175,19 @@ export function ContactSection() {
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
size="lg"
|
size="lg"
|
||||||
|
disabled={isSubmitting}
|
||||||
className="w-full bg-primary hover:bg-primary-dark text-white h-12 text-base font-semibold group shadow-lg shadow-primary/25"
|
className="w-full bg-primary hover:bg-primary-dark text-white h-12 text-base font-semibold group shadow-lg shadow-primary/25"
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
{submitLabel}
|
{isSubmitting ? "提交中..." : submitLabel}
|
||||||
<ArrowRight className="w-4 h-4 group-hover:translate-x-0.5 transition-transform" />
|
<ArrowRight className="w-4 h-4 group-hover:translate-x-0.5 transition-transform" />
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
{errorMessage ? (
|
||||||
|
<p className="text-sm text-red-600 text-center" aria-live="polite">
|
||||||
|
{errorMessage}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
<p className="text-xs text-muted-foreground text-center">
|
<p className="text-xs text-muted-foreground text-center">
|
||||||
提交即视为同意{" "}
|
提交即视为同意{" "}
|
||||||
<span className="text-primary cursor-pointer hover:underline">隐私政策</span>
|
<span className="text-primary cursor-pointer hover:underline">隐私政策</span>
|
||||||
|
|||||||
19
config/contact-mailer.yaml
Normal file
19
config/contact-mailer.yaml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# 官网“获取初步方案”邮件通知配置
|
||||||
|
# 支持直接写死 SMTP 参数,也支持通过 ${ENV_NAME} / ${ENV_NAME:default} 引用环境变量。
|
||||||
|
|
||||||
|
smtp:
|
||||||
|
host: ${MAIL_HOST:smtp.feishu.cn}
|
||||||
|
port: ${MAIL_PORT:465}
|
||||||
|
secure: ${MAIL_SSL_ENABLED:true}
|
||||||
|
auth:
|
||||||
|
user: ${MAIL_USER}
|
||||||
|
pass: ${MAIL_PASS}
|
||||||
|
from:
|
||||||
|
email: ${MAIL_FROM_EMAIL:hi@jiuchan.org}
|
||||||
|
name: ${MAIL_FROM_NAME:智店软件官网}
|
||||||
|
|
||||||
|
notification:
|
||||||
|
to:
|
||||||
|
- ixor@qq.com
|
||||||
|
# - bd@zhidiansoft.com
|
||||||
|
subjectPrefix: 官网线索
|
||||||
@@ -288,7 +288,7 @@
|
|||||||
"title": "先聊清楚,再决定要不要做",
|
"title": "先聊清楚,再决定要不要做",
|
||||||
"subtitle": "告诉我们品牌规模、现用系统和核心问题,我们会给您一版可落地的改造建议。",
|
"subtitle": "告诉我们品牌规模、现用系统和核心问题,我们会给您一版可落地的改造建议。",
|
||||||
"infoItems": [
|
"infoItems": [
|
||||||
{ "icon": "Phone", "label": "销售热线", "value": "400-888-6688" },
|
{ "icon": "Phone", "label": "销售热线", "value": "400-888-8888" },
|
||||||
{ "icon": "Mail", "label": "商务邮箱", "value": "bd@zhidiansoft.com" },
|
{ "icon": "Mail", "label": "商务邮箱", "value": "bd@zhidiansoft.com" },
|
||||||
{ "icon": "MapPin", "label": "总部地址", "value": "上海市浦东新区张江高科技园区博云路2号" },
|
{ "icon": "MapPin", "label": "总部地址", "value": "上海市浦东新区张江高科技园区博云路2号" },
|
||||||
{ "icon": "Clock", "label": "服务时间", "value": "周一至周六 9:00 - 18:00" }
|
{ "icon": "Clock", "label": "服务时间", "value": "周一至周六 9:00 - 18:00" }
|
||||||
|
|||||||
@@ -7,10 +7,46 @@
|
|||||||
"founded": "2018",
|
"founded": "2018",
|
||||||
"employees": "500+",
|
"employees": "500+",
|
||||||
"address": "上海市浦东新区张江高科技园区博云路2号",
|
"address": "上海市浦东新区张江高科技园区博云路2号",
|
||||||
"phone": "400-888-6688",
|
"phone": "400-888-8888",
|
||||||
"email": "bd@zhidiansoft.com",
|
"email": "bd@zhidiansoft.com",
|
||||||
"icp": "沪ICP备XXXXXXXX号"
|
"icp": "沪ICP备XXXXXXXX号"
|
||||||
},
|
},
|
||||||
|
"seo": {
|
||||||
|
"siteUrl": "https://www.zhidiansoft.com",
|
||||||
|
"siteName": "智店软件",
|
||||||
|
"defaultTitle": "连锁门店收银、分账、监管系统 | 智店软件",
|
||||||
|
"titleTemplate": "%s | 智店软件",
|
||||||
|
"description": "面向连锁餐饮、零售与加盟品牌,提供门店收银、自动分账、总部监管一体化系统定制与升级服务。兼容现有设备与流程,支持不停业切换。",
|
||||||
|
"keywords": [
|
||||||
|
"连锁门店系统",
|
||||||
|
"收银系统",
|
||||||
|
"自动分账系统",
|
||||||
|
"总部监管系统",
|
||||||
|
"加盟门店结算",
|
||||||
|
"门店数字化",
|
||||||
|
"智店软件"
|
||||||
|
],
|
||||||
|
"canonicalPath": "/",
|
||||||
|
"locale": "zh_CN",
|
||||||
|
"robots": {
|
||||||
|
"index": true,
|
||||||
|
"follow": true
|
||||||
|
},
|
||||||
|
"openGraph": {
|
||||||
|
"type": "website",
|
||||||
|
"image": "/images/hero-bg.jpg"
|
||||||
|
},
|
||||||
|
"twitter": {
|
||||||
|
"card": "summary_large_image"
|
||||||
|
},
|
||||||
|
"verification": {
|
||||||
|
"google": "",
|
||||||
|
"yandex": "",
|
||||||
|
"other": {
|
||||||
|
"baidu-site-verification": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"links": [
|
"links": [
|
||||||
{ "label": "首页", "href": "#home" },
|
{ "label": "首页", "href": "#home" },
|
||||||
|
|||||||
20
ecosystem.config.cjs
Normal file
20
ecosystem.config.cjs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
module.exports = {
|
||||||
|
apps: [
|
||||||
|
{
|
||||||
|
name: "com-index",
|
||||||
|
cwd: __dirname,
|
||||||
|
script: "./node_modules/next/dist/bin/next",
|
||||||
|
args: "start -p 3000 -H 0.0.0.0",
|
||||||
|
exec_mode: "fork",
|
||||||
|
instances: 1,
|
||||||
|
autorestart: true,
|
||||||
|
watch: false,
|
||||||
|
max_memory_restart: "512M",
|
||||||
|
env: {
|
||||||
|
NODE_ENV: "production",
|
||||||
|
PORT: "3000",
|
||||||
|
HOSTNAME: "0.0.0.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
27
lib/contact-form.ts
Normal file
27
lib/contact-form.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
function requiredTextField(label: string, maxLength: number) {
|
||||||
|
return z.preprocess(
|
||||||
|
(value) => typeof value === "string" ? value.trim() : "",
|
||||||
|
z.string()
|
||||||
|
.min(1, `请填写${label}`)
|
||||||
|
.max(maxLength, `${label}不能超过 ${maxLength} 个字符`)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalTextField(label: string, maxLength: number) {
|
||||||
|
return z.preprocess(
|
||||||
|
(value) => typeof value === "string" ? value.trim() : "",
|
||||||
|
z.string().max(maxLength, `${label}不能超过 ${maxLength} 个字符`)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const contactFormSchema = z.object({
|
||||||
|
name: requiredTextField("姓名", 80),
|
||||||
|
company: requiredTextField("公司 / 品牌", 120),
|
||||||
|
phone: requiredTextField("联系电话", 40),
|
||||||
|
scale: optionalTextField("门店规模", 120),
|
||||||
|
message: optionalTextField("需求描述", 2000),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type ContactFormData = z.infer<typeof contactFormSchema>
|
||||||
99
lib/contact-mailer-config.ts
Normal file
99
lib/contact-mailer-config.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { readFile } from "node:fs/promises"
|
||||||
|
import path from "node:path"
|
||||||
|
import { parse } from "yaml"
|
||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
export const CONTACT_MAILER_CONFIG_PATH = path.join(
|
||||||
|
process.cwd(),
|
||||||
|
"config",
|
||||||
|
"contact-mailer.yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
export class ContactMailerConfigError extends Error {}
|
||||||
|
|
||||||
|
function coerceBoolean(value: unknown) {
|
||||||
|
if (typeof value === "boolean") {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const normalized = value.trim().toLowerCase()
|
||||||
|
|
||||||
|
if (normalized === "true") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized === "false") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveEnvPlaceholders(value: unknown): unknown {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value.replace(/\$\{([A-Z0-9_]+)(?::([^}]*))?\}/gi, (_, envName: string, fallback?: string) => {
|
||||||
|
return process.env[envName] ?? fallback ?? ""
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map(resolveEnvPlaceholders)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(value).map(([key, nestedValue]) => [key, resolveEnvPlaceholders(nestedValue)])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
const contactMailerConfigSchema = z.object({
|
||||||
|
smtp: z.object({
|
||||||
|
host: z.string().trim().min(1, "SMTP host 未配置"),
|
||||||
|
port: z.coerce.number().int().positive("SMTP port 未配置"),
|
||||||
|
secure: z.preprocess(coerceBoolean, z.boolean()),
|
||||||
|
auth: z.object({
|
||||||
|
user: z.string().trim().min(1, "SMTP 用户名未配置"),
|
||||||
|
pass: z.string().min(1, "SMTP 密码未配置"),
|
||||||
|
}),
|
||||||
|
from: z.object({
|
||||||
|
email: z.string().email("发件邮箱格式不正确"),
|
||||||
|
name: z.string().trim().min(1).default("智店软件官网"),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
notification: z.object({
|
||||||
|
to: z.array(z.string().email("通知邮箱格式不正确")).min(1).default(["bd@zhidiansoft.com"]),
|
||||||
|
subjectPrefix: z.string().trim().min(1).default("官网线索"),
|
||||||
|
}).default({
|
||||||
|
to: ["bd@zhidiansoft.com"],
|
||||||
|
subjectPrefix: "官网线索",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type ContactMailerConfig = z.infer<typeof contactMailerConfigSchema>
|
||||||
|
|
||||||
|
export async function loadContactMailerConfig() {
|
||||||
|
try {
|
||||||
|
const fileContent = await readFile(CONTACT_MAILER_CONFIG_PATH, "utf8")
|
||||||
|
const parsed = parse(fileContent) ?? {}
|
||||||
|
const resolved = resolveEnvPlaceholders(parsed)
|
||||||
|
|
||||||
|
return contactMailerConfigSchema.parse(resolved)
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
throw new ContactMailerConfigError(
|
||||||
|
`contact-mailer.yaml 配置不完整:${error.issues.map((issue) => issue.message).join(";")}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
throw new ContactMailerConfigError(`读取邮件配置失败:${error.message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ContactMailerConfigError("读取邮件配置失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
116
lib/contact-mailer.ts
Normal file
116
lib/contact-mailer.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import nodemailer from "nodemailer"
|
||||||
|
import type { ContactFormData } from "@/lib/contact-form"
|
||||||
|
import type { ContactMailerConfig } from "@/lib/contact-mailer-config"
|
||||||
|
|
||||||
|
type ContactNotificationMeta = {
|
||||||
|
submittedAt: string
|
||||||
|
sourceUrl: string
|
||||||
|
ipAddress: string
|
||||||
|
userAgent: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value: string) {
|
||||||
|
return value
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll("\"", """)
|
||||||
|
.replaceAll("'", "'")
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatOptionalField(value: string) {
|
||||||
|
return value || "未填写"
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendContactNotification({
|
||||||
|
config,
|
||||||
|
payload,
|
||||||
|
meta,
|
||||||
|
}: {
|
||||||
|
config: ContactMailerConfig
|
||||||
|
payload: ContactFormData
|
||||||
|
meta: ContactNotificationMeta
|
||||||
|
}) {
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host: config.smtp.host,
|
||||||
|
port: config.smtp.port,
|
||||||
|
secure: config.smtp.secure,
|
||||||
|
auth: {
|
||||||
|
user: config.smtp.auth.user,
|
||||||
|
pass: config.smtp.auth.pass,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const subject = `${config.notification.subjectPrefix} - ${payload.company} / ${payload.name}`
|
||||||
|
const text = [
|
||||||
|
"官网获取初步方案表单有新提交。",
|
||||||
|
"",
|
||||||
|
`姓名:${payload.name}`,
|
||||||
|
`公司 / 品牌:${payload.company}`,
|
||||||
|
`电话:${payload.phone}`,
|
||||||
|
`门店规模:${formatOptionalField(payload.scale)}`,
|
||||||
|
`需求描述:${formatOptionalField(payload.message)}`,
|
||||||
|
"",
|
||||||
|
`提交时间:${meta.submittedAt}`,
|
||||||
|
`来源页面:${meta.sourceUrl}`,
|
||||||
|
`来源 IP:${meta.ipAddress}`,
|
||||||
|
`User-Agent:${meta.userAgent}`,
|
||||||
|
].join("\n")
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<div style="font-family: Arial, sans-serif; color: #111827; line-height: 1.7;">
|
||||||
|
<h2 style="margin: 0 0 16px;">官网获取初步方案表单有新提交</h2>
|
||||||
|
<table style="border-collapse: collapse; width: 100%; max-width: 720px;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 12px; border: 1px solid #e5e7eb; width: 160px; background: #f9fafb;">姓名</td>
|
||||||
|
<td style="padding: 8px 12px; border: 1px solid #e5e7eb;">${escapeHtml(payload.name)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 12px; border: 1px solid #e5e7eb; background: #f9fafb;">公司 / 品牌</td>
|
||||||
|
<td style="padding: 8px 12px; border: 1px solid #e5e7eb;">${escapeHtml(payload.company)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 12px; border: 1px solid #e5e7eb; background: #f9fafb;">电话</td>
|
||||||
|
<td style="padding: 8px 12px; border: 1px solid #e5e7eb;">${escapeHtml(payload.phone)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 12px; border: 1px solid #e5e7eb; background: #f9fafb;">门店规模</td>
|
||||||
|
<td style="padding: 8px 12px; border: 1px solid #e5e7eb;">${escapeHtml(formatOptionalField(payload.scale))}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 12px; border: 1px solid #e5e7eb; background: #f9fafb;">需求描述</td>
|
||||||
|
<td style="padding: 8px 12px; border: 1px solid #e5e7eb; white-space: pre-wrap;">${escapeHtml(formatOptionalField(payload.message))}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 12px; border: 1px solid #e5e7eb; background: #f9fafb;">提交时间</td>
|
||||||
|
<td style="padding: 8px 12px; border: 1px solid #e5e7eb;">${escapeHtml(meta.submittedAt)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 12px; border: 1px solid #e5e7eb; background: #f9fafb;">来源页面</td>
|
||||||
|
<td style="padding: 8px 12px; border: 1px solid #e5e7eb;">${escapeHtml(meta.sourceUrl)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 12px; border: 1px solid #e5e7eb; background: #f9fafb;">来源 IP</td>
|
||||||
|
<td style="padding: 8px 12px; border: 1px solid #e5e7eb;">${escapeHtml(meta.ipAddress)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 12px; border: 1px solid #e5e7eb; background: #f9fafb;">User-Agent</td>
|
||||||
|
<td style="padding: 8px 12px; border: 1px solid #e5e7eb;">${escapeHtml(meta.userAgent)}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: {
|
||||||
|
name: config.smtp.from.name,
|
||||||
|
address: config.smtp.from.email,
|
||||||
|
},
|
||||||
|
to: config.notification.to,
|
||||||
|
subject,
|
||||||
|
text,
|
||||||
|
html,
|
||||||
|
})
|
||||||
|
}
|
||||||
145
lib/seo.ts
Normal file
145
lib/seo.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import type { Metadata, MetadataRoute } from "next"
|
||||||
|
import siteData from "@/data/site.json"
|
||||||
|
|
||||||
|
type SiteSeoConfig = {
|
||||||
|
siteUrl: string
|
||||||
|
siteName: string
|
||||||
|
defaultTitle: string
|
||||||
|
titleTemplate: string
|
||||||
|
description: string
|
||||||
|
keywords: string[]
|
||||||
|
canonicalPath: string
|
||||||
|
locale?: string
|
||||||
|
robots?: {
|
||||||
|
index?: boolean
|
||||||
|
follow?: boolean
|
||||||
|
}
|
||||||
|
openGraph?: {
|
||||||
|
type?: "website" | "article"
|
||||||
|
image?: string
|
||||||
|
}
|
||||||
|
twitter?: {
|
||||||
|
card?: "summary" | "summary_large_image" | "app" | "player"
|
||||||
|
site?: string
|
||||||
|
creator?: string
|
||||||
|
}
|
||||||
|
verification?: {
|
||||||
|
google?: string
|
||||||
|
yandex?: string
|
||||||
|
other?: Record<string, string>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const seo = siteData.seo as SiteSeoConfig
|
||||||
|
|
||||||
|
function nonEmpty(value?: string) {
|
||||||
|
if (!value) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = value.trim()
|
||||||
|
|
||||||
|
return trimmed ? trimmed : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMetadataBase() {
|
||||||
|
return new URL(seo.siteUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCanonicalPath() {
|
||||||
|
return nonEmpty(seo.canonicalPath) ?? "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVerificationOther() {
|
||||||
|
const otherEntries = Object.entries(seo.verification?.other ?? {}).filter(([, value]) => Boolean(nonEmpty(value)))
|
||||||
|
|
||||||
|
return otherEntries.length > 0 ? Object.fromEntries(otherEntries) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOpenGraphImages() {
|
||||||
|
const image = nonEmpty(seo.openGraph?.image)
|
||||||
|
|
||||||
|
return image ? [{ url: image }] : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSiteMetadata(): Metadata {
|
||||||
|
const index = seo.robots?.index ?? true
|
||||||
|
const follow = seo.robots?.follow ?? true
|
||||||
|
const verificationOther = getVerificationOther()
|
||||||
|
const canonicalPath = getCanonicalPath()
|
||||||
|
const openGraphImages = getOpenGraphImages()
|
||||||
|
|
||||||
|
return {
|
||||||
|
metadataBase: getMetadataBase(),
|
||||||
|
applicationName: seo.siteName,
|
||||||
|
title: {
|
||||||
|
default: seo.defaultTitle,
|
||||||
|
template: seo.titleTemplate,
|
||||||
|
},
|
||||||
|
description: seo.description,
|
||||||
|
keywords: seo.keywords,
|
||||||
|
authors: [{ name: siteData.company.name }],
|
||||||
|
creator: siteData.company.name,
|
||||||
|
publisher: siteData.company.name,
|
||||||
|
alternates: {
|
||||||
|
canonical: canonicalPath,
|
||||||
|
},
|
||||||
|
robots: {
|
||||||
|
index,
|
||||||
|
follow,
|
||||||
|
googleBot: {
|
||||||
|
index,
|
||||||
|
follow,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
verification: {
|
||||||
|
google: nonEmpty(seo.verification?.google),
|
||||||
|
yandex: nonEmpty(seo.verification?.yandex),
|
||||||
|
other: verificationOther,
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
title: seo.defaultTitle,
|
||||||
|
description: seo.description,
|
||||||
|
url: canonicalPath,
|
||||||
|
siteName: seo.siteName,
|
||||||
|
locale: nonEmpty(seo.locale),
|
||||||
|
type: seo.openGraph?.type ?? "website",
|
||||||
|
images: openGraphImages,
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: seo.twitter?.card ?? "summary_large_image",
|
||||||
|
title: seo.defaultTitle,
|
||||||
|
description: seo.description,
|
||||||
|
images: openGraphImages?.map((image) => image.url),
|
||||||
|
site: nonEmpty(seo.twitter?.site),
|
||||||
|
creator: nonEmpty(seo.twitter?.creator),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRobots(): MetadataRoute.Robots {
|
||||||
|
return {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
userAgent: "*",
|
||||||
|
allow: "/",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sitemap: `${seo.siteUrl.replace(/\/$/, "")}/sitemap.xml`,
|
||||||
|
host: seo.siteUrl,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSitemap(): MetadataRoute.Sitemap {
|
||||||
|
const canonicalPath = getCanonicalPath()
|
||||||
|
const normalizedSiteUrl = seo.siteUrl.replace(/\/$/, "")
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
url: `${normalizedSiteUrl}${canonicalPath}`,
|
||||||
|
lastModified: new Date(),
|
||||||
|
changeFrequency: "weekly",
|
||||||
|
priority: 1,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -10,7 +10,6 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^3.9.1",
|
"@hookform/resolvers": "^3.9.1",
|
||||||
"@vercel/analytics": "1.6.1",
|
|
||||||
"@radix-ui/react-accordion": "1.2.12",
|
"@radix-ui/react-accordion": "1.2.12",
|
||||||
"@radix-ui/react-alert-dialog": "1.1.15",
|
"@radix-ui/react-alert-dialog": "1.1.15",
|
||||||
"@radix-ui/react-aspect-ratio": "1.1.8",
|
"@radix-ui/react-aspect-ratio": "1.1.8",
|
||||||
@@ -38,6 +37,7 @@
|
|||||||
"@radix-ui/react-toggle": "1.1.10",
|
"@radix-ui/react-toggle": "1.1.10",
|
||||||
"@radix-ui/react-toggle-group": "1.1.11",
|
"@radix-ui/react-toggle-group": "1.1.11",
|
||||||
"@radix-ui/react-tooltip": "1.2.8",
|
"@radix-ui/react-tooltip": "1.2.8",
|
||||||
|
"@vercel/analytics": "1.6.1",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -48,6 +48,7 @@
|
|||||||
"lucide-react": "^0.564.0",
|
"lucide-react": "^0.564.0",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"nodemailer": "^8.0.2",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-day-picker": "9.13.2",
|
"react-day-picker": "9.13.2",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
@@ -57,11 +58,13 @@
|
|||||||
"sonner": "^1.7.1",
|
"sonner": "^1.7.1",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
|
"yaml": "^2.8.2",
|
||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.2.0",
|
"@tailwindcss/postcss": "^4.2.0",
|
||||||
"@types/node": "^22",
|
"@types/node": "^22",
|
||||||
|
"@types/nodemailer": "^7.0.11",
|
||||||
"@types/react": "19.2.14",
|
"@types/react": "19.2.14",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
"postcss": "^8.5",
|
"postcss": "^8.5",
|
||||||
|
|||||||
29
pnpm-lock.yaml
generated
29
pnpm-lock.yaml
generated
@@ -125,6 +125,9 @@ importers:
|
|||||||
next-themes:
|
next-themes:
|
||||||
specifier: ^0.4.6
|
specifier: ^0.4.6
|
||||||
version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
|
nodemailer:
|
||||||
|
specifier: ^8.0.2
|
||||||
|
version: 8.0.2
|
||||||
react:
|
react:
|
||||||
specifier: 19.2.4
|
specifier: 19.2.4
|
||||||
version: 19.2.4
|
version: 19.2.4
|
||||||
@@ -152,6 +155,9 @@ importers:
|
|||||||
vaul:
|
vaul:
|
||||||
specifier: ^1.1.2
|
specifier: ^1.1.2
|
||||||
version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
|
yaml:
|
||||||
|
specifier: ^2.8.2
|
||||||
|
version: 2.8.2
|
||||||
zod:
|
zod:
|
||||||
specifier: ^3.24.1
|
specifier: ^3.24.1
|
||||||
version: 3.25.76
|
version: 3.25.76
|
||||||
@@ -162,6 +168,9 @@ importers:
|
|||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22
|
specifier: ^22
|
||||||
version: 22.19.11
|
version: 22.19.11
|
||||||
|
'@types/nodemailer':
|
||||||
|
specifier: ^7.0.11
|
||||||
|
version: 7.0.11
|
||||||
'@types/react':
|
'@types/react':
|
||||||
specifier: 19.2.14
|
specifier: 19.2.14
|
||||||
version: 19.2.14
|
version: 19.2.14
|
||||||
@@ -1222,6 +1231,9 @@ packages:
|
|||||||
'@types/node@22.19.11':
|
'@types/node@22.19.11':
|
||||||
resolution: {integrity: sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==}
|
resolution: {integrity: sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==}
|
||||||
|
|
||||||
|
'@types/nodemailer@7.0.11':
|
||||||
|
resolution: {integrity: sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==}
|
||||||
|
|
||||||
'@types/react-dom@19.2.3':
|
'@types/react-dom@19.2.3':
|
||||||
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
|
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1543,6 +1555,10 @@ packages:
|
|||||||
node-releases@2.0.27:
|
node-releases@2.0.27:
|
||||||
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
|
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
|
||||||
|
|
||||||
|
nodemailer@8.0.2:
|
||||||
|
resolution: {integrity: sha512-zbj002pZAIkWQFxyAaqoxvn+zoIwRnS40hgjqTXudKOOJkiFFgBeNqjgD3/YCR12sZnrghWYBY+yP1ZucdDRpw==}
|
||||||
|
engines: {node: '>=6.0.0'}
|
||||||
|
|
||||||
object-assign@4.1.1:
|
object-assign@4.1.1:
|
||||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -1751,6 +1767,11 @@ packages:
|
|||||||
victory-vendor@36.9.2:
|
victory-vendor@36.9.2:
|
||||||
resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==}
|
resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==}
|
||||||
|
|
||||||
|
yaml@2.8.2:
|
||||||
|
resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==}
|
||||||
|
engines: {node: '>= 14.6'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
zod@3.25.76:
|
zod@3.25.76:
|
||||||
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
||||||
|
|
||||||
@@ -2726,6 +2747,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 6.21.0
|
undici-types: 6.21.0
|
||||||
|
|
||||||
|
'@types/nodemailer@7.0.11':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 22.19.11
|
||||||
|
|
||||||
'@types/react-dom@19.2.3(@types/react@19.2.14)':
|
'@types/react-dom@19.2.3(@types/react@19.2.14)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/react': 19.2.14
|
'@types/react': 19.2.14
|
||||||
@@ -2977,6 +3002,8 @@ snapshots:
|
|||||||
|
|
||||||
node-releases@2.0.27: {}
|
node-releases@2.0.27: {}
|
||||||
|
|
||||||
|
nodemailer@8.0.2: {}
|
||||||
|
|
||||||
object-assign@4.1.1: {}
|
object-assign@4.1.1: {}
|
||||||
|
|
||||||
picocolors@1.1.1: {}
|
picocolors@1.1.1: {}
|
||||||
@@ -3205,4 +3232,6 @@ snapshots:
|
|||||||
d3-time: 3.1.0
|
d3-time: 3.1.0
|
||||||
d3-timer: 3.0.1
|
d3-timer: 3.0.1
|
||||||
|
|
||||||
|
yaml@2.8.2: {}
|
||||||
|
|
||||||
zod@3.25.76: {}
|
zod@3.25.76: {}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user