feat(shared,tests): add error boundaries, lib utils, i18n messages, and integration tests
Some checks failed
CI / scheduled-backup (push) Has been skipped
CI / backup-verify (push) Has been skipped
CI / weekly-dr-drill (push) Failing after 0s
CI / build-deploy (push) Has been cancelled
CI / security-scan (push) Has been cancelled

shared:

- Add class-filter, error-state, route-error, section-error-boundary, widget-boundary components

- Add ui/alert component

- Add constants directory

- Add breached-password, export-utils, permission-bitmap, rate-limit, resolve-action-error, route-permissions, route-resolver, type-guards lib

- Add i18n messages (en, zh-CN) for invitation-codes, parent, questions, rbac

tests:

- Add integration tests for elective

- Add tests/setup/empty-stub

scripts:

- Add update-md.cjs, tmp_append_en.ps1, tmp_merge_en.ps1 utilities
This commit is contained in:
SpecialX
2026-07-03 10:26:38 +08:00
parent 21142f9b99
commit 0e63c24ed9
97 changed files with 9753 additions and 819 deletions

View File

@@ -0,0 +1,139 @@
"use client"
import { useRouter, useSearchParams } from "next/navigation"
import { cn } from "@/shared/lib/utils"
import { Badge } from "@/shared/components/ui/badge"
export interface ClassFilterItem {
classId: string
className: string
/** 可选徽章文本(已翻译,如 "5 次练习" 或 "3 错题" */
badgeText?: string
/** 可选次要信息文本(如 "2 待复习" */
secondaryText?: string
/** 次要信息是否使用警告样式(红色) */
secondaryWarning?: boolean
}
interface ClassFilterProps {
classes: ClassFilterItem[]
/** 当前选中的班级 ID"all" 表示全部) */
currentClassId: string
/** URL 参数名(默认 "classId" */
paramName?: string
/** "全部班级" 按钮文本(已翻译) */
allLabel: string
/** 无障碍标签(已翻译,如 "选择班级" */
ariaLabel?: string
}
/**
* 通用班级筛选器(共享组件)。
*
* 通过 URL 查询参数(默认 `classId`)管理选中状态,点击切换班级。
*
* 解耦设计:
* - 不依赖任何业务模块的类型或翻译
* - 所有展示文本allLabel、badgeText、secondaryText由调用方翻译后传入
* - 适用于教师/年级主任在 error-book、practice、homework 等模块的班级切换
*
* @example
* // Server Component 页面调用
* <ClassFilter
* classes={classes.map(c => ({
* classId: c.classId,
* className: c.className,
* badgeText: t("classFilter.sessionCount", { count: c.totalSessions }),
* }))}
* currentClassId={effectiveClassId}
* allLabel={t("classFilter.all")}
* ariaLabel={t("classFilter.selectClass")}
* />
*/
export function ClassFilter({
classes,
currentClassId,
paramName = "classId",
allLabel,
ariaLabel,
}: ClassFilterProps): React.ReactNode {
const router = useRouter()
const searchParams = useSearchParams()
function handleSelect(classId: string): void {
const params = new URLSearchParams(searchParams.toString())
if (classId === "all") {
params.delete(paramName)
} else {
params.set(paramName, classId)
}
router.push(`?${params.toString()}`, { scroll: false })
}
if (classes.length === 0) return null
return (
<div
className="flex flex-wrap gap-2"
role="tablist"
aria-label={ariaLabel}
>
<button
type="button"
role="tab"
aria-selected={currentClassId === "all"}
onClick={() => handleSelect("all")}
className={cn(
"inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm transition-colors",
currentClassId === "all"
? "border-primary bg-primary text-primary-foreground"
: "bg-card hover:bg-muted",
)}
>
<span className="font-medium">{allLabel}</span>
</button>
{classes.map((cls) => {
const isActive = currentClassId === cls.classId
return (
<button
key={cls.classId}
type="button"
role="tab"
aria-selected={isActive}
onClick={() => handleSelect(cls.classId)}
className={cn(
"inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm transition-colors",
isActive
? "border-primary bg-primary text-primary-foreground"
: "bg-card hover:bg-muted",
)}
>
<span className="font-medium">{cls.className}</span>
{cls.badgeText ? (
<Badge
variant={isActive ? "secondary" : "outline"}
className="text-xs"
>
{cls.badgeText}
</Badge>
) : null}
{cls.secondaryText ? (
<span
className={cn(
"text-xs",
cls.secondaryWarning
? isActive
? "text-primary-foreground/80"
: "text-rose-600"
: "text-muted-foreground",
)}
>
{cls.secondaryText}
</span>
) : null}
</button>
)
})}
</div>
)
}

