feat: convert campaign and lead views to cards

This commit is contained in:
2026-06-04 17:11:39 +02:00
parent 59824b7336
commit ca42c8d5a6
5 changed files with 563 additions and 485 deletions

View File

@@ -0,0 +1,51 @@
---
id: TASK-20
title: Convert campaigns and leads to compact cards
status: Done
assignee: []
created_date: '2026-06-04 15:01'
updated_date: '2026-06-04 15:10'
labels: []
dependencies: []
priority: high
ordinal: 22000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Update the dashboard campaign and lead review UI so campaigns render as individual cards and leads render as compact expandable cards while preserving existing Convex behavior.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Campaigns page renders each campaign as its own responsive card instead of a desktop table.
- [x] #2 Leads page renders compact cards showing company/name, contact data, and priority while hiding review fields behind Mehr anzeigen.
- [x] #3 Expanded lead cards preserve all existing review fields and save/block actions.
- [x] #4 UI remains responsive without horizontal table overflow on desktop and mobile.
- [x] #5 Lint and test verification are run and results are documented.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add/adjust tests or static checks that fail for table-based Campaigns/Leads layouts before production edits.
2. Convert CampaignsBoard from desktop table plus mobile cards to one responsive card list.
3. Convert LeadsReviewTable from table rows to compact expandable cards.
4. Run lint, tests, and browser/responsive verification.
5. Record verification notes in Backlog; wait for user confirmation before Done.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented via subagent-driven TDD. Campaigns and Leads converted from table layouts to compact cards. Added static layout regression tests for campaign cards and lead expandable cards. Verification: pnpm lint exits 0 with 2 pre-existing generated Better Auth warnings; pnpm test passes 107/107; pnpm build passes after rerun with network access for Google Fonts. Browser automation could launch only outside sandbox, but authenticated dashboard routes redirected to /login in the fresh Playwright context, so final visual validation should be done in the existing logged-in browser session.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Campaigns now render as responsive cards on all breakpoints. Leads now render as compact expandable cards showing company/contact/priority by default and revealing review fields/actions through Mehr anzeigen. Added regression tests for both card layouts. Verified with pnpm lint, pnpm test, and pnpm build; browser automation reached login due fresh unauthenticated context, while user confirmed the authenticated UI manually.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -267,112 +267,7 @@ export function CampaignsBoard() {
</CardHeader> </CardHeader>
</Card> </Card>
) : ( ) : (
<> <div className="grid gap-3">
<div className="hidden overflow-x-auto rounded-lg border bg-card md:block">
<table className="w-full min-w-[820px] border-separate border-spacing-0">
<thead>
<tr className="text-left text-sm text-muted-foreground">
<th className="sticky left-0 bg-card p-3 font-normal">Kampagne</th>
<th className="p-3 font-normal">PLZ / Radius</th>
<th className="p-3 font-normal">Cadence</th>
<th className="p-3 font-normal">Limits</th>
<th className="p-3 font-normal">Status</th>
<th className="p-3 font-normal">Lauf</th>
<th className="p-3 font-normal">Aktionen</th>
</tr>
</thead>
<tbody>
{campaignsSorted.map((campaign) => (
<tr
className="border-t"
key={campaign._id}
>
<td className="max-w-[220px] p-3 align-top">
<div className="space-y-1">
<p className="truncate font-medium">{campaign.name}</p>
<p className="text-sm text-muted-foreground">
{formatNiche(campaign)}
</p>
</div>
</td>
<td className="max-w-[180px] p-3 align-top">
<div className="space-y-1 text-sm text-muted-foreground">
<p className="inline-flex items-center gap-1">
<MapPin className="size-3" />
<span>{campaign.postalCode}</span>
</p>
<p>{campaign.radiusKm} km Umkreis</p>
</div>
</td>
<td className="p-3 align-top">
<span className="rounded-md bg-muted px-2 py-1 text-sm">
{recurrenceLabel[campaign.recurrence]}
</span>
</td>
<td className="p-3 align-top">
<p className="text-sm">
Leads: {campaign.maxNewLeadsPerRun} · Audits:{" "}
{campaign.maxAuditsPerRun}
</p>
</td>
<td className="p-3 align-top">
<Badge
variant={campaign.status === "active" ? "default" : "secondary"}
>
{campaign.status === "active" ? "Aktiv" : "Pausiert"}
</Badge>
</td>
<td className="p-3 align-top">
<div className="space-y-1 text-sm text-muted-foreground">
<p>Letzter Lauf: {formatDateTime(campaign.lastRunAt)}</p>
<p>Nächster Lauf: {formatDateTime(campaign.nextRunAt)}</p>
<p>Run-Status: {statusLabel[campaign.currentRunStatus] ?? campaign.currentRunStatus}</p>
</div>
</td>
<td className="p-3 align-top">
<div className="flex flex-wrap gap-2">
<Button
className="w-full sm:w-auto"
variant="outline"
onClick={() => openEditDialog(campaign)}
disabled={actionBusyId === campaign._id}
>
<Pencil className="size-4" />
Bearbeiten
</Button>
<Button
className="w-full sm:w-auto"
variant="outline"
onClick={() => toggleCampaign(campaign)}
disabled={actionBusyId === campaign._id}
>
<RefreshCcw className="size-4" />
{campaign.status === "active" ? "Pausieren" : "Fortfahren"}
</Button>
<Button
className="w-full sm:w-auto"
onClick={() => runCampaign(campaign)}
disabled={actionBusyId === campaign._id}
>
<Play className="size-4" />
Jetzt ausführen
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="grid gap-3 md:hidden">
{campaignsSorted.map((campaign) => ( {campaignsSorted.map((campaign) => (
<Card key={campaign._id}> <Card key={campaign._id}>
<CardHeader> <CardHeader>
@@ -447,7 +342,6 @@ export function CampaignsBoard() {
</Card> </Card>
))} ))}
</div> </div>
</>
)} )}
</section> </section>
); );

