@bygghammar/cms-platform 0.23.1 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/package.json +13 -3
- package/src/backoffice/AuditView.tsx +171 -0
- package/src/backoffice/ContactDetailsView.tsx +108 -0
- package/src/backoffice/DomainsView.tsx +264 -0
- package/src/backoffice/EmailLogsView.tsx +203 -0
- package/src/backoffice/LogsView.tsx +43 -0
- package/src/backoffice/NavigationView.tsx +133 -0
- package/src/backoffice/RolesView.tsx +50 -0
- package/src/backoffice/TrackingView.tsx +405 -0
- package/src/backoffice/UsersView.tsx +348 -0
- package/src/backoffice/VersionView.tsx +109 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { Fragment, useMemo, useState } from "react";
|
|
2
|
+
|
|
3
|
+
import { emailStatusTone, formatAbsoluteTime, formatRelativeTime } from "../admin/format-log";
|
|
4
|
+
|
|
5
|
+
/** One outgoing email attempt. */
|
|
6
|
+
export type EmailLogRow = {
|
|
7
|
+
id: string;
|
|
8
|
+
created_at: string;
|
|
9
|
+
to_address: string;
|
|
10
|
+
from_address?: string | null;
|
|
11
|
+
reply_to?: string | null;
|
|
12
|
+
subject?: string | null;
|
|
13
|
+
body_preview?: string | null;
|
|
14
|
+
provider?: string | null;
|
|
15
|
+
status: string;
|
|
16
|
+
error?: string | null;
|
|
17
|
+
meta?: Record<string, unknown> | null;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Host-agnostic email delivery log with status filter, search and detail. */
|
|
21
|
+
export function EmailLogsView({ rows }: { rows: EmailLogRow[] }) {
|
|
22
|
+
const [search, setSearch] = useState("");
|
|
23
|
+
const [status, setStatus] = useState("all");
|
|
24
|
+
const [expanded, setExpanded] = useState<string | null>(null);
|
|
25
|
+
|
|
26
|
+
const statuses = useMemo(
|
|
27
|
+
() => Array.from(new Set(rows.map((r) => r.status).filter(Boolean))),
|
|
28
|
+
[rows],
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const filtered = useMemo(() => {
|
|
32
|
+
const s = search.trim().toLowerCase();
|
|
33
|
+
return rows.filter((r) => {
|
|
34
|
+
if (status !== "all" && r.status !== status) return false;
|
|
35
|
+
if (!s) return true;
|
|
36
|
+
const hay = [r.to_address, r.from_address, r.subject, r.provider, r.error]
|
|
37
|
+
.filter(Boolean)
|
|
38
|
+
.join(" ")
|
|
39
|
+
.toLowerCase();
|
|
40
|
+
return hay.includes(s);
|
|
41
|
+
});
|
|
42
|
+
}, [rows, search, status]);
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div className="p-8 md:p-12">
|
|
46
|
+
<div className="flex items-start justify-between gap-6 flex-wrap">
|
|
47
|
+
<div>
|
|
48
|
+
<h2 className="font-serif text-xl">Email log</h2>
|
|
49
|
+
<p className="mt-2 text-sm text-ink/60 max-w-2xl">
|
|
50
|
+
Every outgoing email — contact form replies, password resets and system notifications —
|
|
51
|
+
with delivery status and any error returned by the provider.
|
|
52
|
+
</p>
|
|
53
|
+
</div>
|
|
54
|
+
<div className="flex items-center gap-2">
|
|
55
|
+
<select
|
|
56
|
+
value={status}
|
|
57
|
+
onChange={(e) => setStatus(e.target.value)}
|
|
58
|
+
className="border border-ink/15 bg-white px-3 py-2 text-sm"
|
|
59
|
+
>
|
|
60
|
+
<option value="all">All statuses</option>
|
|
61
|
+
{statuses.map((s) => (
|
|
62
|
+
<option key={s} value={s}>
|
|
63
|
+
{s}
|
|
64
|
+
</option>
|
|
65
|
+
))}
|
|
66
|
+
</select>
|
|
67
|
+
<input
|
|
68
|
+
value={search}
|
|
69
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
70
|
+
placeholder="Search recipient, subject…"
|
|
71
|
+
className="border border-ink/15 bg-white px-3 py-2 text-sm w-72 focus:outline-none focus:border-sea"
|
|
72
|
+
/>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<div className="mt-6 border border-ink/10 bg-white overflow-hidden">
|
|
77
|
+
<table className="w-full text-sm">
|
|
78
|
+
<thead className="bg-ink/5 text-[0.65rem] uppercase tracking-[0.22em] text-ink/60">
|
|
79
|
+
<tr>
|
|
80
|
+
<th className="text-left p-3 w-40">When</th>
|
|
81
|
+
<th className="text-left p-3">Recipient</th>
|
|
82
|
+
<th className="text-left p-3">Subject</th>
|
|
83
|
+
<th className="text-left p-3 w-32">Provider</th>
|
|
84
|
+
<th className="text-left p-3 w-32">Status</th>
|
|
85
|
+
<th className="w-10 p-3" />
|
|
86
|
+
</tr>
|
|
87
|
+
</thead>
|
|
88
|
+
<tbody>
|
|
89
|
+
{filtered.map((r) => {
|
|
90
|
+
const tone = emailStatusTone(r.status);
|
|
91
|
+
const isOpen = expanded === r.id;
|
|
92
|
+
return (
|
|
93
|
+
<Fragment key={r.id}>
|
|
94
|
+
<tr
|
|
95
|
+
className="border-t border-ink/10 align-top hover:bg-ink/[0.02] cursor-pointer"
|
|
96
|
+
onClick={() => setExpanded(isOpen ? null : r.id)}
|
|
97
|
+
>
|
|
98
|
+
<td className="p-3 whitespace-nowrap">
|
|
99
|
+
<div className="text-ink">{formatRelativeTime(r.created_at)}</div>
|
|
100
|
+
<div className="text-[0.65rem] text-ink/50">
|
|
101
|
+
{formatAbsoluteTime(r.created_at)}
|
|
102
|
+
</div>
|
|
103
|
+
</td>
|
|
104
|
+
<td className="p-3">
|
|
105
|
+
<div className="text-ink">{r.to_address}</div>
|
|
106
|
+
{r.from_address && (
|
|
107
|
+
<div className="text-[0.65rem] text-ink/50">from {r.from_address}</div>
|
|
108
|
+
)}
|
|
109
|
+
</td>
|
|
110
|
+
<td className="p-3">
|
|
111
|
+
<div className="text-ink">{r.subject ?? "(no subject)"}</div>
|
|
112
|
+
{r.body_preview && (
|
|
113
|
+
<div className="text-[0.7rem] text-ink/50 mt-1 line-clamp-1">
|
|
114
|
+
{r.body_preview}
|
|
115
|
+
</div>
|
|
116
|
+
)}
|
|
117
|
+
</td>
|
|
118
|
+
<td className="p-3 text-ink/70">{r.provider ?? "—"}</td>
|
|
119
|
+
<td className="p-3">
|
|
120
|
+
<span
|
|
121
|
+
className={`inline-block px-2 py-0.5 text-[0.65rem] uppercase tracking-[0.15em] border ${tone.className}`}
|
|
122
|
+
>
|
|
123
|
+
{tone.label}
|
|
124
|
+
</span>
|
|
125
|
+
{r.error && (
|
|
126
|
+
<div className="mt-1 text-[0.65rem] text-red-600 line-clamp-1">
|
|
127
|
+
{r.error}
|
|
128
|
+
</div>
|
|
129
|
+
)}
|
|
130
|
+
</td>
|
|
131
|
+
<td className="p-3 text-ink/40 text-xs">{isOpen ? "▾" : "▸"}</td>
|
|
132
|
+
</tr>
|
|
133
|
+
{isOpen && (
|
|
134
|
+
<tr className="bg-ink/[0.02] border-t border-ink/10">
|
|
135
|
+
<td colSpan={6} className="p-4 space-y-3 text-xs">
|
|
136
|
+
<dl className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-2">
|
|
137
|
+
<DetailRow label="To" value={r.to_address} />
|
|
138
|
+
<DetailRow label="From" value={r.from_address ?? "—"} />
|
|
139
|
+
<DetailRow label="Reply-to" value={r.reply_to ?? "—"} />
|
|
140
|
+
<DetailRow label="Provider" value={r.provider ?? "—"} />
|
|
141
|
+
<DetailRow label="Status" value={r.status} />
|
|
142
|
+
<DetailRow label="Sent" value={formatAbsoluteTime(r.created_at)} />
|
|
143
|
+
</dl>
|
|
144
|
+
{r.body_preview && (
|
|
145
|
+
<div>
|
|
146
|
+
<div className="text-[0.65rem] uppercase tracking-[0.22em] text-ink/50 mb-1">
|
|
147
|
+
Body preview
|
|
148
|
+
</div>
|
|
149
|
+
<div className="text-ink/80 bg-white border border-ink/10 p-3 whitespace-pre-wrap">
|
|
150
|
+
{r.body_preview}
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
)}
|
|
154
|
+
{r.error && (
|
|
155
|
+
<div>
|
|
156
|
+
<div className="text-[0.65rem] uppercase tracking-[0.22em] text-red-600 mb-1">
|
|
157
|
+
Error
|
|
158
|
+
</div>
|
|
159
|
+
<div className="text-red-700 bg-red-50 border border-red-200 p-3 whitespace-pre-wrap">
|
|
160
|
+
{r.error}
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
)}
|
|
164
|
+
{r.meta && Object.keys(r.meta).length > 0 && (
|
|
165
|
+
<div>
|
|
166
|
+
<div className="text-[0.65rem] uppercase tracking-[0.22em] text-ink/50 mb-1">
|
|
167
|
+
Metadata
|
|
168
|
+
</div>
|
|
169
|
+
<pre className="text-[0.7rem] whitespace-pre-wrap break-all text-ink/70 bg-white border border-ink/10 p-3">
|
|
170
|
+
{JSON.stringify(r.meta, null, 2)}
|
|
171
|
+
</pre>
|
|
172
|
+
</div>
|
|
173
|
+
)}
|
|
174
|
+
</td>
|
|
175
|
+
</tr>
|
|
176
|
+
)}
|
|
177
|
+
</Fragment>
|
|
178
|
+
);
|
|
179
|
+
})}
|
|
180
|
+
{filtered.length === 0 && (
|
|
181
|
+
<tr>
|
|
182
|
+
<td colSpan={6} className="p-6 text-center text-ink/50">
|
|
183
|
+
{rows.length === 0 ? "No email activity yet." : "No emails match your filters."}
|
|
184
|
+
</td>
|
|
185
|
+
</tr>
|
|
186
|
+
)}
|
|
187
|
+
</tbody>
|
|
188
|
+
</table>
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function DetailRow({ label, value }: { label: string; value: string }) {
|
|
195
|
+
return (
|
|
196
|
+
<div className="flex gap-3">
|
|
197
|
+
<dt className="text-ink/50 uppercase tracking-[0.18em] text-[0.6rem] w-24 shrink-0 pt-0.5">
|
|
198
|
+
{label}
|
|
199
|
+
</dt>
|
|
200
|
+
<dd className="text-ink/80 break-all">{value}</dd>
|
|
201
|
+
</div>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export type LogsViewProps = {
|
|
2
|
+
/** Path to the audit log screen, e.g. `/kontoret/admin/audit`. */
|
|
3
|
+
auditPath: string;
|
|
4
|
+
/** Path to the email log screen, e.g. `/kontoret/admin/email-logs`. */
|
|
5
|
+
emailLogsPath: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
/** Host-agnostic index over the two log surfaces. */
|
|
9
|
+
export function LogsView({ auditPath, emailLogsPath }: LogsViewProps) {
|
|
10
|
+
return (
|
|
11
|
+
<div className="p-8 md:p-12 w-full">
|
|
12
|
+
<h2 className="font-serif text-xl">Logs</h2>
|
|
13
|
+
<p className="mt-2 text-sm text-ink/60">Application logs live in two places:</p>
|
|
14
|
+
<ul className="mt-6 space-y-4">
|
|
15
|
+
<li className="border border-ink/10 bg-white p-5">
|
|
16
|
+
<div className="font-serif text-lg">Audit logs</div>
|
|
17
|
+
<p className="mt-1 text-sm text-ink/60">
|
|
18
|
+
Actions performed in the backoffice (user changes, role changes, email settings updates,
|
|
19
|
+
content saves).
|
|
20
|
+
</p>
|
|
21
|
+
<a
|
|
22
|
+
href={auditPath}
|
|
23
|
+
className="mt-3 inline-block text-xs uppercase tracking-[0.22em] text-sea"
|
|
24
|
+
>
|
|
25
|
+
Open audit logs →
|
|
26
|
+
</a>
|
|
27
|
+
</li>
|
|
28
|
+
<li className="border border-ink/10 bg-white p-5">
|
|
29
|
+
<div className="font-serif text-lg">Email logs</div>
|
|
30
|
+
<p className="mt-1 text-sm text-ink/60">
|
|
31
|
+
Every outgoing email attempt (recipient, subject, provider, status).
|
|
32
|
+
</p>
|
|
33
|
+
<a
|
|
34
|
+
href={emailLogsPath}
|
|
35
|
+
className="mt-3 inline-block text-xs uppercase tracking-[0.22em] text-sea"
|
|
36
|
+
>
|
|
37
|
+
Open email logs →
|
|
38
|
+
</a>
|
|
39
|
+
</li>
|
|
40
|
+
</ul>
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from "react";
|
|
2
|
+
import type { ComponentType } from "react";
|
|
3
|
+
import { RotateCcw, Save } from "lucide-react";
|
|
4
|
+
import { toast } from "sonner";
|
|
5
|
+
|
|
6
|
+
/** The two site-wide content sections this screen edits. */
|
|
7
|
+
export type NavigationSectionKey = "nav" | "footer";
|
|
8
|
+
|
|
9
|
+
/** Host-provided data and actions for the navigation & footer screen. */
|
|
10
|
+
export type NavigationDeps = {
|
|
11
|
+
/** Saved overrides keyed by section, as stored in the CMS. */
|
|
12
|
+
overrides: Record<string, unknown> | undefined;
|
|
13
|
+
/** Resolved live site content (defaults merged with overrides). */
|
|
14
|
+
site: Record<string, unknown>;
|
|
15
|
+
/** Code defaults, used by "reset to default". */
|
|
16
|
+
defaults: Record<string, unknown>;
|
|
17
|
+
/** The host's section editor, already wired to its locales and media. */
|
|
18
|
+
SectionForm: ComponentType<{ value: unknown; onChange: (next: unknown) => void }>;
|
|
19
|
+
saveSection: (input: { data: { key: string; data: unknown } }) => Promise<unknown>;
|
|
20
|
+
refresh: () => Promise<unknown> | void;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function Editor({
|
|
24
|
+
deps,
|
|
25
|
+
sectionKey,
|
|
26
|
+
title,
|
|
27
|
+
description,
|
|
28
|
+
}: {
|
|
29
|
+
deps: NavigationDeps;
|
|
30
|
+
sectionKey: NavigationSectionKey;
|
|
31
|
+
title: string;
|
|
32
|
+
description: string;
|
|
33
|
+
}) {
|
|
34
|
+
const { overrides, site, defaults, SectionForm, saveSection, refresh } = deps;
|
|
35
|
+
|
|
36
|
+
const current = useMemo(() => {
|
|
37
|
+
const override = overrides?.[sectionKey];
|
|
38
|
+
return override ?? site[sectionKey];
|
|
39
|
+
}, [overrides, site, sectionKey]);
|
|
40
|
+
|
|
41
|
+
const [draft, setDraft] = useState<unknown>(current);
|
|
42
|
+
const [busy, setBusy] = useState(false);
|
|
43
|
+
const [dirty, setDirty] = useState(false);
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
setDraft(current);
|
|
47
|
+
setDirty(false);
|
|
48
|
+
}, [current]);
|
|
49
|
+
|
|
50
|
+
const onSave = async () => {
|
|
51
|
+
setBusy(true);
|
|
52
|
+
try {
|
|
53
|
+
await saveSection({ data: { key: sectionKey, data: draft } });
|
|
54
|
+
await refresh();
|
|
55
|
+
toast.success(`${title} saved`);
|
|
56
|
+
setDirty(false);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
toast.error(err instanceof Error ? err.message : "Could not save");
|
|
59
|
+
} finally {
|
|
60
|
+
setBusy(false);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const onReset = () => {
|
|
65
|
+
setDraft(defaults[sectionKey]);
|
|
66
|
+
setDirty(true);
|
|
67
|
+
toast.info("Reset to default — remember to save");
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<section className="border border-ink/10 bg-white">
|
|
72
|
+
<header className="border-b border-ink/10 px-6 py-5">
|
|
73
|
+
<div className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/55">
|
|
74
|
+
{sectionKey}
|
|
75
|
+
{dirty && <span className="ml-3 text-sea">unsaved</span>}
|
|
76
|
+
</div>
|
|
77
|
+
<h2 className="mt-2 font-serif text-2xl text-ink">{title}</h2>
|
|
78
|
+
<p className="mt-2 text-sm text-ink/60">{description}</p>
|
|
79
|
+
</header>
|
|
80
|
+
|
|
81
|
+
<div className="p-6">
|
|
82
|
+
<SectionForm
|
|
83
|
+
value={draft}
|
|
84
|
+
onChange={(next) => {
|
|
85
|
+
setDraft(next);
|
|
86
|
+
setDirty(true);
|
|
87
|
+
}}
|
|
88
|
+
/>
|
|
89
|
+
|
|
90
|
+
<div className="mt-6 flex flex-wrap items-center gap-3">
|
|
91
|
+
<button
|
|
92
|
+
onClick={onSave}
|
|
93
|
+
disabled={busy || !dirty}
|
|
94
|
+
className="inline-flex items-center gap-2 bg-ink text-paper px-6 py-3 text-xs uppercase tracking-[0.22em] hover:bg-ink/85 disabled:opacity-50"
|
|
95
|
+
>
|
|
96
|
+
<Save className="size-4" />
|
|
97
|
+
{busy ? "Saving…" : "Save"}
|
|
98
|
+
</button>
|
|
99
|
+
<button
|
|
100
|
+
onClick={onReset}
|
|
101
|
+
className="inline-flex items-center gap-2 border border-ink/20 px-6 py-3 text-xs uppercase tracking-[0.22em] hover:border-ink"
|
|
102
|
+
>
|
|
103
|
+
<RotateCcw className="size-4" />
|
|
104
|
+
Reset to default
|
|
105
|
+
</button>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
</section>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Host-agnostic navigation & footer editor. */
|
|
113
|
+
export function NavigationView({ deps }: { deps: NavigationDeps }) {
|
|
114
|
+
return (
|
|
115
|
+
<div className="p-8 md:p-12 w-full">
|
|
116
|
+
<div className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/55">Admin</div>
|
|
117
|
+
<h1 className="mt-2 font-serif text-3xl md:text-4xl text-ink">Navigation & footer</h1>
|
|
118
|
+
<p className="mt-3 text-ink/65 max-w-2xl">
|
|
119
|
+
Site-wide top menu and footer. Changes apply to every page.
|
|
120
|
+
</p>
|
|
121
|
+
|
|
122
|
+
<div className="mt-8 grid gap-6 xl:grid-cols-2 items-start">
|
|
123
|
+
<Editor deps={deps} sectionKey="nav" title="Navigation" description="Top menu items." />
|
|
124
|
+
<Editor
|
|
125
|
+
deps={deps}
|
|
126
|
+
sectionKey="footer"
|
|
127
|
+
title="Footer"
|
|
128
|
+
description="Footer columns and legal."
|
|
129
|
+
/>
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** Default descriptions for the canonical platform roles. */
|
|
2
|
+
export const DEFAULT_ROLE_DESCRIPTIONS: Record<string, string> = {
|
|
3
|
+
superadmin: "Full control. Manage users, roles, email and everything else. Reserved.",
|
|
4
|
+
admin: "Manage content, pages, media, view logs and audit.",
|
|
5
|
+
editor: "Legacy editor role — behaves like admin for CMS. Prefer admin.",
|
|
6
|
+
seo: "Access SEO tools and page metadata.",
|
|
7
|
+
user: "Signed-in but no backoffice access beyond their own account.",
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type RolesViewProps = {
|
|
11
|
+
allRoles: readonly string[];
|
|
12
|
+
/** Overrides merged over `DEFAULT_ROLE_DESCRIPTIONS`. */
|
|
13
|
+
descriptions?: Record<string, string>;
|
|
14
|
+
/** Path to the users screen, e.g. `/kontoret/admin/users`. */
|
|
15
|
+
usersPath: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** Host-agnostic reference list of the roles that exist in the system. */
|
|
19
|
+
export function RolesView({ allRoles, descriptions, usersPath }: RolesViewProps) {
|
|
20
|
+
const text = { ...DEFAULT_ROLE_DESCRIPTIONS, ...(descriptions ?? {}) };
|
|
21
|
+
return (
|
|
22
|
+
<div className="p-8 md:p-12 w-full">
|
|
23
|
+
<h2 className="font-serif text-xl">User roles</h2>
|
|
24
|
+
<p className="mt-2 text-sm text-ink/60">
|
|
25
|
+
These are the roles that exist in the system. Assign them to users on the{" "}
|
|
26
|
+
<a href={usersPath} className="underline underline-offset-2">
|
|
27
|
+
Users
|
|
28
|
+
</a>{" "}
|
|
29
|
+
page.
|
|
30
|
+
</p>
|
|
31
|
+
<div className="mt-8 border border-ink/10 bg-white divide-y divide-ink/10">
|
|
32
|
+
{allRoles.map((r) => (
|
|
33
|
+
<div key={r} className="p-5">
|
|
34
|
+
<div className="flex items-center gap-3">
|
|
35
|
+
<div className="text-[0.65rem] uppercase tracking-[0.22em] bg-ink text-white px-2 py-1">
|
|
36
|
+
{r}
|
|
37
|
+
</div>
|
|
38
|
+
{r === "superadmin" && (
|
|
39
|
+
<div className="text-[0.65rem] uppercase tracking-[0.22em] text-sea">
|
|
40
|
+
Highest privilege
|
|
41
|
+
</div>
|
|
42
|
+
)}
|
|
43
|
+
</div>
|
|
44
|
+
<p className="mt-2 text-sm text-ink/70">{text[r] ?? ""}</p>
|
|
45
|
+
</div>
|
|
46
|
+
))}
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
}
|