View File

@@ -0,0 +1,35 @@
"use client"
import { AlertTriangle } from "lucide-react"
import { useTranslations } from "next-intl"
import { EmptyState } from "@/shared/components/ui/empty-state"
/**
* 通用错误边界展示组件P1-4 审计修复)。
*
* 统一 error.tsx 的渲染逻辑,避免 30+ error.tsx 重复同样的代码。
* 各模块 error.tsx 只需调用 `<ErrorState error={error} reset={reset} namespace="xxx" />`。
*
* 翻译键查找规则:在传入 namespace 下查找 error.title / error.description / error.retry。
*/
export function ErrorState({
error,
reset,
namespace,
}: {
error: Error & { digest?: string }
reset: () => void
namespace: string
}) {
const t = useTranslations(namespace)
return (
<EmptyState
icon={AlertTriangle}
title={t("error.title")}
description={error.message || t("error.description")}
action={{ label: t("error.retry"), onClick: reset }}
/>
)
}

View File

@@ -1,313 +0,0 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import { useSession } from "next-auth/react"
import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
import { Checkbox } from "@/shared/components/ui/checkbox"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
import { Textarea } from "@/shared/components/ui/textarea"
import { cn } from "@/shared/lib/utils"
import { Permissions } from "@/shared/types/permissions"
type Role = "student" | "teacher" | "parent" | "admin"
const TEACHER_SUBJECTS = ["语文", "数学", "英语", "美术", "体育", "科学", "社会", "音乐"] as const
type TeacherSubject = (typeof TEACHER_SUBJECTS)[number]
function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null
}
export function OnboardingGate() {
const router = useRouter()
const { status, data: session, update } = useSession()
const [required, setRequired] = useState(false)
const [open, setOpen] = useState(false)
const [step, setStep] = useState(0)
const [isSubmitting, setIsSubmitting] = useState(false)
const [role, setRole] = useState<Role>("student")
const [name, setName] = useState("")
const [phone, setPhone] = useState("")
const [address, setAddress] = useState("")
const [classCodes, setClassCodes] = useState("")
const [teacherSubjects, setTeacherSubjects] = useState<TeacherSubject[]>([])
const canClose = useMemo(() => !required, [required])
useEffect(() => {
if (status !== "authenticated") return
let cancelled = false
;(async () => {
const res = await fetch("/api/onboarding/status", { cache: "no-store" }).catch(() => null)
const json = res ? await res.json().catch(() => null) : null
if (cancelled) return
if (isRecord(json)) {
const required = Boolean(json.required)
const role = String(json.role ?? "student") as Role
setRequired(required)
setRole(role === "admin" ? "admin" : role)
setName(String(session?.user?.name ?? "").trim())
if (required) {
setOpen(true)
setStep(0)
}
}
})()
return () => {
cancelled = true
}
}, [status, session?.user?.name])
useEffect(() => {
if (!open) return
if (!required) return
setOpen(true)
}, [open, required])
const title =
step === 0 ? "角色选择" : step === 1 ? "通用信息" : step === 2 ? "角色信息(可跳过)" : "完成"
const description =
step === 0
? "请选择你在系统中的角色"
: step === 1
? "填写姓名、电话、住址等信息"
: step === 2
? "不同角色可配置班级代码、教学科目等"
: "配置完成,可以进入系统"
const canNextFromStep0 = role.length > 0
const canNextFromStep1 = name.trim().length > 0 && phone.trim().length > 0
const permissions = (session?.user?.permissions ?? []) as string[]
const isAdmin = permissions.includes(Permissions.SETTINGS_ADMIN)
const isTeacher = permissions.includes(Permissions.EXAM_CREATE)
const isStudent = permissions.includes(Permissions.HOMEWORK_SUBMIT) && !permissions.includes(Permissions.EXAM_CREATE)
const isParent = !permissions.includes(Permissions.EXAM_CREATE) && !permissions.includes(Permissions.HOMEWORK_SUBMIT) && permissions.includes(Permissions.EXAM_READ)
const onNext = async () => {
if (step === 0) {
if (!canNextFromStep0) return
setStep(1)
return
}
if (step === 1) {
if (!canNextFromStep1) {
toast.error("请填写姓名与电话")
return
}
if (isAdmin) {
setStep(3)
} else {
setStep(2)
}
return
}
if (step === 2) {
setStep(3)
return
}
}
const onBack = () => {
if (step === 0) return
setStep((s) => Math.max(0, s - 1))
}
const toggleSubject = (subject: TeacherSubject) => {
setTeacherSubjects((prev) => (prev.includes(subject) ? prev.filter((s) => s !== subject) : [...prev, subject]))
}
const onFinish = async () => {
setIsSubmitting(true)
try {
const res = await fetch("/api/onboarding/complete", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
role,
name,
phone,
address,
classCodes,
teacherSubjects,
}),
})
const json = await res.json().catch(() => null)
if (!res.ok || !isRecord(json) || json.success !== true) {
const msg = isRecord(json) ? String(json.message ?? "") : ""
throw new Error(msg || "提交失败")
}
await update?.()
toast.success("配置完成")
setRequired(false)
setOpen(false)
router.push("/dashboard")
router.refresh()
} catch (e) {
const msg = e instanceof Error ? e.message : "提交失败"
toast.error(msg)
} finally {
setIsSubmitting(false)
}
}
return (
<Dialog
open={open}
onOpenChange={(v) => {
if (canClose) setOpen(v)
else setOpen(true)
}}
>
<DialogContent className="sm:max-w-[720px]">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className={cn("h-1 flex-1 rounded", step >= 0 ? "bg-primary" : "bg-muted")} />
<div className={cn("h-1 flex-1 rounded", step >= 1 ? "bg-primary" : "bg-muted")} />
<div className={cn("h-1 flex-1 rounded", step >= 2 ? "bg-primary" : "bg-muted")} />
<div className={cn("h-1 flex-1 rounded", step >= 3 ? "bg-primary" : "bg-muted")} />
</div>
{step === 0 ? (
<div className="grid gap-2">
<Label>Role</Label>
{isAdmin ? (
<div className="rounded-md border px-3 py-2 text-sm">admin</div>
) : (
<Select value={role} onValueChange={(v) => setRole(v as Role)}>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="student">student</SelectItem>
<SelectItem value="teacher">teacher</SelectItem>
<SelectItem value="parent">parent</SelectItem>
</SelectContent>
</Select>
)}
</div>
) : null}
{step === 1 ? (
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="onb_name"></Label>
<Input id="onb_name" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="grid gap-2">
<Label htmlFor="onb_phone"></Label>
<Input id="onb_phone" value={phone} onChange={(e) => setPhone(e.target.value)} />
</div>
<div className="grid gap-2">
<Label htmlFor="onb_address"></Label>
<Input id="onb_address" value={address} onChange={(e) => setAddress(e.target.value)} />
</div>
</div>
) : null}
{step === 2 ? (
<div className="grid gap-4">
{isTeacher ? (
<>
<div className="grid gap-2">
<Label htmlFor="onb_codes_teacher"></Label>
<Textarea
id="onb_codes_teacher"
value={classCodes}
onChange={(e) => setClassCodes(e.target.value)}
placeholder="每行一个或用逗号分隔"
/>
</div>
<div className="grid gap-2">
<Label></Label>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{TEACHER_SUBJECTS.map((s) => (
<label key={s} className="flex items-center gap-2 text-sm">
<Checkbox checked={teacherSubjects.includes(s)} onCheckedChange={() => toggleSubject(s)} />
{s}
</label>
))}
</div>
</div>
</>
) : null}
{isStudent ? (
<div className="grid gap-2">
<Label htmlFor="onb_codes_student"></Label>
<Textarea
id="onb_codes_student"
value={classCodes}
onChange={(e) => setClassCodes(e.target.value)}
placeholder="每行一个或用逗号分隔"
/>
</div>
) : null}
{isParent ? (
<div className="rounded-md border px-3 py-2 text-sm text-muted-foreground">
</div>
) : null}
</div>
) : null}
{step === 3 ? (
<div className="rounded-md border px-3 py-3 text-sm">
<div className="font-medium"></div>
<div className="text-muted-foreground"></div>
</div>
) : null}
</div>
<DialogFooter>
<div className="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-between">
<div className="flex gap-2">
<Button type="button" variant="outline" onClick={onBack} disabled={step === 0 || isSubmitting}>
</Button>
{step === 2 ? (
<Button
type="button"
variant="secondary"
onClick={() => setStep(3)}
disabled={isSubmitting}
>
</Button>
) : null}
</div>
<div className="flex gap-2 justify-end">
{step < 3 ? (
<Button type="button" onClick={onNext} disabled={isSubmitting}>
</Button>
) : (
<Button type="button" onClick={onFinish} disabled={isSubmitting}>
</Button>
)}
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,44 @@
"use client"
import { AlertCircle } from "lucide-react"
import { useTranslations } from "next-intl"
import { EmptyState } from "@/shared/components/ui/empty-state"
/**
* 通用路由错误边界组件。
* 供各模块 error.tsx 复用消除重复代码P1-10 重构)。
*
* @example
* ```tsx
* // app/(dashboard)/admin/attendance/error.tsx
* "use client"
* import { RouteErrorBoundary } from "@/shared/components/route-error"
* export default function Error({ reset }: { error: Error; reset: () => void }) {
* return <RouteErrorBoundary reset={reset} namespace="attendance" />
* }
* ```
*/
export function RouteErrorBoundary({
reset,
namespace = "attendance",
}: {
reset: () => void
namespace?: string
}) {
const t = useTranslations(namespace)
return (
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
<EmptyState
icon={AlertCircle}
title={t("errors.unexpected")}
description={t("errors.unexpected")}
action={{
label: t("actions.retry"),
onClick: () => reset(),
}}
className="border-none shadow-none h-auto"
/>
</div>
)
}

View File

@@ -0,0 +1,148 @@
"use client"
import type { JSX } from "react"
import { Component, type ErrorInfo, type ReactNode } from "react"
import { useTranslations } from "next-intl"
import { AlertCircle, RefreshCw } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
interface BoundaryProps {
children: ReactNode
/** 自定义降级 UI函数形式优先级最高。若提供则忽略 title/description/retryLabel */
fallback?: (error: Error, reset: () => void) => ReactNode
/** 渲染失败时的标题(已国际化,默认取 {namespace}.error.boundaryTitle */
title?: string
/** 渲染失败时的描述(已国际化,默认取 {namespace}.error.boundaryDescription */
description?: string
/** 重试按钮文案(已国际化,默认取 {namespace}.error.retry */
retryLabel?: string
/** 错误回调(用于埋点/监控) */
onError?: (error: Error, info: ErrorInfo) => void
}
interface State {
hasError: boolean
error: Error | null
}
/**
* 组件级 Error Boundary类组件实现承载错误捕获能力
* 不直接使用,请使用默认导出的函数式 `SectionErrorBoundary` 包装器以获得 i18n 支持。
*
* 支持两种降级形式:
* 1. `fallback`(函数 `(error, reset) => ReactNode`)— 完全自定义降级 UI优先级最高
* 2. `title` / `description` / `retryLabel` — 默认降级 UI + i18n 文案
*/
class SectionErrorBoundaryBase extends Component<BoundaryProps, State> {
constructor(props: BoundaryProps) {
super(props)
this.state = { hasError: false, error: null }
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
console.error("[SectionErrorBoundary]", error, errorInfo)
this.props.onError?.(error, errorInfo)
}
private handleRetry = (): void => {
this.setState({ hasError: false, error: null })
}
render(): ReactNode {
if (this.state.hasError && this.state.error) {
// 优先使用自定义 fallback
if (this.props.fallback) {
return this.props.fallback(this.state.error, this.handleRetry)
}
// 默认降级 UI
return (
<div
role="alert"
aria-live="assertive"
className="flex flex-col items-center justify-center space-y-3 rounded-lg border border-dashed p-8 text-center"
>
<AlertCircle className="h-8 w-8 text-muted-foreground" aria-hidden="true" />
<div className="space-y-1">
<p className="text-sm font-medium">
{this.props.title ?? "加载失败"}
</p>
<p className="text-xs text-muted-foreground">
{this.props.description ?? "数据加载时发生错误,请重试。"}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={this.handleRetry}
aria-label={this.props.retryLabel ?? "重试"}
>
<RefreshCw className="mr-1.5 h-3.5 w-3.5" aria-hidden="true" />
{this.props.retryLabel ?? "重试"}
</Button>
</div>
)
}
return this.props.children
}
}
interface SectionErrorBoundaryProps {
children: ReactNode
/** i18n namespace默认 "common"。需保证该 namespace 下存在 error.boundaryTitle / error.boundaryDescription / error.retry 键 */
namespace?: string
/** 自定义降级 UI函数形式优先级最高。若提供则忽略 title/description/retryLabel */
fallback?: (error: Error, reset: () => void) => ReactNode
/** 显式覆盖标题(优先级高于 i18n 默认) */
title?: string
/** 显式覆盖描述 */
description?: string
/** 显式覆盖重试按钮文案 */
retryLabel?: string
/** 错误回调(用于埋点/监控) */
onError?: (error: Error, info: ErrorInfo) => void
}
/**
* 组件级 Error Boundary包裹独立数据区块防止单区块错误导致整页崩溃。
* 自动使用 next-intl 提供本地化文案;如需完全自定义可传入 `fallback` 函数。
*
* @example
* ```tsx
* // 默认 i18n 文案
* <SectionErrorBoundary namespace="coursePlans">
* <CoursePlanProgress plan={plan} />
* </SectionErrorBoundary>
*
* // 完全自定义降级 UI
* <SectionErrorBoundary fallback={(error, reset) => <CustomUI error={error} onRetry={reset} />}>
* <CoursePlanProgress plan={plan} />
* </SectionErrorBoundary>
* ```
*/
export function SectionErrorBoundary({
children,
namespace = "common",
fallback,
title,
description,
retryLabel,
onError,
}: SectionErrorBoundaryProps): JSX.Element {
const t = useTranslations(namespace)
return (
<SectionErrorBoundaryBase
fallback={fallback}
title={title ?? t("error.boundaryTitle")}
description={description ?? t("error.boundaryDescription")}
retryLabel={retryLabel ?? t("error.retry")}
onError={onError}
>
{children}
</SectionErrorBoundaryBase>
)
}

View File

@@ -0,0 +1,42 @@
import * as React from "react"
import { cn } from "@/shared/lib/utils"
function Alert({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
role="alert"
data-slot="alert"
className={cn(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
className
)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn("col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", className)}
{...props}
/>
)
}
function AlertDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }

