feat(qr-generate): rewrite settings with content types, style pickers, colors, logo

This commit is contained in:
Siddharth Kumar Sah
2026-04-13 09:57:32 +08:00
parent 67ab5e0cab
commit 0091a5660a
@@ -1,158 +1,774 @@
import { Download, Loader2 } from "lucide-react";
import { useState } from "react";
import { formatHeaders } from "@/lib/api";
export function QrGenerateSettings() {
const [text, setText] = useState("");
const [size, setSize] = useState(400);
const [errorCorrection, setErrorCorrection] = useState<"L" | "M" | "Q" | "H">("M");
const [foreground, setForeground] = useState("#000000");
const [background, setBackground] = useState("#FFFFFF");
const [processing, setProcessing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
import {
Contact,
Download,
Globe,
ImagePlus,
Mail,
MessageSquare,
Phone,
Type,
Wifi,
X,
} from "lucide-react";
import QRCodeStyling from "qr-code-styling";
import { useCallback, useRef } from "react";
import { CollapsibleSection } from "@/components/common/collapsible-section";
import {
type ContentType,
type CornerDotType,
type CornerSquareType,
type DotType,
type DownloadFormat,
encodeQrData,
useQrStore,
} from "@/stores/qr-store";
const handleGenerate = async () => {
if (!text) return;
// ── Content type tab definitions ─────────────────────────────────────
setProcessing(true);
setError(null);
setDownloadUrl(null);
setPreviewUrl(null);
const CONTENT_TYPES: {
id: ContentType;
label: string;
icon: React.ComponentType<{ className?: string }>;
}[] = [
{ id: "url", label: "URL", icon: Globe },
{ id: "text", label: "Text", icon: Type },
{ id: "wifi", label: "WiFi", icon: Wifi },
{ id: "vcard", label: "vCard", icon: Contact },
{ id: "email", label: "Email", icon: Mail },
{ id: "phone", label: "Phone", icon: Phone },
{ id: "sms", label: "SMS", icon: MessageSquare },
];
try {
const res = await fetch("/api/v1/tools/qr-generate", {
method: "POST",
headers: formatHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ text, size, errorCorrection, foreground, background }),
});
// ── Style option definitions ─────────────────────────────────────────
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Generation failed: ${res.status}`);
}
const DOT_TYPES: { value: DotType; label: string }[] = [
{ value: "square", label: "Square" },
{ value: "rounded", label: "Rounded" },
{ value: "dots", label: "Dots" },
{ value: "classy", label: "Classy" },
{ value: "classy-rounded", label: "Classy Rnd" },
{ value: "extra-rounded", label: "Extra Rnd" },
];
const result = await res.json();
setDownloadUrl(result.downloadUrl);
setPreviewUrl(result.downloadUrl);
} catch (err) {
setError(err instanceof Error ? err.message : "Generation failed");
} finally {
setProcessing(false);
}
};
const CORNER_SQUARE_TYPES: { value: CornerSquareType; label: string }[] = [
{ value: "square", label: "Square" },
{ value: "rounded", label: "Rounded" },
{ value: "dot", label: "Dot" },
{ value: "extra-rounded", label: "Extra Rnd" },
];
const CORNER_DOT_TYPES: { value: CornerDotType; label: string }[] = [
{ value: "square", label: "Square" },
{ value: "rounded", label: "Rounded" },
{ value: "dot", label: "Dot" },
];
const DOWNLOAD_FORMATS: { value: DownloadFormat; label: string; desc: string }[] = [
{ value: "png", label: "PNG", desc: "Best for digital" },
{ value: "svg", label: "SVG", desc: "Best for print" },
{ value: "jpeg", label: "JPEG", desc: "Smaller file" },
{ value: "webp", label: "WebP", desc: "Modern format" },
];
// ── Data entry forms per content type ────────────────────────────────
const INPUT_CLASS =
"w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground";
const TEXTAREA_CLASS = `${INPUT_CLASS} resize-none`;
function UrlForm() {
const { textData, setTextData } = useQrStore();
return (
<div className="space-y-4">
<div>
<label className="text-xs text-muted-foreground">URL</label>
<input
type="url"
value={textData}
onChange={(e) => setTextData(e.target.value)}
placeholder="https://example.com"
className={INPUT_CLASS}
data-testid="qr-input-url"
/>
</div>
);
}
function TextForm() {
const { textData, setTextData } = useQrStore();
return (
<div>
<label className="text-xs text-muted-foreground">Text</label>
<textarea
value={textData}
onChange={(e) => setTextData(e.target.value)}
placeholder="Enter any text..."
rows={3}
className={TEXTAREA_CLASS}
data-testid="qr-input-text"
/>
</div>
);
}
function WifiForm() {
const { wifiData, setWifiData } = useQrStore();
return (
<div className="space-y-2">
<div>
<label htmlFor="qr-text" className="text-xs text-muted-foreground">
Text / URL
</label>
<textarea
id="qr-text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Enter text or URL..."
rows={3}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground resize-none"
<label className="text-xs text-muted-foreground">Network Name (SSID)</label>
<input
type="text"
value={wifiData.ssid}
onChange={(e) => setWifiData({ ssid: e.target.value })}
placeholder="MyNetwork"
className={INPUT_CLASS}
/>
</div>
<div>
<div className="flex justify-between items-center">
<label htmlFor="qr-size" className="text-xs text-muted-foreground">
Size
</label>
<span className="text-xs font-mono text-foreground">{size}px</span>
</div>
<label className="text-xs text-muted-foreground">Password</label>
<input
id="qr-size"
type="range"
min={100}
max={2000}
step={50}
value={size}
onChange={(e) => setSize(Number(e.target.value))}
className="w-full mt-1"
type="text"
value={wifiData.password}
onChange={(e) => setWifiData({ password: e.target.value })}
placeholder="Password"
className={INPUT_CLASS}
/>
</div>
<div>
<label htmlFor="qr-error-correction" className="text-xs text-muted-foreground">
Error Correction
</label>
<label className="text-xs text-muted-foreground">Encryption</label>
<select
id="qr-error-correction"
value={errorCorrection}
onChange={(e) => setErrorCorrection(e.target.value as "L" | "M" | "Q" | "H")}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
value={wifiData.encryption}
onChange={(e) => setWifiData({ encryption: e.target.value as "WPA" | "WEP" | "nopass" })}
className={INPUT_CLASS}
>
<option value="L">Low (7%)</option>
<option value="M">Medium (15%)</option>
<option value="Q">Quartile (25%)</option>
<option value="H">High (30%)</option>
<option value="WPA">WPA / WPA2</option>
<option value="WEP">WEP</option>
<option value="nopass">None</option>
</select>
</div>
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={wifiData.hidden}
onChange={(e) => setWifiData({ hidden: e.target.checked })}
className="rounded border-border"
/>
Hidden network
</label>
</div>
);
}
function VCardForm() {
const { vcardData, setVcardData } = useQrStore();
const field = (label: string, key: keyof typeof vcardData, placeholder: string) => (
<div key={key}>
<label className="text-xs text-muted-foreground">{label}</label>
<input
type="text"
value={vcardData[key]}
onChange={(e) => setVcardData({ [key]: e.target.value })}
placeholder={placeholder}
className={INPUT_CLASS}
/>
</div>
);
return (
<div className="space-y-2">
<div className="flex gap-2">
<div className="flex-1">
<label htmlFor="qr-foreground" className="text-xs text-muted-foreground">
Foreground
<div className="flex-1">{field("First Name", "firstName", "John")}</div>
<div className="flex-1">{field("Last Name", "lastName", "Doe")}</div>
</div>
{field("Phone", "phone", "+1 234 567 890")}
{field("Email", "email", "john@example.com")}
{field("Organization", "organization", "Company")}
{field("Job Title", "title", "Developer")}
{field("Website", "url", "https://example.com")}
</div>
);
}
function EmailForm() {
const { emailData, setEmailData } = useQrStore();
return (
<div className="space-y-2">
<div>
<label className="text-xs text-muted-foreground">To</label>
<input
type="email"
value={emailData.to}
onChange={(e) => setEmailData({ to: e.target.value })}
placeholder="recipient@example.com"
className={INPUT_CLASS}
/>
</div>
<div>
<label className="text-xs text-muted-foreground">Subject</label>
<input
type="text"
value={emailData.subject}
onChange={(e) => setEmailData({ subject: e.target.value })}
placeholder="Subject line"
className={INPUT_CLASS}
/>
</div>
<div>
<label className="text-xs text-muted-foreground">Body</label>
<textarea
value={emailData.body}
onChange={(e) => setEmailData({ body: e.target.value })}
placeholder="Email body..."
rows={2}
className={TEXTAREA_CLASS}
/>
</div>
</div>
);
}
function PhoneForm() {
const { phoneData, setPhoneData } = useQrStore();
return (
<div>
<label className="text-xs text-muted-foreground">Phone Number</label>
<input
type="tel"
value={phoneData}
onChange={(e) => setPhoneData(e.target.value)}
placeholder="+1 234 567 890"
className={INPUT_CLASS}
/>
</div>
);
}
function SmsForm() {
const { smsData, setSmsData } = useQrStore();
return (
<div className="space-y-2">
<div>
<label className="text-xs text-muted-foreground">Phone Number</label>
<input
type="tel"
value={smsData.phone}
onChange={(e) => setSmsData({ phone: e.target.value })}
placeholder="+1 234 567 890"
className={INPUT_CLASS}
/>
</div>
<div>
<label className="text-xs text-muted-foreground">Message</label>
<textarea
value={smsData.message}
onChange={(e) => setSmsData({ message: e.target.value })}
placeholder="Your message..."
rows={2}
className={TEXTAREA_CLASS}
/>
</div>
</div>
);
}
const FORM_MAP: Record<ContentType, React.ComponentType> = {
url: UrlForm,
text: TextForm,
wifi: WifiForm,
vcard: VCardForm,
email: EmailForm,
phone: PhoneForm,
sms: SmsForm,
};
// ── Pill button helpers ──────────────────────────────────────────────
function PillButton({
selected,
onClick,
children,
}: {
selected: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={`text-[10px] py-1.5 rounded transition-colors ${
selected
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:text-foreground"
}`}
>
{children}
</button>
);
}
// ── Main settings component ──────────────────────────────────────────
export function QrGenerateSettings() {
const store = useQrStore();
const logoInputRef = useRef<HTMLInputElement>(null);
const hasData = !!encodeQrData(store);
const handleDownload = useCallback(() => {
const data = encodeQrData(store);
if (!data) return;
const qr = new QRCodeStyling({
width: store.size,
height: store.size,
data,
margin: 8,
qrOptions: { errorCorrectionLevel: store.errorCorrection },
dotsOptions: {
type: store.dotType,
...(store.dotGradientEnabled
? {
gradient: {
type: store.dotGradientType,
rotation: store.dotGradientRotation * (Math.PI / 180),
colorStops: [
{ offset: 0, color: store.dotGradientColor1 },
{ offset: 1, color: store.dotGradientColor2 },
],
},
}
: { color: store.dotColor }),
},
cornersSquareOptions: {
type: store.cornerSquareType,
color: store.useCustomCornerColors ? store.cornerSquareColor : undefined,
},
cornersDotOptions: {
type: store.cornerDotType,
color: store.useCustomCornerColors ? store.cornerDotColor : undefined,
},
backgroundOptions: store.bgTransparent ? { color: "transparent" } : { color: store.bgColor },
...(store.logoDataUrl
? {
image: store.logoDataUrl,
imageOptions: {
hideBackgroundDots: store.hideBackgroundDots,
imageSize: store.logoSize,
margin: store.logoMargin,
crossOrigin: "anonymous" as const,
},
}
: {}),
} as never);
qr.download({
name: "qrcode",
extension: store.downloadFormat,
});
}, [store]);
const handleLogoUpload = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
store.setLogoFile(file ?? null);
},
[store],
);
const ContentForm = FORM_MAP[store.contentType];
return (
<div className="space-y-4">
{/* ── Content type tabs ── */}
<div className="flex flex-wrap gap-1">
{CONTENT_TYPES.map(({ id, label, icon: Icon }) => (
<button
key={id}
type="button"
onClick={() => store.setContentType(id)}
className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors ${
store.contentType === id
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:text-foreground"
}`}
>
<Icon className="h-3 w-3" />
{label}
</button>
))}
</div>
{/* ── Data entry form ── */}
<ContentForm />
{/* ── Style section ── */}
<CollapsibleSection title="Style" defaultOpen>
<div className="space-y-3">
<div>
<label className="text-xs text-muted-foreground mb-1.5 block">Dot Pattern</label>
<div className="grid grid-cols-3 gap-1.5">
{DOT_TYPES.map(({ value, label }) => (
<PillButton
key={value}
selected={store.dotType === value}
onClick={() => store.setDotType(value)}
>
{label}
</PillButton>
))}
</div>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1.5 block">Corner Square</label>
<div className="grid grid-cols-2 gap-1.5">
{CORNER_SQUARE_TYPES.map(({ value, label }) => (
<PillButton
key={value}
selected={store.cornerSquareType === value}
onClick={() => store.setCornerSquareType(value)}
>
{label}
</PillButton>
))}
</div>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1.5 block">Corner Dot</label>
<div className="grid grid-cols-3 gap-1.5">
{CORNER_DOT_TYPES.map(({ value, label }) => (
<PillButton
key={value}
selected={store.cornerDotType === value}
onClick={() => store.setCornerDotType(value)}
>
{label}
</PillButton>
))}
</div>
</div>
</div>
</CollapsibleSection>
{/* ── Colors section ── */}
<CollapsibleSection title="Colors">
<div className="space-y-3">
<div>
<label className="text-xs text-muted-foreground">Dot Color</label>
<div className="flex items-center gap-2 mt-0.5">
<input
type="color"
value={store.dotColor}
onChange={(e) => store.setDotColor(e.target.value)}
className="w-8 h-8 rounded border border-border shrink-0"
disabled={store.dotGradientEnabled}
/>
<input
type="text"
value={store.dotColor}
onChange={(e) => store.setDotColor(e.target.value)}
className="flex-1 px-2 py-1 rounded border border-border bg-background text-xs text-foreground font-mono"
disabled={store.dotGradientEnabled}
/>
</div>
</div>
{/* Gradient toggle */}
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={store.dotGradientEnabled}
onChange={(e) => store.setDotGradientEnabled(e.target.checked)}
className="rounded border-border"
/>
Use gradient
</label>
<input
id="qr-foreground"
type="color"
value={foreground}
onChange={(e) => setForeground(e.target.value)}
className="w-full mt-0.5 h-8 rounded border border-border"
/>
{store.dotGradientEnabled && (
<div className="space-y-2 pl-2 border-l-2 border-primary/20 ml-1">
<div className="flex gap-2">
<div className="flex-1">
<label className="text-[10px] text-muted-foreground">From</label>
<input
type="color"
value={store.dotGradientColor1}
onChange={(e) => store.setDotGradientColor1(e.target.value)}
className="w-full h-7 rounded border border-border"
/>
</div>
<div className="flex-1">
<label className="text-[10px] text-muted-foreground">To</label>
<input
type="color"
value={store.dotGradientColor2}
onChange={(e) => store.setDotGradientColor2(e.target.value)}
className="w-full h-7 rounded border border-border"
/>
</div>
</div>
<div className="flex gap-2">
<PillButton
selected={store.dotGradientType === "linear"}
onClick={() => store.setDotGradientType("linear")}
>
Linear
</PillButton>
<PillButton
selected={store.dotGradientType === "radial"}
onClick={() => store.setDotGradientType("radial")}
>
Radial
</PillButton>
</div>
{store.dotGradientType === "linear" && (
<div>
<div className="flex justify-between items-center">
<label className="text-[10px] text-muted-foreground">Rotation</label>
<span className="text-[10px] font-mono text-foreground">
{store.dotGradientRotation}&deg;
</span>
</div>
<input
type="range"
min={0}
max={360}
step={15}
value={store.dotGradientRotation}
onChange={(e) => store.setDotGradientRotation(Number(e.target.value))}
className="w-full mt-0.5"
/>
</div>
)}
</div>
)}
<div>
<div className="flex items-center justify-between">
<label className="text-xs text-muted-foreground">Background</label>
<label className="flex items-center gap-1 text-[10px] text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={store.bgTransparent}
onChange={(e) => store.setBgTransparent(e.target.checked)}
className="rounded border-border"
/>
Transparent
</label>
</div>
{!store.bgTransparent && (
<div className="flex items-center gap-2 mt-0.5">
<input
type="color"
value={store.bgColor}
onChange={(e) => store.setBgColor(e.target.value)}
className="w-8 h-8 rounded border border-border shrink-0"
/>
<input
type="text"
value={store.bgColor}
onChange={(e) => store.setBgColor(e.target.value)}
className="flex-1 px-2 py-1 rounded border border-border bg-background text-xs text-foreground font-mono"
/>
</div>
)}
</div>
{/* Custom corner colors */}
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={store.useCustomCornerColors}
onChange={(e) => store.setUseCustomCornerColors(e.target.checked)}
className="rounded border-border"
/>
Custom corner colors
</label>
{store.useCustomCornerColors && (
<div className="flex gap-2">
<div className="flex-1">
<label className="text-[10px] text-muted-foreground">Corner Square</label>
<input
type="color"
value={store.cornerSquareColor}
onChange={(e) => store.setCornerSquareColor(e.target.value)}
className="w-full h-7 rounded border border-border"
/>
</div>
<div className="flex-1">
<label className="text-[10px] text-muted-foreground">Corner Dot</label>
<input
type="color"
value={store.cornerDotColor}
onChange={(e) => store.setCornerDotColor(e.target.value)}
className="w-full h-7 rounded border border-border"
/>
</div>
</div>
)}
</div>
<div className="flex-1">
<label htmlFor="qr-background" className="text-xs text-muted-foreground">
Background
</label>
</CollapsibleSection>
{/* ── Logo section ── */}
<CollapsibleSection title="Logo">
<div className="space-y-3">
<input
id="qr-background"
type="color"
value={background}
onChange={(e) => setBackground(e.target.value)}
className="w-full mt-0.5 h-8 rounded border border-border"
ref={logoInputRef}
type="file"
accept="image/*"
onChange={handleLogoUpload}
className="hidden"
/>
{store.logoDataUrl ? (
<div className="flex items-center gap-2">
<img
src={store.logoDataUrl}
alt="Logo"
className="w-10 h-10 rounded border border-border object-contain"
/>
<span className="flex-1 text-xs text-foreground truncate">
{store.logoFile?.name}
</span>
<button
type="button"
onClick={() => store.setLogoFile(null)}
className="p-1 rounded hover:bg-muted"
>
<X className="h-3 w-3 text-muted-foreground" />
</button>
</div>
) : (
<button
type="button"
onClick={() => logoInputRef.current?.click()}
className="w-full py-3 rounded-lg border-2 border-dashed border-border text-xs text-muted-foreground hover:border-primary/40 hover:text-foreground transition-colors flex items-center justify-center gap-2"
>
<ImagePlus className="h-4 w-4" />
Add logo image
</button>
)}
{store.logoDataUrl && (
<>
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Logo Size</label>
<span className="text-xs font-mono text-foreground">
{Math.round(store.logoSize * 100)}%
</span>
</div>
<input
type="range"
min={0.1}
max={0.5}
step={0.05}
value={store.logoSize}
onChange={(e) => store.setLogoSize(Number(e.target.value))}
className="w-full mt-0.5"
/>
</div>
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Logo Margin</label>
<span className="text-xs font-mono text-foreground">{store.logoMargin}px</span>
</div>
<input
type="range"
min={0}
max={20}
step={1}
value={store.logoMargin}
onChange={(e) => store.setLogoMargin(Number(e.target.value))}
className="w-full mt-0.5"
/>
</div>
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={store.hideBackgroundDots}
onChange={(e) => store.setHideBackgroundDots(e.target.checked)}
className="rounded border-border"
/>
Clean area behind logo
</label>
</>
)}
</div>
</div>
</CollapsibleSection>
{/* ── Download section ── */}
<CollapsibleSection title="Download" defaultOpen>
<div className="space-y-3">
<div>
<label className="text-xs text-muted-foreground mb-1.5 block">Format</label>
<div className="grid grid-cols-2 gap-1.5">
{DOWNLOAD_FORMATS.map(({ value, label, desc }) => (
<button
key={value}
type="button"
onClick={() => store.setDownloadFormat(value)}
className={`text-left px-2 py-1.5 rounded transition-colors ${
store.downloadFormat === value
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:text-foreground"
}`}
>
<span className="text-xs font-medium">{label}</span>
<span
className={`block text-[10px] ${
store.downloadFormat === value
? "text-primary-foreground/70"
: "text-muted-foreground"
}`}
>
{desc}
</span>
</button>
))}
</div>
</div>
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Size</label>
<span className="text-xs font-mono text-foreground">{store.size}px</span>
</div>
<input
type="range"
min={200}
max={2000}
step={100}
value={store.size}
onChange={(e) => store.setSize(Number(e.target.value))}
className="w-full mt-0.5"
/>
</div>
<div>
<label className="text-xs text-muted-foreground">Error Correction</label>
<select
value={store.errorCorrection}
onChange={(e) => store.setErrorCorrection(e.target.value as "L" | "M" | "Q" | "H")}
className={INPUT_CLASS}
>
<option value="L">Low (7%) - Max density</option>
<option value="M">Medium (15%) - General use</option>
<option value="Q">Quartile (25%) - With small logo</option>
<option value="H">High (30%) - With large logo</option>
</select>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
<button
type="button"
data-testid="qr-generate-submit"
onClick={handleGenerate}
disabled={!text || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
{processing ? "Generating..." : "Generate QR Code"}
</button>
{previewUrl && (
<div className="flex flex-col items-center gap-2">
<img
src={previewUrl}
alt="QR Code"
className="max-w-full rounded border border-border"
style={{ maxHeight: 200 }}
/>
<a
href={downloadUrl ?? undefined}
download
<button
type="button"
data-testid="qr-generate-download"
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
onClick={handleDownload}
disabled={!hasData}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
<Download className="h-4 w-4" />
Download QR Code
</a>
</button>
</div>
)}
</CollapsibleSection>
</div>
);
}