feat: add products/cases pages and lead-capture form block
- types: Product/CaseStudy page types, CaseStudyBlockValue, FormBlockValue - services: product.service.ts, case.service.ts - app/products, app/cases: list + detail pages - blocks: CaseStudyCard, LeadForm (submits to /api/v1/custom/leads/) - Header nav: add 产品/案例 links
This commit is contained in:
@@ -0,0 +1,38 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { getCaseStudyBySlug } from "@/services/case.service";
|
||||||
|
import { BlockRenderer } from "@/blocks/BlockRenderer";
|
||||||
|
|
||||||
|
interface CaseStudyDetailPageProps {
|
||||||
|
params: Promise<{ slug: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: CaseStudyDetailPageProps): Promise<Metadata> {
|
||||||
|
const { slug } = await params;
|
||||||
|
const item = await getCaseStudyBySlug(slug).catch(() => null);
|
||||||
|
return { title: item?.title ?? "案例未找到" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function CaseStudyDetailPage({
|
||||||
|
params,
|
||||||
|
}: CaseStudyDetailPageProps) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const item = await getCaseStudyBySlug(slug).catch(() => null);
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="mx-auto max-w-3xl px-6 py-24">
|
||||||
|
<h1 className="mb-2 text-4xl font-bold text-slate-900">{item.title}</h1>
|
||||||
|
<p className="mb-12 text-sm text-slate-500">
|
||||||
|
{item.client_name}
|
||||||
|
{item.industry ? ` · ${item.industry}` : ""}
|
||||||
|
</p>
|
||||||
|
<BlockRenderer blocks={item.body} />
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { getCaseStudyList } from "@/services/case.service";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "客户案例",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function CaseStudyListPage() {
|
||||||
|
const caseStudies = await getCaseStudyList().catch(() => []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-4xl px-6 py-24">
|
||||||
|
<h1 className="mb-12 text-4xl font-bold text-slate-900">客户案例</h1>
|
||||||
|
<ul className="space-y-6">
|
||||||
|
{caseStudies.map((item) => (
|
||||||
|
<li key={item.id} className="border-b border-slate-100 pb-6">
|
||||||
|
<Link
|
||||||
|
href={`/cases/${item.meta.slug}`}
|
||||||
|
className="text-2xl font-semibold text-slate-900 hover:text-primary"
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</Link>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">
|
||||||
|
{item.client_name}
|
||||||
|
{item.industry ? ` · ${item.industry}` : ""}
|
||||||
|
</p>
|
||||||
|
{item.summary && (
|
||||||
|
<p className="mt-2 text-slate-600">{item.summary}</p>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{caseStudies.length === 0 && (
|
||||||
|
<p className="text-slate-500">暂无案例,或后端服务未连接。</p>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { getProductBySlug } from "@/services/product.service";
|
||||||
|
import { BlockRenderer } from "@/blocks/BlockRenderer";
|
||||||
|
|
||||||
|
interface ProductDetailPageProps {
|
||||||
|
params: Promise<{ slug: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: ProductDetailPageProps): Promise<Metadata> {
|
||||||
|
const { slug } = await params;
|
||||||
|
const product = await getProductBySlug(slug).catch(() => null);
|
||||||
|
return { title: product?.title ?? "产品未找到" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ProductDetailPage({
|
||||||
|
params,
|
||||||
|
}: ProductDetailPageProps) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const product = await getProductBySlug(slug).catch(() => null);
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="mx-auto max-w-3xl px-6 py-24">
|
||||||
|
<h1 className="mb-4 text-4xl font-bold text-slate-900">
|
||||||
|
{product.title}
|
||||||
|
</h1>
|
||||||
|
{product.summary && (
|
||||||
|
<p className="mb-12 text-lg text-slate-600">{product.summary}</p>
|
||||||
|
)}
|
||||||
|
<BlockRenderer blocks={product.body} />
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { getProductList } from "@/services/product.service";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "产品",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function ProductListPage() {
|
||||||
|
const products = await getProductList().catch(() => []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-4xl px-6 py-24">
|
||||||
|
<h1 className="mb-12 text-4xl font-bold text-slate-900">产品</h1>
|
||||||
|
<ul className="space-y-6">
|
||||||
|
{products.map((product) => (
|
||||||
|
<li key={product.id} className="border-b border-slate-100 pb-6">
|
||||||
|
<Link
|
||||||
|
href={`/products/${product.meta.slug}`}
|
||||||
|
className="text-2xl font-semibold text-slate-900 hover:text-primary"
|
||||||
|
>
|
||||||
|
{product.title}
|
||||||
|
</Link>
|
||||||
|
{product.summary && (
|
||||||
|
<p className="mt-2 text-slate-600">{product.summary}</p>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{products.length === 0 && (
|
||||||
|
<p className="text-slate-500">暂无产品,或后端服务未连接。</p>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,10 +5,12 @@ import FeatureGrid from "./feature-grid/FeatureGrid";
|
|||||||
import FAQ from "./faq/FAQ";
|
import FAQ from "./faq/FAQ";
|
||||||
import CTA from "./cta/CTA";
|
import CTA from "./cta/CTA";
|
||||||
import LogoCloud from "./logo-cloud/LogoCloud";
|
import LogoCloud from "./logo-cloud/LogoCloud";
|
||||||
|
import CaseStudyCard from "./case-study/CaseStudyCard";
|
||||||
|
import LeadForm from "./form/LeadForm";
|
||||||
import type { StreamFieldBlock } from "@/types/wagtail";
|
import type { StreamFieldBlock } from "@/types/wagtail";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* StreamField type → 组件映射表。
|
* StreamField 类型 → 组件映射表。
|
||||||
* 必须与后端 apps/core/blocks.py 的 COMMON_BLOCKS 保持一致,
|
* 必须与后端 apps/core/blocks.py 的 COMMON_BLOCKS 保持一致,
|
||||||
* 否则新增/重命名 Block 时前后端会出现字段不对齐问题
|
* 否则新增/重命名 Block 时前后端会出现字段不对齐问题
|
||||||
* (详见 documents/设计方案分析与完善版.md §2.6)。
|
* (详见 documents/设计方案分析与完善版.md §2.6)。
|
||||||
@@ -21,6 +23,8 @@ const components: Record<string, ComponentType<{ value: any }>> = {
|
|||||||
faq: FAQ,
|
faq: FAQ,
|
||||||
cta: CTA,
|
cta: CTA,
|
||||||
logo_cloud: LogoCloud,
|
logo_cloud: LogoCloud,
|
||||||
|
case_study: CaseStudyCard,
|
||||||
|
form: LeadForm,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function BlockRenderer({ blocks }: { blocks: StreamFieldBlock[] }) {
|
export function BlockRenderer({ blocks }: { blocks: StreamFieldBlock[] }) {
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import type { CaseStudyBlockValue } from "@/types/wagtail";
|
||||||
|
|
||||||
|
export default function CaseStudyCard({ value }: { value: CaseStudyBlockValue }) {
|
||||||
|
if (!value.case_page) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-3xl px-6 py-8">
|
||||||
|
<Link
|
||||||
|
href={value.case_page.meta.html_url ?? "#"}
|
||||||
|
className="block rounded-3xl border border-slate-100 p-8 shadow-sm transition hover:shadow-md"
|
||||||
|
>
|
||||||
|
<h3 className="text-xl font-semibold text-slate-900">
|
||||||
|
{value.case_page.title}
|
||||||
|
</h3>
|
||||||
|
{value.summary && (
|
||||||
|
<p className="mt-2 text-slate-600">{value.summary}</p>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import type { FormBlockValue } from "@/types/wagtail";
|
||||||
|
|
||||||
|
const API_BASE_URL =
|
||||||
|
process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000";
|
||||||
|
|
||||||
|
export default function LeadForm({ value }: { value: FormBlockValue }) {
|
||||||
|
const [values, setValues] = useState<Record<string, string>>({});
|
||||||
|
const [status, setStatus] = useState<"idle" | "submitting" | "done" | "error">(
|
||||||
|
"idle",
|
||||||
|
);
|
||||||
|
|
||||||
|
const form = value.form;
|
||||||
|
if (!form) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setStatus("submitting");
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/v1/custom/leads/`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
form_id: form!.id,
|
||||||
|
data: values,
|
||||||
|
source_url: typeof window !== "undefined" ? window.location.href : "",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("submit failed");
|
||||||
|
setStatus("done");
|
||||||
|
} catch {
|
||||||
|
setStatus("error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "done") {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-xl px-6 py-12 text-center text-slate-700">
|
||||||
|
{form.success_message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="mx-auto max-w-xl space-y-4 px-6 py-12"
|
||||||
|
>
|
||||||
|
<h3 className="text-2xl font-semibold text-slate-900">{form.name}</h3>
|
||||||
|
{form.fields.map((field) => (
|
||||||
|
<div key={field.field_key}>
|
||||||
|
<label className="mb-1 block text-sm font-medium text-slate-700">
|
||||||
|
{field.label}
|
||||||
|
{field.required && <span className="text-red-500">*</span>}
|
||||||
|
</label>
|
||||||
|
{field.field_type === "textarea" ? (
|
||||||
|
<textarea
|
||||||
|
required={field.required}
|
||||||
|
className="w-full rounded-lg border border-slate-200 px-4 py-2"
|
||||||
|
rows={4}
|
||||||
|
onChange={(e) =>
|
||||||
|
setValues((v) => ({ ...v, [field.field_key]: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : field.field_type === "select" ? (
|
||||||
|
<select
|
||||||
|
required={field.required}
|
||||||
|
className="w-full rounded-lg border border-slate-200 px-4 py-2"
|
||||||
|
onChange={(e) =>
|
||||||
|
setValues((v) => ({ ...v, [field.field_key]: e.target.value }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">请选择</option>
|
||||||
|
{field.choices.map((choice) => (
|
||||||
|
<option key={choice} value={choice}>
|
||||||
|
{choice}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type={field.field_type}
|
||||||
|
required={field.required}
|
||||||
|
className="w-full rounded-lg border border-slate-200 px-4 py-2"
|
||||||
|
onChange={(e) =>
|
||||||
|
setValues((v) => ({ ...v, [field.field_key]: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={status === "submitting"}
|
||||||
|
className="w-full rounded-full bg-primary px-6 py-3 font-medium text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{status === "submitting" ? "提交中..." : form.submit_button_text}
|
||||||
|
</button>
|
||||||
|
{status === "error" && (
|
||||||
|
<p className="text-sm text-red-500">提交失败,请稍后重试。</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import Link from "next/link";
|
|||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ title: "首页", href: "/" },
|
{ title: "首页", href: "/" },
|
||||||
|
{ title: "产品", href: "/products" },
|
||||||
|
{ title: "案例", href: "/cases" },
|
||||||
{ title: "博客", href: "/blog" },
|
{ title: "博客", href: "/blog" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { fetchAPI } from "./api-client";
|
||||||
|
import type {
|
||||||
|
CaseStudyPageDetail,
|
||||||
|
CaseStudyPageSummary,
|
||||||
|
WagtailListResponse,
|
||||||
|
} from "@/types/wagtail";
|
||||||
|
|
||||||
|
/** 获取客户案例列表(按发布时间倒序)。 */
|
||||||
|
export async function getCaseStudyList(): Promise<CaseStudyPageSummary[]> {
|
||||||
|
const res = await fetchAPI<WagtailListResponse<CaseStudyPageSummary>>(
|
||||||
|
"/api/v2/pages/?type=cases.CaseStudyPage&fields=client_name,industry,published_at,summary&order=-published_at",
|
||||||
|
{ tags: ["case-study-list"] },
|
||||||
|
);
|
||||||
|
return res.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据 slug 获取客户案例详情。 */
|
||||||
|
export async function getCaseStudyBySlug(
|
||||||
|
slug: string,
|
||||||
|
): Promise<CaseStudyPageDetail | null> {
|
||||||
|
const res = await fetchAPI<WagtailListResponse<CaseStudyPageDetail>>(
|
||||||
|
`/api/v2/pages/?type=cases.CaseStudyPage&slug=${encodeURIComponent(slug)}&fields=body,client_name,industry,published_at,summary&limit=1`,
|
||||||
|
{ tags: [`case-study:${slug}`] },
|
||||||
|
);
|
||||||
|
return res.items[0] ?? null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { fetchAPI } from "./api-client";
|
||||||
|
import type {
|
||||||
|
ProductPageDetail,
|
||||||
|
ProductPageSummary,
|
||||||
|
WagtailListResponse,
|
||||||
|
} from "@/types/wagtail";
|
||||||
|
|
||||||
|
/** 获取产品列表。 */
|
||||||
|
export async function getProductList(): Promise<ProductPageSummary[]> {
|
||||||
|
const res = await fetchAPI<WagtailListResponse<ProductPageSummary>>(
|
||||||
|
"/api/v2/pages/?type=products.ProductPage&fields=summary",
|
||||||
|
{ tags: ["product-list"] },
|
||||||
|
);
|
||||||
|
return res.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据 slug 获取产品详情。 */
|
||||||
|
export async function getProductBySlug(
|
||||||
|
slug: string,
|
||||||
|
): Promise<ProductPageDetail | null> {
|
||||||
|
const res = await fetchAPI<WagtailListResponse<ProductPageDetail>>(
|
||||||
|
`/api/v2/pages/?type=products.ProductPage&slug=${encodeURIComponent(slug)}&fields=body,summary&limit=1`,
|
||||||
|
{ tags: [`product:${slug}`] },
|
||||||
|
);
|
||||||
|
return res.items[0] ?? null;
|
||||||
|
}
|
||||||
@@ -75,6 +75,34 @@ export interface LogoCloudBlockValue {
|
|||||||
logos: { url: string; title: string }[];
|
logos: { url: string; title: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CaseStudyBlockValue {
|
||||||
|
case_page: WagtailPageSummary | null;
|
||||||
|
summary?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 与后端 apps/forms/models.py FIELD_TYPE_CHOICES 保持一致 */
|
||||||
|
export type FormFieldType = "text" | "textarea" | "email" | "tel" | "select";
|
||||||
|
|
||||||
|
export interface FormFieldDef {
|
||||||
|
label: string;
|
||||||
|
field_key: string;
|
||||||
|
field_type: FormFieldType;
|
||||||
|
required: boolean;
|
||||||
|
choices: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormDefinitionValue {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
submit_button_text: string;
|
||||||
|
success_message: string;
|
||||||
|
fields: FormFieldDef[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormBlockValue {
|
||||||
|
form: FormDefinitionValue | null;
|
||||||
|
}
|
||||||
|
|
||||||
export type StreamFieldBlock =
|
export type StreamFieldBlock =
|
||||||
| { id: string; type: "hero"; value: HeroBlockValue }
|
| { id: string; type: "hero"; value: HeroBlockValue }
|
||||||
| { id: string; type: "stats"; value: StatsBlockValue }
|
| { id: string; type: "stats"; value: StatsBlockValue }
|
||||||
@@ -82,6 +110,8 @@ export type StreamFieldBlock =
|
|||||||
| { id: string; type: "faq"; value: FAQBlockValue }
|
| { id: string; type: "faq"; value: FAQBlockValue }
|
||||||
| { id: string; type: "cta"; value: CTABlockValue }
|
| { id: string; type: "cta"; value: CTABlockValue }
|
||||||
| { id: string; type: "logo_cloud"; value: LogoCloudBlockValue }
|
| { id: string; type: "logo_cloud"; value: LogoCloudBlockValue }
|
||||||
|
| { id: string; type: "case_study"; value: CaseStudyBlockValue }
|
||||||
|
| { id: string; type: "form"; value: FormBlockValue }
|
||||||
| { id: string; type: "richtext"; value: string };
|
| { id: string; type: "richtext"; value: string };
|
||||||
|
|
||||||
export interface HomePageDetail extends WagtailPageSummary {
|
export interface HomePageDetail extends WagtailPageSummary {
|
||||||
@@ -98,3 +128,22 @@ export interface BlogPageSummary extends WagtailPageSummary {
|
|||||||
export interface BlogPageDetail extends BlogPageSummary {
|
export interface BlogPageDetail extends BlogPageSummary {
|
||||||
body: StreamFieldBlock[];
|
body: StreamFieldBlock[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProductPageSummary extends WagtailPageSummary {
|
||||||
|
summary?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductPageDetail extends ProductPageSummary {
|
||||||
|
body: StreamFieldBlock[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CaseStudyPageSummary extends WagtailPageSummary {
|
||||||
|
client_name: string;
|
||||||
|
industry?: string;
|
||||||
|
published_at?: string;
|
||||||
|
summary?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CaseStudyPageDetail extends CaseStudyPageSummary {
|
||||||
|
body: StreamFieldBlock[];
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user