View File

@@ -0,0 +1,155 @@
"use client"
/**
* 通用 Widget 边界组件shared 层)。
*
* 组合三个能力:
* 1. Error Boundary — 隔离故障域,单个 Widget 抛错不影响其他区块
* 2. Suspense — 流式渲染时显示骨架屏,避免白屏等待
* 3. Skeleton — 与 Widget 尺寸匹配的占位
*
* 用法:
* ```tsx
* <WidgetBoundary title="成绩趋势">
* <GradeTrendChart data={data} />
* </WidgetBoundary>
* ```
*/
import { Component, Suspense, type ReactNode } from "react"
import { AlertCircle } from "lucide-react"
import { useTranslations } from "next-intl"
import { Button } from "@/shared/components/ui/button"
import { Skeleton } from "@/shared/components/ui/skeleton"
interface WidgetBoundaryProps {
children: ReactNode
/** Widget 标题(用于错误提示和 aria-label */
title?: string
/** 骨架屏高度(默认 200px */
skeletonHeight?: number
/** 自定义错误描述 */
fallbackDescription?: string
/** 重试按钮文案 */
retryLabel?: string
}
interface WidgetBoundaryState {
hasError: boolean
}
interface WidgetErrorBoundaryProps {
title: string
fallbackDescription: string
retryLabel: string
loadFailedMessage: string
retryAriaLabel: string
children: ReactNode
}
class WidgetErrorBoundary extends Component<
WidgetErrorBoundaryProps,
WidgetBoundaryState
> {
constructor(props: WidgetErrorBoundaryProps) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError(): WidgetBoundaryState {
return { hasError: true }
}
handleReset = (): void => {
this.setState({ hasError: false })
}
render(): ReactNode {
if (this.state.hasError) {
return (
<div
role="alert"
aria-live="assertive"
className="flex h-full min-h-[200px] flex-col items-center justify-center gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-6 text-center"
>
<AlertCircle className="h-8 w-8 text-destructive" aria-hidden="true" />
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">
{this.props.loadFailedMessage}
</p>
<p className="text-xs text-muted-foreground">
{this.props.fallbackDescription}
</p>
</div>
<Button
size="sm"
variant="outline"
onClick={this.handleReset}
aria-label={this.props.retryAriaLabel}
>
{this.props.retryLabel}
</Button>
</div>
)
}
return this.props.children
}
}
function WidgetSkeleton({
height,
loadingAriaLabel,
}: {
height: number
loadingAriaLabel: string
}): ReactNode {
return (
<div
role="status"
aria-label={loadingAriaLabel}
aria-live="polite"
className="space-y-3 p-4"
style={{ minHeight: height }}
>
<Skeleton className="h-6 w-1/3" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-32 w-full" />
</div>
)
}
export function WidgetBoundary({
children,
title,
skeletonHeight = 200,
fallbackDescription,
retryLabel,
}: WidgetBoundaryProps): ReactNode {
const t = useTranslations("common")
const effectiveTitle = title ?? t("widget.block")
const effectiveFallbackDescription = fallbackDescription ?? t("widget.defaultFallback")
const effectiveRetryLabel = retryLabel ?? t("widget.retry")
const loadFailedMessage = t("widget.loadFailed", { title: effectiveTitle })
const retryAriaLabel = t("widget.retryAriaLabel", { title: effectiveTitle })
const loadingAriaLabel = t("widget.loadingAriaLabel", { title: effectiveTitle })
return (
<WidgetErrorBoundary
title={effectiveTitle}
fallbackDescription={effectiveFallbackDescription}
retryLabel={effectiveRetryLabel}
loadFailedMessage={loadFailedMessage}
retryAriaLabel={retryAriaLabel}
>
<Suspense
fallback={
<WidgetSkeleton height={skeletonHeight} loadingAriaLabel={loadingAriaLabel} />
}
>
{children}
</Suspense>
</WidgetErrorBoundary>
)
}