View File

@@ -22,7 +22,7 @@ import {
type LeadBlacklistStatus, type LeadBlacklistStatus,
} from "@/lib/dashboard-model"; } from "@/lib/dashboard-model";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card"; import { Card, CardHeader } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -148,59 +148,23 @@ export function LeadsReviewTable() {
<h1 className="text-2xl font-semibold tracking-normal">Leads prüfen</h1> <h1 className="text-2xl font-semibold tracking-normal">Leads prüfen</h1>
</div> </div>
<div className="mx-auto w-full max-w-7xl"> <div className="mx-auto grid w-full max-w-7xl gap-3">
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<div className="min-w-[1150px]">
<table className="w-full border-separate border-spacing-0 text-sm">
<thead>
<tr className="text-left text-xs text-muted-foreground">
<th className="p-3 font-normal">Firma / Ort</th>
<th className="p-3 font-normal">Kontakt + Quelle</th>
<th className="p-3 font-normal">Priorität</th>
<th className="p-3 font-normal">Kontaktstatus</th>
<th className="p-3 font-normal">Qualität</th>
<th className="p-3 font-normal">Review-Felder</th>
<th className="p-3 font-normal">Aktionen</th>
</tr>
</thead>
{leads === undefined ? ( {leads === undefined ? (
<tbody> <p className="rounded-md bg-muted p-4 text-sm">Leads werden geladen</p>
<tr>
<td className="p-3" colSpan={7}>
<p className="rounded-md bg-muted p-4 text-sm">
Leads werden geladen
</p>
</td>
</tr>
</tbody>
) : sortedLeads.length === 0 ? ( ) : sortedLeads.length === 0 ? (
<tbody>
<tr>
<td className="p-3" colSpan={7}>
<p className="rounded-md border p-4 text-sm text-muted-foreground"> <p className="rounded-md border p-4 text-sm text-muted-foreground">
Keine Leads vorhanden. Bitte zuerst eine Kampagne starten Keine Leads vorhanden. Bitte zuerst eine Kampagne starten oder
oder importieren. importieren.
</p> </p>
</td>
</tr>
</tbody>
) : ( ) : (
<tbody> sortedLeads.map((lead) => (
{sortedLeads.map((lead) => (
<LeadReviewRow <LeadReviewRow
key={lead._id} key={lead._id}
lead={lead} lead={lead}
onActionMessage={setActionMessage} onActionMessage={setActionMessage}
/> />
))} ))
</tbody>
)} )}
</table>
</div>
</div>
</Card>
</div> </div>
{actionMessage ? ( {actionMessage ? (
@@ -219,6 +183,7 @@ function LeadReviewRow({
lead: LeadRow; lead: LeadRow;
onActionMessage: (value: string) => void; onActionMessage: (value: string) => void;
}) { }) {
const [isExpanded, setIsExpanded] = useState(false);
const [draft, setDraft] = useState<LeadReviewDraft>(() => ({ const [draft, setDraft] = useState<LeadReviewDraft>(() => ({
priority: lead.priority, priority: lead.priority,
contactStatus: lead.contactStatus, contactStatus: lead.contactStatus,
@@ -313,57 +278,86 @@ function LeadReviewRow({
setDraft((current) => ({ ...current, [field]: value })); setDraft((current) => ({ ...current, [field]: value }));
}; };
const detailsId = `lead-review-details-${lead._id}`;
return ( return (
<tr className="border-t"> <Card>
<td className="max-w-[260px] p-3 align-top"> <CardHeader className="pb-3">
<p className="font-medium">{lead.companyName}</p> <div className="grid min-w-0 gap-2">
<p className="mt-1 inline-flex items-center gap-1 truncate text-xs text-muted-foreground"> <div className="flex min-w-0 flex-wrap items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<p className="max-w-full truncate font-medium">
{lead.companyName}
</p>
<p className="mt-1 inline-flex items-center gap-1 text-xs text-muted-foreground">
<Building2 className="size-3 shrink-0" /> <Building2 className="size-3 shrink-0" />
<span className="truncate">{lead.niche ?? "Nische offen"}</span> <span className="inline-flex min-w-0 max-w-full break-words">
{lead.niche ?? "Nische offen"}
</span>
</p> </p>
<p className="mt-2 inline-flex items-center gap-1 text-xs text-muted-foreground"> <p className="mt-2 inline-flex items-center gap-1 text-xs text-muted-foreground">
<MapPin className="size-3 shrink-0" /> <MapPin className="size-3 shrink-0" />
<span>{location}</span> <span className="inline-flex min-w-0 max-w-full truncate">
</p> {location}
{lead.address ? (
<p className="mt-1 max-w-full truncate text-xs text-muted-foreground">
{lead.address}
</p>
) : null}
</td>
<td className="max-w-[260px] p-3 align-top">
<p className="inline-flex w-full items-start gap-1 text-sm">
<Mail className="mt-0.5 size-3 shrink-0" />
<span className="min-w-0 break-all">
{lead.email || "Keine E-Mail"}
</span> </span>
</p> </p>
{lead.phone ? ( </div>
<p className="mt-2 inline-flex w-full items-start gap-1 text-xs text-muted-foreground">
<Phone className="size-3 shrink-0" />
<span className="break-all">{lead.phone}</span>
</p>
) : null}
<p className="mt-2 text-xs text-muted-foreground">
Quelle: {contactSourceLabel(lead)}
</p>
{lead.websiteDomain ? (
<p className="mt-1 text-xs text-muted-foreground">
Domain: {lead.websiteDomain}
</p>
) : null}
</td>
<td className="p-3 align-top">
<p <p
className={`inline-flex rounded-md border px-2 py-1 text-xs font-medium ${priorityBadgeClass( className={`inline-flex shrink-0 rounded-md border px-2 py-1 text-xs font-medium ${priorityBadgeClass(
draft.priority, draft.priority,
)}`} )}`}
> >
{getLeadPriorityLabel(draft.priority)} {getLeadPriorityLabel(draft.priority)}
</p> </p>
<div className="mt-2 max-w-[160px]"> </div>
<div className="grid min-w-0 gap-1 text-xs text-muted-foreground">
<p className="inline-flex min-w-0 items-center gap-1">
<Mail className="size-3 shrink-0" />
<span className="max-w-full min-w-0 break-all">
{lead.email || "Keine E-Mail"}
</span>
</p>
{lead.phone ? (
<p className="inline-flex min-w-0 items-center gap-1">
<Phone className="size-3 shrink-0" />
<span className="max-w-full min-w-0 break-all">{lead.phone}</span>
</p>
) : null}
<p className="truncate max-w-full">
Quelle: {contactSourceLabel(lead)}
</p>
{lead.websiteDomain ? (
<p className="truncate max-w-full">Domain: {lead.websiteDomain}</p>
) : null}
</div>
</div>
</CardHeader>
<div className="border-t p-4 pt-3">
<Button
type="button"
variant="outline"
onClick={() => setIsExpanded((previous) => !previous)}
aria-expanded={isExpanded}
aria-controls={detailsId}
size="sm"
>
{isExpanded ? "Weniger anzeigen" : "Mehr anzeigen"}
</Button>
</div>
<div
id={detailsId}
className="grid gap-3 border-t p-4"
hidden={!isExpanded}
>
<div className="grid gap-3 xl:grid-cols-2">
<section className="grid gap-2">
<div>
<p className="text-xs text-muted-foreground">Priorität</p>
<div className="mt-2">
<Select <Select
value={draft.priority} value={draft.priority}
onValueChange={(nextPriority) => onValueChange={(nextPriority) =>
@@ -382,13 +376,11 @@ function LeadReviewRow({
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
</td> </div>
<td className="p-3 align-top"> <div>
<Badge variant="outline"> <p className="text-xs text-muted-foreground">Kontaktstatus</p>
{getLeadContactStatusLabel(draft.contactStatus)} <div className="mt-2">
</Badge>
<div className="mt-2 max-w-[180px]">
<Select <Select
value={draft.contactStatus} value={draft.contactStatus}
onValueChange={(nextStatus) => onValueChange={(nextStatus) =>
@@ -407,10 +399,11 @@ function LeadReviewRow({
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
</td> </div>
</section>
<td className="max-w-[220px] p-3 align-top"> <section className="grid gap-2">
<div className="grid gap-2"> <div>
<p className="text-xs text-muted-foreground">Prioritätsgrund</p> <p className="text-xs text-muted-foreground">Prioritätsgrund</p>
<Input <Input
value={draft.priorityReason} value={draft.priorityReason}
@@ -419,8 +412,10 @@ function LeadReviewRow({
}} }}
/> />
</div> </div>
<div className="mt-2 grid gap-2"> <div>
<p className="text-xs text-muted-foreground">Kontaktstatus-Notiz</p> <p className="mt-2 text-xs text-muted-foreground">
Kontaktstatus-Notiz
</p>
<Input <Input
value={draft.contactStatusReason} value={draft.contactStatusReason}
onChange={(event) => { onChange={(event) => {
@@ -428,8 +423,8 @@ function LeadReviewRow({
}} }}
/> />
</div> </div>
<div className="mt-2 grid gap-2"> <div>
<p className="text-xs text-muted-foreground">Notiz</p> <p className="mt-2 text-xs text-muted-foreground">Notiz</p>
<Input <Input
value={draft.notes} value={draft.notes}
onChange={(event) => { onChange={(event) => {
@@ -437,22 +432,6 @@ function LeadReviewRow({
}} }}
/> />
</div> </div>
<div className="mt-3 flex flex-wrap gap-2">
<Badge
variant={duplicateBadgeVariant(draft.duplicateStatus)}
title={lead.duplicateReason ?? undefined}
>
{getLeadDuplicateStatusLabel(draft.duplicateStatus)}
</Badge>
<Badge
variant={lead.blacklistStatus === "blocked" ? "destructive" : "secondary"}
>
{getLeadBlacklistStatusLabel(lead.blacklistStatus)}
</Badge>
</div>
<div className="mt-2 space-y-1 text-xs text-muted-foreground"> <div className="mt-2 space-y-1 text-xs text-muted-foreground">
{reasonParts.length === 0 ? ( {reasonParts.length === 0 ? (
<p>Keine Zusatzhinweise</p> <p>Keine Zusatzhinweise</p>
@@ -460,10 +439,10 @@ function LeadReviewRow({
reasonParts.map((reason) => <p key={reason}> {reason}</p>) reasonParts.map((reason) => <p key={reason}> {reason}</p>)
)} )}
</div> </div>
</td> </section>
<td className="min-w-[260px] p-3 align-top"> <section className="grid gap-2">
<div className="grid gap-2"> <div>
<p className="text-xs text-muted-foreground">Review-E-Mail</p> <p className="text-xs text-muted-foreground">Review-E-Mail</p>
<Input <Input
value={draft.reviewEmail} value={draft.reviewEmail}
@@ -473,8 +452,8 @@ function LeadReviewRow({
/> />
</div> </div>
<div className="mt-2 grid gap-2"> <div>
<p className="text-xs text-muted-foreground">Review-Quelle</p> <p className="mt-2 text-xs text-muted-foreground">Review-Quelle</p>
<Input <Input
value={draft.reviewEmailSource} value={draft.reviewEmailSource}
onChange={(event) => { onChange={(event) => {
@@ -482,9 +461,8 @@ function LeadReviewRow({
}} }}
/> />
</div> </div>
<div>
<div className="mt-2 grid gap-2"> <p className="mt-2 text-xs text-muted-foreground">Ansprechperson</p>
<p className="text-xs text-muted-foreground">Ansprechperson</p>
<Input <Input
value={draft.reviewContactPerson} value={draft.reviewContactPerson}
onChange={(event) => { onChange={(event) => {
@@ -492,8 +470,7 @@ function LeadReviewRow({
}} }}
/> />
</div> </div>
<label className="mt-2 inline-flex items-center gap-2 text-xs text-muted-foreground">
<label className="mt-3 inline-flex items-center gap-2 text-xs text-muted-foreground">
<Switch <Switch
checked={draft.reviewIsBusinessContactAddress} checked={draft.reviewIsBusinessContactAddress}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
@@ -502,9 +479,12 @@ function LeadReviewRow({
/> />
Genannte E-Mail als Business-Kontakt Genannte E-Mail als Business-Kontakt
</label> </label>
</section>
<div className="mt-3 grid gap-2"> <section className="grid gap-2">
<div>
<p className="text-xs text-muted-foreground">Duplikatstatus</p> <p className="text-xs text-muted-foreground">Duplikatstatus</p>
<div className="mt-2">
<Select <Select
value={draft.duplicateStatus} value={draft.duplicateStatus}
onValueChange={(nextStatus) => onValueChange={(nextStatus) =>
@@ -523,9 +503,11 @@ function LeadReviewRow({
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
</div>
<div className="mt-2"> <div>
<label className="text-xs text-muted-foreground">Sperrstatus</label> <label className="text-xs text-muted-foreground">Sperrstatus</label>
<div className="mt-2">
<Select <Select
value={draft.blacklistStatus} value={draft.blacklistStatus}
onValueChange={(nextStatus) => onValueChange={(nextStatus) =>
@@ -544,18 +526,26 @@ function LeadReviewRow({
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
</td> </div>
<td className="max-w-[170px] p-3 align-top"> <div className="mt-2 grid gap-2 sm:grid-cols-2">
<div className="grid gap-2"> <Badge
<Button variant={duplicateBadgeVariant(draft.duplicateStatus)}
onClick={saveRow} title={lead.duplicateReason ?? undefined}
disabled={isSaving || isBlocking}
size="sm"
> >
{getLeadDuplicateStatusLabel(draft.duplicateStatus)}
</Badge>
<Badge
variant={lead.blacklistStatus === "blocked" ? "destructive" : "secondary"}
>
{getLeadBlacklistStatusLabel(lead.blacklistStatus)}
</Badge>
</div>
<div className="mt-2 grid gap-2 sm:grid-cols-2">
<Button onClick={saveRow} disabled={isSaving || isBlocking} size="sm">
<span>Speichern</span> <span>Speichern</span>
</Button> </Button>
<Button <Button
variant="destructive" variant="destructive"
onClick={blockLead} onClick={blockLead}
@@ -566,11 +556,12 @@ function LeadReviewRow({
Sperren Sperren
</Button> </Button>
</div> </div>
{rowMessage ? ( {rowMessage ? (
<p className="mt-2 text-xs text-muted-foreground">{rowMessage}</p> <p className="text-xs text-muted-foreground">{rowMessage}</p>
) : null} ) : null}
</td> </section>
</tr> </div>
</div>
</Card>
); );
} }

View File

@@ -0,0 +1,30 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import test from "node:test";
const campaignsBoardPath = join(
process.cwd(),
"components",
"campaigns",
"campaigns-board.tsx",
);
test("campaign board renders campaigns as responsive cards", async () => {
const source = await readFile(campaignsBoardPath, "utf8");
assert.doesNotMatch(source, /<table\b/i);
assert.doesNotMatch(source, /<thead\b/i);
assert.doesNotMatch(source, /<tbody\b/i);
assert.doesNotMatch(source, /<tr\b/i);
assert.doesNotMatch(source, /<td\b/i);
assert.doesNotMatch(source, /<th\b/i);
assert.doesNotMatch(source, /md:hidden/i);
assert.doesNotMatch(source, /md:block/i);
assert.match(source, /className="grid gap-3"/);
assert.match(source, /openEditDialog\(campaign\)/);
assert.match(source, /toggleCampaign\(campaign\)/);
assert.match(source, /runCampaign\(campaign\)/);
});

View File

@@ -0,0 +1,112 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import test from "node:test";
const leadsReviewPath = join(
process.cwd(),
"components",
"leads",
"leads-review-table.tsx",
);
test("LeadsReviewTable uses compact card summaries with expandable review details", async () => {
const source = await readFile(leadsReviewPath, "utf8");
assert.doesNotMatch(source, /<table\b/i);
assert.doesNotMatch(source, /<thead\b/i);
assert.doesNotMatch(source, /<tbody\b/i);
assert.doesNotMatch(source, /<tr\b/i);
assert.doesNotMatch(source, /<td\b/i);
assert.doesNotMatch(source, /<th\b/i);
assert.doesNotMatch(source, /min-w-\[/i);
assert.match(source, /Mehr anzeigen/);
assert.match(source, /Weniger anzeigen/);
assert.match(source, /aria-expanded=\{[^}]+\}/);
assert.match(source, /aria-controls=\{[^}]+\}/);
assert.match(source, /id=\{[^}]+\}/);
assert.match(
source,
/aria-expanded=\{[^}]+\}[\s\S]{0,160}aria-controls=\{[^}]+\}[\s\S]{0,160}(Mehr anzeigen|Weniger anzeigen)/i,
);
assert.match(
source,
/hidden=\{!?isExpanded\}/,
);
const companyNameMatch = source.match(
/<p className="([^"]+)">\s*\{lead\.companyName\}\s*<\/p>/,
);
assert.ok(
companyNameMatch !== null &&
/(?:^|\s)(truncate|max-w-full|min-w-0|break-words)(?:\s|$)/.test(
companyNameMatch[1],
),
"Company name should use overflow-safe text classes in compact card.",
);
const nicheMatch = source.match(
/lead\.niche\s+\?\?\s+"Nische offen"\}\s*<\/span>/,
);
assert.ok(
nicheMatch !== null,
"Niche rendering should still be asserted in test fixture.",
);
const nicheContainerMatch = source.match(
/<span className="([^"]+)">\s*\{lead\.niche\s+\?\?\s+"Nische offen"\}\s*<\/span>/,
);
assert.ok(
nicheContainerMatch !== null &&
/(?:^|\s)(truncate|max-w-full|break-all|break-words)(?:\s|$)/.test(
nicheContainerMatch[1],
),
"Niche should use overflow-safe text classes in compact card.",
);
const locationMatch = source.match(/\{location\}/);
assert.ok(
locationMatch !== null,
"Location rendering should still be present in compact card.",
);
const locationContainerMatch = source.match(
/<span className="([^"]+)">\s*\{location\}\s*<\/span>/,
);
assert.ok(
locationContainerMatch !== null &&
/(?:^|\s)(truncate|max-w-full|break-words)(?:\s|$)/.test(
locationContainerMatch[1],
),
"Location should use overflow-safe text classes in compact card.",
);
const emailSpanMatch = source.match(
/<span className="([^"]+)">\s*\{lead\.email \|\| "Keine E-Mail"\}\s*<\/span>/,
);
assert.ok(
emailSpanMatch !== null &&
/(?:^|\s)(break-all|max-w-full|min-w-0)(?:\s|$)/.test(
emailSpanMatch[1],
),
"Lead email should use overflow-safe text classes in compact card.",
);
const phoneSpanMatch = source.match(
/<span className="([^"]+)">\s*\{lead\.phone\}\s*<\/span>/,
);
assert.ok(
phoneSpanMatch !== null &&
/(?:^|\s)(break-all|max-w-full|min-w-0)(?:\s|$)/.test(phoneSpanMatch[1]),
"Lead phone should use overflow-safe text classes in compact card.",
);
assert.match(source, /Kontaktstatus/);
assert.match(source, /Review-E-Mail/);
assert.match(source, /Review-Quelle/);
assert.match(source, /Ansprechperson/);
assert.match(source, /Genannte E-Mail als Business-Kontakt/);
assert.match(source, /Duplikatstatus/);
assert.match(source, /Sperrstatus/);
assert.match(source, /Sperren/);
assert.match(source, /Speichern/);
});