添加埋点管理台

This commit is contained in:
2026-04-15 11:07:19 +08:00
parent c7194825e5
commit 20ae332706
18 changed files with 2013 additions and 87 deletions
+204
View File
@@ -0,0 +1,204 @@
import Link from "next/link";
import { ArrowUpRight, Clock3, Smartphone, Sparkles } from "lucide-react";
import { DashboardCharts } from "@/components/admin/dashboard-charts";
import { getDashboardData, parseDashboardQuery } from "@/lib/analytics/server";
import { formatDateTime, formatNumber } from "@/lib/utils";
export const dynamic = "force-dynamic";
const ranges = [
{ label: "24 小时", value: "1" },
{ label: "7 天", value: "7" },
{ label: "30 天", value: "30" },
{ label: "90 天", value: "90" },
] as const;
type AdminPageProps = {
searchParams: Promise<{
range?: string;
}>;
};
export default async function AdminPage({ searchParams }: AdminPageProps) {
const rawParams = await searchParams;
const query = parseDashboardQuery(rawParams);
const data = await getDashboardData(query);
return (
<main className="relative overflow-hidden px-6 py-8 sm:px-8 lg:px-10">
<div className="hero-orb left-[-120px] top-[10%]" />
<div className="hero-orb right-[-120px] top-[-40px] bg-[radial-gradient(circle,_rgba(29,111,104,0.16)_0%,_rgba(29,111,104,0)_72%)]" />
<div className="mx-auto flex max-w-7xl flex-col gap-6">
<section className="soft-card p-7 sm:p-8">
<div className="flex flex-col gap-6 xl:flex-row xl:items-end xl:justify-between">
<div className="space-y-4">
<div className="inline-flex items-center gap-2 rounded-full bg-[var(--surface-2)] px-4 py-2 text-sm text-[var(--ink-2)]">
<Sparkles className="h-4 w-4 text-[var(--accent-1)]" />
Android 陪伴数据台
</div>
<div>
<p className="eyebrow">使用总览</p>
<h1 className="font-[var(--font-display)] text-4xl leading-tight text-[var(--ink-1)] sm:text-5xl">
最近谁在使用,哪里需要留意,一眼看清。
</h1>
</div>
<p className="max-w-3xl text-base leading-8 text-[var(--ink-2)]">
这里持续汇总老年陪伴助手在 Android 应用中的使用情况,方便你查看哪些功能最常被打开、哪些设备最近还在使用,以及哪里出现了中断或失败。
</p>
</div>
<div className="flex flex-col gap-3 xl:items-end">
<Link
href="/"
className="inline-flex items-center gap-2 rounded-full border border-[var(--line-soft)] bg-white/75 px-5 py-3 text-sm font-semibold text-[var(--ink-1)]"
>
返回首页
<ArrowUpRight className="h-4 w-4" />
</Link>
<div className="flex flex-wrap gap-2 text-sm text-[var(--ink-2)]">
<span className="pill-tag">Android 应用</span>
<span className="pill-tag">数据自动更新</span>
</div>
</div>
</div>
<div className="mt-8 flex flex-col gap-5">
<div className="space-y-2">
<p className="text-sm font-medium text-[var(--ink-3)]">时间范围</p>
<div className="flex flex-wrap gap-2">
{ranges.map((item) => (
<Link
key={item.value}
href={`?range=${item.value}`}
className={query.days === Number(item.value) ? "filter-pill filter-pill-active" : "filter-pill"}
>
{item.label}
</Link>
))}
</div>
</div>
</div>
</section>
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{data.stats.map((stat) => (
<article key={stat.label} className="stat-card">
<p className="stat-label">{stat.label}</p>
<strong className="stat-value">{formatNumber(stat.value)}</strong>
<p className="stat-hint">{stat.hint}</p>
</article>
))}
</section>
<DashboardCharts activityTrend={data.activityTrend} topEvents={data.topEvents} />
<section className="grid gap-6 xl:grid-cols-[1.05fr_0.95fr]">
<article className="soft-card p-6">
<div className="mb-5 flex items-center justify-between gap-4">
<div>
<p className="eyebrow">常用功能</p>
<h2 className="text-2xl font-semibold text-[var(--ink-1)]">最近更常打开哪里</h2>
</div>
<div className="pill-tag">
<Clock3 className="h-4 w-4" />
近 {query.days} 天
</div>
</div>
<div className="space-y-3">
{data.topPaths.map((item, index) => (
<div key={item.label} className="list-row">
<div className="flex items-center gap-4">
<span className="index-badge">{index + 1}</span>
<div>
<p className="text-sm text-[var(--ink-3)]">功能页面</p>
<p className="text-base font-semibold text-[var(--ink-1)]">{item.label}</p>
</div>
</div>
<strong className="text-lg font-semibold text-[var(--ink-1)]">{formatNumber(item.total)}</strong>
</div>
))}
</div>
</article>
<article className="soft-card p-6">
<div className="mb-5 flex items-center justify-between gap-4">
<div>
<p className="eyebrow">最近活跃设备</p>
<h2 className="text-2xl font-semibold text-[var(--ink-1)]">刚刚还在使用的是谁</h2>
</div>
</div>
<div className="space-y-3">
{data.recentSessions.map((session) => (
<div key={session.sessionKey} className="rounded-[24px] border border-[var(--line-soft)] bg-white/80 px-4 py-4">
<div className="mb-2 flex items-center justify-between gap-3">
<div className="flex items-center gap-2 text-sm text-[var(--ink-2)]">
<Smartphone className="h-4 w-4 text-[var(--accent-2)]" />
Android 设备
</div>
<span className="text-xs text-[var(--ink-3)]">{formatDateTime(session.lastSeenAt)}</span>
</div>
<div className="space-y-1">
<p className="text-base font-semibold text-[var(--ink-1)]">{session.deviceLabel}</p>
<p className="text-sm text-[var(--ink-2)]">设备会话 {session.sessionKey.slice(0, 18)}...</p>
<p className="text-sm text-[var(--ink-3)]">
{formatNumber(session.eventCount)} 条事件
{session.appVersion ? ` · 版本 ${session.appVersion}` : ""}
{session.locale ? ` · ${session.locale}` : ""}
</p>
</div>
</div>
))}
</div>
</article>
</section>
<section className="soft-card p-6">
<div className="mb-5 flex items-center justify-between gap-4">
<div>
<p className="eyebrow">最近记录</p>
<h2 className="text-2xl font-semibold text-[var(--ink-1)]">刚刚发生了什么</h2>
</div>
</div>
<div className="overflow-x-auto">
<table className="min-w-full border-separate border-spacing-y-3">
<thead>
<tr className="text-left text-sm text-[var(--ink-3)]">
<th className="px-4">时间</th>
<th className="px-4">终端</th>
<th className="px-4">动作</th>
<th className="px-4">页面 / 功能</th>
<th className="px-4">补充说明</th>
</tr>
</thead>
<tbody>
{data.recentEvents.map((event) => (
<tr key={event.id} className="rounded-[24px] bg-white/80 text-sm text-[var(--ink-2)] shadow-[var(--shadow-card)]">
<td className="rounded-l-[20px] px-4 py-4">{formatDateTime(event.occurredAt)}</td>
<td className="px-4 py-4">Android</td>
<td className="px-4 py-4">
<span className="inline-flex rounded-full bg-[var(--surface-2)] px-3 py-1 text-[var(--ink-1)]">
{event.name}
</span>
<p className="mt-2 text-xs tracking-[0.18em] text-[var(--ink-3)]">{event.type}</p>
</td>
<td className="px-4 py-4">
<p className="font-medium text-[var(--ink-1)]">{event.pathname ?? "未标记功能"}</p>
<p className="mt-1 text-xs text-[var(--ink-3)]">{event.component ?? "无额外标签"}</p>
</td>
<td className="rounded-r-[20px] px-4 py-4">
<p>{event.elementText ?? "无额外说明"}</p>
<p className="mt-1 text-xs text-[var(--ink-3)]">设备会话 {event.sessionKey.slice(0, 18)}...</p>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</div>
</main>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { ZodError } from "zod";
import { ingestAnalyticsBatch } from "@/lib/analytics/server";
import { TRACK_API_PATH } from "@/lib/site";
export const dynamic = "force-dynamic";
const responseHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "content-type, authorization",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Cache-Control": "no-store",
};
export async function GET() {
return Response.json(
{
ok: true,
endpoint: TRACK_API_PATH,
message: "Analytics ingestion endpoint is ready.",
},
{ headers: responseHeaders },
);
}
export async function OPTIONS() {
return new Response(null, {
status: 204,
headers: responseHeaders,
});
}
export async function POST(request: Request) {
try {
const payload = await request.json();
const result = await ingestAnalyticsBatch(payload, request.headers);
return Response.json(
{
ok: true,
accepted: result.accepted,
sessionKey: result.sessionKey,
},
{ headers: responseHeaders },
);
} catch (error) {
if (error instanceof ZodError) {
return Response.json(
{
ok: false,
error: "INVALID_ANALYTICS_PAYLOAD",
issues: error.issues,
},
{ status: 400, headers: responseHeaders },
);
}
return Response.json(
{
ok: false,
error: error instanceof Error ? error.message : "UNKNOWN_ERROR",
},
{ status: 500, headers: responseHeaders },
);
}
}
+153 -14
View File
@@ -1,26 +1,165 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
--surface-0: #fff7f0;
--surface-1: rgba(255, 251, 246, 0.84);
--surface-2: #f8e6d7;
--surface-3: #f2dcc7;
--ink-1: #241915;
--ink-2: #5f4a3d;
--ink-3: #9a806d;
--accent-1: #b6522d;
--accent-2: #1d6f68;
--accent-3: #e39657;
--line-soft: rgba(86, 61, 39, 0.12);
--shadow-card: 0 20px 50px rgba(81, 51, 30, 0.12);
--shadow-soft: 0 28px 60px rgba(95, 55, 24, 0.08);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-background: var(--surface-0);
--color-foreground: var(--ink-1);
--font-sans: var(--font-body);
--font-mono: var(--font-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
* {
box-sizing: border-box;
}
html {
min-height: 100%;
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
min-height: 100vh;
background:
radial-gradient(circle at top left, rgba(251, 219, 190, 0.75) 0%, rgba(251, 219, 190, 0) 28%),
radial-gradient(circle at bottom right, rgba(194, 230, 221, 0.65) 0%, rgba(194, 230, 221, 0) 30%),
linear-gradient(180deg, #fffaf5 0%, #fff7f0 32%, #f8efe5 100%);
color: var(--ink-1);
font-family: var(--font-body), "Noto Sans SC", sans-serif;
}
a {
color: inherit;
text-decoration: none;
}
::selection {
background: rgba(182, 82, 45, 0.18);
}
.soft-card {
border: 1px solid var(--line-soft);
border-radius: 32px;
background: var(--surface-1);
box-shadow: var(--shadow-soft);
backdrop-filter: blur(18px);
}
.hero-orb {
position: absolute;
pointer-events: none;
height: 320px;
width: 320px;
border-radius: 999px;
background: radial-gradient(circle, rgba(230, 150, 87, 0.18) 0%, rgba(230, 150, 87, 0) 72%);
}
.eyebrow {
margin-bottom: 0.6rem;
color: var(--accent-1);
font-size: 0.82rem;
font-weight: 700;
letter-spacing: 0.24em;
text-transform: uppercase;
}
.pill-tag {
display: inline-flex;
align-items: center;
gap: 0.45rem;
border-radius: 999px;
border: 1px solid var(--line-soft);
background: rgba(255, 255, 255, 0.75);
padding: 0.55rem 0.9rem;
}
.stat-card {
border: 1px solid var(--line-soft);
border-radius: 28px;
background: rgba(255, 255, 255, 0.82);
padding: 1.35rem;
box-shadow: var(--shadow-card);
}
.stat-label {
font-size: 0.86rem;
color: var(--ink-3);
}
.stat-value {
display: block;
margin-top: 0.7rem;
font-family: var(--font-display), serif;
font-size: clamp(2rem, 4vw, 3rem);
line-height: 1;
color: var(--ink-1);
}
.stat-hint {
margin-top: 0.85rem;
font-size: 0.95rem;
line-height: 1.65;
color: var(--ink-2);
}
.filter-pill {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
border: 1px solid var(--line-soft);
background: rgba(255, 255, 255, 0.72);
padding: 0.7rem 1rem;
color: var(--ink-2);
transition:
transform 140ms ease,
background-color 140ms ease,
border-color 140ms ease;
}
.filter-pill:hover {
transform: translateY(-1px);
background: rgba(255, 255, 255, 0.96);
}
.filter-pill-active {
border-color: rgba(182, 82, 45, 0.18);
background: linear-gradient(135deg, rgba(182, 82, 45, 0.14), rgba(182, 82, 45, 0.06));
color: var(--ink-1);
}
.index-badge {
display: inline-flex;
height: 2rem;
width: 2rem;
align-items: center;
justify-content: center;
border-radius: 999px;
background: var(--surface-2);
color: var(--accent-1);
font-weight: 700;
}
.list-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
border-radius: 26px;
border: 1px solid var(--line-soft);
background: rgba(255, 255, 255, 0.8);
padding: 1rem 1.1rem;
}
+33 -10
View File
@@ -1,20 +1,43 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Fraunces, IBM_Plex_Mono, Noto_Sans_SC } from "next/font/google";
import { SITE_NAME, SITE_URL } from "@/lib/site";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
const bodyFont = Noto_Sans_SC({
variable: "--font-body",
subsets: ["latin"],
weight: ["400", "500", "700"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
const displayFont = Fraunces({
variable: "--font-display",
subsets: ["latin"],
weight: ["500", "700"],
});
const monoFont = IBM_Plex_Mono({
variable: "--font-mono",
subsets: ["latin"],
weight: ["400", "500"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
metadataBase: new URL(SITE_URL),
title: {
default: SITE_NAME,
template: `%s | ${SITE_NAME}`,
},
description: "查看老年陪伴助手 Android 应用的使用情况、关键流程和异常提醒。",
openGraph: {
title: SITE_NAME,
description: "聚焦老年陪伴助手 Android 应用的使用概况与运营观察。",
url: SITE_URL,
siteName: SITE_NAME,
locale: "zh_CN",
type: "website",
},
};
export default function RootLayout({
@@ -24,10 +47,10 @@ export default function RootLayout({
}>) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
lang="zh-CN"
className={`${bodyFont.variable} ${displayFont.variable} ${monoFont.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
<body className="min-h-full bg-[var(--surface-0)] text-[var(--ink-1)]">{children}</body>
</html>
);
}
+155 -61
View File
@@ -1,65 +1,159 @@
import Image from "next/image";
import Link from "next/link";
import { Activity, ArrowRight, Clock3, Smartphone, Sparkles } from "lucide-react";
import { getPublicAnalyticsSnapshot } from "@/lib/analytics/server";
import { ADMIN_PATH } from "@/lib/site";
import { formatNumber } from "@/lib/utils";
export const dynamic = "force-dynamic";
const focusCards = [
{
icon: Smartphone,
title: "看哪些长辈还在持续使用",
description: "活跃设备和最近打开的功能会持续更新,方便判断陪伴助手有没有被真正用起来。",
},
{
icon: Sparkles,
title: "看陪伴流程是否顺畅",
description: "进入通话、开始互动、创建专属音色这些关键动作,都会按时间留下记录。",
},
{
icon: Activity,
title: "看哪里需要尽快处理",
description: "当连接、播放、录音或资源准备失败时,后台会留下清晰的异常提醒。",
},
];
export default async function Home() {
const snapshot = await getPublicAnalyticsSnapshot();
export default function Home() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
<main className="relative overflow-hidden px-6 py-8 sm:px-8 lg:px-10">
<div className="hero-orb left-[-120px] top-[-80px]" />
<div className="hero-orb right-[-80px] top-[20%] bg-[radial-gradient(circle,_rgba(25,113,103,0.18)_0%,_rgba(25,113,103,0)_72%)]" />
<div className="mx-auto flex max-w-7xl flex-col gap-8">
<section className="soft-card relative overflow-hidden px-7 py-8 sm:px-10 sm:py-10">
<div className="absolute inset-y-0 right-0 hidden w-1/3 bg-[radial-gradient(circle_at_center,_rgba(255,255,255,0.5)_0%,_rgba(255,255,255,0)_70%)] lg:block" />
<div className="grid gap-8 lg:grid-cols-[1.15fr_0.85fr] lg:items-center">
<div className="space-y-6">
<div className="flex flex-wrap items-center gap-3 text-sm text-[var(--ink-2)]">
<span className="pill-tag">老年陪伴助手</span>
<span className="pill-tag">Android 使用概览</span>
</div>
<div className="space-y-4">
<p className="eyebrow">陪伴助手总览</p>
<h1 className="max-w-3xl font-[var(--font-display)] text-4xl leading-tight text-[var(--ink-1)] sm:text-5xl lg:text-6xl">
把每一次陪伴是否顺畅,看得更清楚一些。
</h1>
<p className="max-w-2xl text-lg leading-8 text-[var(--ink-2)]">
在这里关注老年陪伴助手数字人应用里的真实使用情况,帮助你了解长辈最近是否在使用、常用哪些功能、有没有遇到卡住或失败。
</p>
</div>
<div className="flex flex-wrap gap-4">
<Link
href={ADMIN_PATH}
className="inline-flex items-center gap-2 rounded-full bg-[var(--accent-1)] px-6 py-3 text-sm font-semibold text-white shadow-[var(--shadow-card)] transition-transform hover:-translate-y-0.5"
>
打开管理员面板
<ArrowRight className="h-4 w-4" />
</Link>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-3 lg:grid-cols-1 xl:grid-cols-3">
<article className="stat-card">
<p className="stat-label">24 小时互动记录</p>
<strong className="stat-value">{formatNumber(snapshot.events24h)}</strong>
<p className="stat-hint">最近一天里记录到的关键动作</p>
</article>
<article className="stat-card">
<p className="stat-label">24 小时活跃设备</p>
<strong className="stat-value">{formatNumber(snapshot.sessions24h)}</strong>
<p className="stat-hint">最近一天里仍在使用的 Android 设备</p>
</article>
<article className="stat-card">
<p className="stat-label">最近最常见动作</p>
<strong className="stat-value text-[clamp(1.5rem,2vw,2rem)]">{snapshot.topAction}</strong>
<p className="stat-hint">帮助快速判断大家最近最常用哪项能力</p>
</article>
</div>
</div>
</section>
<section className="grid gap-5 lg:grid-cols-3">
{focusCards.map((card) => {
const Icon = card.icon;
return (
<article key={card.title} className="soft-card p-6">
<div className="mb-5 inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--surface-2)] text-[var(--accent-1)]">
<Icon className="h-5 w-5" />
</div>
<h2 className="mb-3 text-2xl font-semibold text-[var(--ink-1)]">{card.title}</h2>
<p className="text-base leading-7 text-[var(--ink-2)]">{card.description}</p>
</article>
);
})}
</section>
<section className="grid gap-6 lg:grid-cols-[1.15fr_0.85fr]">
<article className="soft-card p-7">
<p className="eyebrow">后台重点关注</p>
<h2 className="mb-5 text-3xl font-semibold text-[var(--ink-1)]">这些关键过程会持续留痕</h2>
<div className="grid gap-4 sm:grid-cols-2">
{[
"进入首页、通话页和音色克隆页",
"选择形象、进入陪伴通话",
"开始录音、重录、完成录音",
"创建专属音色和开始体验",
"陪伴通话连接、断开和结束",
"资源准备、播放和摄像头异常",
].map((item) => (
<div key={item} className="rounded-[22px] border border-[var(--line-soft)] bg-white/80 px-4 py-4 text-[var(--ink-2)]">
{item}
</div>
))}
</div>
</article>
<article className="soft-card p-7">
<p className="eyebrow">适合日常巡检</p>
<h2 className="mb-5 text-3xl font-semibold text-[var(--ink-1)]">打开后台后,先看这三件事</h2>
<div className="space-y-4">
{[
{
icon: Clock3,
title: "先看最近一天是不是还有人在使用",
description: "如果活跃设备突然明显减少,就要尽快确认入口、资源和服务是不是出了问题。",
},
{
icon: Sparkles,
title: "再看最近最常见的动作有没有变化",
description: "高频动作能反映长辈现在更依赖哪项能力,也能判断新功能有没有被接受。",
},
{
icon: Activity,
title: "最后看异常提醒有没有集中出现",
description: "一旦某类失败突然增多,通常就说明某个陪伴流程需要尽快排查。",
},
].map((item) => {
const Icon = item.icon;
return (
<div key={item.title} className="rounded-[22px] border border-[var(--line-soft)] bg-white/80 px-4 py-4">
<div className="mb-3 inline-flex h-10 w-10 items-center justify-center rounded-2xl bg-[var(--surface-2)] text-[var(--accent-1)]">
<Icon className="h-4 w-4" />
</div>
<h3 className="mb-2 text-lg font-semibold text-[var(--ink-1)]">{item.title}</h3>
<p className="text-sm leading-7 text-[var(--ink-2)]">{item.description}</p>
</div>
);
})}
</div>
</article>
</section>
</div>
</main>
);
}