From 5db471fba4f5e5d231b574f983f8269b506f5772 Mon Sep 17 00:00:00 2001 From: Matthias Meister Date: Wed, 1 Jul 2026 09:36:45 +0200 Subject: [PATCH] Refine PitchFast campaign visual identity --- app/dashboard/page.tsx | 2 +- app/globals.css | 73 ++- app/layout.tsx | 4 +- ...sk-53 - Refine-SaaS-MVP-visual-identity.md | 17 +- components/auth-entry.tsx | 4 +- components/campaigns/campaigns-board.tsx | 521 +++++++++++++----- components/dashboard-placeholder-page.tsx | 2 +- components/dashboard-sidebar.tsx | 4 +- tests/campaigns-board-layout.test.ts | 34 +- tests/dashboard-sidebar-branding.test.ts | 42 ++ 10 files changed, 526 insertions(+), 177 deletions(-) create mode 100644 tests/dashboard-sidebar-branding.test.ts diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 64f8395..dbd7e5e 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -48,7 +48,7 @@ export default function DashboardPage() { Review, Evidence und Outreach-Sicherheit in einem Arbeitsfluss.

- 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.

diff --git a/app/globals.css b/app/globals.css index ec37407..2563e3c 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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; + } } diff --git a/app/layout.tsx b/app/layout.tsx index 91bb475..71b196c 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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({ diff --git a/backlog/tasks/task-53 - Refine-SaaS-MVP-visual-identity.md b/backlog/tasks/task-53 - Refine-SaaS-MVP-visual-identity.md index d14a3c5..4be4487 100644 --- a/backlog/tasks/task-53 - Refine-SaaS-MVP-visual-identity.md +++ b/backlog/tasks/task-53 - Refine-SaaS-MVP-visual-identity.md @@ -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 -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 ## 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. diff --git a/components/auth-entry.tsx b/components/auth-entry.tsx index 50b37a3..3169f41 100644 --- a/components/auth-entry.tsx +++ b/components/auth-entry.tsx @@ -72,11 +72,11 @@ export function AuthEntry() {
- WP + PF

- WebDev Pipeline + PitchFast

Agency Evidence Desk diff --git a/components/campaigns/campaigns-board.tsx b/components/campaigns/campaigns-board.tsx index 1bf896b..9e125dc 100644 --- a/components/campaigns/campaigns-board.tsx +++ b/components/campaigns/campaigns-board.tsx @@ -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; type CampaignRunsListResult = FunctionReturnType; @@ -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() {

Controlled Sourcing

- Kampagnen + Kontrollierte Suchläufe

- 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.

@@ -278,153 +333,321 @@ export function CampaignsBoard() { {rowError ?

{rowError}

: null} {actionLabel ?

{actionLabel}

: null} - {campaignsSorted.length === 0 ? ( - - - Keine Kampagnen - - Lege zuerst eine Kampagne mit Kategorie, PLZ und Limits an. - - - - ) : ( -
- {campaignsSorted.map((campaign) => { - const campaignTitleId = `campaign-title-${campaign._id}`; - - return ( - - -
-
- - {campaign.name} - - - {formatNiche(campaign)} - -
- - {campaign.status === "active" ? "Aktiv" : "Pausiert"} - -
-
- - -
-
- - {campaign.postalCode} -
- {campaign.radiusKm} km -
- -
-

- - Cadence: {recurrenceLabel[campaign.recurrence]} -

-

- - Lead-Limit: {campaign.maxNewLeadsPerRun} -

-
-
-

- Letzter Lauf: {formatDateTime(campaign.lastRunAt)} -

-

- Nächster Lauf: {formatDateTime(campaign.nextRunAt)} -

-

- Run-Status:{" "} - {statusLabel[campaign.currentRunStatus] ?? - campaign.currentRunStatus} -

-
- -
- - - -
-
-
- ); - })} -
- )} - - - - Aktuelle Run-Logs - - Letzte Kampagnenläufe inklusive Cron-Skips und Fehlerhinweisen. - - - - {recentCampaignRuns === undefined ? ( - - ) : visibleRuns.length === 0 ? ( -

Noch keine Kampagnenläufe.

+
+
+ {campaignsSorted.length === 0 ? ( + + + Keine Kampagnen + + Lege zuerst eine Kampagne mit Kategorie, PLZ und Limits an. + + + ) : ( - visibleRuns.map((run) => ( -
-
-

- {statusLabel[run.status] ?? run.status} -

-

- {formatDateTime(run.updatedAt)} -

+ <> +
+
+
+
+
Kampagne
+
Suchgebiet
+
Status
+
Cadence
+
Limits
+
Läufe
+
Run
+
Aktionen
+
+
+
+ {campaignsSorted.map((campaign) => { + const campaignTitleId = `campaign-title-${campaign._id}`; + const isBusy = actionBusyId === campaign._id; + const isActive = campaign.status === "active"; + + return ( +
+
+

+ {campaign.name} +

+

+ {formatNiche(campaign)} +

+
+
+ + + {campaign.postalCode} + + + Radius {campaign.radiusKm} km + +
+
+ +
+
+ + + {recurrenceLabel[campaign.recurrence]} + +
+
+ Lead-Limit: {campaign.maxNewLeadsPerRun} + Audit-Limit: {campaign.maxAuditsPerRun} +
+
+ Letzter Lauf: {formatDateTime(campaign.lastRunAt)} + Nächster Lauf: {formatDateTime(campaign.nextRunAt)} +
+
+ + {formatRunStatus(campaign.currentRunStatus)} + +
+
+ + +
+
+ ); + })} +
-

- {stepLabel[run.currentStep ?? ""] ?? run.currentStep ?? "Schritt offen"} -

- {run.currentStep === "campaign_cron_skipped" ? ( -

- Cron wurde übersprungen, weil bereits ein Agentenlauf aktiv war. -

- ) : null} - {run.errorSummary ? ( -

- {run.errorSummary} -

- ) : null}
- )) + +
+ {campaignsSorted.map((campaign) => { + const campaignTitleId = `campaign-title-mobile-${campaign._id}`; + const isBusy = actionBusyId === campaign._id; + const isActive = campaign.status === "active"; + + return ( +
+
+
+

+ {campaign.name} +

+

+ {formatNiche(campaign)} · {campaign.postalCode} +

+
+ +
+ +
+

+ Radius {campaign.radiusKm} km · {recurrenceLabel[campaign.recurrence]} +

+

+ Lead-Limit: {campaign.maxNewLeadsPerRun} · Audit-Limit:{" "} + {campaign.maxAuditsPerRun} +

+

+ Letzter Lauf: {formatDateTime(campaign.lastRunAt)} +

+

+ Nächster Lauf: {formatDateTime(campaign.nextRunAt)} +

+
+ +
+ + Run: {formatRunStatus(campaign.currentRunStatus)} + +
+ + +
+
+
+ ); + })} +
+ )} - - +
+ + +
); } diff --git a/components/dashboard-placeholder-page.tsx b/components/dashboard-placeholder-page.tsx index 494071f..48781e8 100644 --- a/components/dashboard-placeholder-page.tsx +++ b/components/dashboard-placeholder-page.tsx @@ -9,7 +9,7 @@ export function DashboardPlaceholderPage({

- WebDev Pipeline + PitchFast

{title}

diff --git a/components/dashboard-sidebar.tsx b/components/dashboard-sidebar.tsx index e8c0a10..ad7fa8b 100644 --- a/components/dashboard-sidebar.tsx +++ b/components/dashboard-sidebar.tsx @@ -23,11 +23,11 @@ export function DashboardSidebar() {