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.
|
Review, Evidence und Outreach-Sicherheit in einem Arbeitsfluss.
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-3 max-w-3xl text-sm leading-6 text-muted-foreground">
|
<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
|
konkrete Belege zu sammeln und Outreach erst nach sauberer
|
||||||
Freigabe zu starten.
|
Freigabe zu starten.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -73,14 +73,14 @@
|
|||||||
--chart-4: oklch(0.57 0.12 30);
|
--chart-4: oklch(0.57 0.12 30);
|
||||||
--chart-5: oklch(0.48 0.08 296);
|
--chart-5: oklch(0.48 0.08 296);
|
||||||
--radius: 0.45rem;
|
--radius: 0.45rem;
|
||||||
--sidebar: oklch(0.214 0.034 181);
|
--sidebar: oklch(0.94 0.021 101);
|
||||||
--sidebar-foreground: oklch(0.94 0.02 103);
|
--sidebar-foreground: oklch(0.19 0.035 178);
|
||||||
--sidebar-primary: oklch(0.76 0.112 157);
|
--sidebar-primary: oklch(0.31 0.083 177);
|
||||||
--sidebar-primary-foreground: oklch(0.145 0.024 185);
|
--sidebar-primary-foreground: oklch(0.986 0.014 104);
|
||||||
--sidebar-accent: oklch(0.285 0.041 181);
|
--sidebar-accent: oklch(0.885 0.03 102);
|
||||||
--sidebar-accent-foreground: oklch(0.96 0.018 103);
|
--sidebar-accent-foreground: oklch(0.19 0.035 178);
|
||||||
--sidebar-border: oklch(0.94 0.018 103 / 15%);
|
--sidebar-border: oklch(0.175 0.033 178 / 14%);
|
||||||
--sidebar-ring: oklch(0.76 0.112 157);
|
--sidebar-ring: oklch(0.54 0.095 177);
|
||||||
--surface-rail: oklch(0.93 0.027 98);
|
--surface-rail: oklch(0.93 0.027 98);
|
||||||
--surface-panel: oklch(0.984 0.012 100);
|
--surface-panel: oklch(0.984 0.012 100);
|
||||||
--surface-pressed: oklch(0.885 0.035 103);
|
--surface-pressed: oklch(0.885 0.035 103);
|
||||||
@@ -222,4 +222,61 @@
|
|||||||
background: var(--surface-safe);
|
background: var(--surface-safe);
|
||||||
color: color-mix(in oklch, var(--primary), var(--foreground) 42%);
|
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 = {
|
export const metadata: Metadata = {
|
||||||
title: "WebDev Pipeline",
|
title: "PitchFast",
|
||||||
description: "Interner Akquise-Agent fuer lokale Webdesign-Leads",
|
description: "Agency Evidence Desk fuer lokale Webdesign-Leads",
|
||||||
};
|
};
|
||||||
|
|
||||||
async function AuthenticatedConvexProvider({
|
async function AuthenticatedConvexProvider({
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ title: Refine SaaS MVP visual identity
|
|||||||
status: In Progress
|
status: In Progress
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-11 11:29'
|
created_date: '2026-06-11 11:29'
|
||||||
updated_date: '2026-06-12 18:54'
|
updated_date: '2026-06-13 20:34'
|
||||||
labels: []
|
labels: []
|
||||||
dependencies: []
|
dependencies: []
|
||||||
priority: high
|
priority: high
|
||||||
@@ -27,11 +27,12 @@ Review the current SaaS MVP interface with impeccable design principles and an a
|
|||||||
## Implementation Plan
|
## Implementation Plan
|
||||||
|
|
||||||
<!-- SECTION:PLAN:BEGIN -->
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
1. Review product/design context and current dashboard surfaces
|
1. Create branch codex/pitchfast-campaigns-visual-b and keep .superpowers/ untracked
|
||||||
2. Run an agency-style critique across visual identity, workflow clarity, and implementation risk
|
2. Update campaigns layout regression test for desktop ledger plus mobile records
|
||||||
3. Apply a focused UI polish pass to reduce generic shadcn feel
|
3. Refactor CampaignsBoard into Agency Evidence Desk ledger, status rail, and preserved actions
|
||||||
4. Verify with lint/tests and a local browser pass
|
4. Update PitchFast sidebar branding and supporting global UI utilities
|
||||||
5. Leave TASK-53 in progress until user confirms manual acceptance
|
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 -->
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
## Implementation Notes
|
## 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.
|
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.
|
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 -->
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|||||||
@@ -72,11 +72,11 @@ export function AuthEntry() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="mb-8 inline-flex items-center gap-3">
|
<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">
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-heading text-sm font-semibold">
|
<p className="font-heading text-sm font-semibold">
|
||||||
WebDev Pipeline
|
PitchFast
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs font-medium text-muted-foreground">
|
<p className="text-xs font-medium text-muted-foreground">
|
||||||
Agency Evidence Desk
|
Agency Evidence Desk
|
||||||
|
|||||||
@@ -3,17 +3,31 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useMutation, useQuery } from "convex/react";
|
import { useMutation, useQuery } from "convex/react";
|
||||||
import { FunctionReturnType } from "convex/server";
|
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 { api } from "@/convex/_generated/api";
|
||||||
import { Id } from "@/convex/_generated/dataModel";
|
import { Id } from "@/convex/_generated/dataModel";
|
||||||
import { campaignFormDefaults } from "@/lib/campaign-form";
|
import { campaignFormDefaults } from "@/lib/campaign-form";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
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 { 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 { CampaignFormDialog } from "@/components/campaigns/campaign-form-dialog";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type CampaignsListResult = FunctionReturnType<typeof api.campaigns.list>;
|
type CampaignsListResult = FunctionReturnType<typeof api.campaigns.list>;
|
||||||
type CampaignRunsListResult = FunctionReturnType<typeof api.runs.list>;
|
type CampaignRunsListResult = FunctionReturnType<typeof api.runs.list>;
|
||||||
@@ -91,6 +105,38 @@ const formatNiche = (campaign: CampaignRow): string => {
|
|||||||
: campaign.category;
|
: 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() {
|
export function CampaignsBoard() {
|
||||||
const campaigns = useQuery(api.campaigns.list, { limit: 100 });
|
const campaigns = useQuery(api.campaigns.list, { limit: 100 });
|
||||||
const recentCampaignRuns = useQuery(api.runs.list, {
|
const recentCampaignRuns = useQuery(api.runs.list, {
|
||||||
@@ -147,6 +193,15 @@ export function CampaignsBoard() {
|
|||||||
return recentCampaignRuns ?? [];
|
return recentCampaignRuns ?? [];
|
||||||
}, [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 = () => {
|
const closeDialog = () => {
|
||||||
setEditingCampaign(null);
|
setEditingCampaign(null);
|
||||||
setIsFormOpen(false);
|
setIsFormOpen(false);
|
||||||
@@ -260,11 +315,11 @@ export function CampaignsBoard() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="agency-kicker">Controlled Sourcing</p>
|
<p className="agency-kicker">Controlled Sourcing</p>
|
||||||
<h1 className="mt-2 font-heading text-3xl font-semibold tracking-normal">
|
<h1 className="mt-2 font-heading text-3xl font-semibold tracking-normal">
|
||||||
Kampagnen
|
Kontrollierte Suchläufe
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
|
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
|
||||||
Plane Suchläufe mit festen Limits, sauberem Radius und manuellen
|
Kampagnen bleiben bewusst limitiert: Suchgebiet, Cadence, Run-Status
|
||||||
Prüfstationen vor Audit und Outreach.
|
und manuelle Freigaben stehen vor jeder Outreach-Bewegung.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -278,6 +333,8 @@ export function CampaignsBoard() {
|
|||||||
{rowError ? <p className="text-sm text-destructive" role="alert">{rowError}</p> : null}
|
{rowError ? <p className="text-sm text-destructive" role="alert">{rowError}</p> : null}
|
||||||
{actionLabel ? <p className="text-sm" role="status">{actionLabel}</p> : null}
|
{actionLabel ? <p className="text-sm" role="status">{actionLabel}</p> : null}
|
||||||
|
|
||||||
|
<div className="grid gap-4 2xl:grid-cols-[minmax(0,1fr)_21rem]">
|
||||||
|
<div className="min-w-0">
|
||||||
{campaignsSorted.length === 0 ? (
|
{campaignsSorted.length === 0 ? (
|
||||||
<Card className="agency-panel">
|
<Card className="agency-panel">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -288,131 +345,295 @@ export function CampaignsBoard() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
<>
|
||||||
|
<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) => {
|
{campaignsSorted.map((campaign) => {
|
||||||
const campaignTitleId = `campaign-title-${campaign._id}`;
|
const campaignTitleId = `campaign-title-${campaign._id}`;
|
||||||
|
const isBusy = actionBusyId === campaign._id;
|
||||||
|
const isActive = campaign.status === "active";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<div
|
||||||
aria-labelledby={campaignTitleId}
|
aria-labelledby={campaignTitleId}
|
||||||
className="agency-panel overflow-hidden"
|
className="campaign-ledger-row"
|
||||||
key={campaign._id}
|
key={campaign._id}
|
||||||
|
role="row"
|
||||||
|
>
|
||||||
|
<div className="min-w-0" role="cell">
|
||||||
|
<h2
|
||||||
|
className="truncate font-heading text-sm font-semibold"
|
||||||
|
id={campaignTitleId}
|
||||||
>
|
>
|
||||||
<CardHeader>
|
|
||||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<CardTitle className="truncate" id={campaignTitleId}>
|
|
||||||
{campaign.name}
|
{campaign.name}
|
||||||
</CardTitle>
|
</h2>
|
||||||
<CardDescription className="truncate">
|
<p className="mt-1 truncate text-xs text-muted-foreground">
|
||||||
{formatNiche(campaign)}
|
{formatNiche(campaign)}
|
||||||
</CardDescription>
|
</p>
|
||||||
</div>
|
</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
|
<Badge
|
||||||
variant={campaign.status === "active" ? "default" : "secondary"}
|
className={getRunStatusClassName(
|
||||||
|
campaign.currentRunStatus,
|
||||||
|
)}
|
||||||
|
variant="outline"
|
||||||
>
|
>
|
||||||
{campaign.status === "active" ? "Aktiv" : "Pausiert"}
|
{formatRunStatus(campaign.currentRunStatus)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
<div className="flex flex-wrap items-center justify-end gap-1" role="cell">
|
||||||
|
|
||||||
<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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
onClick={() => openEditDialog(campaign)}
|
onClick={() => openEditDialog(campaign)}
|
||||||
disabled={actionBusyId === campaign._id}
|
disabled={isBusy}
|
||||||
className="w-full justify-start"
|
aria-label={`${campaign.name} bearbeiten`}
|
||||||
>
|
>
|
||||||
<Pencil className="size-4" />
|
<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>
|
||||||
|
</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
|
Bearbeiten
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
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)}
|
onClick={() => runCampaign(campaign)}
|
||||||
disabled={actionBusyId === campaign._id}
|
disabled={isBusy}
|
||||||
className="w-full justify-start"
|
size="sm"
|
||||||
>
|
>
|
||||||
<Play className="size-4" />
|
<Play className="size-3.5" />
|
||||||
Jetzt ausführen
|
Suchlauf starten
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</article>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<Card className="agency-panel">
|
<aside className="campaign-status-rail agency-panel grid gap-3 p-4">
|
||||||
<CardHeader>
|
<div>
|
||||||
<CardTitle>Aktuelle Run-Logs</CardTitle>
|
<p className="agency-kicker">Sourcing Status</p>
|
||||||
<CardDescription>
|
<h2 className="mt-2 font-heading text-lg font-semibold">
|
||||||
Letzte Kampagnenläufe inklusive Cron-Skips und Fehlerhinweisen.
|
Kampagnenkontrolle
|
||||||
</CardDescription>
|
</h2>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent className="grid gap-2 text-sm">
|
|
||||||
|
<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 ? (
|
{recentCampaignRuns === undefined ? (
|
||||||
<Skeleton className="h-16 rounded-lg" />
|
<Skeleton className="h-16 rounded-lg" />
|
||||||
) : visibleRuns.length === 0 ? (
|
) : visibleRuns.length === 0 ? (
|
||||||
<p className="text-muted-foreground">Noch keine Kampagnenläufe.</p>
|
<p className="text-muted-foreground">
|
||||||
|
Noch keine Kampagnenläufe.
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
visibleRuns.map((run) => (
|
visibleRuns.map((run) => (
|
||||||
<div className="rounded-md border border-border/75 bg-background/60 p-3" key={run._id}>
|
<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">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<p className="font-medium">
|
<p className="font-medium">
|
||||||
{statusLabel[run.status] ?? run.status}
|
{formatRunStatus(run.status)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{formatDateTime(run.updatedAt)}
|
{formatDateTime(run.updatedAt)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-muted-foreground">
|
<p className="mt-1 text-muted-foreground">
|
||||||
{stepLabel[run.currentStep ?? ""] ?? run.currentStep ?? "Schritt offen"}
|
{stepLabel[run.currentStep ?? ""] ??
|
||||||
|
run.currentStep ??
|
||||||
|
"Schritt offen"}
|
||||||
</p>
|
</p>
|
||||||
{run.currentStep === "campaign_cron_skipped" ? (
|
{run.currentStep === "campaign_cron_skipped" ? (
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
Cron wurde übersprungen, weil bereits ein Agentenlauf aktiv war.
|
Cron wurde übersprungen, weil bereits ein Agentenlauf
|
||||||
|
aktiv war.
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{run.errorSummary ? (
|
{run.errorSummary ? (
|
||||||
@@ -423,8 +644,10 @@ export function CampaignsBoard() {
|
|||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export function DashboardPlaceholderPage({
|
|||||||
<main className="px-4 py-5 sm:px-6 lg:px-8">
|
<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">
|
<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">
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
WebDev Pipeline
|
PitchFast
|
||||||
</p>
|
</p>
|
||||||
<h1 className="mt-2 text-2xl font-semibold tracking-normal">{title}</h1>
|
<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">
|
<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">
|
<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 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%)]">
|
<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>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate font-heading text-sm font-semibold">
|
<p className="truncate font-heading text-sm font-semibold">
|
||||||
WebDev Pipeline
|
PitchFast
|
||||||
</p>
|
</p>
|
||||||
<p className="truncate text-xs font-medium text-muted-foreground">
|
<p className="truncate text-xs font-medium text-muted-foreground">
|
||||||
Agency Evidence Desk
|
Agency Evidence Desk
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ const campaignsBoardPath = join(
|
|||||||
"campaigns",
|
"campaigns",
|
||||||
"campaigns-board.tsx",
|
"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");
|
const source = await readFile(campaignsBoardPath, "utf8");
|
||||||
|
|
||||||
assert.doesNotMatch(source, /<table\b/i);
|
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, /<td\b/i);
|
||||||
assert.doesNotMatch(source, /<th\b/i);
|
assert.doesNotMatch(source, /<th\b/i);
|
||||||
|
|
||||||
assert.doesNotMatch(source, /md:hidden/i);
|
assert.doesNotMatch(source, /className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3"/);
|
||||||
assert.doesNotMatch(source, /md:block/i);
|
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, /<Switch/);
|
||||||
assert.match(source, /<Card[^>]+aria-labelledby=\{campaignTitleId\}/);
|
|
||||||
assert.match(source, /id=\{campaignTitleId\}/);
|
assert.match(source, /id=\{campaignTitleId\}/);
|
||||||
assert.match(source, /openEditDialog\(campaign\)/);
|
assert.match(source, /openEditDialog\(campaign\)/);
|
||||||
assert.match(source, /toggleCampaign\(campaign\)/);
|
assert.match(source, /toggleCampaign\(campaign\)/);
|
||||||
assert.match(source, /runCampaign\(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/);
|
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, /api\.runs\.list/);
|
||||||
assert.match(source, /type:\s*"campaign"/);
|
assert.match(source, /type:\s*"campaign"/);
|
||||||
assert.match(source, /Aktuelle Run-Logs/);
|
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, /run\.errorSummary/);
|
||||||
assert.match(source, /campaign_cron_skipped/);
|
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