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.*
|
||||
|
||||
# Environment variables
|
||||
.env*.local
|
||||
# .env*.local
|
||||
|
||||
# Common ignores
|
||||
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 { Inter } from 'next/font/google'
|
||||
import { Analytics } from '@vercel/analytics/next'
|
||||
import { buildSiteMetadata } from '@/lib/seo'
|
||||
import './globals.css'
|
||||
|
||||
const inter = Inter({
|
||||
@@ -9,17 +10,7 @@ const inter = Inter({
|
||||
display: 'swap',
|
||||
})
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: '连锁门店收银、分账、监管系统 | 智店软件',
|
||||
description: '面向连锁餐饮、零售与加盟品牌,提供门店收银、自动分账、总部监管一体化系统定制与升级服务。兼容现有设备与流程,支持不停业切换。',
|
||||
keywords: '连锁门店系统, 收银系统, 自动分账系统, 总部监管系统, 加盟门店结算, 门店数字化, 智店软件',
|
||||
authors: [{ name: '智店软件' }],
|
||||
openGraph: {
|
||||
title: '连锁门店收银、分账、监管系统 | 智店软件',
|
||||
description: '为连锁餐饮、零售与加盟品牌提供门店收银、自动分账、总部监管一体化系统定制与升级服务。',
|
||||
type: 'website',
|
||||
},
|
||||
}
|
||||
export const metadata: Metadata = buildSiteMetadata()
|
||||
|
||||
export default function RootLayout({
|
||||
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"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useState, type FormEvent, type ReactNode } from "react"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
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 homeData from "@/data/home.json"
|
||||
|
||||
const iconMap: Record<string, React.ReactNode> = {
|
||||
const iconMap: Record<string, ReactNode> = {
|
||||
phone: <Phone className="w-5 h-5" />,
|
||||
mail: <Mail 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 }>
|
||||
const submitLabel = (contact.form as any).submitLabel ?? (contact.form as any).submit
|
||||
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()
|
||||
setSubmitted(true)
|
||||
|
||||
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)
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : "提交失败,请稍后重试。")
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -91,38 +127,46 @@ export function ContactSection() {
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-foreground mb-2">信息已提交</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
工作时间内 2 小时响应,非工作时间次一工作日上午回复。
|
||||
管理人员已收到邮件通知,工作时间内 2 小时响应,非工作时间次一工作日上午回复。
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => setSubmitted(false)} className="mt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSubmitted(false)
|
||||
setErrorMessage(null)
|
||||
}}
|
||||
className="mt-2"
|
||||
>
|
||||
重新提交
|
||||
</Button>
|
||||
</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>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<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 className="flex flex-col gap-1.5">
|
||||
<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 className="flex flex-col gap-1.5">
|
||||
<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 className="flex flex-col gap-1.5">
|
||||
<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 className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground" htmlFor="message">需求描述</label>
|
||||
<Textarea
|
||||
id="message"
|
||||
name="message"
|
||||
placeholder={contact.form.messagePlaceholder}
|
||||
rows={4}
|
||||
className="resize-none"
|
||||
@@ -131,13 +175,19 @@ export function ContactSection() {
|
||||
<Button
|
||||
type="submit"
|
||||
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"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{submitLabel}
|
||||
{isSubmitting ? "提交中..." : submitLabel}
|
||||
<ArrowRight className="w-4 h-4 group-hover:translate-x-0.5 transition-transform" />
|
||||
</span>
|
||||
</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">
|
||||
提交即视为同意{" "}
|
||||
<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": "先聊清楚,再决定要不要做",
|
||||
"subtitle": "告诉我们品牌规模、现用系统和核心问题,我们会给您一版可落地的改造建议。",
|
||||
"infoItems": [
|
||||
{ "icon": "Phone", "label": "销售热线", "value": "400-888-6688" },
|
||||
{ "icon": "Phone", "label": "销售热线", "value": "400-888-8888" },
|
||||
{ "icon": "Mail", "label": "商务邮箱", "value": "bd@zhidiansoft.com" },
|
||||
{ "icon": "MapPin", "label": "总部地址", "value": "上海市浦东新区张江高科技园区博云路2号" },
|
||||
{ "icon": "Clock", "label": "服务时间", "value": "周一至周六 9:00 - 18:00" }
|
||||
|
||||
@@ -7,10 +7,46 @@
|
||||
"founded": "2018",
|
||||
"employees": "500+",
|
||||
"address": "上海市浦东新区张江高科技园区博云路2号",
|
||||
"phone": "400-888-6688",
|
||||
"phone": "400-888-8888",
|
||||
"email": "bd@zhidiansoft.com",
|
||||
"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": {
|
||||
"links": [
|
||||
{ "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": {
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@vercel/analytics": "1.6.1",
|
||||
"@radix-ui/react-accordion": "1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "1.1.15",
|
||||
"@radix-ui/react-aspect-ratio": "1.1.8",
|
||||
@@ -38,6 +37,7 @@
|
||||
"@radix-ui/react-toggle": "1.1.10",
|
||||
"@radix-ui/react-toggle-group": "1.1.11",
|
||||
"@radix-ui/react-tooltip": "1.2.8",
|
||||
"@vercel/analytics": "1.6.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -48,6 +48,7 @@
|
||||
"lucide-react": "^0.564.0",
|
||||
"next": "16.1.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"nodemailer": "^8.0.2",
|
||||
"react": "19.2.4",
|
||||
"react-day-picker": "9.13.2",
|
||||
"react-dom": "19.2.4",
|
||||
@@ -57,11 +58,13 @@
|
||||
"sonner": "^1.7.1",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"vaul": "^1.1.2",
|
||||
"yaml": "^2.8.2",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.2.0",
|
||||
"@types/node": "^22",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"postcss": "^8.5",
|
||||
|
||||
29
pnpm-lock.yaml
generated
29
pnpm-lock.yaml
generated
@@ -125,6 +125,9 @@ importers:
|
||||
next-themes:
|
||||
specifier: ^0.4.6
|
||||
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:
|
||||
specifier: 19.2.4
|
||||
version: 19.2.4
|
||||
@@ -152,6 +155,9 @@ importers:
|
||||
vaul:
|
||||
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)
|
||||
yaml:
|
||||
specifier: ^2.8.2
|
||||
version: 2.8.2
|
||||
zod:
|
||||
specifier: ^3.24.1
|
||||
version: 3.25.76
|
||||
@@ -162,6 +168,9 @@ importers:
|
||||
'@types/node':
|
||||
specifier: ^22
|
||||
version: 22.19.11
|
||||
'@types/nodemailer':
|
||||
specifier: ^7.0.11
|
||||
version: 7.0.11
|
||||
'@types/react':
|
||||
specifier: 19.2.14
|
||||
version: 19.2.14
|
||||
@@ -1222,6 +1231,9 @@ packages:
|
||||
'@types/node@22.19.11':
|
||||
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':
|
||||
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
|
||||
peerDependencies:
|
||||
@@ -1543,6 +1555,10 @@ packages:
|
||||
node-releases@2.0.27:
|
||||
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:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -1751,6 +1767,11 @@ packages:
|
||||
victory-vendor@36.9.2:
|
||||
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:
|
||||
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
||||
|
||||
@@ -2726,6 +2747,10 @@ snapshots:
|
||||
dependencies:
|
||||
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)':
|
||||
dependencies:
|
||||
'@types/react': 19.2.14
|
||||
@@ -2977,6 +3002,8 @@ snapshots:
|
||||
|
||||
node-releases@2.0.27: {}
|
||||
|
||||
nodemailer@8.0.2: {}
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
@@ -3205,4 +3232,6 @@ snapshots:
|
||||
d3-time: 3.1.0
|
||||
d3-timer: 3.0.1
|
||||
|
||||
yaml@2.8.2: {}
|
||||
|
||||
zod@3.25.76: {}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user