Refine PitchFast campaign visual identity
This commit is contained in:
@@ -48,7 +48,7 @@ export default function DashboardPage() {
|
||||
Review, Evidence und Outreach-Sicherheit in einem Arbeitsfluss.
|
||||
</h1>
|
||||
<p className="mt-3 max-w-3xl text-sm leading-6 text-muted-foreground">
|
||||
WebDev Pipeline hilft Agenturen, lokale Chancen zu finden,
|
||||
PitchFast hilft Agenturen, lokale Chancen zu finden,
|
||||
konkrete Belege zu sammeln und Outreach erst nach sauberer
|
||||
Freigabe zu starten.
|
||||
</p>
|
||||
|
||||
@@ -73,14 +73,14 @@
|
||||
--chart-4: oklch(0.57 0.12 30);
|
||||
--chart-5: oklch(0.48 0.08 296);
|
||||
--radius: 0.45rem;
|
||||
--sidebar: oklch(0.214 0.034 181);
|
||||
--sidebar-foreground: oklch(0.94 0.02 103);
|
||||
--sidebar-primary: oklch(0.76 0.112 157);
|
||||
--sidebar-primary-foreground: oklch(0.145 0.024 185);
|
||||
--sidebar-accent: oklch(0.285 0.041 181);
|
||||
--sidebar-accent-foreground: oklch(0.96 0.018 103);
|
||||
--sidebar-border: oklch(0.94 0.018 103 / 15%);
|
||||
--sidebar-ring: oklch(0.76 0.112 157);
|
||||
--sidebar: oklch(0.94 0.021 101);
|
||||
--sidebar-foreground: oklch(0.19 0.035 178);
|
||||
--sidebar-primary: oklch(0.31 0.083 177);
|
||||
--sidebar-primary-foreground: oklch(0.986 0.014 104);
|
||||
--sidebar-accent: oklch(0.885 0.03 102);
|
||||
--sidebar-accent-foreground: oklch(0.19 0.035 178);
|
||||
--sidebar-border: oklch(0.175 0.033 178 / 14%);
|
||||
--sidebar-ring: oklch(0.54 0.095 177);
|
||||
--surface-rail: oklch(0.93 0.027 98);
|
||||
--surface-panel: oklch(0.984 0.012 100);
|
||||
--surface-pressed: oklch(0.885 0.035 103);
|
||||
@@ -222,4 +222,61 @@
|
||||
background: var(--surface-safe);
|
||||
color: color-mix(in oklch, var(--primary), var(--foreground) 42%);
|
||||
}
|
||||
|
||||
.campaign-ledger {
|
||||
overflow-x: auto;
|
||||
border: 1px solid color-mix(in oklch, var(--border), transparent 8%);
|
||||
border-radius: var(--radius);
|
||||
background:
|
||||
linear-gradient(180deg, color-mix(in oklch, var(--card), white 18%), var(--card));
|
||||
box-shadow:
|
||||
0 1px 0 color-mix(in oklch, var(--foreground), transparent 94%),
|
||||
0 20px 48px color-mix(in oklch, var(--foreground), transparent 95%);
|
||||
}
|
||||
|
||||
.campaign-ledger-row {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
minmax(10rem, 1.15fr)
|
||||
minmax(6.5rem, 0.75fr)
|
||||
minmax(7rem, 0.7fr)
|
||||
minmax(6rem, 0.55fr)
|
||||
minmax(7rem, 0.62fr)
|
||||
minmax(8rem, 0.85fr)
|
||||
minmax(6rem, 0.55fr)
|
||||
minmax(7rem, 0.65fr);
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
min-width: 58rem;
|
||||
min-height: 4.75rem;
|
||||
border-top: 1px solid color-mix(in oklch, var(--border), transparent 25%);
|
||||
padding: 0.85rem 1rem;
|
||||
}
|
||||
|
||||
.campaign-ledger-row:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.campaign-ledger-header {
|
||||
min-height: 2.75rem;
|
||||
background: color-mix(in oklch, var(--muted), var(--card) 35%);
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.campaign-mobile-record {
|
||||
border: 1px solid color-mix(in oklch, var(--border), transparent 10%);
|
||||
border-radius: var(--radius);
|
||||
background:
|
||||
linear-gradient(180deg, color-mix(in oklch, var(--card), white 20%), var(--card));
|
||||
padding: 1rem;
|
||||
box-shadow: 0 14px 34px color-mix(in oklch, var(--foreground), transparent 94%);
|
||||
}
|
||||
|
||||
.campaign-status-rail {
|
||||
align-content: start;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "WebDev Pipeline",
|
||||
description: "Interner Akquise-Agent fuer lokale Webdesign-Leads",
|
||||
title: "PitchFast",
|
||||
description: "Agency Evidence Desk fuer lokale Webdesign-Leads",
|
||||
};
|
||||
|
||||
async function AuthenticatedConvexProvider({
|
||||
|
||||
@@ -4,7 +4,7 @@ title: Refine SaaS MVP visual identity
|
||||
status: In Progress
|
||||
assignee: []
|
||||
created_date: '2026-06-11 11:29'
|
||||
updated_date: '2026-06-12 18:54'
|
||||
updated_date: '2026-06-13 20:34'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: high
|
||||
@@ -27,11 +27,12 @@ Review the current SaaS MVP interface with impeccable design principles and an a
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Review product/design context and current dashboard surfaces
|
||||
2. Run an agency-style critique across visual identity, workflow clarity, and implementation risk
|
||||
3. Apply a focused UI polish pass to reduce generic shadcn feel
|
||||
4. Verify with lint/tests and a local browser pass
|
||||
5. Leave TASK-53 in progress until user confirms manual acceptance
|
||||
1. Create branch codex/pitchfast-campaigns-visual-b and keep .superpowers/ untracked
|
||||
2. Update campaigns layout regression test for desktop ledger plus mobile records
|
||||
3. Refactor CampaignsBoard into Agency Evidence Desk ledger, status rail, and preserved actions
|
||||
4. Update PitchFast sidebar branding and supporting global UI utilities
|
||||
5. Run pnpm test, pnpm lint, and browser smoke checks
|
||||
6. Record verification notes while leaving TASK-53 In Progress pending user acceptance
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
@@ -50,4 +51,8 @@ Clarified .impeccable.md for the actual SaaS repo: primary audience is external
|
||||
User requested a full design rebuild using skills and agents. Scope expanded from context setup/theme polish to a bolder SaaS-facing redesign of dashboard shell and core workspace surfaces guided by .impeccable.md.
|
||||
|
||||
Full redesign pass applied after agent review: rebuilt SaaS visual identity around Agency Evidence Desk tokens, dark sidebar, dashboard approval queue, evidence pipeline, safety ledger, campaign control cards, audit evidence dossier cards, outreach approval bench, lead intake, and customer-facing workspace login. Preserved Convex query/mutation semantics and protected workflow boundaries. Verification: pnpm lint passed with 0 errors and the same 3 unrelated warnings in generated/Convex files; pnpm test passed 409 tests; Browser verified http://localhost:3001/login renders Agency Evidence Desk / Workspace Login, old MVP/Admin copy removed, and no console errors. Dashboard remains auth-protected and redirects unauthenticated sessions to login as expected.
|
||||
|
||||
Starting Variant B campaign visual pass on branch codex/pitchfast-campaigns-visual-b. Scope: convert campaigns from card grid to light-first Agency Evidence Desk ledger with mobile records, right status rail, PitchFast sidebar branding, and updated layout regression tests. Keep Convex schemas, routes, auth, and mutation semantics unchanged.
|
||||
|
||||
Variant B campaign evidence desk implemented on branch codex/pitchfast-campaigns-visual-b. CampaignsBoard now uses a desktop ledger, mobile records, and a right status rail while preserving existing campaign edit/toggle/run flows and Convex API contracts. Sidebar and public entry branding now use PitchFast / Agency Evidence Desk. Verification: pnpm test passed 424 tests; pnpm lint passed with 0 errors and the existing 3 warnings in generated/Convex files. Browser smoke for /dashboard/campaigns at 1440x960 and 390x844 redirects unauthenticated sessions to /login; PitchFast login branding renders cleanly there. Authenticated campaign visual check remains manual. TASK-53 remains In Progress pending user acceptance.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
@@ -72,11 +72,11 @@ export function AuthEntry() {
|
||||
<div>
|
||||
<div className="mb-8 inline-flex items-center gap-3">
|
||||
<div className="flex size-11 items-center justify-center rounded-md bg-primary font-heading text-sm font-black text-primary-foreground">
|
||||
WP
|
||||
PF
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-heading text-sm font-semibold">
|
||||
WebDev Pipeline
|
||||
PitchFast
|
||||
</p>
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
Agency Evidence Desk
|
||||
|
||||
@@ -3,17 +3,31 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMutation, useQuery } from "convex/react";
|
||||
import { FunctionReturnType } from "convex/server";
|
||||
import { Clock3, MapPin, Pencil, Play, RefreshCcw, Plus, ShieldCheck } from "lucide-react";
|
||||
import {
|
||||
CalendarClock,
|
||||
Clock3,
|
||||
MapPin,
|
||||
Pencil,
|
||||
Play,
|
||||
Plus,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import { Id } from "@/convex/_generated/dataModel";
|
||||
import { campaignFormDefaults } from "@/lib/campaign-form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Card,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { CampaignFormDialog } from "@/components/campaigns/campaign-form-dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type CampaignsListResult = FunctionReturnType<typeof api.campaigns.list>;
|
||||
type CampaignRunsListResult = FunctionReturnType<typeof api.runs.list>;
|
||||
@@ -91,6 +105,38 @@ const formatNiche = (campaign: CampaignRow): string => {
|
||||
: campaign.category;
|
||||
};
|
||||
|
||||
const formatRunStatus = (value: string): string => {
|
||||
return statusLabel[value] ?? value;
|
||||
};
|
||||
|
||||
const getRunStatusClassName = (value: string): string => {
|
||||
if (value === "running" || value === "pending") {
|
||||
return "evidence-surface border-transparent";
|
||||
}
|
||||
|
||||
if (value === "succeeded") {
|
||||
return "safe-surface border-transparent";
|
||||
}
|
||||
|
||||
if (value === "failed" || value === "canceled") {
|
||||
return "border-transparent bg-[var(--danger-soft)] text-destructive";
|
||||
}
|
||||
|
||||
return "border-border/75 bg-muted/55 text-muted-foreground";
|
||||
};
|
||||
|
||||
const getRunStepClassName = (value?: string | null): string => {
|
||||
if (value === "campaign_cron_skipped" || value === "campaign_cron_stale_pending") {
|
||||
return "review-surface border-transparent";
|
||||
}
|
||||
|
||||
if (value === "lead_discovery" || value === "campaign_cron_queued") {
|
||||
return "evidence-surface border-transparent";
|
||||
}
|
||||
|
||||
return "border-border/75 bg-background/60";
|
||||
};
|
||||
|
||||
export function CampaignsBoard() {
|
||||
const campaigns = useQuery(api.campaigns.list, { limit: 100 });
|
||||
const recentCampaignRuns = useQuery(api.runs.list, {
|
||||
@@ -147,6 +193,15 @@ export function CampaignsBoard() {
|
||||
return recentCampaignRuns ?? [];
|
||||
}, [recentCampaignRuns]);
|
||||
|
||||
const activeCampaignCount = campaignsSorted.filter(
|
||||
(campaign) => campaign.status === "active",
|
||||
).length;
|
||||
const pausedCampaignCount = campaignsSorted.length - activeCampaignCount;
|
||||
const nextScheduledCampaign = [...campaignsSorted]
|
||||
.filter((campaign) => campaign.nextRunAt)
|
||||
.sort((a, b) => (a.nextRunAt ?? Infinity) - (b.nextRunAt ?? Infinity))
|
||||
.at(0);
|
||||
|
||||
const closeDialog = () => {
|
||||
setEditingCampaign(null);
|
||||
setIsFormOpen(false);
|
||||
@@ -260,11 +315,11 @@ export function CampaignsBoard() {
|
||||
<div>
|
||||
<p className="agency-kicker">Controlled Sourcing</p>
|
||||
<h1 className="mt-2 font-heading text-3xl font-semibold tracking-normal">
|
||||
Kampagnen
|
||||
Kontrollierte Suchläufe
|
||||
</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
|
||||
Plane Suchläufe mit festen Limits, sauberem Radius und manuellen
|
||||
Prüfstationen vor Audit und Outreach.
|
||||
Kampagnen bleiben bewusst limitiert: Suchgebiet, Cadence, Run-Status
|
||||
und manuelle Freigaben stehen vor jeder Outreach-Bewegung.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -278,153 +333,321 @@ export function CampaignsBoard() {
|
||||
{rowError ? <p className="text-sm text-destructive" role="alert">{rowError}</p> : null}
|
||||
{actionLabel ? <p className="text-sm" role="status">{actionLabel}</p> : null}
|
||||
|
||||
{campaignsSorted.length === 0 ? (
|
||||
<Card className="agency-panel">
|
||||
<CardHeader>
|
||||
<CardTitle>Keine Kampagnen</CardTitle>
|
||||
<CardDescription>
|
||||
Lege zuerst eine Kampagne mit Kategorie, PLZ und Limits an.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{campaignsSorted.map((campaign) => {
|
||||
const campaignTitleId = `campaign-title-${campaign._id}`;
|
||||
|
||||
return (
|
||||
<Card
|
||||
aria-labelledby={campaignTitleId}
|
||||
className="agency-panel overflow-hidden"
|
||||
key={campaign._id}
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="truncate" id={campaignTitleId}>
|
||||
{campaign.name}
|
||||
</CardTitle>
|
||||
<CardDescription className="truncate">
|
||||
{formatNiche(campaign)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge
|
||||
variant={campaign.status === "active" ? "default" : "secondary"}
|
||||
>
|
||||
{campaign.status === "active" ? "Aktiv" : "Pausiert"}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="grid gap-3 text-sm">
|
||||
<div className="evidence-surface flex flex-wrap items-center justify-between gap-3 rounded-md px-3 py-2">
|
||||
<div className="inline-flex items-center gap-1 text-muted-foreground">
|
||||
<MapPin className="size-3" />
|
||||
<span>{campaign.postalCode}</span>
|
||||
</div>
|
||||
<span className="font-semibold">{campaign.radiusKm} km</span>
|
||||
</div>
|
||||
<Separator className="bg-border" />
|
||||
<div className="grid gap-2 rounded-md border border-border/75 bg-background/60 p-3">
|
||||
<p className="inline-flex items-center gap-2 font-medium">
|
||||
<Clock3 className="size-3.5 text-primary" />
|
||||
Cadence: {recurrenceLabel[campaign.recurrence]}
|
||||
</p>
|
||||
<p className="inline-flex items-center gap-2 font-medium">
|
||||
<ShieldCheck className="size-3.5 text-primary" />
|
||||
Lead-Limit: {campaign.maxNewLeadsPerRun}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-1 rounded-md bg-muted/45 p-3">
|
||||
<p className="text-muted-foreground">
|
||||
Letzter Lauf: {formatDateTime(campaign.lastRunAt)}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
Nächster Lauf: {formatDateTime(campaign.nextRunAt)}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
Run-Status:{" "}
|
||||
{statusLabel[campaign.currentRunStatus] ??
|
||||
campaign.currentRunStatus}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => openEditDialog(campaign)}
|
||||
disabled={actionBusyId === campaign._id}
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
Bearbeiten
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => toggleCampaign(campaign)}
|
||||
disabled={actionBusyId === campaign._id}
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<RefreshCcw className="size-4" />
|
||||
{campaign.status === "active" ? "Pausieren" : "Fortfahren"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => runCampaign(campaign)}
|
||||
disabled={actionBusyId === campaign._id}
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<Play className="size-4" />
|
||||
Jetzt ausführen
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card className="agency-panel">
|
||||
<CardHeader>
|
||||
<CardTitle>Aktuelle Run-Logs</CardTitle>
|
||||
<CardDescription>
|
||||
Letzte Kampagnenläufe inklusive Cron-Skips und Fehlerhinweisen.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-2 text-sm">
|
||||
{recentCampaignRuns === undefined ? (
|
||||
<Skeleton className="h-16 rounded-lg" />
|
||||
) : visibleRuns.length === 0 ? (
|
||||
<p className="text-muted-foreground">Noch keine Kampagnenläufe.</p>
|
||||
<div className="grid gap-4 2xl:grid-cols-[minmax(0,1fr)_21rem]">
|
||||
<div className="min-w-0">
|
||||
{campaignsSorted.length === 0 ? (
|
||||
<Card className="agency-panel">
|
||||
<CardHeader>
|
||||
<CardTitle>Keine Kampagnen</CardTitle>
|
||||
<CardDescription>
|
||||
Lege zuerst eine Kampagne mit Kategorie, PLZ und Limits an.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
) : (
|
||||
visibleRuns.map((run) => (
|
||||
<div className="rounded-md border border-border/75 bg-background/60 p-3" key={run._id}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="font-medium">
|
||||
{statusLabel[run.status] ?? run.status}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatDateTime(run.updatedAt)}
|
||||
</p>
|
||||
<>
|
||||
<div className="campaign-ledger hidden 2xl:block">
|
||||
<div role="table" aria-label="Kampagnen-Ledger">
|
||||
<div role="rowgroup">
|
||||
<div
|
||||
className="campaign-ledger-row campaign-ledger-header"
|
||||
role="row"
|
||||
>
|
||||
<div role="columnheader">Kampagne</div>
|
||||
<div role="columnheader">Suchgebiet</div>
|
||||
<div role="columnheader">Status</div>
|
||||
<div role="columnheader">Cadence</div>
|
||||
<div role="columnheader">Limits</div>
|
||||
<div role="columnheader">Läufe</div>
|
||||
<div role="columnheader">Run</div>
|
||||
<div role="columnheader">Aktionen</div>
|
||||
</div>
|
||||
</div>
|
||||
<div role="rowgroup">
|
||||
{campaignsSorted.map((campaign) => {
|
||||
const campaignTitleId = `campaign-title-${campaign._id}`;
|
||||
const isBusy = actionBusyId === campaign._id;
|
||||
const isActive = campaign.status === "active";
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-labelledby={campaignTitleId}
|
||||
className="campaign-ledger-row"
|
||||
key={campaign._id}
|
||||
role="row"
|
||||
>
|
||||
<div className="min-w-0" role="cell">
|
||||
<h2
|
||||
className="truncate font-heading text-sm font-semibold"
|
||||
id={campaignTitleId}
|
||||
>
|
||||
{campaign.name}
|
||||
</h2>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{formatNiche(campaign)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-1 text-sm" role="cell">
|
||||
<span className="inline-flex items-center gap-1 font-medium">
|
||||
<MapPin className="size-3.5 text-primary" />
|
||||
{campaign.postalCode}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Radius {campaign.radiusKm} km
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-1" role="cell">
|
||||
<label className="inline-flex items-center gap-2 text-sm font-medium">
|
||||
<Switch
|
||||
checked={isActive}
|
||||
onCheckedChange={() => toggleCampaign(campaign)}
|
||||
disabled={isBusy}
|
||||
aria-label={`${campaign.name} Status ändern`}
|
||||
/>
|
||||
{isActive ? "Aktiv" : "Pausiert"}
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid gap-1 text-sm" role="cell">
|
||||
<span className="inline-flex items-center gap-1 font-medium">
|
||||
<Clock3 className="size-3.5 text-primary" />
|
||||
{recurrenceLabel[campaign.recurrence]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-1 text-sm" role="cell">
|
||||
<span>Lead-Limit: {campaign.maxNewLeadsPerRun}</span>
|
||||
<span>Audit-Limit: {campaign.maxAuditsPerRun}</span>
|
||||
</div>
|
||||
<div className="grid gap-1 text-xs text-muted-foreground" role="cell">
|
||||
<span>Letzter Lauf: {formatDateTime(campaign.lastRunAt)}</span>
|
||||
<span>Nächster Lauf: {formatDateTime(campaign.nextRunAt)}</span>
|
||||
</div>
|
||||
<div className="grid gap-1" role="cell">
|
||||
<Badge
|
||||
className={getRunStatusClassName(
|
||||
campaign.currentRunStatus,
|
||||
)}
|
||||
variant="outline"
|
||||
>
|
||||
{formatRunStatus(campaign.currentRunStatus)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-1" role="cell">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
onClick={() => openEditDialog(campaign)}
|
||||
disabled={isBusy}
|
||||
aria-label={`${campaign.name} bearbeiten`}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => runCampaign(campaign)}
|
||||
disabled={isBusy}
|
||||
size="sm"
|
||||
>
|
||||
<Play className="size-3.5" />
|
||||
Suchlauf starten
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{stepLabel[run.currentStep ?? ""] ?? run.currentStep ?? "Schritt offen"}
|
||||
</p>
|
||||
{run.currentStep === "campaign_cron_skipped" ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Cron wurde übersprungen, weil bereits ein Agentenlauf aktiv war.
|
||||
</p>
|
||||
) : null}
|
||||
{run.errorSummary ? (
|
||||
<p className="mt-1 text-xs text-destructive">
|
||||
{run.errorSummary}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
))
|
||||
|
||||
<div className="campaign-mobile-records grid gap-3 2xl:hidden">
|
||||
{campaignsSorted.map((campaign) => {
|
||||
const campaignTitleId = `campaign-title-mobile-${campaign._id}`;
|
||||
const isBusy = actionBusyId === campaign._id;
|
||||
const isActive = campaign.status === "active";
|
||||
|
||||
return (
|
||||
<article
|
||||
aria-labelledby={campaignTitleId}
|
||||
className="campaign-mobile-record"
|
||||
key={campaign._id}
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2
|
||||
className="truncate font-heading text-base font-semibold"
|
||||
id={campaignTitleId}
|
||||
>
|
||||
{campaign.name}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{formatNiche(campaign)} · {campaign.postalCode}
|
||||
</p>
|
||||
</div>
|
||||
<label className="inline-flex items-center gap-2 text-sm font-medium">
|
||||
<Switch
|
||||
checked={isActive}
|
||||
onCheckedChange={() => toggleCampaign(campaign)}
|
||||
disabled={isBusy}
|
||||
aria-label={`${campaign.name} Status ändern`}
|
||||
/>
|
||||
{isActive ? "Aktiv" : "Pausiert"}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-2 text-sm sm:grid-cols-2">
|
||||
<p className="rounded-md bg-muted/50 px-3 py-2">
|
||||
Radius {campaign.radiusKm} km · {recurrenceLabel[campaign.recurrence]}
|
||||
</p>
|
||||
<p className="rounded-md bg-muted/50 px-3 py-2">
|
||||
Lead-Limit: {campaign.maxNewLeadsPerRun} · Audit-Limit:{" "}
|
||||
{campaign.maxAuditsPerRun}
|
||||
</p>
|
||||
<p className="rounded-md bg-muted/50 px-3 py-2">
|
||||
Letzter Lauf: {formatDateTime(campaign.lastRunAt)}
|
||||
</p>
|
||||
<p className="rounded-md bg-muted/50 px-3 py-2">
|
||||
Nächster Lauf: {formatDateTime(campaign.nextRunAt)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<Badge
|
||||
className={getRunStatusClassName(
|
||||
campaign.currentRunStatus,
|
||||
)}
|
||||
variant="outline"
|
||||
>
|
||||
Run: {formatRunStatus(campaign.currentRunStatus)}
|
||||
</Badge>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openEditDialog(campaign)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
Bearbeiten
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => runCampaign(campaign)}
|
||||
disabled={isBusy}
|
||||
size="sm"
|
||||
>
|
||||
<Play className="size-3.5" />
|
||||
Suchlauf starten
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<aside className="campaign-status-rail agency-panel grid gap-3 p-4">
|
||||
<div>
|
||||
<p className="agency-kicker">Sourcing Status</p>
|
||||
<h2 className="mt-2 font-heading text-lg font-semibold">
|
||||
Kampagnenkontrolle
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="safe-surface rounded-md p-3">
|
||||
<p className="text-2xl font-semibold">{activeCampaignCount}</p>
|
||||
<p className="text-xs font-medium">aktiv</p>
|
||||
</div>
|
||||
<div className="review-surface rounded-md p-3">
|
||||
<p className="text-2xl font-semibold">{pausedCampaignCount}</p>
|
||||
<p className="text-xs font-medium">pausiert</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-border/75 bg-background/60 p-3 text-sm">
|
||||
<p className="inline-flex items-center gap-2 font-semibold">
|
||||
<CalendarClock className="size-4 text-primary" />
|
||||
Nächster geplanter Lauf
|
||||
</p>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
{nextScheduledCampaign
|
||||
? `${nextScheduledCampaign.name}: ${formatDateTime(nextScheduledCampaign.nextRunAt)}`
|
||||
: "Kein Lauf geplant"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="safe-surface rounded-md p-3 text-sm">
|
||||
<p className="inline-flex items-center gap-2 font-semibold">
|
||||
<ShieldCheck className="size-4" />
|
||||
Versand nur nach Freigabe
|
||||
</p>
|
||||
<p className="mt-1 text-xs">
|
||||
Kampagnen erzeugen Recherche und Audits. Outreach bleibt manuell
|
||||
geprüft.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-heading text-base font-semibold">
|
||||
Aktuelle Run-Logs
|
||||
</h2>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Cron-Skips, Fehler und letzte Kampagnenläufe.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2 text-sm">
|
||||
{recentCampaignRuns === undefined ? (
|
||||
<Skeleton className="h-16 rounded-lg" />
|
||||
) : visibleRuns.length === 0 ? (
|
||||
<p className="text-muted-foreground">
|
||||
Noch keine Kampagnenläufe.
|
||||
</p>
|
||||
) : (
|
||||
visibleRuns.map((run) => (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-md border p-3",
|
||||
getRunStatusClassName(run.status),
|
||||
getRunStepClassName(run.currentStep),
|
||||
)}
|
||||
key={run._id}
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="font-medium">
|
||||
{formatRunStatus(run.status)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatDateTime(run.updatedAt)}
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{stepLabel[run.currentStep ?? ""] ??
|
||||
run.currentStep ??
|
||||
"Schritt offen"}
|
||||
</p>
|
||||
{run.currentStep === "campaign_cron_skipped" ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Cron wurde übersprungen, weil bereits ein Agentenlauf
|
||||
aktiv war.
|
||||
</p>
|
||||
) : null}
|
||||
{run.errorSummary ? (
|
||||
<p className="mt-1 text-xs text-destructive">
|
||||
{run.errorSummary}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ export function DashboardPlaceholderPage({
|
||||
<main className="px-4 py-5 sm:px-6 lg:px-8">
|
||||
<section className="mx-auto max-w-7xl rounded-lg border bg-card p-5 text-card-foreground">
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
WebDev Pipeline
|
||||
PitchFast
|
||||
</p>
|
||||
<h1 className="mt-2 text-2xl font-semibold tracking-normal">{title}</h1>
|
||||
<p className="mt-3 max-w-2xl text-sm leading-6 text-muted-foreground">
|
||||
|
||||
@@ -23,11 +23,11 @@ export function DashboardSidebar() {
|
||||
<aside className="flex w-full shrink-0 flex-col border-b border-sidebar-border bg-sidebar text-sidebar-foreground md:sticky md:top-0 md:min-h-dvh md:w-[19rem] md:border-b-0 md:border-r">
|
||||
<div className="flex h-[5.25rem] items-center gap-3 border-b border-sidebar-border px-4">
|
||||
<div className="flex size-11 items-center justify-center rounded-md bg-sidebar-primary font-heading text-sm font-black text-sidebar-primary-foreground shadow-[inset_0_1px_0_color-mix(in_oklch,var(--sidebar-primary-foreground),transparent_75%)]">
|
||||
WP
|
||||
PF
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-heading text-sm font-semibold">
|
||||
WebDev Pipeline
|
||||
PitchFast
|
||||
</p>
|
||||
<p className="truncate text-xs font-medium text-muted-foreground">
|
||||
Agency Evidence Desk
|
||||
|
||||
@@ -9,8 +9,9 @@ const campaignsBoardPath = join(
|
||||
"campaigns",
|
||||
"campaigns-board.tsx",
|
||||
);
|
||||
const globalsPath = join(process.cwd(), "app", "globals.css");
|
||||
|
||||
test("campaign board renders campaigns as responsive cards", async () => {
|
||||
test("campaign board renders campaigns as a desktop ledger with mobile records", async () => {
|
||||
const source = await readFile(campaignsBoardPath, "utf8");
|
||||
|
||||
assert.doesNotMatch(source, /<table\b/i);
|
||||
@@ -20,16 +21,25 @@ test("campaign board renders campaigns as responsive cards", async () => {
|
||||
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.doesNotMatch(source, /className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3"/);
|
||||
assert.match(source, /2xl:grid-cols-\[minmax\(0,1fr\)_21rem\]/);
|
||||
assert.match(source, /campaign-ledger hidden 2xl:block/);
|
||||
assert.match(source, /campaign-mobile-records grid gap-3 2xl:hidden/);
|
||||
assert.match(source, /role="table"/);
|
||||
assert.match(source, /role="rowgroup"/);
|
||||
assert.match(source, /role="row"/);
|
||||
assert.match(source, /role="cell"/);
|
||||
|
||||
assert.match(source, /className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3"/);
|
||||
assert.match(source, /<Card[^>]+aria-labelledby=\{campaignTitleId\}/);
|
||||
assert.match(source, /<Switch/);
|
||||
assert.match(source, /id=\{campaignTitleId\}/);
|
||||
assert.match(source, /openEditDialog\(campaign\)/);
|
||||
assert.match(source, /toggleCampaign\(campaign\)/);
|
||||
assert.match(source, /runCampaign\(campaign\)/);
|
||||
assert.match(source, /Lead-Limit:\s*\{campaign\.maxNewLeadsPerRun\}/);
|
||||
assert.match(source, /Suchlauf starten/);
|
||||
assert.match(source, /Lead-Limit/);
|
||||
assert.match(source, /Audit-Limit/);
|
||||
assert.match(source, /Letzter Lauf/);
|
||||
assert.match(source, /Nächster Lauf/);
|
||||
assert.doesNotMatch(source, /Limits:\s*L/);
|
||||
});
|
||||
|
||||
@@ -39,6 +49,18 @@ test("campaign board surfaces recent run logs", async () => {
|
||||
assert.match(source, /api\.runs\.list/);
|
||||
assert.match(source, /type:\s*"campaign"/);
|
||||
assert.match(source, /Aktuelle Run-Logs/);
|
||||
assert.match(source, /campaign-status-rail/);
|
||||
assert.match(source, /Versand nur nach Freigabe/);
|
||||
assert.match(source, /getRunStatusClassName/);
|
||||
assert.match(source, /getRunStepClassName/);
|
||||
assert.match(source, /run\.errorSummary/);
|
||||
assert.match(source, /campaign_cron_skipped/);
|
||||
});
|
||||
|
||||
test("campaign ledger avoids clipping actions at desktop breakpoints", async () => {
|
||||
const styles = await readFile(globalsPath, "utf8");
|
||||
|
||||
assert.match(styles, /\.campaign-ledger\s*{[\s\S]*overflow-x:\s*auto;/);
|
||||
assert.doesNotMatch(styles, /\.campaign-ledger\s*{[\s\S]*overflow:\s*hidden;/);
|
||||
assert.match(styles, /\.campaign-ledger-row\s*{[\s\S]*min-width:\s*58rem;/);
|
||||
});
|
||||
|
||||
42
tests/dashboard-sidebar-branding.test.ts
Normal file
42
tests/dashboard-sidebar-branding.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
const sidebarPath = join(
|
||||
process.cwd(),
|
||||
"components",
|
||||
"dashboard-sidebar.tsx",
|
||||
);
|
||||
const authEntryPath = join(process.cwd(), "components", "auth-entry.tsx");
|
||||
const layoutPath = join(process.cwd(), "app", "layout.tsx");
|
||||
const globalsPath = join(process.cwd(), "app", "globals.css");
|
||||
|
||||
test("dashboard sidebar uses PitchFast SaaS branding", async () => {
|
||||
const source = await readFile(sidebarPath, "utf8");
|
||||
|
||||
assert.match(source, />\s*PF\s*</);
|
||||
assert.match(source, />\s*PitchFast\s*</);
|
||||
assert.match(source, />\s*Agency Evidence Desk\s*</);
|
||||
assert.doesNotMatch(source, />\s*WebDev Pipeline\s*</);
|
||||
});
|
||||
|
||||
test("light theme sidebar remains a light operational rail", async () => {
|
||||
const styles = await readFile(globalsPath, "utf8");
|
||||
const rootBlock = styles.match(/:root\s*{([\s\S]*?)\n}/)?.[1] ?? "";
|
||||
const darkBlock = styles.match(/\.dark\s*{([\s\S]*?)\n}/)?.[1] ?? "";
|
||||
|
||||
assert.match(rootBlock, /--sidebar:\s*oklch\(0\.94/);
|
||||
assert.match(rootBlock, /--sidebar-foreground:\s*oklch\(0\.19/);
|
||||
assert.match(darkBlock, /--sidebar:\s*oklch\(0\.13/);
|
||||
});
|
||||
|
||||
test("public entry branding also uses PitchFast", async () => {
|
||||
const authEntry = await readFile(authEntryPath, "utf8");
|
||||
const layout = await readFile(layoutPath, "utf8");
|
||||
|
||||
assert.match(authEntry, />\s*PF\s*</);
|
||||
assert.match(authEntry, />\s*PitchFast\s*</);
|
||||
assert.doesNotMatch(authEntry, />\s*WebDev Pipeline\s*</);
|
||||
assert.match(layout, /title:\s*"PitchFast"/);
|
||||
});
|
||||
Reference in New Issue
Block a user