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:
@@ -5,10 +5,12 @@ import FeatureGrid from "./feature-grid/FeatureGrid";
|
||||
import FAQ from "./faq/FAQ";
|
||||
import CTA from "./cta/CTA";
|
||||
import LogoCloud from "./logo-cloud/LogoCloud";
|
||||
import CaseStudyCard from "./case-study/CaseStudyCard";
|
||||
import LeadForm from "./form/LeadForm";
|
||||
import type { StreamFieldBlock } from "@/types/wagtail";
|
||||
|
||||
/**
|
||||
* StreamField type → 组件映射表。
|
||||
* StreamField 类型 → 组件映射表。
|
||||
* 必须与后端 apps/core/blocks.py 的 COMMON_BLOCKS 保持一致,
|
||||
* 否则新增/重命名 Block 时前后端会出现字段不对齐问题
|
||||
* (详见 documents/设计方案分析与完善版.md §2.6)。
|
||||
@@ -21,6 +23,8 @@ const components: Record<string, ComponentType<{ value: any }>> = {
|
||||
faq: FAQ,
|
||||
cta: CTA,
|
||||
logo_cloud: LogoCloud,
|
||||
case_study: CaseStudyCard,
|
||||
form: LeadForm,
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user