Installation
npx shadcn@latest add https://boardcn.dev/r/marketing-dashboard.jsonnpx shadcn@latest add https://boardcn.dev/r/marketing-dashboard.jsonnpm packages
- @remixicon/react
- react-aria-components
BoardCN dependencies
The CLI installs these for you — you do not need to add them yourself.
What it composes
22 BoardCN components, installed automatically.
Source
The 4 files the CLI copies into your project.
"use client";
import { useRef, useState } from "react";
import {
RiAddLine,
RiFilter3Line,
RiFlashlightLine,
RiMenuLine,
RiNotification3Line,
} from "@remixicon/react";
import { Dialog, Heading, Modal, ModalOverlay } from "react-aria-components";
import { NotificationCenter, type NotificationCenterItem } from "@/components/blocks/notification-center/notification-center";
import { DashboardSidebar } from "@/components/blocks/dashboard/dashboard-sidebar";
import { StatCards, type Stat } from "@/components/blocks/dashboard/stat-cards";
import { Avatar } from "@/components/base/avatar/avatar";
import { Breadcrumb, BreadcrumbItem } from "@/components/base/breadcrumb/breadcrumb";
import { Button } from "@/components/base/buttons/button";
import { IconButton } from "@/components/base/buttons/icon-button";
import {
Dropdown,
DropdownItem,
DropdownPopover,
DropdownTrigger,
} from "@/components/base/dropdown/dropdown";
import { Input } from "@/components/base/input/input";
import { AreaChartCard, type AreaChartRange } from "@/components/charts/area-chart-card";
import { BarListCard, type BarListTab } from "@/components/charts/bar-list-card";
import { ComboChartCard, type ComboChartRange } from "@/components/charts/combo-chart-card";
import { FunnelChartCard, type FunnelChartRange } from "@/components/charts/funnel-chart-card";
import { cx } from "@/utils/cx";
import { MarketingCampaignTable, type MarketingCampaignActions } from "./marketing-campaign-table";
import type { MarketingCampaign, SpendChannel } from "./marketing-types";
function compact(value: number) {
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(2).replace(/0+$/, "").replace(/\.$/, "")}M`;
if (value >= 1_000) return `${(value / 1_000).toFixed(1).replace(/\.0$/, "")}K`;
return value.toLocaleString("en-US");
}
function moneyCompact(value: number) {
return value >= 1_000 ? `$${(value / 1_000).toFixed(1).replace(/\.0$/, "")}K` : `$${value}`;
}
function SpendByChannelCard({ channels }: { channels: readonly SpendChannel[] }) {
const [range, setRange] = useState("30d");
const [rangeOpen, setRangeOpen] = useState(false);
const [active, setActive] = useState(0);
const rangeLabel = range === "30d" ? "Last 30 days" : range === "7d" ? "Last 7 days" : "Last 90 days";
const activeChannel = channels[active];
const total = channels.reduce((sum, item) => sum + item.value, 0);
const share = Math.round(activeChannel.value / total * 100);
return (
<section className="flex h-[329px] w-full min-w-0 flex-col gap-4 rounded-2xl bg-background-secondary-default px-4 pt-4 pb-3" aria-label="Spend by channel chart">
<div className="flex w-full items-start justify-between gap-3">
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<p className="truncate text-body-medium text-text-secondary">Spend by channel</p>
<div className="flex items-center gap-2">
<p className="text-title-1-medium text-text-primary tabular-nums">$24,880</p>
<span className="rounded-md bg-status-lime-background px-1.5 py-0.5 text-body-medium text-status-lime-text">+8.4%</span>
</div>
</div>
<Dropdown isOpen={rangeOpen} onOpenChange={setRangeOpen}>
<DropdownTrigger className="flex h-8 shrink-0 items-center gap-1.5 rounded-2lg border border-border-button-default bg-background-primary-default px-2 text-body-medium text-text-primary shadow-xs">
{rangeLabel}
</DropdownTrigger>
<DropdownPopover aria-label="Spend period" placement="bottom end" className="w-[176px] p-2">
{(["7d", "30d", "90d"] as const).map((id) => (
<DropdownItem
key={id}
selected={range === id}
onSelect={() => { setRange(id); setRangeOpen(false); }}
className="w-full rounded-2lg px-2 py-1.5 text-left text-body-medium text-text-primary hover:bg-background-secondary-hover"
>
{id === "7d" ? "Last 7 days" : id === "30d" ? "Last 30 days" : "Last 90 days"}
</DropdownItem>
))}
</DropdownPopover>
</Dropdown>
</div>
<div className="relative min-h-[132px] w-full flex-1" role="application" aria-label={`Spend by channel. ${channels.map((item) => `${item.label} ${moneyCompact(item.value)}`).join(", ")}`}>
<svg viewBox="0 0 260 145" className="size-full overflow-visible" aria-hidden>
{channels.map((item, index) => {
const radii = [102, 84, 66, 48];
const lengths = [320.4, 263.9, 207.3, 150.8];
const channelShare = item.value / total;
return (
<g key={item.label} onMouseEnter={() => setActive(index)} onFocus={() => setActive(index)} tabIndex={0} role="button" aria-label={`${item.label}: ${moneyCompact(item.value)}`}>
<path d={`M ${130 - radii[index]} 128 A ${radii[index]} ${radii[index]} 0 0 1 ${130 + radii[index]} 128`} fill="none" stroke="var(--color-chart-track)" strokeWidth="10" strokeLinecap="round" />
<path
d={`M ${130 - radii[index]} 128 A ${radii[index]} ${radii[index]} 0 0 1 ${130 + radii[index]} 128`}
fill="none"
stroke={item.color}
strokeWidth="10"
strokeLinecap="round"
pathLength={lengths[index]}
strokeDasharray={`${lengths[index] * channelShare} ${lengths[index]}`}
className={cx("transition-opacity duration-200 ease-out motion-reduce:transition-none", active !== index && "opacity-45")}
/>
</g>
);
})}
</svg>
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex flex-col items-center">
<span className="text-display-4-medium text-text-primary tabular-nums">{share}%</span>
<span className="-mt-1 max-w-[120px] truncate text-caption-1-medium text-text-tertiary">{activeChannel.label}</span>
</div>
</div>
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1 pb-1">
{channels.map((item, index) => (
<button key={item.label} type="button" onClick={() => setActive(index)} onMouseEnter={() => setActive(index)} className={cx("flex items-center gap-1.5 transition-opacity duration-200", active !== index && "opacity-55")}>
<span className="size-3 rounded-[4px]" style={{ backgroundColor: item.color }} aria-hidden />
<span className="text-body-regular text-text-secondary">{item.label}</span>
<span className="text-body-medium text-text-primary tabular-nums">{moneyCompact(item.value)}</span>
</button>
))}
</div>
</section>
);
}
function NewCampaignDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
const [created, setCreated] = useState(false);
return (
<ModalOverlay
isOpen={open}
onOpenChange={onOpenChange}
isDismissable
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4 transition-opacity duration-200 ease-out data-[entering]:opacity-0 data-[exiting]:opacity-0 motion-reduce:transition-none"
>
<Modal className="w-full max-w-[460px] rounded-3xl border border-border-button-default bg-background-primary-default p-5 shadow-dropdown outline-none transition-[transform,opacity] duration-200 ease-out data-[entering]:scale-95 data-[entering]:opacity-0 data-[exiting]:scale-95 data-[exiting]:opacity-0 motion-reduce:transition-none">
<Dialog className="flex flex-col gap-4 outline-none">
{({ close }) => (
<>
<div className="flex items-start justify-between gap-3">
<div>
<Heading slot="title" className="text-title-2-medium text-text-primary">New campaign</Heading>
<p className="mt-0.5 text-body-regular text-text-secondary">Create a marketing campaign draft.</p>
</div>
<IconButton icon={RiAddLine} aria-label="Close new campaign" className="rotate-45" onClick={close} />
</div>
<form
className="flex flex-col gap-3"
onSubmit={(event) => {
event.preventDefault();
setCreated(true);
window.setTimeout(() => { setCreated(false); close(); }, 600);
}}
>
<Input label="Campaign name" placeholder="Campaign name" isRequired />
<Input label="Budget" placeholder="$0" inputMode="numeric" />
<div className="mt-1 flex items-center justify-end gap-2">
<Button variant="secondary" onClick={close}>Cancel</Button>
<Button type="submit" leadingIcon={created ? RiFlashlightLine : RiAddLine}>{created ? "Created" : "Create draft"}</Button>
</div>
</form>
</>
)}
</Dialog>
</Modal>
</ModalOverlay>
);
}
export interface MarketingDashboardProps extends MarketingCampaignActions {
className?: string;
onFilters?: () => void;
stats: Stat[];
notifications: readonly NotificationCenterItem[];
campaigns: readonly MarketingCampaign[];
acquisitionRanges: readonly FunnelChartRange[];
sessionsTabs: readonly BarListTab[];
roasRanges: readonly ComboChartRange[];
visitorRanges: readonly AreaChartRange[];
spendChannels: readonly SpendChannel[];
}
export function MarketingDashboard({
className,
onFilters,
onDeleteCampaign,
onEditCampaign,
onViewCampaignReport,
onMoreCampaignActions,
stats,
notifications,
campaigns,
acquisitionRanges,
sessionsTabs,
roasRanges,
visitorRanges,
spendChannels,
}: MarketingDashboardProps) {
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const [newCampaignOpen, setNewCampaignOpen] = useState(false);
const campaignsRef = useRef<HTMLDivElement>(null);
return (
<div className={cx("relative flex w-full bg-background-full max-lg:h-dvh max-lg:overflow-hidden lg:min-h-screen", className)}>
<div className="sticky top-3 z-10 hidden h-[calc(100vh-24px)] shrink-0 py-0 pl-3 lg:block">
<DashboardSidebar selected="marketing" />
</div>
<ModalOverlay
isOpen={mobileNavOpen}
onOpenChange={setMobileNavOpen}
isDismissable
className="fixed inset-0 z-40 bg-black/40 transition-opacity duration-300 ease-in-out data-[entering]:opacity-0 data-[exiting]:opacity-0 motion-reduce:transition-none lg:hidden"
>
<Modal className="fixed inset-y-0 left-0 w-[272px] py-3 pl-[6px] outline-none transition-transform duration-300 ease-in-out data-[entering]:-translate-x-full data-[exiting]:-translate-x-full motion-reduce:transition-none">
<Dialog aria-label="Navigation" className="h-full outline-none">
<DashboardSidebar mobile flat selected="marketing" onClose={() => setMobileNavOpen(false)} className="h-full" />
</Dialog>
</Modal>
</ModalOverlay>
<main className="relative z-20 flex min-w-0 flex-1 justify-center overflow-x-hidden overflow-y-auto bg-background-full p-3 will-change-transform sm:pt-6 lg:z-0 lg:overflow-visible">
<div className="flex w-full max-w-[1300px] flex-col gap-2.5">
<header className="flex w-full flex-col gap-2">
<Breadcrumb>
<BreadcrumbItem href="/templates/dashboard">
<Avatar size="xs" initials="B" color="blue" /> BoardCN team
</BreadcrumbItem>
<BreadcrumbItem href="/templates/dashboard">
<Avatar size="xs" initials="M" /> Mertcan
</BreadcrumbItem>
<BreadcrumbItem current>Marketing</BreadcrumbItem>
</Breadcrumb>
<div className="flex w-full flex-wrap items-end justify-between gap-2">
<div className="flex min-w-0 items-center gap-1.5">
<IconButton icon={RiMenuLine} aria-label="Open navigation" onClick={() => setMobileNavOpen(true)} className="lg:hidden" />
<h1 className="px-1 text-title-2-medium whitespace-nowrap text-text-primary">Marketing</h1>
</div>
<div className="flex flex-wrap items-center justify-end gap-2.5">
<Dropdown>
<span className="group relative inline-flex">
<DropdownTrigger
aria-label="Notifications"
className="relative inline-flex size-9 items-center justify-center rounded-2lg border border-border-button-default bg-background-primary-default text-foreground-icon-primary shadow-xs hover:bg-background-primary-hover"
>
<RiNotification3Line className="size-5" aria-hidden />
</DropdownTrigger>
<span className="pointer-events-none absolute top-0.5 left-[18px] flex size-4 items-center justify-center rounded-full border-[1.5px] border-background-primary-default bg-red-600">
<span className="w-4 text-center text-[10px] leading-4 font-bold text-white">5</span>
</span>
</span>
<DropdownPopover aria-label="Notifications" placement="bottom end" className="w-[min(430px,calc(100vw-24px))] border-0 bg-transparent p-0 shadow-none">
<NotificationCenter notifications={[...notifications]} />
</DropdownPopover>
</Dropdown>
<Button variant="secondary" leadingIcon={RiFilter3Line} onClick={() => {
onFilters?.();
campaignsRef.current?.scrollIntoView?.({ behavior: "smooth", block: "start" });
}}>Filters</Button>
<Button leadingIcon={RiAddLine} onClick={() => setNewCampaignOpen(true)}>New campaign</Button>
</div>
</div>
</header>
<div className="flex w-full flex-col gap-4">
<StatCards stats={stats} columns={4} className="max-sm:grid-cols-1" />
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
<FunnelChartCard
title="Acquisition funnel"
shape="sharp"
stages={acquisitionRanges[0].stages}
ranges={[...acquisitionRanges]}
defaultRange="30d"
format={compact}
className="pb-3"
/>
<SpendByChannelCard channels={spendChannels} />
<BarListCard
tabs={[...sessionsTabs]}
defaultTab="channels"
metricLabel="Sessions"
metric="value"
format={(value) => `${value}%`}
limit={5}
className="md:col-span-2 xl:col-span-1"
/>
</div>
<div className="grid w-full grid-cols-1 gap-4 lg:grid-cols-2">
<ComboChartCard
title="Ad spend"
data={roasRanges[0].data}
ranges={[...roasRanges]}
defaultRange="year"
bar={{ key: "spend", label: "Ad spend", format: moneyCompact }}
line={{ key: "roas", label: "ROAS", format: (value) => `${value.toFixed(1)}x` }}
tiles
/>
<AreaChartCard
title="Visitors"
data={visitorRanges[0].data}
ranges={[...visitorRanges]}
defaultRange="year"
series={[
{ key: "organic", label: "Organic" },
{ key: "paid", label: "Paid" },
{ key: "social", label: "Social" },
]}
variant="stacked"
shape="curved"
tiles
valueFormatter={(value) => value.toLocaleString("en-US")}
/>
</div>
<div ref={campaignsRef} className="scroll-mt-3">
<MarketingCampaignTable
campaigns={campaigns}
onDeleteCampaign={onDeleteCampaign}
onEditCampaign={onEditCampaign}
onViewCampaignReport={onViewCampaignReport}
onMoreCampaignActions={onMoreCampaignActions}
/>
</div>
</div>
</div>
</main>
<NewCampaignDialog open={newCampaignOpen} onOpenChange={setNewCampaignOpen} />
</div>
);
}
export default MarketingDashboard;"use client";
import { useRef, useState } from "react";
import {
RiAddLine,
RiFilter3Line,
RiFlashlightLine,
RiMenuLine,
RiNotification3Line,
} from "@remixicon/react";
import { Dialog, Heading, Modal, ModalOverlay } from "react-aria-components";
import { NotificationCenter, type NotificationCenterItem } from "@/components/blocks/notification-center/notification-center";
import { DashboardSidebar } from "@/components/blocks/dashboard/dashboard-sidebar";
import { StatCards, type Stat } from "@/components/blocks/dashboard/stat-cards";
import { Avatar } from "@/components/base/avatar/avatar";
import { Breadcrumb, BreadcrumbItem } from "@/components/base/breadcrumb/breadcrumb";
import { Button } from "@/components/base/buttons/button";
import { IconButton } from "@/components/base/buttons/icon-button";
import {
Dropdown,
DropdownItem,
DropdownPopover,
DropdownTrigger,
} from "@/components/base/dropdown/dropdown";
import { Input } from "@/components/base/input/input";
import { AreaChartCard, type AreaChartRange } from "@/components/charts/area-chart-card";
import { BarListCard, type BarListTab } from "@/components/charts/bar-list-card";
import { ComboChartCard, type ComboChartRange } from "@/components/charts/combo-chart-card";
import { FunnelChartCard, type FunnelChartRange } from "@/components/charts/funnel-chart-card";
import { cx } from "@/utils/cx";
import { MarketingCampaignTable, type MarketingCampaignActions } from "./marketing-campaign-table";
import type { MarketingCampaign, SpendChannel } from "./marketing-types";
function compact(value: number) {
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(2).replace(/0+$/, "").replace(/\.$/, "")}M`;
if (value >= 1_000) return `${(value / 1_000).toFixed(1).replace(/\.0$/, "")}K`;
return value.toLocaleString("en-US");
}
function moneyCompact(value: number) {
return value >= 1_000 ? `$${(value / 1_000).toFixed(1).replace(/\.0$/, "")}K` : `$${value}`;
}
function SpendByChannelCard({ channels }: { channels: readonly SpendChannel[] }) {
const [range, setRange] = useState("30d");
const [rangeOpen, setRangeOpen] = useState(false);
const [active, setActive] = useState(0);
const rangeLabel = range === "30d" ? "Last 30 days" : range === "7d" ? "Last 7 days" : "Last 90 days";
const activeChannel = channels[active];
const total = channels.reduce((sum, item) => sum + item.value, 0);
const share = Math.round(activeChannel.value / total * 100);
return (
<section className="flex h-[329px] w-full min-w-0 flex-col gap-4 rounded-2xl bg-background-secondary-default px-4 pt-4 pb-3" aria-label="Spend by channel chart">
<div className="flex w-full items-start justify-between gap-3">
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<p className="truncate text-body-medium text-text-secondary">Spend by channel</p>
<div className="flex items-center gap-2">
<p className="text-title-1-medium text-text-primary tabular-nums">$24,880</p>
<span className="rounded-md bg-status-lime-background px-1.5 py-0.5 text-body-medium text-status-lime-text">+8.4%</span>
</div>
</div>
<Dropdown isOpen={rangeOpen} onOpenChange={setRangeOpen}>
<DropdownTrigger className="flex h-8 shrink-0 items-center gap-1.5 rounded-2lg border border-border-button-default bg-background-primary-default px-2 text-body-medium text-text-primary shadow-xs">
{rangeLabel}
</DropdownTrigger>
<DropdownPopover aria-label="Spend period" placement="bottom end" className="w-[176px] p-2">
{(["7d", "30d", "90d"] as const).map((id) => (
<DropdownItem
key={id}
selected={range === id}
onSelect={() => { setRange(id); setRangeOpen(false); }}
className="w-full rounded-2lg px-2 py-1.5 text-left text-body-medium text-text-primary hover:bg-background-secondary-hover"
>
{id === "7d" ? "Last 7 days" : id === "30d" ? "Last 30 days" : "Last 90 days"}
</DropdownItem>
))}
</DropdownPopover>
</Dropdown>
</div>
<div className="relative min-h-[132px] w-full flex-1" role="application" aria-label={`Spend by channel. ${channels.map((item) => `${item.label} ${moneyCompact(item.value)}`).join(", ")}`}>
<svg viewBox="0 0 260 145" className="size-full overflow-visible" aria-hidden>
{channels.map((item, index) => {
const radii = [102, 84, 66, 48];
const lengths = [320.4, 263.9, 207.3, 150.8];
const channelShare = item.value / total;
return (
<g key={item.label} onMouseEnter={() => setActive(index)} onFocus={() => setActive(index)} tabIndex={0} role="button" aria-label={`${item.label}: ${moneyCompact(item.value)}`}>
<path d={`M ${130 - radii[index]} 128 A ${radii[index]} ${radii[index]} 0 0 1 ${130 + radii[index]} 128`} fill="none" stroke="var(--color-chart-track)" strokeWidth="10" strokeLinecap="round" />
<path
d={`M ${130 - radii[index]} 128 A ${radii[index]} ${radii[index]} 0 0 1 ${130 + radii[index]} 128`}
fill="none"
stroke={item.color}
strokeWidth="10"
strokeLinecap="round"
pathLength={lengths[index]}
strokeDasharray={`${lengths[index] * channelShare} ${lengths[index]}`}
className={cx("transition-opacity duration-200 ease-out motion-reduce:transition-none", active !== index && "opacity-45")}
/>
</g>
);
})}
</svg>
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex flex-col items-center">
<span className="text-display-4-medium text-text-primary tabular-nums">{share}%</span>
<span className="-mt-1 max-w-[120px] truncate text-caption-1-medium text-text-tertiary">{activeChannel.label}</span>
</div>
</div>
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1 pb-1">
{channels.map((item, index) => (
<button key={item.label} type="button" onClick={() => setActive(index)} onMouseEnter={() => setActive(index)} className={cx("flex items-center gap-1.5 transition-opacity duration-200", active !== index && "opacity-55")}>
<span className="size-3 rounded-[4px]" style={{ backgroundColor: item.color }} aria-hidden />
<span className="text-body-regular text-text-secondary">{item.label}</span>
<span className="text-body-medium text-text-primary tabular-nums">{moneyCompact(item.value)}</span>
</button>
))}
</div>
</section>
);
}
function NewCampaignDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
const [created, setCreated] = useState(false);
return (
<ModalOverlay
isOpen={open}
onOpenChange={onOpenChange}
isDismissable
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4 transition-opacity duration-200 ease-out data-[entering]:opacity-0 data-[exiting]:opacity-0 motion-reduce:transition-none"
>
<Modal className="w-full max-w-[460px] rounded-3xl border border-border-button-default bg-background-primary-default p-5 shadow-dropdown outline-none transition-[transform,opacity] duration-200 ease-out data-[entering]:scale-95 data-[entering]:opacity-0 data-[exiting]:scale-95 data-[exiting]:opacity-0 motion-reduce:transition-none">
<Dialog className="flex flex-col gap-4 outline-none">
{({ close }) => (
<>
<div className="flex items-start justify-between gap-3">
<div>
<Heading slot="title" className="text-title-2-medium text-text-primary">New campaign</Heading>
<p className="mt-0.5 text-body-regular text-text-secondary">Create a marketing campaign draft.</p>
</div>
<IconButton icon={RiAddLine} aria-label="Close new campaign" className="rotate-45" onClick={close} />
</div>
<form
className="flex flex-col gap-3"
onSubmit={(event) => {
event.preventDefault();
setCreated(true);
window.setTimeout(() => { setCreated(false); close(); }, 600);
}}
>
<Input label="Campaign name" placeholder="Campaign name" isRequired />
<Input label="Budget" placeholder="$0" inputMode="numeric" />
<div className="mt-1 flex items-center justify-end gap-2">
<Button variant="secondary" onClick={close}>Cancel</Button>
<Button type="submit" leadingIcon={created ? RiFlashlightLine : RiAddLine}>{created ? "Created" : "Create draft"}</Button>
</div>
</form>
</>
)}
</Dialog>
</Modal>
</ModalOverlay>
);
}
export interface MarketingDashboardProps extends MarketingCampaignActions {
className?: string;
onFilters?: () => void;
stats: Stat[];
notifications: readonly NotificationCenterItem[];
campaigns: readonly MarketingCampaign[];
acquisitionRanges: readonly FunnelChartRange[];
sessionsTabs: readonly BarListTab[];
roasRanges: readonly ComboChartRange[];
visitorRanges: readonly AreaChartRange[];
spendChannels: readonly SpendChannel[];
}
export function MarketingDashboard({
className,
onFilters,
onDeleteCampaign,
onEditCampaign,
onViewCampaignReport,
onMoreCampaignActions,
stats,
notifications,
campaigns,
acquisitionRanges,
sessionsTabs,
roasRanges,
visitorRanges,
spendChannels,
}: MarketingDashboardProps) {
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const [newCampaignOpen, setNewCampaignOpen] = useState(false);
const campaignsRef = useRef<HTMLDivElement>(null);
return (
<div className={cx("relative flex w-full bg-background-full max-lg:h-dvh max-lg:overflow-hidden lg:min-h-screen", className)}>
<div className="sticky top-3 z-10 hidden h-[calc(100vh-24px)] shrink-0 py-0 pl-3 lg:block">
<DashboardSidebar selected="marketing" />
</div>
<ModalOverlay
isOpen={mobileNavOpen}
onOpenChange={setMobileNavOpen}
isDismissable
className="fixed inset-0 z-40 bg-black/40 transition-opacity duration-300 ease-in-out data-[entering]:opacity-0 data-[exiting]:opacity-0 motion-reduce:transition-none lg:hidden"
>
<Modal className="fixed inset-y-0 left-0 w-[272px] py-3 pl-[6px] outline-none transition-transform duration-300 ease-in-out data-[entering]:-translate-x-full data-[exiting]:-translate-x-full motion-reduce:transition-none">
<Dialog aria-label="Navigation" className="h-full outline-none">
<DashboardSidebar mobile flat selected="marketing" onClose={() => setMobileNavOpen(false)} className="h-full" />
</Dialog>
</Modal>
</ModalOverlay>
<main className="relative z-20 flex min-w-0 flex-1 justify-center overflow-x-hidden overflow-y-auto bg-background-full p-3 will-change-transform sm:pt-6 lg:z-0 lg:overflow-visible">
<div className="flex w-full max-w-[1300px] flex-col gap-2.5">
<header className="flex w-full flex-col gap-2">
<Breadcrumb>
<BreadcrumbItem href="/templates/dashboard">
<Avatar size="xs" initials="B" color="blue" /> BoardCN team
</BreadcrumbItem>
<BreadcrumbItem href="/templates/dashboard">
<Avatar size="xs" initials="M" /> Mertcan
</BreadcrumbItem>
<BreadcrumbItem current>Marketing</BreadcrumbItem>
</Breadcrumb>
<div className="flex w-full flex-wrap items-end justify-between gap-2">
<div className="flex min-w-0 items-center gap-1.5">
<IconButton icon={RiMenuLine} aria-label="Open navigation" onClick={() => setMobileNavOpen(true)} className="lg:hidden" />
<h1 className="px-1 text-title-2-medium whitespace-nowrap text-text-primary">Marketing</h1>
</div>
<div className="flex flex-wrap items-center justify-end gap-2.5">
<Dropdown>
<span className="group relative inline-flex">
<DropdownTrigger
aria-label="Notifications"
className="relative inline-flex size-9 items-center justify-center rounded-2lg border border-border-button-default bg-background-primary-default text-foreground-icon-primary shadow-xs hover:bg-background-primary-hover"
>
<RiNotification3Line className="size-5" aria-hidden />
</DropdownTrigger>
<span className="pointer-events-none absolute top-0.5 left-[18px] flex size-4 items-center justify-center rounded-full border-[1.5px] border-background-primary-default bg-red-600">
<span className="w-4 text-center text-[10px] leading-4 font-bold text-white">5</span>
</span>
</span>
<DropdownPopover aria-label="Notifications" placement="bottom end" className="w-[min(430px,calc(100vw-24px))] border-0 bg-transparent p-0 shadow-none">
<NotificationCenter notifications={[...notifications]} />
</DropdownPopover>
</Dropdown>
<Button variant="secondary" leadingIcon={RiFilter3Line} onClick={() => {
onFilters?.();
campaignsRef.current?.scrollIntoView?.({ behavior: "smooth", block: "start" });
}}>Filters</Button>
<Button leadingIcon={RiAddLine} onClick={() => setNewCampaignOpen(true)}>New campaign</Button>
</div>
</div>
</header>
<div className="flex w-full flex-col gap-4">
<StatCards stats={stats} columns={4} className="max-sm:grid-cols-1" />
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
<FunnelChartCard
title="Acquisition funnel"
shape="sharp"
stages={acquisitionRanges[0].stages}
ranges={[...acquisitionRanges]}
defaultRange="30d"
format={compact}
className="pb-3"
/>
<SpendByChannelCard channels={spendChannels} />
<BarListCard
tabs={[...sessionsTabs]}
defaultTab="channels"
metricLabel="Sessions"
metric="value"
format={(value) => `${value}%`}
limit={5}
className="md:col-span-2 xl:col-span-1"
/>
</div>
<div className="grid w-full grid-cols-1 gap-4 lg:grid-cols-2">
<ComboChartCard
title="Ad spend"
data={roasRanges[0].data}
ranges={[...roasRanges]}
defaultRange="year"
bar={{ key: "spend", label: "Ad spend", format: moneyCompact }}
line={{ key: "roas", label: "ROAS", format: (value) => `${value.toFixed(1)}x` }}
tiles
/>
<AreaChartCard
title="Visitors"
data={visitorRanges[0].data}
ranges={[...visitorRanges]}
defaultRange="year"
series={[
{ key: "organic", label: "Organic" },
{ key: "paid", label: "Paid" },
{ key: "social", label: "Social" },
]}
variant="stacked"
shape="curved"
tiles
valueFormatter={(value) => value.toLocaleString("en-US")}
/>
</div>
<div ref={campaignsRef} className="scroll-mt-3">
<MarketingCampaignTable
campaigns={campaigns}
onDeleteCampaign={onDeleteCampaign}
onEditCampaign={onEditCampaign}
onViewCampaignReport={onViewCampaignReport}
onMoreCampaignActions={onMoreCampaignActions}
/>
</div>
</div>
</div>
</main>
<NewCampaignDialog open={newCampaignOpen} onOpenChange={setNewCampaignOpen} />
</div>
);
}
export default MarketingDashboard;export { MarketingDashboard, type MarketingDashboardProps } from "./marketing-dashboard";
export {
MarketingCampaignTable,
type MarketingCampaignActions,
type MarketingCampaignTableProps,
} from "./marketing-campaign-table";
export type {
CampaignChannel,
CampaignDelivery,
CampaignObjective,
MarketingCampaign,
SpendChannel,
} from "./marketing-types";export { MarketingDashboard, type MarketingDashboardProps } from "./marketing-dashboard";
export {
MarketingCampaignTable,
type MarketingCampaignActions,
type MarketingCampaignTableProps,
} from "./marketing-campaign-table";
export type {
CampaignChannel,
CampaignDelivery,
CampaignObjective,
MarketingCampaign,
SpendChannel,
} from "./marketing-types";"use client";
import { useMemo, useState, type ComponentType } from "react";
import {
RiDeleteBin6Line,
RiEditLine,
RiGlobalLine,
RiGoogleFill,
RiLinkedinFill,
RiMetaFill,
RiMore2Fill,
RiSearchLine,
RiTwitterXFill,
} from "@remixicon/react";
import { Chip } from "@/components/base/badges/chip";
import { StatusDot } from "@/components/base/badges/status-dot";
import { IconButton } from "@/components/base/buttons/icon-button";
import { Checkbox } from "@/components/base/checkbox/checkbox";
import {
Dropdown,
DropdownItem,
DropdownPopover,
DropdownTrigger,
} from "@/components/base/dropdown/dropdown";
import { InputBase } from "@/components/base/input/input";
import { Pagination } from "@/components/base/pagination/pagination";
import { Select, SelectItem } from "@/components/base/select/select";
import {
Table,
TableBody,
TableCell,
TableColumn,
TableHeader,
TableRow,
} from "@/components/base/table/table";
import { ChevronSortDown } from "@/components/foundations/icons/chevrons";
import { cx } from "@/utils/cx";
import type {
CampaignChannel,
CampaignDelivery,
CampaignObjective,
MarketingCampaign,
} from "./marketing-types";
export type { CampaignChannel, CampaignDelivery, CampaignObjective, MarketingCampaign };
type SortKey = "name" | "updatedAt" | "spend";
type SortDirection = "ascending" | "descending";
type Icon = ComponentType<{ className?: string; "aria-hidden"?: boolean | "true" | "false" }>;
const CHANNEL_ICONS: Record<CampaignChannel, Icon> = {
Referral: RiGlobalLine,
Meta: RiMetaFill,
LinkedIn: RiLinkedinFill,
"X Ads": RiTwitterXFill,
"Google Ads": RiGoogleFill,
};
const DELIVERY_DOT: Record<CampaignDelivery, "green" | "yellow" | "indigo"> = {
Active: "green",
Paused: "yellow",
Draft: "indigo",
};
const OBJECTIVE_COLOR: Record<CampaignObjective, "blue" | "yellow" | "lime" | "cyan" | "purple"> = {
Awareness: "blue",
Traffic: "yellow",
Conversions: "lime",
Leads: "yellow",
Retargeting: "purple",
};
const PAGE_SIZE = 10;
function money(value: number) {
return value === 0 ? "$0" : `$${value.toLocaleString("en-US")}`;
}
function SortLabel({ label, active, direction }: { label: string; active: boolean; direction: SortDirection }) {
return (
<span className="flex items-center gap-1">
{label}
<ChevronSortDown
className={cx(
"size-6 transition-[transform,color] duration-150 ease",
active ? "text-text-secondary" : "text-text-tertiary",
active && direction === "ascending" && "rotate-180",
)}
aria-hidden
/>
</span>
);
}
function DeliverySelect({ campaign, onChange }: { campaign: MarketingCampaign; onChange: (value: CampaignDelivery) => void }) {
return (
<Select
aria-label={`Delivery status for ${campaign.name}`}
selectedKey={campaign.delivery.toLowerCase()}
onSelectionChange={(key) => onChange((String(key).slice(0, 1).toUpperCase() + String(key).slice(1)) as CampaignDelivery)}
className="w-[124px]"
>
{(["Active", "Paused", "Draft"] as CampaignDelivery[]).map((delivery) => (
<SelectItem key={delivery} id={delivery.toLowerCase()} textValue={delivery}>
<StatusDot color={DELIVERY_DOT[delivery]} />
{delivery}
</SelectItem>
))}
</Select>
);
}
export interface MarketingCampaignActions {
onDeleteCampaign?: (campaign: MarketingCampaign) => void;
onEditCampaign?: (campaign: MarketingCampaign) => void;
onViewCampaignReport?: (campaign: MarketingCampaign) => void;
onMoreCampaignActions?: (campaign: MarketingCampaign) => void;
}
function MoreActions({
campaign,
onDeleteCampaign,
onEditCampaign,
onViewCampaignReport,
onMoreCampaignActions,
}: { campaign: MarketingCampaign } & MarketingCampaignActions) {
const [open, setOpen] = useState(false);
const select = (action?: (campaign: MarketingCampaign) => void) => () => {
setOpen(false);
action?.(campaign);
};
return (
<Dropdown
isOpen={open}
onOpenChange={(next) => {
setOpen(next);
if (next) onMoreCampaignActions?.(campaign);
}}
>
<DropdownTrigger
aria-label={`More actions for ${campaign.name}`}
className="relative inline-flex size-8 shrink-0 items-center justify-center rounded-2lg border border-border-button-default bg-background-primary-default text-foreground-icon-primary shadow-xs transition-colors duration-150 ease hover:bg-background-primary-hover"
>
<RiMore2Fill className="size-4" aria-hidden />
</DropdownTrigger>
<DropdownPopover aria-label={`Actions for ${campaign.name}`} placement="bottom end" className="w-[196px] p-2">
<DropdownItem onSelect={select(onEditCampaign)}>
<RiEditLine className="size-4" aria-hidden /> Edit campaign
</DropdownItem>
<DropdownItem onSelect={select(onViewCampaignReport)}>
<RiGlobalLine className="size-4" aria-hidden /> View report
</DropdownItem>
<DropdownItem onSelect={select(onDeleteCampaign)} className="text-status-rose-text">
<RiDeleteBin6Line className="size-4" aria-hidden /> Delete campaign
</DropdownItem>
</DropdownPopover>
</Dropdown>
);
}
export interface MarketingCampaignTableProps extends MarketingCampaignActions {
campaigns: readonly MarketingCampaign[];
className?: string;
}
export function MarketingCampaignTable({
campaigns,
className,
onDeleteCampaign,
onEditCampaign,
onViewCampaignReport,
onMoreCampaignActions,
}: MarketingCampaignTableProps) {
const [query, setQuery] = useState("");
const [channel, setChannel] = useState("all");
const [objective, setObjective] = useState("all");
const [spend, setSpend] = useState("all");
const [page, setPage] = useState(1);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [sortKey, setSortKey] = useState<SortKey | null>(null);
const [sortDirection, setSortDirection] = useState<SortDirection>("ascending");
const [deliveryById, setDeliveryById] = useState<Record<string, CampaignDelivery>>({});
const [deletedIds, setDeletedIds] = useState<Set<string>>(() => new Set());
const deleteCampaign = (campaign: MarketingCampaign) => {
setDeletedIds((current) => new Set(current).add(campaign.id));
setSelected((current) => {
const next = new Set(current);
next.delete(campaign.id);
return next;
});
onDeleteCampaign?.(campaign);
};
const filtered = useMemo(() => {
const normalizedQuery = query.trim().toLocaleLowerCase();
return campaigns
.filter((campaign) => !deletedIds.has(campaign.id))
.map((campaign) => ({ ...campaign, delivery: deliveryById[campaign.id] ?? campaign.delivery }))
.filter((campaign) =>
(!normalizedQuery || campaign.name.toLocaleLowerCase().includes(normalizedQuery)) &&
(channel === "all" || campaign.channel === channel) &&
(objective === "all" || campaign.objective === objective) &&
(spend === "all" || (spend === "zero" ? campaign.spend === 0 : spend === "under-10k" ? campaign.spend > 0 && campaign.spend < 10_000 : campaign.spend >= 10_000)),
)
.sort((a, b) => {
if (sortKey === null) return 0;
const left = a[sortKey];
const right = b[sortKey];
const compared = typeof left === "string" ? left.localeCompare(String(right)) : left - Number(right);
return sortDirection === "ascending" ? compared : -compared;
});
}, [campaigns, channel, deletedIds, deliveryById, objective, query, sortDirection, sortKey, spend]);
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const safePage = Math.min(page, totalPages);
const rows = filtered.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE);
const allVisibleSelected = rows.length > 0 && rows.every((row) => selected.has(row.id));
const someVisibleSelected = rows.some((row) => selected.has(row.id)) && !allVisibleSelected;
const selectVisible = (checked: boolean) => {
setSelected((current) => {
const next = new Set(current);
rows.forEach((row) => {
if (checked) next.add(row.id);
else next.delete(row.id);
});
return next;
});
};
const sort = (key: SortKey) => {
if (sortKey === key) setSortDirection((current) => current === "ascending" ? "descending" : "ascending");
else {
setSortKey(key);
setSortDirection("ascending");
}
};
return (
<section className={cx("flex w-full flex-col rounded-2xl border border-border-table pt-2 pb-3", className)} aria-label="Campaigns">
<div className="flex w-full flex-col items-start gap-3 px-3 py-1 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 flex-col items-start justify-center">
<span className="text-body-medium text-text-secondary">Total Results</span>
<span className="flex items-baseline gap-1">
<span className="text-title-2-medium tabular-nums text-text-primary">{filtered.length}</span>
<span className="text-body-regular text-text-secondary">campaigns</span>
</span>
</div>
<div className="flex w-full flex-wrap items-center justify-end gap-2 sm:w-auto">
<Select aria-label="Channel" selectedKey={channel} onSelectionChange={(key) => { setChannel(String(key)); setPage(1); }} className="min-w-[134px] flex-1 sm:flex-none">
<SelectItem id="all">All channels</SelectItem>
{(["Google Ads", "Meta", "LinkedIn", "X Ads", "Referral"] as CampaignChannel[]).map((item) => <SelectItem key={item} id={item}>{item}</SelectItem>)}
</Select>
<Select aria-label="Objective" selectedKey={objective} onSelectionChange={(key) => { setObjective(String(key)); setPage(1); }} className="min-w-[145px] flex-1 sm:flex-none">
<SelectItem id="all">All objectives</SelectItem>
{(["Awareness", "Traffic", "Conversions", "Leads", "Retargeting"] as CampaignObjective[]).map((item) => <SelectItem key={item} id={item}>{item}</SelectItem>)}
</Select>
<Select aria-label="Spend" selectedKey={spend} onSelectionChange={(key) => { setSpend(String(key)); setPage(1); }} className="min-w-[124px] flex-1 sm:flex-none">
<SelectItem id="all">All spend</SelectItem>
<SelectItem id="zero">No spend</SelectItem>
<SelectItem id="under-10k">Under $10K</SelectItem>
<SelectItem id="over-10k">$10K and over</SelectItem>
</Select>
<InputBase
aria-label="Search campaigns"
placeholder="Search"
value={query}
onChange={(event) => { setQuery(event.target.value); setPage(1); }}
leadingIcon={RiSearchLine}
fieldClassName="min-w-[153px] flex-1 rounded-full bg-background-secondary-default sm:w-[153px] sm:min-w-0 sm:flex-none"
/>
</div>
</div>
<div className="mt-2 w-full overflow-x-auto">
<Table aria-label="Marketing campaigns" size="sm" className="min-w-[900px]" selectionMode="none">
<TableHeader>
<TableColumn isRowHeader className="w-[32%]">
<span className="flex items-center gap-2">
<Checkbox
slot={null}
aria-label="Select all campaigns on this page"
isSelected={allVisibleSelected}
isIndeterminate={someVisibleSelected}
onChange={selectVisible}
/>
<button type="button" onClick={() => sort("name")} className="cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring">
<SortLabel label="Campaign" active={sortKey === "name"} direction={sortDirection} />
</button>
</span>
</TableColumn>
<TableColumn>Delivery</TableColumn>
<TableColumn>Objective</TableColumn>
<TableColumn>
<button type="button" onClick={() => sort("updatedAt")} className="cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring">
<SortLabel label="Last updated" active={sortKey === "updatedAt"} direction={sortDirection} />
</button>
</TableColumn>
<TableColumn>
<button type="button" onClick={() => sort("spend")} className="cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring">
<SortLabel label="Spend" active={sortKey === "spend"} direction={sortDirection} />
</button>
</TableColumn>
<TableColumn className="w-[140px] text-right">Actions</TableColumn>
</TableHeader>
<TableBody renderEmptyState={() => <div className="p-8 text-center text-body-regular text-text-secondary">No campaigns match these filters.</div>}>
{rows.map((campaign) => {
const ChannelIcon = CHANNEL_ICONS[campaign.channel];
return (
<TableRow key={campaign.id} id={campaign.id}>
<TableCell>
<span className="flex min-w-0 items-center gap-2">
<Checkbox
slot={null}
aria-label={`Select ${campaign.name}`}
isSelected={selected.has(campaign.id)}
onChange={(checked) => setSelected((current) => {
const next = new Set(current);
if (checked) next.add(campaign.id);
else next.delete(campaign.id);
return next;
})}
/>
<span className="flex min-w-0 items-center gap-2">
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-background-tertiary-default" title={campaign.channel}>
<ChannelIcon className="size-4 text-foreground-icon-primary" aria-hidden />
</span>
<span className="truncate text-body-medium text-text-primary">{campaign.name}</span>
</span>
</span>
</TableCell>
<TableCell>
<DeliverySelect campaign={campaign} onChange={(value) => setDeliveryById((current) => ({ ...current, [campaign.id]: value }))} />
</TableCell>
<TableCell><Chip color={OBJECTIVE_COLOR[campaign.objective]}>{campaign.objective}</Chip></TableCell>
<TableCell><span className="whitespace-nowrap text-body-medium text-text-primary">{campaign.updated}</span></TableCell>
<TableCell>
<span className={cx("text-body-medium", campaign.spend === 0 ? "text-text-tertiary" : "rounded-md bg-background-secondary-default px-1.5 py-1 text-text-primary")}>{money(campaign.spend)}</span>
</TableCell>
<TableCell>
<span className="flex items-center justify-end gap-2.5">
<IconButton icon={RiDeleteBin6Line} size="small" aria-label={`Delete ${campaign.name}`} onClick={() => deleteCampaign(campaign)} />
<IconButton icon={RiEditLine} size="small" aria-label={`Edit ${campaign.name}`} onClick={() => onEditCampaign?.(campaign)} />
<MoreActions
campaign={campaign}
onDeleteCampaign={deleteCampaign}
onEditCampaign={onEditCampaign}
onViewCampaignReport={onViewCampaignReport}
onMoreCampaignActions={onMoreCampaignActions}
/>
</span>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
<div className="px-3 pt-3">
<Pagination page={safePage} totalPages={totalPages} onChange={setPage} />
</div>
</section>
);
}"use client";
import { useMemo, useState, type ComponentType } from "react";
import {
RiDeleteBin6Line,
RiEditLine,
RiGlobalLine,
RiGoogleFill,
RiLinkedinFill,
RiMetaFill,
RiMore2Fill,
RiSearchLine,
RiTwitterXFill,
} from "@remixicon/react";
import { Chip } from "@/components/base/badges/chip";
import { StatusDot } from "@/components/base/badges/status-dot";
import { IconButton } from "@/components/base/buttons/icon-button";
import { Checkbox } from "@/components/base/checkbox/checkbox";
import {
Dropdown,
DropdownItem,
DropdownPopover,
DropdownTrigger,
} from "@/components/base/dropdown/dropdown";
import { InputBase } from "@/components/base/input/input";
import { Pagination } from "@/components/base/pagination/pagination";
import { Select, SelectItem } from "@/components/base/select/select";
import {
Table,
TableBody,
TableCell,
TableColumn,
TableHeader,
TableRow,
} from "@/components/base/table/table";
import { ChevronSortDown } from "@/components/foundations/icons/chevrons";
import { cx } from "@/utils/cx";
import type {
CampaignChannel,
CampaignDelivery,
CampaignObjective,
MarketingCampaign,
} from "./marketing-types";
export type { CampaignChannel, CampaignDelivery, CampaignObjective, MarketingCampaign };
type SortKey = "name" | "updatedAt" | "spend";
type SortDirection = "ascending" | "descending";
type Icon = ComponentType<{ className?: string; "aria-hidden"?: boolean | "true" | "false" }>;
const CHANNEL_ICONS: Record<CampaignChannel, Icon> = {
Referral: RiGlobalLine,
Meta: RiMetaFill,
LinkedIn: RiLinkedinFill,
"X Ads": RiTwitterXFill,
"Google Ads": RiGoogleFill,
};
const DELIVERY_DOT: Record<CampaignDelivery, "green" | "yellow" | "indigo"> = {
Active: "green",
Paused: "yellow",
Draft: "indigo",
};
const OBJECTIVE_COLOR: Record<CampaignObjective, "blue" | "yellow" | "lime" | "cyan" | "purple"> = {
Awareness: "blue",
Traffic: "yellow",
Conversions: "lime",
Leads: "yellow",
Retargeting: "purple",
};
const PAGE_SIZE = 10;
function money(value: number) {
return value === 0 ? "$0" : `$${value.toLocaleString("en-US")}`;
}
function SortLabel({ label, active, direction }: { label: string; active: boolean; direction: SortDirection }) {
return (
<span className="flex items-center gap-1">
{label}
<ChevronSortDown
className={cx(
"size-6 transition-[transform,color] duration-150 ease",
active ? "text-text-secondary" : "text-text-tertiary",
active && direction === "ascending" && "rotate-180",
)}
aria-hidden
/>
</span>
);
}
function DeliverySelect({ campaign, onChange }: { campaign: MarketingCampaign; onChange: (value: CampaignDelivery) => void }) {
return (
<Select
aria-label={`Delivery status for ${campaign.name}`}
selectedKey={campaign.delivery.toLowerCase()}
onSelectionChange={(key) => onChange((String(key).slice(0, 1).toUpperCase() + String(key).slice(1)) as CampaignDelivery)}
className="w-[124px]"
>
{(["Active", "Paused", "Draft"] as CampaignDelivery[]).map((delivery) => (
<SelectItem key={delivery} id={delivery.toLowerCase()} textValue={delivery}>
<StatusDot color={DELIVERY_DOT[delivery]} />
{delivery}
</SelectItem>
))}
</Select>
);
}
export interface MarketingCampaignActions {
onDeleteCampaign?: (campaign: MarketingCampaign) => void;
onEditCampaign?: (campaign: MarketingCampaign) => void;
onViewCampaignReport?: (campaign: MarketingCampaign) => void;
onMoreCampaignActions?: (campaign: MarketingCampaign) => void;
}
function MoreActions({
campaign,
onDeleteCampaign,
onEditCampaign,
onViewCampaignReport,
onMoreCampaignActions,
}: { campaign: MarketingCampaign } & MarketingCampaignActions) {
const [open, setOpen] = useState(false);
const select = (action?: (campaign: MarketingCampaign) => void) => () => {
setOpen(false);
action?.(campaign);
};
return (
<Dropdown
isOpen={open}
onOpenChange={(next) => {
setOpen(next);
if (next) onMoreCampaignActions?.(campaign);
}}
>
<DropdownTrigger
aria-label={`More actions for ${campaign.name}`}
className="relative inline-flex size-8 shrink-0 items-center justify-center rounded-2lg border border-border-button-default bg-background-primary-default text-foreground-icon-primary shadow-xs transition-colors duration-150 ease hover:bg-background-primary-hover"
>
<RiMore2Fill className="size-4" aria-hidden />
</DropdownTrigger>
<DropdownPopover aria-label={`Actions for ${campaign.name}`} placement="bottom end" className="w-[196px] p-2">
<DropdownItem onSelect={select(onEditCampaign)}>
<RiEditLine className="size-4" aria-hidden /> Edit campaign
</DropdownItem>
<DropdownItem onSelect={select(onViewCampaignReport)}>
<RiGlobalLine className="size-4" aria-hidden /> View report
</DropdownItem>
<DropdownItem onSelect={select(onDeleteCampaign)} className="text-status-rose-text">
<RiDeleteBin6Line className="size-4" aria-hidden /> Delete campaign
</DropdownItem>
</DropdownPopover>
</Dropdown>
);
}
export interface MarketingCampaignTableProps extends MarketingCampaignActions {
campaigns: readonly MarketingCampaign[];
className?: string;
}
export function MarketingCampaignTable({
campaigns,
className,
onDeleteCampaign,
onEditCampaign,
onViewCampaignReport,
onMoreCampaignActions,
}: MarketingCampaignTableProps) {
const [query, setQuery] = useState("");
const [channel, setChannel] = useState("all");
const [objective, setObjective] = useState("all");
const [spend, setSpend] = useState("all");
const [page, setPage] = useState(1);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [sortKey, setSortKey] = useState<SortKey | null>(null);
const [sortDirection, setSortDirection] = useState<SortDirection>("ascending");
const [deliveryById, setDeliveryById] = useState<Record<string, CampaignDelivery>>({});
const [deletedIds, setDeletedIds] = useState<Set<string>>(() => new Set());
const deleteCampaign = (campaign: MarketingCampaign) => {
setDeletedIds((current) => new Set(current).add(campaign.id));
setSelected((current) => {
const next = new Set(current);
next.delete(campaign.id);
return next;
});
onDeleteCampaign?.(campaign);
};
const filtered = useMemo(() => {
const normalizedQuery = query.trim().toLocaleLowerCase();
return campaigns
.filter((campaign) => !deletedIds.has(campaign.id))
.map((campaign) => ({ ...campaign, delivery: deliveryById[campaign.id] ?? campaign.delivery }))
.filter((campaign) =>
(!normalizedQuery || campaign.name.toLocaleLowerCase().includes(normalizedQuery)) &&
(channel === "all" || campaign.channel === channel) &&
(objective === "all" || campaign.objective === objective) &&
(spend === "all" || (spend === "zero" ? campaign.spend === 0 : spend === "under-10k" ? campaign.spend > 0 && campaign.spend < 10_000 : campaign.spend >= 10_000)),
)
.sort((a, b) => {
if (sortKey === null) return 0;
const left = a[sortKey];
const right = b[sortKey];
const compared = typeof left === "string" ? left.localeCompare(String(right)) : left - Number(right);
return sortDirection === "ascending" ? compared : -compared;
});
}, [campaigns, channel, deletedIds, deliveryById, objective, query, sortDirection, sortKey, spend]);
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const safePage = Math.min(page, totalPages);
const rows = filtered.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE);
const allVisibleSelected = rows.length > 0 && rows.every((row) => selected.has(row.id));
const someVisibleSelected = rows.some((row) => selected.has(row.id)) && !allVisibleSelected;
const selectVisible = (checked: boolean) => {
setSelected((current) => {
const next = new Set(current);
rows.forEach((row) => {
if (checked) next.add(row.id);
else next.delete(row.id);
});
return next;
});
};
const sort = (key: SortKey) => {
if (sortKey === key) setSortDirection((current) => current === "ascending" ? "descending" : "ascending");
else {
setSortKey(key);
setSortDirection("ascending");
}
};
return (
<section className={cx("flex w-full flex-col rounded-2xl border border-border-table pt-2 pb-3", className)} aria-label="Campaigns">
<div className="flex w-full flex-col items-start gap-3 px-3 py-1 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 flex-col items-start justify-center">
<span className="text-body-medium text-text-secondary">Total Results</span>
<span className="flex items-baseline gap-1">
<span className="text-title-2-medium tabular-nums text-text-primary">{filtered.length}</span>
<span className="text-body-regular text-text-secondary">campaigns</span>
</span>
</div>
<div className="flex w-full flex-wrap items-center justify-end gap-2 sm:w-auto">
<Select aria-label="Channel" selectedKey={channel} onSelectionChange={(key) => { setChannel(String(key)); setPage(1); }} className="min-w-[134px] flex-1 sm:flex-none">
<SelectItem id="all">All channels</SelectItem>
{(["Google Ads", "Meta", "LinkedIn", "X Ads", "Referral"] as CampaignChannel[]).map((item) => <SelectItem key={item} id={item}>{item}</SelectItem>)}
</Select>
<Select aria-label="Objective" selectedKey={objective} onSelectionChange={(key) => { setObjective(String(key)); setPage(1); }} className="min-w-[145px] flex-1 sm:flex-none">
<SelectItem id="all">All objectives</SelectItem>
{(["Awareness", "Traffic", "Conversions", "Leads", "Retargeting"] as CampaignObjective[]).map((item) => <SelectItem key={item} id={item}>{item}</SelectItem>)}
</Select>
<Select aria-label="Spend" selectedKey={spend} onSelectionChange={(key) => { setSpend(String(key)); setPage(1); }} className="min-w-[124px] flex-1 sm:flex-none">
<SelectItem id="all">All spend</SelectItem>
<SelectItem id="zero">No spend</SelectItem>
<SelectItem id="under-10k">Under $10K</SelectItem>
<SelectItem id="over-10k">$10K and over</SelectItem>
</Select>
<InputBase
aria-label="Search campaigns"
placeholder="Search"
value={query}
onChange={(event) => { setQuery(event.target.value); setPage(1); }}
leadingIcon={RiSearchLine}
fieldClassName="min-w-[153px] flex-1 rounded-full bg-background-secondary-default sm:w-[153px] sm:min-w-0 sm:flex-none"
/>
</div>
</div>
<div className="mt-2 w-full overflow-x-auto">
<Table aria-label="Marketing campaigns" size="sm" className="min-w-[900px]" selectionMode="none">
<TableHeader>
<TableColumn isRowHeader className="w-[32%]">
<span className="flex items-center gap-2">
<Checkbox
slot={null}
aria-label="Select all campaigns on this page"
isSelected={allVisibleSelected}
isIndeterminate={someVisibleSelected}
onChange={selectVisible}
/>
<button type="button" onClick={() => sort("name")} className="cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring">
<SortLabel label="Campaign" active={sortKey === "name"} direction={sortDirection} />
</button>
</span>
</TableColumn>
<TableColumn>Delivery</TableColumn>
<TableColumn>Objective</TableColumn>
<TableColumn>
<button type="button" onClick={() => sort("updatedAt")} className="cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring">
<SortLabel label="Last updated" active={sortKey === "updatedAt"} direction={sortDirection} />
</button>
</TableColumn>
<TableColumn>
<button type="button" onClick={() => sort("spend")} className="cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring">
<SortLabel label="Spend" active={sortKey === "spend"} direction={sortDirection} />
</button>
</TableColumn>
<TableColumn className="w-[140px] text-right">Actions</TableColumn>
</TableHeader>
<TableBody renderEmptyState={() => <div className="p-8 text-center text-body-regular text-text-secondary">No campaigns match these filters.</div>}>
{rows.map((campaign) => {
const ChannelIcon = CHANNEL_ICONS[campaign.channel];
return (
<TableRow key={campaign.id} id={campaign.id}>
<TableCell>
<span className="flex min-w-0 items-center gap-2">
<Checkbox
slot={null}
aria-label={`Select ${campaign.name}`}
isSelected={selected.has(campaign.id)}
onChange={(checked) => setSelected((current) => {
const next = new Set(current);
if (checked) next.add(campaign.id);
else next.delete(campaign.id);
return next;
})}
/>
<span className="flex min-w-0 items-center gap-2">
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-background-tertiary-default" title={campaign.channel}>
<ChannelIcon className="size-4 text-foreground-icon-primary" aria-hidden />
</span>
<span className="truncate text-body-medium text-text-primary">{campaign.name}</span>
</span>
</span>
</TableCell>
<TableCell>
<DeliverySelect campaign={campaign} onChange={(value) => setDeliveryById((current) => ({ ...current, [campaign.id]: value }))} />
</TableCell>
<TableCell><Chip color={OBJECTIVE_COLOR[campaign.objective]}>{campaign.objective}</Chip></TableCell>
<TableCell><span className="whitespace-nowrap text-body-medium text-text-primary">{campaign.updated}</span></TableCell>
<TableCell>
<span className={cx("text-body-medium", campaign.spend === 0 ? "text-text-tertiary" : "rounded-md bg-background-secondary-default px-1.5 py-1 text-text-primary")}>{money(campaign.spend)}</span>
</TableCell>
<TableCell>
<span className="flex items-center justify-end gap-2.5">
<IconButton icon={RiDeleteBin6Line} size="small" aria-label={`Delete ${campaign.name}`} onClick={() => deleteCampaign(campaign)} />
<IconButton icon={RiEditLine} size="small" aria-label={`Edit ${campaign.name}`} onClick={() => onEditCampaign?.(campaign)} />
<MoreActions
campaign={campaign}
onDeleteCampaign={deleteCampaign}
onEditCampaign={onEditCampaign}
onViewCampaignReport={onViewCampaignReport}
onMoreCampaignActions={onMoreCampaignActions}
/>
</span>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
<div className="px-3 pt-3">
<Pagination page={safePage} totalPages={totalPages} onChange={setPage} />
</div>
</section>
);
}export type CampaignDelivery = "Active" | "Paused" | "Draft";
export type CampaignObjective =
| "Awareness"
| "Traffic"
| "Conversions"
| "Leads"
| "Retargeting";
export type CampaignChannel = "Referral" | "Meta" | "LinkedIn" | "X Ads" | "Google Ads";
export interface MarketingCampaign {
id: string;
name: string;
channel: CampaignChannel;
delivery: CampaignDelivery;
objective: CampaignObjective;
updated: string;
updatedAt: number;
spend: number;
}
export interface SpendChannel {
label: string;
value: number;
color: string;
}export type CampaignDelivery = "Active" | "Paused" | "Draft";
export type CampaignObjective =
| "Awareness"
| "Traffic"
| "Conversions"
| "Leads"
| "Retargeting";
export type CampaignChannel = "Referral" | "Meta" | "LinkedIn" | "X Ads" | "Google Ads";
export interface MarketingCampaign {
id: string;
name: string;
channel: CampaignChannel;
delivery: CampaignDelivery;
objective: CampaignObjective;
updated: string;
updatedAt: number;
spend: number;
}
export interface SpendChannel {
label: string;
value: number;
color: string;
}