@@ -239,6 +239,6 @@ function LogViewer({ kind, id, followExecution = false }: Props) {
No logs are available for this run.
)}
-
+
);
}
diff --git a/apps/admin/src/components/organisms/notification-settings.tsx b/apps/admin/src/components/organisms/notification-settings.tsx
index df1e94eb4..d7b7d52e5 100644
--- a/apps/admin/src/components/organisms/notification-settings.tsx
+++ b/apps/admin/src/components/organisms/notification-settings.tsx
@@ -1,8 +1,8 @@
"use client";
import { useEffect, useState } from "react";
import type { ChimelyClient } from "@chimely/client";
-import { Button } from "@/components/atoms/button";
-import { Input } from "@/components/atoms/input";
+import { Button } from "@devfeed/ui/button";
+import { Input } from "@devfeed/ui/input";
import { SettingsForm, SettingToggle } from "@/components/molecules/settings-form";
import { DataTable } from "@/components/molecules/data-table";
import { RequestState } from "@/components/molecules/request-state";
diff --git a/apps/admin/src/components/organisms/overview-breakdown-charts.tsx b/apps/admin/src/components/organisms/overview-breakdown-charts.tsx
index d4c4d8b31..7677c1f75 100644
--- a/apps/admin/src/components/organisms/overview-breakdown-charts.tsx
+++ b/apps/admin/src/components/organisms/overview-breakdown-charts.tsx
@@ -1,7 +1,7 @@
"use client";
import { CartesianGrid, Cell, Scatter, ScatterChart, Tooltip, XAxis, YAxis, ZAxis } from "recharts";
-import { ChartContainer } from "@/components/atoms/chart";
+import { ChartContainer } from "@devfeed/ui/chart";
export type DistributionRow = { label: string; value: number; color: string; detail?: string };
export function DistributionChart({
diff --git a/apps/admin/src/components/organisms/overview-charts.tsx b/apps/admin/src/components/organisms/overview-charts.tsx
index 702450576..4a382e2eb 100644
--- a/apps/admin/src/components/organisms/overview-charts.tsx
+++ b/apps/admin/src/components/organisms/overview-charts.tsx
@@ -5,8 +5,8 @@ import { useChartWindow } from "./overview-chart-window";
import { useState } from "react";
import { Bar, BarChart, CartesianGrid, Line, LineChart, Tooltip, XAxis, YAxis } from "recharts";
import { InfoTooltip } from "@/components/molecules/info-tooltip";
-import { Button } from "@/components/atoms/button";
-import { ChartContainer, ChartTooltip } from "@/components/atoms/chart";
+import { Button } from "@devfeed/ui/button";
+import { ChartContainer, ChartTooltip } from "@devfeed/ui/chart";
import type { OverviewPanel as AdminOverview } from "@/lib/api/generated/models";
import { humanize } from "@/lib/resources";
diff --git a/apps/admin/src/components/organisms/overview-engagement-charts.tsx b/apps/admin/src/components/organisms/overview-engagement-charts.tsx
index 600be92cf..9a48c0388 100644
--- a/apps/admin/src/components/organisms/overview-engagement-charts.tsx
+++ b/apps/admin/src/components/organisms/overview-engagement-charts.tsx
@@ -4,7 +4,7 @@ import { useChartWindow } from "./overview-chart-window";
import type { ReactNode } from "react";
import { Bar, BarChart, CartesianGrid, Line, LineChart, Tooltip, XAxis, YAxis } from "recharts";
-import { ChartContainer, ChartTooltip } from "@/components/atoms/chart";
+import { ChartContainer, ChartTooltip } from "@devfeed/ui/chart";
import { InfoTooltip } from "@/components/molecules/info-tooltip";
import type { OverviewPanel as AdminOverview } from "@/lib/api/generated/models";
import { formatCompactCount } from "@/lib/format-count";
diff --git a/apps/admin/src/components/organisms/overview-inference-charts.tsx b/apps/admin/src/components/organisms/overview-inference-charts.tsx
index cb04ec910..6f4abc0f0 100644
--- a/apps/admin/src/components/organisms/overview-inference-charts.tsx
+++ b/apps/admin/src/components/organisms/overview-inference-charts.tsx
@@ -1,7 +1,7 @@
"use client";
import { Bar, BarChart, CartesianGrid, Tooltip, XAxis, YAxis } from "recharts";
-import { ChartContainer, ChartTooltip } from "@/components/atoms/chart";
+import { ChartContainer, ChartTooltip } from "@devfeed/ui/chart";
import { InfoTooltip } from "@/components/molecules/info-tooltip";
import type { OverviewPanelAutomation as AutomationOverview } from "@/lib/api/generated/models";
import { formatCompactCount } from "@/lib/format-count";
diff --git a/apps/admin/src/components/organisms/overview-source-chart.tsx b/apps/admin/src/components/organisms/overview-source-chart.tsx
index 6cb7bb9dc..008e71cb8 100644
--- a/apps/admin/src/components/organisms/overview-source-chart.tsx
+++ b/apps/admin/src/components/organisms/overview-source-chart.tsx
@@ -3,7 +3,7 @@
import Link from "next/link";
import { Cell, Pie, PieChart, Tooltip } from "recharts";
import type { OverviewSourcePerformance } from "@/lib/api/generated/models";
-import { ChartContainer, DistributionTooltip } from "@/components/atoms/chart";
+import { ChartContainer, DistributionTooltip } from "@devfeed/ui/chart";
import { formatCompactCount } from "@/lib/format-count";
export function OverviewSourceChart({
diff --git a/apps/admin/src/components/organisms/overview-token-chart.tsx b/apps/admin/src/components/organisms/overview-token-chart.tsx
index ac5247ac8..06539a259 100644
--- a/apps/admin/src/components/organisms/overview-token-chart.tsx
+++ b/apps/admin/src/components/organisms/overview-token-chart.tsx
@@ -3,7 +3,7 @@
import { useChartWindow } from "./overview-chart-window";
import { Bar, BarChart, CartesianGrid, Tooltip, XAxis, YAxis } from "recharts";
-import { ChartContainer, ChartTooltip } from "@/components/atoms/chart";
+import { ChartContainer, ChartTooltip } from "@devfeed/ui/chart";
import { formatCompactCount } from "@/lib/format-count";
import { InfoTooltip } from "@/components/molecules/info-tooltip";
import type { OverviewPanelAutomation as AutomationOverview } from "@/lib/api/generated/models";
diff --git a/apps/admin/src/components/organisms/overview.tsx b/apps/admin/src/components/organisms/overview.tsx
index a12eeec3a..fc34508a8 100644
--- a/apps/admin/src/components/organisms/overview.tsx
+++ b/apps/admin/src/components/organisms/overview.tsx
@@ -1,7 +1,7 @@
"use client";
import { lazy, useState, type ReactNode } from "react";
-import { Button } from "@/components/atoms/button";
+import { Button } from "@devfeed/ui/button";
import type { OverviewPanel as PanelData } from "@/lib/api/generated/models";
import { OverviewPanel, type PanelName } from "./overview-panel";
import { OverviewMetrics } from "./overview-metrics";
diff --git a/apps/admin/src/components/organisms/partner-account-management.tsx b/apps/admin/src/components/organisms/partner-account-management.tsx
new file mode 100644
index 000000000..d5118ed32
--- /dev/null
+++ b/apps/admin/src/components/organisms/partner-account-management.tsx
@@ -0,0 +1,492 @@
+"use client";
+
+import { useEffect, useState, type FormEvent } from "react";
+import { useRouter } from "next/navigation";
+import { Button } from "@devfeed/ui/button";
+import { Input } from "@devfeed/ui/input";
+import { listRecords, getRecord } from "@/lib/resource-api";
+import { EntityPicker, type EntityPickerSource } from "@/components/molecules/entity-picker";
+import { Field } from "@/components/molecules/field";
+import { Select } from "@/components/molecules/select";
+import { RequestState } from "@/components/molecules/request-state";
+import { useAdmin } from "@/components/molecules/admin-session";
+import type { AccountOut, AssetMetrics } from "@/lib/api/generated/models";
+import { type PartnershipTierOut, AccountInputTier } from "@/lib/api/generated/models";
+import { adminPartnerToolsList } from "@/lib/api/generated/admin";
+import { accountsPath, accountHref } from "./partner-accounts";
+import { PartnerDestinationPreview } from "./partner-destination-preview";
+import { ApiError, returnToLogin } from "@/lib/api/client";
+import { audienceValue } from "@devfeed/ui/audience-targeting";
+import { PartnerAudience } from "./partner-audience";
+import { PartnerAdFormats } from "./partner-ad-formats";
+import type { AdFormat } from "@devfeed/ui/advertising-formats";
+import {
+ PromotionSurfaces,
+ promotionSurfaces,
+ type PromotionSurface,
+} from "@/components/molecules/promotion-surfaces";
+
+const memberUsers: EntityPickerSource = {
+ key: "partner-member-users",
+ list: (params, signal) => listRecords("users", { ...params, identity_only: "true" }, signal),
+ get: (id, signal) => getRecord("users", id, signal),
+};
+
+const productCatalog = { label: "Products", singular: "Catalog product", title: "name" };
+const catalogProducts: EntityPickerSource = {
+ key: "partner-asset-products",
+ list: async ({ q, offset, limit }, signal) => {
+ const page = await adminPartnerToolsList({ q, offset, limit, status: "approved" }, { signal });
+ return {
+ ...page,
+ items: page.items.map((product) => ({ id: product.id, name: product.name })),
+ };
+ },
+ get: async (id, signal) => {
+ const page = await adminPartnerToolsList({ product_id: id, limit: 1 }, { signal });
+ if (!page.items[0]) throw new Error("Catalog product not found.");
+ return { id: page.items[0].id, name: page.items[0].name };
+ },
+};
+
+export function PartnerAccountManagement({
+ account,
+ asset,
+ mode,
+}: {
+ account?: AccountOut;
+ asset?: AssetMetrics;
+ mode: "account" | "member" | "asset";
+}) {
+ const admin = useAdmin();
+ const router = useRouter();
+ const [status, setStatus] = useState
(
+ asset?.status ?? account?.status ?? (mode === "asset" ? "draft" : "active"),
+ );
+ const [kind, setKind] = useState(asset?.kind ?? "product");
+ const [productId, setProductId] = useState(asset?.product_id ?? "");
+ const [memberUser, setMemberUser] = useState("");
+ const [tier, setTier] = useState(account?.tier ?? "bronze");
+ const [promotionKind, setPromotionKind] = useState<"inherit" | "product" | "ad">(
+ account?.promotion_kind ?? "inherit",
+ );
+ const [approvalMode, setApprovalMode] = useState<"qualified" | "trusted">(
+ account?.approval_mode ?? "qualified",
+ );
+ const [destination, setDestination] = useState<"product_url" | "listing_url">(
+ account?.outbound_destination ?? "product_url",
+ );
+ const [organicEnabled, setOrganicEnabled] = useState(account?.organic_enabled ?? true);
+ const [autoInclude, setAutoInclude] = useState(account?.auto_include_products ?? true);
+ const [adFormats, setAdFormats] = useState(account?.ad_formats ?? ["feed"]);
+ const [audience, setAudience] = useState(() => audienceValue(account?.audience));
+ const [surfaces, setSurfaces] = useState(
+ account?.promotion_surfaces ?? [...promotionSurfaces],
+ );
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState();
+ const [tiers, setTiers] = useState([]);
+ const [tierError, setTierError] = useState();
+ const [catalogRevision, setCatalogRevision] = useState(0);
+ useEffect(() => {
+ if (mode !== "account") return;
+ const controller = new AbortController();
+ fetch("/api/v1/admin/partner-accounts/tiers", { cache: "no-store", signal: controller.signal })
+ .then(async (response) => {
+ if (response.status === 401) {
+ returnToLogin();
+ throw new ApiError(401);
+ }
+ if (!response.ok) throw new Error("Could not load tier benefits.");
+ return response.json() as Promise;
+ })
+ .then((catalog) => {
+ if (!controller.signal.aborted) {
+ setTiers(catalog);
+ setTierError(undefined);
+ }
+ })
+ .catch((reason) => {
+ if (!controller.signal.aborted) setTierError(reason);
+ });
+ return () => controller.abort();
+ }, [mode, catalogRevision]);
+ const selectedTier = tiers.find((item) => item.tier === tier);
+ const selectedBenefits = selectedTier?.benefits;
+ const allowedKinds = selectedTier?.allowed_promotion_kinds ??
+ account?.allowed_promotion_kinds ?? ["product"];
+ const defaultKind = selectedTier?.default_promotion_kind ?? "product";
+ const effectiveKind = promotionKind === "inherit" ? defaultKind : promotionKind;
+ const promotionName = (value: string) =>
+ value === "ad" ? "Sponsored" : "Product recommendation";
+ const cancel = account
+ ? accountHref(account.id) + (mode === "account" ? "" : "/related")
+ : accountsPath;
+ async function save(event: FormEvent) {
+ event.preventDefault();
+ if (busy) return;
+ const form = new FormData(event.currentTarget);
+ if (mode === "account" && !surfaces.length) {
+ setError(new Error("Select at least one placement surface."));
+ return;
+ }
+ const base = account ? `/${account.id}` : "";
+ const path =
+ mode === "member"
+ ? `${base}/members`
+ : mode === "asset"
+ ? `${base}/assets${asset ? `/${asset.id}` : ""}`
+ : base;
+ const payload =
+ mode === "member"
+ ? { user_id: memberUser }
+ : mode === "asset"
+ ? {
+ name: form.get("name"),
+ kind,
+ product_id: productId || null,
+ status,
+ }
+ : {
+ name: form.get("name"),
+ tier,
+ status,
+ organic_enabled: organicEnabled,
+ auto_include_products: autoInclude,
+ promotion_surfaces: surfaces,
+ promotion_kind: promotionKind === "inherit" ? null : promotionKind,
+ approval_mode: approvalMode,
+ outbound_destination: destination,
+ audience,
+ ad_formats: adFormats,
+ };
+ setBusy(true);
+ setError(undefined);
+ try {
+ const response = await fetch(`/api/v1/admin/partner-accounts${path}`, {
+ method: mode === "member" || asset || (mode === "account" && account) ? "PUT" : "POST",
+ headers: { "content-type": "application/json", "x-csrf-token": admin.csrf_token },
+ body: JSON.stringify(payload),
+ });
+ if (response.status === 401) {
+ returnToLogin();
+ throw new ApiError(401);
+ }
+ if (response.status === 403) throw new ApiError(403);
+ if (!response.ok)
+ throw new Error(
+ response.status === 422
+ ? "Check the form values and selected catalog product."
+ : "Could not save changes. Please retry.",
+ );
+ const saved = mode === "account" ? ((await response.json()) as AccountOut) : undefined;
+ router.push(saved ? accountHref(saved.id) : cancel);
+ } catch (reason) {
+ setError(reason instanceof Error ? reason : new Error("Could not save changes."));
+ setBusy(false);
+ }
+ }
+ return (
+
+ );
+}
diff --git a/apps/admin/src/components/organisms/partner-account-page.tsx b/apps/admin/src/components/organisms/partner-account-page.tsx
new file mode 100644
index 000000000..e3a94bf1b
--- /dev/null
+++ b/apps/admin/src/components/organisms/partner-account-page.tsx
@@ -0,0 +1,458 @@
+"use client";
+
+import Link from "next/link";
+import { useEffect, useState } from "react";
+import { Pencil, Plus } from "lucide-react";
+import { Button } from "@devfeed/ui/button";
+import { ResourceHeading } from "@devfeed/ui/resource-layout";
+import { RequestState } from "@/components/molecules/request-state";
+import { ResourcePanel, DataValue } from "@devfeed/ui/resource-layout";
+import { audienceValue } from "@devfeed/ui/audience-targeting";
+import { PartnerAudience } from "./partner-audience";
+import { adFormatLabel } from "@devfeed/ui/advertising-formats";
+import { StatusBadge } from "@/components/molecules/status-badge";
+import { DataTable, type DataTableColumn } from "@/components/molecules/data-table";
+import { useAdmin } from "@/components/molecules/admin-session";
+import type { Dashboard, AssetMetrics, MemberOut } from "@/lib/api/generated/models";
+import { PartnerAccountManagement } from "./partner-account-management";
+import { accountsPath, accountHref, accountsTrail } from "./partner-accounts";
+import { PartnerCampaigns } from "./partner-campaigns";
+import { PartnerCampaignPage } from "./partner-campaign-form";
+import { PartnerInventory } from "./partner-inventory";
+import { ApiError, returnToLogin } from "@/lib/api/client";
+
+type Member = MemberOut;
+export type AccountSection =
+ | "details"
+ | "edit"
+ | "related"
+ | "products"
+ | "campaigns"
+ | "campaigns/new"
+ | `campaigns/${string}/edit`
+ | "members/new"
+ | "assets/new"
+ | `assets/${string}/edit`;
+export function PartnerAccountPage({
+ id,
+ section = "details",
+ initialOffset = 0,
+ initialLimit = 100,
+}: {
+ id?: string;
+ section?: AccountSection;
+ initialOffset?: number;
+ initialLimit?: number;
+}) {
+ const admin = useAdmin();
+ const [data, setData] = useState();
+ const [members, setMembers] = useState([]);
+ const [error, setError] = useState();
+ const [busy, setBusy] = useState(false);
+ const [revision, setRevision] = useState(0);
+ const [offset, setOffset] = useState(initialOffset);
+ const [limit, setLimit] = useState(initialLimit);
+ useEffect(() => {
+ if (!id) return;
+ const controller = new AbortController();
+ Promise.all([
+ fetch(`/api/v1/admin/partner-accounts/${id}/dashboard?offset=${offset}&limit=${limit}`, {
+ cache: "no-store",
+ signal: controller.signal,
+ }),
+ fetch(`/api/v1/admin/partner-accounts/${id}/members`, {
+ cache: "no-store",
+ signal: controller.signal,
+ }),
+ ])
+ .then(async (responses) => {
+ if (responses.some((response) => response.status === 401)) {
+ returnToLogin();
+ throw new ApiError(401);
+ }
+ if (responses.some((response) => !response.ok))
+ throw new Error("Could not load partner account details.");
+ const [dashboard, membership] = await Promise.all(
+ responses.map((response) => response.json()),
+ );
+ if (!controller.signal.aborted) {
+ setData(dashboard);
+ setMembers(membership);
+ setError(undefined);
+ }
+ })
+ .catch((reason) => {
+ if (!controller.signal.aborted) setError(reason);
+ });
+ return () => controller.abort();
+ }, [id, offset, limit, revision]);
+ const href = id ? accountHref(id) : accountsPath;
+ const editingAsset = section.startsWith("assets/") && section.endsWith("/edit");
+ const campaignForm = section === "campaigns/new" || /^campaigns\/[^/]+\/edit$/.test(section);
+ const asset = editingAsset
+ ? data?.assets.find((item) => item.id === section.split("/")[1])
+ : undefined;
+ const form =
+ !id ||
+ section === "edit" ||
+ section === "members/new" ||
+ section === "assets/new" ||
+ campaignForm ||
+ editingAsset;
+ const title = !id
+ ? "Create partner account"
+ : section === "edit"
+ ? `Edit ${data?.account.name ?? "partner account"}`
+ : section === "members/new"
+ ? "Add member"
+ : campaignForm
+ ? section === "campaigns/new"
+ ? "Create paid campaign"
+ : "Edit paid campaign"
+ : section === "assets/new"
+ ? "Associate asset"
+ : editingAsset
+ ? `Edit ${asset?.name ?? "asset"}`
+ : (data?.account.name ?? "Partner account");
+ async function removeMember(subject: string) {
+ setBusy(true);
+ try {
+ const response = await fetch(
+ `/api/v1/admin/partner-accounts/${id}/members/${encodeURIComponent(subject)}`,
+ { method: "DELETE", headers: { "x-csrf-token": admin.csrf_token } },
+ );
+ if (!response.ok) throw new Error("Could not remove membership.");
+ setRevision((n) => n + 1);
+ } catch (reason) {
+ setError(reason instanceof Error ? reason : new Error("Could not remove membership."));
+ } finally {
+ setBusy(false);
+ }
+ }
+ const memberColumns: DataTableColumn[] = [
+ {
+ accessorKey: "name",
+ header: "Name",
+ cell: ({ row }) =>
+ row.original.user_id ? (
+
+ {row.original.name || "Unnamed user"}
+
+ ) : (
+ "User details unavailable"
+ ),
+ },
+ { accessorKey: "email", header: "Email", cell: ({ row }) => row.original.email || "—" },
+ { accessorKey: "subject", header: "Zitadel subject ID" },
+ { accessorKey: "issuer", header: "Issuer" },
+ {
+ id: "actions",
+ header: "Actions",
+ cell: ({ row }) => (
+
+ ),
+ },
+ ];
+ const assetColumns: DataTableColumn[] = [
+ { accessorKey: "name", header: "Name" },
+ {
+ accessorKey: "ad_formats",
+ header: "Format",
+ cell: ({ row }) =>
+ (row.original.ad_formats?.length
+ ? row.original.ad_formats
+ : [row.original.ad_format ?? "feed"]
+ )
+ .map(adFormatLabel)
+ .join(" · "),
+ },
+ {
+ id: "association",
+ header: "Association",
+ cell: ({ row }) =>
+ row.original.aggregated
+ ? "Product totals"
+ : row.original.automatic
+ ? "Automatic"
+ : "Legacy manual asset",
+ },
+ { accessorKey: "status", header: "Status", kind: "pill" },
+ {
+ id: "actions",
+ header: "Actions",
+ cell: ({ row }) => (
+
+ ),
+ },
+ ];
+ return (
+
+
+ {id && data && !form && (
+
+ )}
+
+ setRevision((n) => n + 1)}
+ />
+ {!error &&
+ (!id || data) &&
+ (campaignForm && data ? (
+
+ ) : form ? (
+ editingAsset && !asset ? (
+
+ This asset is unavailable. Return to related objects to select an asset.
+
+ ) : editingAsset && asset?.aggregated ? (
+
+ This product total combines multiple reporting records and cannot be edited as one
+ asset. Change promotion settings in the account defaults or its paid campaigns.
+
+ ) : editingAsset && asset?.automatic ? (
+
+ This reporting record is managed automatically. Change promotion settings in the
+ account defaults or its paid campaign.
+
+ ) : (
+
+ )
+ ) : (
+ <>
+
+ {section === "products" ? (
+
+ ) : section === "campaigns" ? (
+
+ ) : section === "related" ? (
+
+
`${row.issuer}/${row.subject}`}
+ empty="No members added yet."
+ toolbar={
+
+ }
+ />
+ row.id}
+ empty="No placement reporting records yet. Products and campaigns create these records automatically."
+ pagination={{
+ offset,
+ limit,
+ total: data!.asset_total,
+ onChange: (values) => {
+ setOffset(Number(values.offset));
+ if (values.limit) setLimit(Number(values.limit));
+ },
+ }}
+ />
+
+ ) : (
+
+
+
},
+ {
+ label: "Partnership tier",
+ value: (
+
+ ),
+ },
+ { label: "Status", value:
},
+ { label: "Benefits", value:
},
+ ]}
+ />
+
+ ),
+ },
+ {
+ label: "Ordinary recommendations",
+ value:
,
+ },
+ {
+ label: "Automatic product inclusion",
+ value:
,
+ },
+ {
+ label: "Allowed placements",
+ value: (
+
+ ),
+ },
+ {
+ label: "Paid promotion",
+ value: (
+
+ Manage agreed paid campaigns
+
+ ),
+ },
+ ]}
+ >
+
+ Products added directly or imported inherit the account’s acceptance and
+ promotion policies. Sponsored defaults create an automatic standing campaign.
+ Contextual matches are preferred; sponsored feed placements can fill unmatched
+ feeds. Article and topic placements require a relevant match.
+
+
+
+
+
},
+ {
+ label: "Members",
+ value: (
+
+ {members.length}
+
+ ),
+ },
+ {
+ label: "Placement reporting records",
+ value: (
+
+ {data!.asset_total}
+
+ ),
+ },
+ ]}
+ />
+
+ )}
+ >
+ ))}
+
+ );
+}
diff --git a/apps/admin/src/components/organisms/partner-accounts.tsx b/apps/admin/src/components/organisms/partner-accounts.tsx
new file mode 100644
index 000000000..470f48c82
--- /dev/null
+++ b/apps/admin/src/components/organisms/partner-accounts.tsx
@@ -0,0 +1,118 @@
+"use client";
+
+import Link from "next/link";
+import { useEffect, useState } from "react";
+import { Plus } from "lucide-react";
+import { Button } from "@devfeed/ui/button";
+import { ResourceHeading } from "@devfeed/ui/resource-layout";
+import { DataTable, type DataTableColumn } from "@/components/molecules/data-table";
+import type { AccountOut, AccountsOut } from "@/lib/api/generated/models";
+
+export const accountsPath = "/partnerships/accounts";
+export const accountHref = (id: string) => `${accountsPath}/${encodeURIComponent(id)}`;
+export const accountsTrail = [{ label: "Partnerships", href: "/partnerships" }];
+const columns: DataTableColumn[] = [
+ {
+ accessorKey: "name",
+ header: "Name",
+ cell: ({ row }) => (
+
+ {row.original.name}
+
+ ),
+ },
+ {
+ accessorKey: "tier",
+ header: "Partnership tier",
+ accessorFn: (row) => row.tier.charAt(0).toUpperCase() + row.tier.slice(1),
+ },
+ { accessorKey: "status", header: "Status", kind: "pill" },
+ {
+ id: "actions",
+ header: "Actions",
+ cell: ({ row }) => (
+
+ ),
+ },
+];
+
+export function PartnerAccounts() {
+ const [accounts, setAccounts] = useState({ items: [], total: 0 });
+ const [offset, setOffset] = useState(0);
+ const [limit, setLimit] = useState(100);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState();
+ const [revision, setRevision] = useState(0);
+ useEffect(() => {
+ const controller = new AbortController();
+ fetch(`/api/v1/admin/partner-accounts?offset=${offset}&limit=${limit}`, {
+ cache: "no-store",
+ signal: controller.signal,
+ })
+ .then(async (response) => {
+ if (!response.ok) throw new Error("Could not load partner accounts.");
+ return response.json() as Promise;
+ })
+ .then((page) => {
+ if (!controller.signal.aborted) {
+ setAccounts(page);
+ setError(undefined);
+ }
+ })
+ .catch((reason) => {
+ if (!controller.signal.aborted) setError(reason);
+ })
+ .finally(() => {
+ if (!controller.signal.aborted) setLoading(false);
+ });
+ return () => controller.abort();
+ }, [offset, limit, revision]);
+ return (
+
+
+
+
+ row.id}
+ loading={loading}
+ error={error}
+ onRetry={() => setRevision((n) => n + 1)}
+ empty="No partner accounts."
+ pagination={{
+ offset,
+ limit,
+ total: accounts.total,
+ onChange: (values) => {
+ setLoading(true);
+ setOffset(Number(values.offset));
+ if (values.limit) setLimit(Number(values.limit));
+ },
+ }}
+ />
+
+ );
+}
diff --git a/apps/admin/src/components/organisms/partner-ad-formats.tsx b/apps/admin/src/components/organisms/partner-ad-formats.tsx
new file mode 100644
index 000000000..3eebb3182
--- /dev/null
+++ b/apps/admin/src/components/organisms/partner-ad-formats.tsx
@@ -0,0 +1,248 @@
+"use client";
+
+import { useCallback, useState } from "react";
+import {
+ AdCreativePreview,
+ AutomaticAdFormats,
+ adFormats,
+ automaticAdFormats,
+ type AdFormat,
+ type CreativePreviewArticle,
+ type CreativePreviewProduct,
+ ProductPlacementDialog,
+ parseCreativePreview,
+ parseProductPlacementPolicy,
+ type PlacementSurface,
+} from "@devfeed/ui/advertising-formats";
+import {
+ adminPartnerCreativePreview,
+ adminPartnerSponsorArticles,
+ adminPartnerProductPromotionGet,
+ adminPartnerProductPromotionUpdate,
+ adminPartnerProductAdImageUpload,
+} from "@/lib/api/generated/admin";
+import { ApiError, returnToLogin } from "@/lib/api/client";
+import { useRequest } from "@/lib/use-request";
+import { Select } from "@/components/molecules/select";
+import { useAdmin } from "@/components/molecules/admin-session";
+
+export function PartnerProductPlacements({
+ accountId,
+ product,
+ allowedFormats,
+ accountFormats,
+ accountSurfaces,
+ destination,
+ disabled,
+ onSaved,
+}: {
+ accountId: string;
+ product: { id: string; name: string };
+ allowedFormats: AdFormat[];
+ accountFormats?: AdFormat[];
+ accountSurfaces?: PlacementSurface[];
+ destination?: "product_url" | "listing_url";
+ disabled?: boolean;
+ onSaved: () => void;
+}) {
+ const admin = useAdmin();
+ function parsePolicy(value: unknown) {
+ const row = value as { account_id?: unknown; product_id?: unknown } | null;
+ if (row?.account_id !== accountId || row?.product_id !== product.id)
+ throw new Error("Could not confirm product placement settings.");
+ return parseProductPlacementPolicy(value);
+ }
+ const loadPreview = useCallback(
+ async (signal: AbortSignal) => {
+ try {
+ const preview = parseCreativePreview(
+ await adminPartnerCreativePreview(accountId, { product_id: product.id }, { signal }),
+ );
+ if (preview && preview.id !== product.id) throw new Error("Unexpected preview product.");
+ return preview;
+ } catch (error) {
+ if (!signal.aborted && error instanceof ApiError && error.status === 401) returnToLogin();
+ throw error;
+ }
+ },
+ [accountId, product.id],
+ );
+ return (
+ {
+ try {
+ return parsePolicy(
+ await adminPartnerProductPromotionGet(accountId, product.id, { signal }),
+ );
+ } catch (error) {
+ if (!signal.aborted && error instanceof ApiError && error.status === 401) returnToLogin();
+ throw error;
+ }
+ }}
+ savePolicy={async (restrictions, signal) => {
+ try {
+ return parsePolicy(
+ await adminPartnerProductPromotionUpdate(accountId, product.id, restrictions, {
+ signal,
+ headers: { "X-CSRF-Token": admin.csrf_token },
+ }),
+ );
+ } catch (error) {
+ if (!signal.aborted && error instanceof ApiError && error.status === 401) returnToLogin();
+ throw error;
+ }
+ }}
+ uploadImage={async (file, signal) => {
+ try {
+ return parsePolicy(
+ await adminPartnerProductAdImageUpload(
+ accountId,
+ product.id,
+ { file },
+ { signal, headers: { "X-CSRF-Token": admin.csrf_token } },
+ ),
+ );
+ } catch (error) {
+ if (!signal.aborted && error instanceof ApiError) {
+ if (error.status === 401) returnToLogin();
+ if (error.status === 413) throw new Error("Choose an image under 5 MB.");
+ if ([415, 422].includes(error.status))
+ throw new Error("Choose a valid JPEG, PNG or WebP image.");
+ }
+ throw error;
+ }
+ }}
+ loadPreview={loadPreview}
+ />
+ );
+}
+
+export function PartnerCreativePreview({
+ accountId,
+ format,
+ productId,
+ articleId,
+ destination = "product_url",
+}: {
+ accountId?: string;
+ format: AdFormat;
+ productId?: string;
+ articleId?: string;
+ destination?: "product_url" | "listing_url";
+}) {
+ const [open, setOpen] = useState(false);
+ const [revision, setRevision] = useState(0);
+ const load = useCallback(
+ async (
+ signal: AbortSignal,
+ ): Promise<{
+ product: CreativePreviewProduct | null;
+ article: CreativePreviewArticle | null;
+ } | null> => {
+ if (!open || !accountId) return null;
+ try {
+ if (format === "sponsored_content") {
+ if (!articleId) return null;
+ const page = await adminPartnerSponsorArticles(
+ accountId,
+ { article_id: articleId, limit: 1 },
+ { signal },
+ );
+ const article = page.items.find((item) => item.id === articleId);
+ if (!article) throw new Error("Choose an approved, published article.");
+ return { product: null, article };
+ }
+ const result = await adminPartnerCreativePreview(
+ accountId,
+ productId ? { product_id: productId } : undefined,
+ { signal },
+ );
+ return { product: result.product, article: null };
+ } catch (error) {
+ if (error instanceof ApiError && error.status === 401) returnToLogin();
+ throw error;
+ }
+ },
+ [open, accountId, format, articleId, productId],
+ );
+ const result = useRequest(
+ `creative-preview/${accountId}/${format}/${productId}/${articleId}/${open}/${revision}`,
+ load,
+ );
+ return (
+ setOpen(event.currentTarget.open)}
+ >
+ Creative preview
+ {open && (
+ setRevision((value) => value + 1)}
+ />
+ )}
+
+ );
+}
+
+export function PartnerAdFormats({
+ accountId,
+ value,
+ onChange,
+ allowed,
+ eligible,
+ disabled,
+ destination,
+}: {
+ accountId?: string;
+ value: AdFormat[];
+ onChange: (value: AdFormat[]) => void;
+ allowed: AdFormat[];
+ eligible: boolean;
+ disabled?: boolean;
+ destination: "product_url" | "listing_url";
+}) {
+ const [preview, setPreview] = useState(value[0] ?? "feed");
+ const selected = value.includes(preview) ? preview : (value[0] ?? "feed");
+ return (
+
+ Automatic ad formats
+
+ {value.length > 1 && (
+
+ );
+}
diff --git a/apps/admin/src/components/organisms/partner-api-response.tsx b/apps/admin/src/components/organisms/partner-api-response.tsx
new file mode 100644
index 000000000..dd025b2ab
--- /dev/null
+++ b/apps/admin/src/components/organisms/partner-api-response.tsx
@@ -0,0 +1,111 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import type { ResponseMapping } from "@devfeed/ui/connector-response-mappings";
+import { ConnectorResponse } from "@devfeed/ui/connector-response";
+import { ApiError } from "@/lib/api/client";
+import { RequestState } from "@/components/molecules/request-state";
+import { ValidationErrors } from "@/components/molecules/validation-errors";
+import { useAdmin } from "@/components/molecules/admin-session";
+import { adminPartnerConnectorResponse } from "@/lib/api/generated/admin";
+import type { ConnectorConfig, ConnectorResponseOut } from "@/lib/api/generated/models";
+
+export function PartnerApiResponse({
+ provider,
+ connector,
+ mappings,
+ onResponse,
+}: {
+ provider: string;
+ connector: ConnectorConfig;
+ mappings: ResponseMapping[];
+ onResponse: (data: unknown) => void;
+}) {
+ const admin = useAdmin();
+ const [refresh, setRefresh] = useState(0);
+ const [state, setState] = useState<{
+ key: string;
+ loading?: boolean;
+ response?: ConnectorResponseOut;
+ error?: Error;
+ }>();
+ // Mapping edits do not refetch the API. Only request settings determine the preview.
+ const key = JSON.stringify({
+ provider: provider || "connector-preview",
+ connector: {
+ base_url: connector.base_url,
+ list_path: connector.list_path,
+ auth: connector.auth,
+ parameters: connector.parameters,
+ pagination: {
+ mode: connector.pagination?.mode,
+ parameter: connector.pagination?.parameter,
+ size_parameter: connector.pagination?.size_parameter,
+ page_size: connector.pagination?.page_size,
+ start: connector.pagination?.start,
+ },
+ timeout_seconds: connector.timeout_seconds,
+ max_response_bytes: connector.max_response_bytes,
+ requests_per_minute: connector.requests_per_minute,
+ },
+ });
+ const ready =
+ /^https:\/\/[^/]+\/?$/.test(connector.base_url) && !!connector.list_path?.startsWith("/");
+ useEffect(() => {
+ if (!ready) return;
+ const controller = new AbortController();
+ const timer = setTimeout(() => {
+ setState({ key, loading: true });
+ void adminPartnerConnectorResponse(JSON.parse(key), {
+ signal: controller.signal,
+ headers: { "X-CSRF-Token": admin.csrf_token },
+ }).then(
+ (response) => {
+ if (!controller.signal.aborted) setState({ key, response });
+ },
+ (error: unknown) => {
+ if (!controller.signal.aborted)
+ setState({
+ key,
+ error: error instanceof Error ? error : new Error("Could not load API response."),
+ });
+ },
+ );
+ }, 700);
+ return () => {
+ clearTimeout(timer);
+ controller.abort();
+ };
+ }, [key, ready, refresh, admin.csrf_token]);
+ const current = state?.key === key ? state : undefined;
+ useEffect(() => {
+ onResponse(current?.response?.data);
+ }, [current?.response?.data, onResponse]);
+ return (
+ setRefresh((value) => value + 1)}
+ status={
+ <>
+
+
+ {!ready && (
+
+ Enter the API base URL and products endpoint to preview the response.
+
+ )}
+ >
+ }
+ />
+ );
+}
diff --git a/apps/admin/src/components/organisms/partner-audience.tsx b/apps/admin/src/components/organisms/partner-audience.tsx
new file mode 100644
index 000000000..765ef6ecd
--- /dev/null
+++ b/apps/admin/src/components/organisms/partner-audience.tsx
@@ -0,0 +1,71 @@
+"use client";
+
+import { useCallback } from "react";
+import {
+ AudienceTargeting,
+ hasAudience,
+ type Audience,
+ type AudienceTopicLoader,
+} from "@devfeed/ui/audience-targeting";
+import {
+ adminPartnerAudienceTopics,
+ adminPartnerAudienceTopicsCatalog,
+} from "@/lib/api/generated/admin";
+
+export function PartnerAudience({
+ accountId,
+ value,
+ onChange,
+ enabled,
+ disabled,
+}: {
+ accountId?: string;
+ value: Audience;
+ onChange?: (value: Audience) => void;
+ enabled: boolean;
+ disabled?: boolean;
+}) {
+ const loadTopics = useCallback(
+ (dimension, params, signal) =>
+ accountId
+ ? adminPartnerAudienceTopics(accountId, { dimension, ...params }, { signal })
+ : adminPartnerAudienceTopicsCatalog({ dimension, ...params }, { signal }),
+ [accountId],
+ );
+ return (
+
+
+
Audience targeting
+
+ Any match within a group qualifies. Selected groups must all match the reader’s profile.
+
+ {!enabled && (
+
+ Advanced targeting requires Platinum or Diamond.
+ {hasAudience(value) &&
+ " Saved restrictions are retained. These placements stay disabled until the account has an eligible tier or the restrictions are explicitly cleared."}
+
+ )}
+
+
+ {!enabled && onChange && hasAudience(value) && (
+
+ )}
+
+ );
+}
diff --git a/apps/admin/src/components/organisms/partner-campaign-form.tsx b/apps/admin/src/components/organisms/partner-campaign-form.tsx
new file mode 100644
index 000000000..c5b634f45
--- /dev/null
+++ b/apps/admin/src/components/organisms/partner-campaign-form.tsx
@@ -0,0 +1,591 @@
+"use client";
+
+import { useCallback, useMemo, useState, type FormEvent } from "react";
+import { useRouter } from "next/navigation";
+import Link from "next/link";
+import { X } from "lucide-react";
+import { Button } from "@devfeed/ui/button";
+import { Input } from "@devfeed/ui/input";
+import { EntityPicker, type EntityPickerSource } from "@/components/molecules/entity-picker";
+import { Field } from "@/components/molecules/field";
+import { Select } from "@/components/molecules/select";
+import { RequestState } from "@/components/molecules/request-state";
+import { ValidationErrors } from "@/components/molecules/validation-errors";
+import {
+ PromotionSurfaces,
+ promotionSurfaces,
+ type PromotionSurface,
+} from "@/components/molecules/promotion-surfaces";
+import { useAdmin } from "@/components/molecules/admin-session";
+import {
+ adminPartnerCampaignCreate,
+ adminPartnerCampaignGet,
+ adminPartnerCampaignUpdate,
+ adminPartnerInventoryList,
+ adminPartnerSponsorArticles,
+} from "@/lib/api/generated/admin";
+import type { AccountOut, CampaignInput, CampaignOut } from "@/lib/api/generated/models";
+import { ApiError, returnToLogin } from "@/lib/api/client";
+import { useRequest } from "@/lib/use-request";
+import { accountHref } from "./partner-accounts";
+import { audienceValue } from "@devfeed/ui/audience-targeting";
+import { PartnerAudience } from "./partner-audience";
+import { PartnerCreativePreview } from "./partner-ad-formats";
+import { adFormats } from "@devfeed/ui/advertising-formats";
+
+const productCatalog = { label: "Account products", singular: "Account product", title: "name" };
+const articleCatalog = {
+ label: "Approved articles",
+ singular: "Sponsored article",
+ title: "title",
+};
+
+function localDate(value: Date) {
+ const offset = value.getTimezoneOffset() * 60_000;
+ return new Date(value.getTime() - offset).toISOString().slice(0, 16);
+}
+
+export function PartnerCampaignPage({
+ account,
+ campaignId,
+}: {
+ account: AccountOut;
+ campaignId?: string;
+}) {
+ const [revision, setRevision] = useState(0);
+ const load = useCallback(
+ (signal: AbortSignal) =>
+ campaignId
+ ? adminPartnerCampaignGet(account.id, campaignId, { signal })
+ : Promise.resolve(null),
+ [account.id, campaignId],
+ );
+ const result = useRequest(`partner-campaign/${account.id}/${campaignId}/${revision}`, load);
+ if (campaignId && (result.loading || result.error))
+ return (
+ setRevision((value) => value + 1)}
+ />
+ );
+ if (campaignId && !result.data)
+ return This campaign is unavailable for this account.
;
+ if (result.data?.managed)
+ return (
+
+ {result.data.name}
+
+ This standing campaign follows the account’s sponsored promotion default and automatically
+ includes accepted imported products. Its settings are managed through the partner account.
+
+
+
+ );
+ if (!(
+ account.allowed_promotion_kinds?.includes("ad") ??
+ ["silver", "gold", "platinum", "diamond"].includes(account.tier)
+ ))
+ return (
+
+ Sponsored campaigns require Silver or higher
+
+ {campaignId
+ ? "This dated campaign is retained for reporting. Sponsored delivery and campaign changes require Silver or higher."
+ : "This account’s current tier supports product recommendations. Change its partnership tier to create a sponsored campaign."}
+
+
+
+ );
+ return (
+
+ );
+}
+
+function PartnerCampaignForm({
+ account,
+ campaign,
+}: {
+ account: AccountOut;
+ campaign?: CampaignOut;
+}) {
+ const admin = useAdmin();
+ const router = useRouter();
+ const [status, setStatus] = useState>(
+ campaign?.status ?? "draft",
+ );
+ const [targeting, setTargeting] = useState>(
+ campaign?.targeting ?? "all",
+ );
+ const [products, setProducts] = useState(campaign?.product_ids ?? []);
+ const [topics, setTopics] = useState(campaign?.topic_ids ?? []);
+ const [adFormat, setAdFormat] = useState>(
+ campaign?.ad_format ?? "feed",
+ );
+ const [articleId, setArticleId] = useState(campaign?.article_id ?? "");
+ const sponsoredContent = adFormat === "sponsored_content";
+ const feedOnly = sponsoredContent || adFormat === "homepage_sponsorship";
+ const [audienceMode, setAudienceMode] = useState(
+ campaign?.audience == null ? "inherit" : "custom",
+ );
+ const [audience, setAudience] = useState(() =>
+ audienceValue(campaign?.audience ?? account.audience),
+ );
+ const [surfaces, setSurfaces] = useState(
+ campaign?.surfaces ?? account.promotion_surfaces ?? [...promotionSurfaces],
+ );
+ const [error, setError] = useState();
+ const [busy, setBusy] = useState(false);
+ const [dates] = useState(() => {
+ const start = campaign?.starts_at ? new Date(campaign.starts_at) : new Date();
+ const end = campaign?.ends_at
+ ? new Date(campaign.ends_at)
+ : new Date(start.getTime() + 30 * 86_400_000);
+ return { start: localDate(start), end: localDate(end) };
+ });
+ const source = useMemo(
+ () => ({
+ key: `campaign-inventory/${account.id}`,
+ list: async ({ q, offset, limit }, signal) => {
+ const page = await adminPartnerInventoryList(account.id, { q, offset, limit }, { signal });
+ return {
+ ...page,
+ offset: offset ?? 0,
+ limit: limit ?? 25,
+ items: page.items.map((product) => ({ ...product })),
+ };
+ },
+ get: async (id, signal) => {
+ const page = await adminPartnerInventoryList(
+ account.id,
+ { product_id: id, limit: 1 },
+ { signal },
+ );
+ const product = page.items.find((item) => item.id === id);
+ if (!product) throw new Error("Product is unavailable for this account.");
+ return { ...product };
+ },
+ }),
+ [account.id],
+ );
+ const cancel = `${accountHref(account.id)}/campaigns`;
+ const articleSource = useMemo(
+ () => ({
+ key: `sponsor-articles/${account.id}`,
+ list: async ({ q, offset, limit }, signal) => {
+ const page = await adminPartnerSponsorArticles(
+ account.id,
+ { q, offset, limit },
+ { signal },
+ );
+ return {
+ ...page,
+ offset: offset ?? 0,
+ limit: limit ?? 25,
+ items: page.items.map((item) => ({ ...item })),
+ };
+ },
+ get: async (id, signal) => {
+ const page = await adminPartnerSponsorArticles(
+ account.id,
+ { article_id: id, limit: 1 },
+ { signal },
+ );
+ const article = page.items.find((item) => item.id === id);
+ if (!article) throw new Error("Choose an approved, published article.");
+ return { ...article };
+ },
+ }),
+ [account.id],
+ );
+ async function save(event: FormEvent) {
+ event.preventDefault();
+ if (busy) return;
+ const form = new FormData(event.currentTarget);
+ const start = new Date(String(form.get("starts_at")));
+ const end = new Date(String(form.get("ends_at")));
+ const technologies = String(form.get("technologies") ?? "")
+ .split(",")
+ .map((value) => value.trim())
+ .filter(Boolean);
+ if (!Number.isFinite(start.getTime()) || !Number.isFinite(end.getTime()) || end <= start) {
+ setError(new Error("Choose an end date after the start date."));
+ return;
+ }
+ if (!feedOnly && !surfaces.length) {
+ setError(new Error("Select at least one placement surface."));
+ return;
+ }
+ if (sponsoredContent && !articleId) {
+ setError(new Error("Choose an approved article for sponsored content."));
+ return;
+ }
+ if (targeting === "selected" && !products.length) {
+ setError(new Error("Select at least one product from this account."));
+ return;
+ }
+ if (targeting === "filters" && !topics.length && !technologies.length) {
+ setError(new Error("Choose a topic or enter a technology for product targeting."));
+ return;
+ }
+ const payload: CampaignInput = {
+ name: String(form.get("name")).trim(),
+ status,
+ targeting,
+ product_ids: targeting === "selected" ? products : [],
+ topic_ids: targeting === "filters" ? topics : [],
+ technologies: targeting === "filters" ? technologies : [],
+ surfaces: feedOnly ? ["feed"] : surfaces,
+ ad_format: adFormat,
+ article_id: sponsoredContent ? articleId : null,
+ starts_at: start.toISOString(),
+ ends_at: end.toISOString(),
+ priority: Number(form.get("priority")),
+ daily_impression_limit: form.get("daily_impression_limit")
+ ? Number(form.get("daily_impression_limit"))
+ : null,
+ audience: audienceMode === "inherit" ? null : audience,
+ };
+ setBusy(true);
+ setError(undefined);
+ try {
+ const options = { headers: { "X-CSRF-Token": admin.csrf_token } };
+ if (campaign) await adminPartnerCampaignUpdate(account.id, campaign.id, payload, options);
+ else await adminPartnerCampaignCreate(account.id, payload, options);
+ router.push(cancel);
+ } catch (reason) {
+ if (reason instanceof ApiError && reason.status === 401) returnToLogin();
+ setError(reason instanceof Error ? reason : new Error("Could not save campaign."));
+ setBusy(false);
+ }
+ }
+ return (
+
+ );
+}
diff --git a/apps/admin/src/components/organisms/partner-campaigns.tsx b/apps/admin/src/components/organisms/partner-campaigns.tsx
new file mode 100644
index 000000000..b91f34423
--- /dev/null
+++ b/apps/admin/src/components/organisms/partner-campaigns.tsx
@@ -0,0 +1,159 @@
+"use client";
+
+import Link from "next/link";
+import { useCallback, useState } from "react";
+import { Plus } from "lucide-react";
+import { Button } from "@devfeed/ui/button";
+import { DataTable, type DataTableColumn } from "@/components/molecules/data-table";
+import { DateTime } from "@/components/molecules/date-time";
+import { StatusBadge } from "@/components/molecules/status-badge";
+import { adminPartnerCampaignsList } from "@/lib/api/generated/admin";
+import type { AccountOut, CampaignOut } from "@/lib/api/generated/models";
+import { useRequest } from "@/lib/use-request";
+import { accountHref } from "./partner-accounts";
+import { hasAudience } from "@devfeed/ui/audience-targeting";
+import { adFormatLabel } from "@devfeed/ui/advertising-formats";
+
+export function PartnerCampaigns({ account }: { account: AccountOut }) {
+ const accountId = account.id;
+ const canCreate =
+ account.allowed_promotion_kinds?.includes("ad") ??
+ ["silver", "gold", "platinum", "diamond"].includes(account.tier);
+ const [page, setPage] = useState({ offset: 0, limit: 25 });
+ const [revision, setRevision] = useState(0);
+ const load = useCallback(
+ (signal: AbortSignal) => adminPartnerCampaignsList(accountId, page, { signal }),
+ [accountId, page],
+ );
+ const result = useRequest(
+ `partner-campaigns/${accountId}/${page.offset}/${page.limit}/${revision}`,
+ load,
+ );
+ const columns: DataTableColumn[] = [
+ {
+ accessorKey: "name",
+ header: "Campaign",
+ cell: ({ row }) => (
+
+ {row.original.name}
+ {row.original.managed && (
+ Account default
+ )}
+
+ ),
+ },
+ {
+ accessorKey: "status",
+ header: "Status",
+ cell: ({ row }) => ,
+ },
+ {
+ accessorKey: "ad_format",
+ header: "Format",
+ cell: ({ row }) => adFormatLabel(row.original.ad_format ?? "feed"),
+ },
+ {
+ accessorKey: "targeting",
+ header: "Products",
+ cell: ({ row }) =>
+ row.original.ad_format === "sponsored_content"
+ ? "Approved article"
+ : row.original.targeting === "all"
+ ? "All eligible products"
+ : row.original.targeting === "selected"
+ ? `${row.original.product_ids?.length ?? 0} selected`
+ : "Matching filters",
+ },
+ {
+ accessorKey: "audience",
+ header: "Audience",
+ cell: ({ row }) => {
+ const audience = row.original.audience;
+ if (audience == null) return "Account defaults";
+ if (!hasAudience(audience)) return "Any audience";
+ return [
+ audience.experience_levels?.length &&
+ `${audience.experience_levels.length} experience levels`,
+ audience.language_topic_ids?.length && `${audience.language_topic_ids.length} languages`,
+ audience.tool_topic_ids?.length && `${audience.tool_topic_ids.length} tools`,
+ ]
+ .filter(Boolean)
+ .join(" · ");
+ },
+ },
+ {
+ accessorKey: "ends_at",
+ header: "Ends",
+ cell: ({ row }) =>
+ row.original.managed ? (
+ "Follows account defaults"
+ ) : (
+
+ ),
+ },
+ { accessorKey: "impressions", header: "Impressions" },
+ { accessorKey: "clicks", header: "Clicks" },
+ {
+ accessorKey: "ctr",
+ header: "Click-through rate",
+ cell: ({ row }) =>
+ row.original.ctr == null ? "—" : `${(row.original.ctr * 100).toFixed(2)}%`,
+ },
+ ];
+ return (
+
+
+
Paid campaigns
+
+ Sponsored account defaults create a standing campaign covering accepted imported products.
+ Create additional campaigns for specific dates, formats or product selections on Silver or
+ higher. Contextual matches are preferred; sponsored defaults can fill remaining slots in
+ the main feed. Article, topic, and search placements require a relevant match.
+
+ {!canCreate && (
+
+ This tier supports product recommendations. Dated campaigns remain available for
+ reporting; sponsored delivery and campaign changes require Silver or higher.
+
+ )}
+
+ row.id}
+ loading={result.loading}
+ error={result.error}
+ onRetry={() => setRevision((value) => value + 1)}
+ empty="No paid campaigns yet. Eligible products can still appear as ordinary recommendations."
+ pagination={{
+ ...page,
+ total: result.data?.total ?? 0,
+ onChange: (values) =>
+ setPage((current) => ({
+ offset: Number(values.offset ?? current.offset),
+ limit: Number(values.limit ?? current.limit),
+ })),
+ }}
+ toolbar={
+ canCreate ? (
+
+ ) : undefined
+ }
+ />
+
+ );
+}
diff --git a/apps/admin/src/components/organisms/partner-connection-form.tsx b/apps/admin/src/components/organisms/partner-connection-form.tsx
new file mode 100644
index 000000000..6ab2649b9
--- /dev/null
+++ b/apps/admin/src/components/organisms/partner-connection-form.tsx
@@ -0,0 +1,352 @@
+"use client";
+
+import { useEffect, useRef, useState, type FormEvent } from "react";
+import { Input } from "@devfeed/ui/input";
+import { Button } from "@devfeed/ui/button";
+import { Field } from "@/components/molecules/field";
+import { Select } from "@/components/molecules/select";
+import { EntityPicker, type EntityPickerSource } from "@/components/molecules/entity-picker";
+import { PartnerApiResponse } from "./partner-api-response";
+import { ConnectorFields } from "@devfeed/ui/connector-fields";
+import { responseMappings } from "@devfeed/ui/connector-response-mappings";
+import { connectorDefaults } from "@/lib/partner-connectors";
+import { ApiError } from "@/lib/api/client";
+import { ValidationErrors } from "@/components/molecules/validation-errors";
+import { RequestState } from "@/components/molecules/request-state";
+import { useAdmin } from "@/components/molecules/admin-session";
+import {
+ accountsV1AdminPartnerAccountsGet,
+ dashboardV1AdminPartnerAccountsAccountIdDashboardGet,
+ adminPartnerConnectionCreate,
+ adminPartnerConnectionUpdate,
+ adminPartnerConnectorPreview,
+} from "@/lib/api/generated/admin";
+import type {
+ ConnectionOut,
+ PartnerProviderOut,
+ ConnectorConfig,
+ ConnectorPreviewOut,
+} from "@/lib/api/generated/models";
+
+const accountCatalog = { label: "Partner accounts", singular: "Partner account", title: "name" };
+const partnerAccounts: EntityPickerSource = {
+ key: "connection-owner-accounts",
+ list: async ({ q, offset, limit }, signal) => {
+ const page = await accountsV1AdminPartnerAccountsGet({ q, offset, limit }, { signal });
+ return {
+ ...page,
+ offset: offset ?? 0,
+ limit: limit ?? 25,
+ items: page.items.map((account) => ({ ...account })),
+ };
+ },
+ get: async (id, signal) => {
+ const dashboard = await dashboardV1AdminPartnerAccountsAccountIdDashboardGet(id, undefined, {
+ signal,
+ });
+ return { ...dashboard.account };
+ },
+};
+
+export function PartnerConnectionForm({
+ providers,
+ connection,
+ onSaved,
+ onCancel,
+}: {
+ providers: PartnerProviderOut[];
+ connection?: ConnectionOut;
+ onSaved: (connection: ConnectionOut) => void;
+ onCancel: () => void;
+}) {
+ const admin = useAdmin();
+ const form = useRef(null);
+ const [provider, setProvider] = useState(
+ connection?.provider ?? providers[0]?.provider ?? "__custom__",
+ );
+ const [customId, setCustomId] = useState(connection?.provider ?? "");
+ const [name, setName] = useState(connection?.name ?? providers[0]?.name ?? "");
+ const [connector, setConnector] = useState(
+ connection?.connector ?? connectorDefaults(provider),
+ );
+ const [responseData, setResponseData] = useState();
+ const mappings =
+ responseData === undefined
+ ? []
+ : responseMappings(connector, responseData, connectorDefaults());
+ const [testing, setTesting] = useState(false);
+ const [preview, setPreview] = useState();
+ const identifier = provider === "__custom__" ? customId : provider;
+ function changeConnector(value: ConnectorConfig) {
+ setConnector(value);
+ setPreview(undefined);
+ }
+ async function test() {
+ setTesting(true);
+ setError(undefined);
+ setPreview(undefined);
+ try {
+ setPreview(
+ await adminPartnerConnectorPreview(
+ { provider: identifier, connector },
+ { headers: { "X-CSRF-Token": admin.csrf_token } },
+ ),
+ );
+ } catch (error) {
+ setError(error instanceof Error ? error : new Error("Could not test import connection."));
+ } finally {
+ setTesting(false);
+ }
+ }
+ const [accountId, setAccountId] = useState(connection?.account_id ?? "");
+ const [enabled, setEnabled] = useState(connection?.enabled ?? true);
+ const [interval, setInterval] = useState(String(connection?.sync_interval_minutes ?? 360));
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState();
+ const errors = error instanceof ApiError ? error.fields : {};
+ useEffect(() => {
+ const invalid = form.current?.querySelectorAll('[aria-invalid="true"]');
+ invalid?.forEach((control) => {
+ let parent = control.parentElement;
+ while (parent) {
+ if (parent instanceof HTMLDetailsElement) parent.open = true;
+ parent = parent.parentElement;
+ }
+ });
+ invalid?.[0]?.focus();
+ }, [error]);
+
+ async function save(event: FormEvent) {
+ event.preventDefault();
+ if (saving || testing) return;
+ if (!accountId) {
+ setError(
+ new ApiError(422, "Please correct the highlighted fields.", {
+ account_id: "Select a partner account.",
+ }),
+ );
+ return;
+ }
+ const sync_interval_minutes = Number(interval);
+ if (
+ !Number.isInteger(sync_interval_minutes) ||
+ sync_interval_minutes < 1 ||
+ sync_interval_minutes > 10080
+ ) {
+ setError(
+ new ApiError(422, "Please correct the highlighted fields.", {
+ sync_interval_minutes: "Enter a whole number of minutes between 1 and 10,080.",
+ }),
+ );
+ return;
+ }
+ setSaving(true);
+ setError(undefined);
+ try {
+ const options = { headers: { "X-CSRF-Token": admin.csrf_token } };
+ const result = connection
+ ? await adminPartnerConnectionUpdate(
+ connection.provider,
+ {
+ account_id: accountId || null,
+ enabled,
+ sync_interval_minutes,
+ expected_revision: connection.revision,
+ name,
+ connector,
+ },
+ options,
+ )
+ : await adminPartnerConnectionCreate(
+ {
+ provider: identifier,
+ name,
+ connector,
+ account_id: accountId || null,
+ enabled,
+ sync_interval_minutes,
+ },
+ options,
+ );
+ onSaved(result);
+ } catch (error) {
+ setError(
+ error instanceof Error
+ ? error
+ : new Error("Could not save import connection settings. Try again."),
+ );
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ return (
+
+ );
+}
diff --git a/apps/admin/src/components/organisms/partner-connection-page.tsx b/apps/admin/src/components/organisms/partner-connection-page.tsx
new file mode 100644
index 000000000..1f8b5ad9a
--- /dev/null
+++ b/apps/admin/src/components/organisms/partner-connection-page.tsx
@@ -0,0 +1,348 @@
+"use client";
+
+import { partnerSyncInterval } from "@/lib/partner-interval";
+
+import { useRefreshInterval } from "@/lib/use-refresh-interval";
+
+import Link from "next/link";
+import { Pencil, RefreshCw } from "lucide-react";
+import { ResourcePanel, DataValue } from "@devfeed/ui/resource-layout";
+import { DateTime } from "@/components/molecules/date-time";
+import { StatusBadge } from "@/components/molecules/status-badge";
+import { PartnerRelated } from "./partner-related";
+import { useRouter } from "next/navigation";
+import { useEffect, useState } from "react";
+import { Button } from "@devfeed/ui/button";
+import { ResourceHeading } from "@devfeed/ui/resource-layout";
+import { RequestState } from "@/components/molecules/request-state";
+import { useAdmin } from "@/components/molecules/admin-session";
+import { PartnerConnectionForm } from "./partner-connection-form";
+import {
+ importConnectionHref,
+ importConnectionsPath,
+ partnershipTrail,
+} from "./partner-connections";
+import {
+ dashboardV1AdminPartnerAccountsAccountIdDashboardGet,
+ adminPartnerConnectionGet,
+ adminPartnerProvidersList,
+ adminPartnerConnectionAction,
+} from "@/lib/api/generated/admin";
+import type { AccountOut, ConnectionOut, PartnerProviderOut } from "@/lib/api/generated/models";
+import { ApiError } from "@/lib/api/client";
+
+export function PartnerConnectionPage({
+ provider,
+ editing = false,
+ section = "details",
+}: {
+ provider?: string;
+ editing?: boolean;
+ section?: "details" | "related";
+}) {
+ const refreshSeconds = useRefreshInterval();
+ const router = useRouter();
+ const admin = useAdmin();
+ const [account, setAccount] = useState();
+ const [connection, setConnection] = useState();
+ const [providers, setProviders] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState();
+ const [refresh, setRefresh] = useState(0);
+ useEffect(() => {
+ let live = true;
+ async function findConnection(identifier: string) {
+ try {
+ return await adminPartnerConnectionGet(identifier);
+ } catch (error) {
+ if (error instanceof ApiError && error.status === 404) return undefined;
+ throw error;
+ }
+ }
+ async function load() {
+ try {
+ const [found, supported] = await Promise.all([
+ provider ? findConnection(provider) : undefined,
+ !provider || editing ? adminPartnerProvidersList() : [],
+ ]);
+ if (provider && !found) throw new Error("Import connection not found.");
+ // Presets are a small fixed catalog. Probe each identity directly so an
+ // existing preset outside the connections page cannot be offered again.
+ const existing = provider
+ ? []
+ : await Promise.all(supported.map((preset) => findConnection(preset.provider)));
+ const available = provider
+ ? supported
+ : supported.filter((_preset, index) => !existing[index]);
+ const dashboard =
+ found?.account_id && !editing && section === "details"
+ ? await dashboardV1AdminPartnerAccountsAccountIdDashboardGet(found.account_id, {
+ limit: 1,
+ })
+ : undefined;
+ if (live) {
+ setConnection(found);
+ setAccount(dashboard?.account);
+ setProviders(available);
+ setError(undefined);
+ }
+ } catch (error) {
+ if (live)
+ setError(error instanceof Error ? error : new Error("Could not load import connection."));
+ } finally {
+ if (live) setLoading(false);
+ }
+ }
+ void load();
+ // Keep the edit snapshot stable so a concurrent settings change returns a conflict.
+ const timer =
+ provider && !editing && refreshSeconds > 0
+ ? setInterval(() => void load(), refreshSeconds * 1000)
+ : undefined;
+ return () => {
+ live = false;
+ clearInterval(timer);
+ };
+ }, [provider, editing, section, refresh, refreshSeconds]);
+ const trail = [
+ ...partnershipTrail,
+ { label: "Import connections", href: importConnectionsPath },
+ ...(editing && connection
+ ? [{ label: connection.name, href: importConnectionHref(connection.provider) }]
+ : []),
+ ];
+ async function sync() {
+ if (!connection) return;
+ setBusy(true);
+ setError(undefined);
+ try {
+ setConnection(
+ await adminPartnerConnectionAction(
+ connection.provider,
+ { action: "sync" },
+ { headers: { "X-CSRF-Token": admin.csrf_token } },
+ ),
+ );
+ } catch (error) {
+ setError(error instanceof Error ? error : new Error("Could not sync import connection."));
+ } finally {
+ setBusy(false);
+ }
+ }
+ return (
+
+
+ {connection && !editing && (
+ <>
+
+
+ >
+ )}
+
+ setRefresh((n) => n + 1)} />
+ {!loading && !error && (!provider || editing) && (
+ router.push(importConnectionHref(saved.provider))}
+ onCancel={() =>
+ router.push(
+ connection ? importConnectionHref(connection.provider) : importConnectionsPath,
+ )
+ }
+ />
+ )}
+ {connection && !editing && (
+ <>
+
+ {section === "related" ? (
+
+ ) : (
+
+
+
},
+ { label: "Type", value: "Launch platform" },
+ {
+ label: "Partner account",
+ value: connection.account_id ? (
+
+ {account?.name}
+
+ ) : (
+ "Unassigned"
+ ),
+ },
+ {
+ label: "Enabled",
+ value:
,
+ },
+ { label: "API", value:
},
+ {
+ label: "Sync interval",
+ value: partnerSyncInterval(connection.sync_interval_minutes),
+ },
+ ]}
+ />
+ {account && (
+
+
+ Existing and newly imported products follow these account settings
+ automatically. Contextual matches are preferred; sponsored feed placements can
+ fill unmatched feeds. Article and topic placements require a relevant match.
+
+
+ Edit partner account defaults
+
+
+ )}
+
+ {connection.products}
+
+ ),
+ },
+ ...[
+ ["Accepted for account", connection.commercial_approved ?? 0],
+ ["Qualified", connection.qualified],
+ ["Checking", connection.checking],
+ ["Needs attention", connection.needs_attention],
+ ["Excluded", connection.excluded],
+ ].map(([label, value]) => ({
+ label: String(label),
+ value: ,
+ })),
+ ]}
+ />
+
+
+
,
+ },
+ { label: "Revision", value:
},
+ ]}
+ />
+
+ ),
+ },
+ {
+ label: "Last synced",
+ value: connection.last_sync_at ? (
+
+ ) : (
+ "Not synced yet"
+ ),
+ },
+ {
+ label: "Next sync",
+ value: connection.next_sync_at ? (
+
+ ) : (
+ "Not scheduled"
+ ),
+ },
+ { label: "Last error", value:
},
+ ]}
+ />
+ {connection.enabled && !connection.ai_enabled && (
+
+ Automatic product checks are waiting for AI to be enabled in settings.
+
+ )}
+
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/apps/admin/src/components/organisms/partner-connections.tsx b/apps/admin/src/components/organisms/partner-connections.tsx
new file mode 100644
index 000000000..23fe71d61
--- /dev/null
+++ b/apps/admin/src/components/organisms/partner-connections.tsx
@@ -0,0 +1,166 @@
+"use client";
+
+import { partnerSyncInterval } from "@/lib/partner-interval";
+
+import { useRefreshInterval } from "@/lib/use-refresh-interval";
+
+import Link from "next/link";
+import { useCallback, useState } from "react";
+import { useRouter } from "next/navigation";
+import { Plus } from "lucide-react";
+import { Button } from "@devfeed/ui/button";
+import { ResourceHeading } from "@devfeed/ui/resource-layout";
+import { SearchField, searchScope } from "@/components/molecules/search-field";
+import { useRequest } from "@/lib/use-request";
+import { useTableQuery } from "@/lib/use-table-query";
+import { DataTable, type DataTableColumn } from "@/components/molecules/data-table";
+import { adminPartnerConnectionsList } from "@/lib/api/generated/admin";
+import type { ConnectionOut } from "@/lib/api/generated/models";
+
+export const importConnectionsPath = "/partnerships/connections";
+export const importConnectionHref = (provider: string) =>
+ `${importConnectionsPath}/${encodeURIComponent(provider)}`;
+export const partnershipTrail = [{ label: "Partnerships", href: "/partnerships" }];
+const columns: DataTableColumn[] = [
+ {
+ id: "name",
+ accessorKey: "name",
+ header: "Name",
+ enableSorting: true,
+ cell: ({ row }) => (
+
+ {row.original.name}
+
+ ),
+ },
+ { id: "type", header: "Type", cell: () => "Launch platform" },
+ {
+ id: "sync_interval",
+ header: "Sync interval",
+ accessorFn: (row) => partnerSyncInterval(row.sync_interval_minutes),
+ },
+
+ {
+ id: "enabled",
+ header: "Enabled",
+ accessorFn: (row) => (row.enabled ? "Enabled" : "Disabled"),
+ kind: "pill",
+ },
+ {
+ id: "state",
+ header: "Sync status",
+ accessorFn: (row) =>
+ !row.enabled
+ ? "Paused"
+ : row.state === "syncing"
+ ? "Syncing"
+ : row.state === "error"
+ ? "Failed"
+ : "Idle",
+ kind: "pill",
+ },
+ { id: "products", header: "Products", accessorKey: "products", kind: "number" },
+ {
+ id: "commercial_approved",
+ header: "Accepted for account",
+ accessorFn: (row) => row.commercial_approved ?? 0,
+ kind: "number",
+ },
+ { id: "last_sync_at", header: "Last synced", accessorKey: "last_sync_at", kind: "datetime" },
+ {
+ id: "actions",
+ header: "Actions",
+ enableHiding: false,
+ cell: ({ row }) => (
+
+ ),
+ },
+];
+
+export function PartnerConnections() {
+ const query = useTableQuery("partner-connections").toString();
+ return ;
+}
+
+function ConnectionTable({ query }: { query: string }) {
+ const router = useRouter();
+ const params = new URLSearchParams(query);
+ const bounded = (value: string | null, fallback: number, max: number) =>
+ value !== null && /^\d+$/.test(value) ? Math.min(max, Number(value)) : fallback;
+ const offset = bounded(params.get("offset"), 0, 100000000);
+ const limit = Math.max(1, bounded(params.get("limit"), 25, 100));
+ const requestedSort = params.get("sort") ?? "provider";
+ const sort = ["provider", "-provider", "name", "-name"].includes(requestedSort)
+ ? requestedSort
+ : "provider";
+ const q = params.get("q") ?? "";
+ const refreshSeconds = useRefreshInterval();
+ const [refresh, setRefresh] = useState(0);
+ const load = useCallback(
+ async (signal: AbortSignal) =>
+ adminPartnerConnectionsList({ q, sort, offset, limit }, { signal }),
+ [q, sort, offset, limit],
+ );
+ const result = useRequest(`partner-connections/${query}/${refresh}`, load, refreshSeconds * 1000);
+ function change(values: Record) {
+ const next = new URLSearchParams(query);
+ next.set("offset", "0");
+ Object.entries(values).forEach(([key, value]) =>
+ value ? next.set(key, value) : next.delete(key),
+ );
+ router.replace(`${importConnectionsPath}?${next}`, { scroll: false });
+ }
+ return (
+
+
+
+
+
+ Connect product APIs and manage imports into partner accounts.
+
+ ({ enableSorting: false, ...column }))}
+ getRowId={(row) => row.provider}
+ columnChoices
+ loading={result.loading}
+ error={result.error}
+ onRetry={() => setRefresh((n) => n + 1)}
+ sort={sort}
+ onSortChange={(next) => change({ sort: next })}
+ pagination={{ offset, limit, total: result.data?.total ?? 0, onChange: change }}
+ toolbar={
+ change({ q: value })}
+ />
+ }
+ empty={
+ q
+ ? "No import connections match your search."
+ : "No import connections. Create an import connection to configure a product API."
+ }
+ />
+
+ );
+}
diff --git a/apps/admin/src/components/organisms/partner-destination-preview.tsx b/apps/admin/src/components/organisms/partner-destination-preview.tsx
new file mode 100644
index 000000000..a249d6989
--- /dev/null
+++ b/apps/admin/src/components/organisms/partner-destination-preview.tsx
@@ -0,0 +1,34 @@
+"use client";
+
+import { useCallback, useState } from "react";
+import { ProductDestinationPreview } from "@devfeed/ui/product-destination-preview";
+import { adminPartnerDestinationPreview } from "@/lib/api/generated/admin";
+import { useRequest } from "@/lib/use-request";
+
+type Props = { accountId?: string; destination: "product_url" | "listing_url" };
+
+function AccountPreview({ accountId, destination }: Props & { accountId: string }) {
+ const [revision, setRevision] = useState(0);
+ const load = useCallback(
+ (signal: AbortSignal) => adminPartnerDestinationPreview(accountId, { signal }),
+ [accountId],
+ );
+ const result = useRequest(`partner-destination-preview/${accountId}/${revision}`, load);
+ return (
+ setRevision((value) => value + 1)}
+ />
+ );
+}
+
+export function PartnerDestinationPreview({ accountId, destination }: Props) {
+ return accountId ? (
+
+ ) : (
+
+ );
+}
diff --git a/apps/admin/src/components/organisms/partner-inventory.tsx b/apps/admin/src/components/organisms/partner-inventory.tsx
new file mode 100644
index 000000000..38732cc64
--- /dev/null
+++ b/apps/admin/src/components/organisms/partner-inventory.tsx
@@ -0,0 +1,204 @@
+"use client";
+
+import Link from "next/link";
+import { useCallback, useState } from "react";
+import { Button } from "@devfeed/ui/button";
+import { DataTable, type DataTableColumn } from "@/components/molecules/data-table";
+import { RequestState } from "@/components/molecules/request-state";
+import { SearchField } from "@/components/molecules/search-field";
+import { StatusBadge } from "@/components/molecules/status-badge";
+import { useAdmin } from "@/components/molecules/admin-session";
+import {
+ adminPartnerInventoryList,
+ adminPartnerProductPromotionUpdate,
+} from "@/lib/api/generated/admin";
+import type { AccountOut, InventoryProductOut } from "@/lib/api/generated/models";
+import { ApiError, returnToLogin } from "@/lib/api/client";
+import { useRequest } from "@/lib/use-request";
+import { partnerProductHref } from "@/lib/partner-runs";
+import { PartnerProductPlacements } from "./partner-ad-formats";
+
+export function PartnerInventory({ account }: { account: AccountOut }) {
+ const admin = useAdmin();
+ const [query, setQuery] = useState("");
+ const [page, setPage] = useState({ offset: 0, limit: 25 });
+ const [revision, setRevision] = useState(0);
+ const [busy, setBusy] = useState();
+ const [error, setError] = useState();
+ const load = useCallback(
+ (signal: AbortSignal) =>
+ adminPartnerInventoryList(account.id, { ...page, q: query || undefined }, { signal }),
+ [account.id, page, query],
+ );
+ const result = useRequest(
+ `partner-inventory/${account.id}/${query}/${page.offset}/${page.limit}/${revision}`,
+ load,
+ );
+ async function toggle(product: InventoryProductOut) {
+ setBusy(product.id);
+ setError(undefined);
+ try {
+ await adminPartnerProductPromotionUpdate(
+ account.id,
+ product.id,
+ { excluded: !product.excluded },
+ { headers: { "X-CSRF-Token": admin.csrf_token } },
+ );
+ setRevision((value) => value + 1);
+ } catch (reason) {
+ if (reason instanceof ApiError && reason.status === 401) returnToLogin();
+ setError(reason instanceof Error ? reason : new Error("Could not update product."));
+ } finally {
+ setBusy(undefined);
+ }
+ }
+ const columns: DataTableColumn[] = [
+ {
+ accessorKey: "name",
+ header: "Product",
+ meta: { headerClassName: "w-[24%]", className: "whitespace-normal" },
+ cell: ({ row }) => (
+
+
+ {row.original.name}
+
+
+ Developer check
+
+
+
+ ),
+ },
+ {
+ accessorKey: "commercial_approved",
+ header: "Acceptance",
+ meta: { headerClassName: "w-[18%]", className: "whitespace-normal" },
+ cell: ({ row }) => (
+
+
+ {row.original.commercial_approved && (
+
+ {row.original.approval_source === "trusted" ? "Trusted partner" : "Verified product"}
+
+ )}
+
+ ),
+ },
+ {
+ accessorKey: "technologies",
+ header: "Technologies",
+ meta: {
+ headerClassName: "w-[28%]",
+ className: "whitespace-normal break-words leading-5 text-muted-foreground",
+ },
+ cell: ({ row }) => row.original.technologies.join(", ") || "—",
+ },
+ {
+ accessorKey: "excluded",
+ header: "Promotion",
+ meta: { headerClassName: "w-[16%]", className: "whitespace-normal" },
+ cell: ({ row }) => (
+
+ {row.original.globally_excluded ? (
+ "Blocked by product checks"
+ ) : row.original.excluded ? (
+
+ ) : row.original.ad_formats != null || row.original.promotion_surfaces != null ? (
+ "Custom restrictions"
+ ) : (
+ "Inherits account defaults"
+ )}
+
+ ),
+ },
+ {
+ id: "actions",
+ header: "Actions",
+ meta: { headerClassName: "w-[14%]", className: "whitespace-normal" },
+ cell: ({ row }) => (
+
+
+
setRevision((value) => value + 1)}
+ />
+
+ ),
+ },
+ ];
+ return (
+
+
+
+
Imported products
+
+ Products inherit this account’s promotion defaults.
+
+
+
{
+ setQuery(value);
+ setPage((current) => ({ ...current, offset: 0 }));
+ }}
+ />
+
+ {!account.auto_include_products && (
+
+ Automatic inclusion is off. Products can only appear through campaigns that select them
+ explicitly until you enable automatic inclusion in account settings.
+
+ )}
+
+ row.id}
+ loading={result.loading}
+ error={result.error}
+ onRetry={() => setRevision((value) => value + 1)}
+ empty="No matching products. Products added directly or imported into this account appear here."
+ pagination={{
+ ...page,
+ total: result.data?.total ?? 0,
+ onChange: (values) =>
+ setPage((current) => ({
+ offset: Number(values.offset ?? current.offset),
+ limit: Number(values.limit ?? current.limit),
+ })),
+ }}
+ />
+
+ );
+}
diff --git a/apps/admin/src/components/organisms/partner-product-page.tsx b/apps/admin/src/components/organisms/partner-product-page.tsx
new file mode 100644
index 000000000..a17d9b1b6
--- /dev/null
+++ b/apps/admin/src/components/organisms/partner-product-page.tsx
@@ -0,0 +1,396 @@
+"use client";
+
+import Link from "next/link";
+import { useCallback, useState } from "react";
+import { Button } from "@devfeed/ui/button";
+import { useAdmin } from "@/components/molecules/admin-session";
+import { DateTime } from "@/components/molecules/date-time";
+import { ResourcePanel, DataValue } from "@devfeed/ui/resource-layout";
+import { ResourceHeading } from "@devfeed/ui/resource-layout";
+import { RequestState } from "@/components/molecules/request-state";
+import { StatusBadge } from "@/components/molecules/status-badge";
+import { adminPartnerToolsList, adminPartnerProductAction } from "@/lib/api/generated/admin";
+import { partnerProductHref } from "@/lib/partner-runs";
+import { useRefreshInterval } from "@/lib/use-refresh-interval";
+import { useRequest } from "@/lib/use-request";
+import { importConnectionHref, partnershipTrail } from "./partner-connections";
+import { RelatedPartnerRuns } from "./partner-runs";
+
+export function PartnerProductPage({
+ id,
+ section = "details",
+}: {
+ id: string;
+ section?: "details" | "related";
+}) {
+ const admin = useAdmin();
+ const interval = useRefreshInterval();
+ const [revision, setRevision] = useState(0);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState();
+ const load = useCallback(
+ async (signal: AbortSignal) => {
+ const page = await adminPartnerToolsList({ product_id: id, limit: 1 }, { signal });
+ if (!page.items[0]) throw new Error("Product not found.");
+ return page.items[0];
+ },
+ [id],
+ );
+ const result = useRequest(`partner-product/${id}/${revision}`, load, interval * 1000);
+ const product = result.data;
+ async function act(action: "exclude" | "retry") {
+ if (!product) return;
+ setBusy(true);
+ setError(undefined);
+ try {
+ await adminPartnerProductAction(
+ product.id,
+ { action, expected_revision: product.revision },
+ { headers: { "X-CSRF-Token": admin.csrf_token } },
+ );
+ setRevision((n) => n + 1);
+ } catch (error) {
+ setError(error instanceof Error ? error : new Error("Could not update product."));
+ } finally {
+ setBusy(false);
+ }
+ }
+ return (
+
+
+ {product && (
+ <>
+
+ {!product.excluded && (
+
+ )}
+ >
+ )}
+
+ setRevision((n) => n + 1)}
+ />
+ {product && (
+ <>
+
+ {section === "related" ? (
+
+ {!!product.manual_sources?.length && (
+
+
+ Direct accounts ({product.manual_sources.length})
+
+
+ {product.manual_sources.map((source) => (
+
+ {source.account_name}
+
+ ),
+ },
+ { label: "Source", value: "Added directly" },
+ {
+ label: "Status",
+ value: ,
+ },
+ {
+ label: "Account acceptance",
+ value: (
+
+ ),
+ },
+ { label: "Updated", value: },
+ ]}
+ />
+ ))}
+
+
+ )}
+ {!!product.listings.length && (
+
+ Platform listings ({product.listings.length})
+
+ {product.listings.map((listing) => (
+
+ {listing.platform_name}
+
+ ),
+ },
+ { label: "Name", value: listing.name },
+ {
+ label: "Partner account",
+ value: listing.account_id ? (
+
+ {listing.account_name ?? "View partner account"}
+
+ ) : (
+ "Unassigned"
+ ),
+ },
+ {
+ label: "Account acceptance",
+ value: (
+
+ ),
+ },
+ {
+ label: "Platform product ID",
+ value: ,
+ },
+ {
+ label: "Status",
+ value: (
+
+ ),
+ },
+ {
+ label: "Identity",
+ value: ,
+ },
+ {
+ label: "Identity details",
+ value: ,
+ },
+ {
+ label: "Description",
+ value: ,
+ },
+ { label: "Pricing", value: },
+ {
+ label: "Attribution",
+ value: ,
+ },
+ {
+ label: "Display information source",
+ value: ,
+ },
+ { label: "Website", value: },
+ {
+ label: "Listing",
+ value: (
+
+ View {listing.platform_name} listing
+
+ ),
+ },
+ { label: "Updated", value: },
+ ]}
+ />
+ ))}
+
+
+ )}
+
+
+
+ ) : (
+
+
+
},
+ { label: "Description", value:
},
+ { label: "Pricing", value:
},
+ { label: "Technologies", value:
},
+ ]}
+ />
+
+ {product.evidence?.length ? (
+
+ {product.evidence.map((evidence, index) => (
+
+ {evidence.quote}
+
+
+ ))}
+
+ ) : (
+ No verified evidence yet.
+ )}
+
+
+
+
+ {product.commercial_approved
+ ? "Accepted for associated partner accounts"
+ : "Awaiting account acceptance"}
+ {product.approval_sources?.includes("trusted") &&
+ " · trusted account products"}
+
+
+ This product inherits promotion settings from each account that added it
+ directly or imported it through a connection. Account and tier defaults
+ control product or sponsored appearances; trusted account products can be
+ accepted automatically while capability checks continue. Contextual matches
+ are preferred; sponsored feed placements can fill unmatched feeds. Article and
+ topic placements require a relevant match. Exclusions still apply.
+
+
+ Manage partner account defaults and campaigns
+
+
+
+
+
+
+ ),
+ },
+ { label: "Reason", value: },
+ {
+ label: "Eligible for matching",
+ value: ,
+ },
+ {
+ label: "Verified",
+ value: product.verified_at ? (
+
+ ) : (
+ "Not verified yet"
+ ),
+ },
+ ]}
+ />
+ },
+ { label: "Revision", value: },
+ { label: "Updated", value: },
+ {
+ label: "Sources",
+ value: (
+
+ {[
+ ...new Set(product.listings.map((listing) => listing.platform_name)),
+ ...(product.manual_sources ?? []).map(
+ (source) => `Direct · ${source.account_name}`,
+ ),
+ ].join(", ")}
+
+ ),
+ },
+ ]}
+ />
+
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/apps/admin/src/components/organisms/partner-related.tsx b/apps/admin/src/components/organisms/partner-related.tsx
new file mode 100644
index 000000000..275a8e4cd
--- /dev/null
+++ b/apps/admin/src/components/organisms/partner-related.tsx
@@ -0,0 +1,75 @@
+"use client";
+
+import Link from "next/link";
+import { useCallback, useState } from "react";
+import { DataTable } from "@/components/molecules/data-table";
+import { useRequest } from "@/lib/use-request";
+import { useRefreshInterval } from "@/lib/use-refresh-interval";
+import { adminPartnerToolsList } from "@/lib/api/generated/admin";
+import { productColumns } from "./partner-tools";
+import { RelatedPartnerRuns } from "./partner-runs";
+
+export function PartnerRelated({ provider }: { provider: string }) {
+ return (
+
+
+
+
+
+ );
+}
+
+function RelatedProducts({ provider }: { provider: string }) {
+ const refreshSeconds = useRefreshInterval();
+ const [page, setPage] = useState({ offset: 0, limit: 25 });
+ const [sort, setSort] = useState("-updated_at");
+ const [revision, setRevision] = useState(0);
+ const load = useCallback(
+ (signal: AbortSignal) => adminPartnerToolsList({ provider, ...page, sort }, { signal }),
+ [provider, page, sort],
+ );
+ const result = useRequest(
+ `partner-products/${provider}/${JSON.stringify(page)}/${sort}/${revision}`,
+ load,
+ refreshSeconds * 1000,
+ );
+ return (
+
+
+
Products{result.data ? ` (${result.data.total})` : ""}
+
+ View full list
+
+
+ ({ enableSorting: false, ...column }))}
+ sort={sort}
+ onSortChange={(next) => {
+ setSort(next);
+ setPage((current) => ({ ...current, offset: 0 }));
+ }}
+ getRowId={(row) => row.id}
+ columnChoices
+ loading={result.loading}
+ error={result.error}
+ onRetry={() => setRevision((n) => n + 1)}
+ pagination={{
+ ...page,
+ total: result.data?.total ?? 0,
+ onChange: (values) =>
+ setPage((current) => ({
+ offset: Number(values.offset ?? current.offset),
+ limit: Number(values.limit ?? current.limit),
+ })),
+ }}
+ empty="No products synced from this import connection yet."
+ />
+
+ );
+}
diff --git a/apps/admin/src/components/organisms/partner-run-detail.tsx b/apps/admin/src/components/organisms/partner-run-detail.tsx
new file mode 100644
index 000000000..d1f4c7ef1
--- /dev/null
+++ b/apps/admin/src/components/organisms/partner-run-detail.tsx
@@ -0,0 +1,342 @@
+"use client";
+
+import Link from "next/link";
+import { useCallback, useState } from "react";
+import { ResourceHeading } from "@devfeed/ui/resource-layout";
+import { ResourcePanel, DataValue } from "@devfeed/ui/resource-layout";
+import { DateTime } from "@/components/molecules/date-time";
+import { StatusBadge } from "@/components/molecules/status-badge";
+import { RequestState } from "@/components/molecules/request-state";
+import { DataTable } from "@/components/molecules/data-table";
+import { useRequest } from "@/lib/use-request";
+import { useRefreshInterval } from "@/lib/use-refresh-interval";
+import { adminPartnerPipelineGet, adminPartnerEvaluationGet } from "@/lib/api/generated/admin";
+import type { PartnerPipelineOut, PartnerEvaluationDetail } from "@/lib/api/generated/models";
+import {
+ partnerRunLabels,
+ partnerRunHref,
+ partnerRunPath,
+ partnerProductHref,
+ partnerOperation,
+ type PartnerRunKind,
+} from "@/lib/partner-runs";
+import { importConnectionHref, partnershipTrail } from "./partner-connections";
+import { RelatedPartnerRuns } from "./partner-runs";
+import { JobLogs } from "./job-logs";
+
+export type PartnerRunSection = "details" | "related" | "results" | "logs";
+const linked = (href: string, label: string) => (
+
+ {label}
+
+);
+
+export function PartnerRunDetail({
+ kind,
+ id,
+ section = "details",
+}: {
+ kind: PartnerRunKind;
+ id: string;
+ section?: PartnerRunSection;
+}) {
+ const interval = useRefreshInterval();
+ const [revision, setRevision] = useState(0);
+ const load = useCallback(
+ async (signal: AbortSignal): Promise =>
+ kind === "pipeline"
+ ? adminPartnerPipelineGet(id, { signal })
+ : adminPartnerEvaluationGet(id, { signal }),
+ [kind, id],
+ );
+ const result = useRequest(`partner-${kind}/${id}/${revision}`, load, interval * 1000);
+ const run = result.data;
+ const evaluation = run && "snapshot" in run ? run : undefined;
+ const pipeline = run && "operation" in run ? run : undefined;
+ const tabs: PartnerRunSection[] = [
+ "details",
+ "related",
+ ...(kind === "evaluations" ? ["results" as const] : []),
+ "logs",
+ ];
+ return (
+
+
+
+ setRevision((n) => n + 1)}
+ />
+ {run && section === "details" && (
+
+
+
+ ),
+ },
+ {
+ label: "Platform product ID",
+ value: ,
+ },
+ {
+ label: "Parent run",
+ value: pipeline.parent_id
+ ? linked(
+ partnerRunHref("pipeline", pipeline.parent_id),
+ pipeline.parent_id,
+ )
+ : "—",
+ },
+ ]
+ : evaluation
+ ? [
+ {
+ label: "Product",
+ value: linked(
+ partnerProductHref(evaluation.product_id),
+ evaluation.snapshot.product.name,
+ ),
+ },
+ { label: "Product revision", value: evaluation.snapshot.product.revision },
+ { label: "Articles evaluated", value: evaluation.snapshot.articles.length },
+ {
+ label: "Matches",
+ value: evaluation.result?.decisions.filter((d) => d.relevant).length ?? 0,
+ },
+ {
+ label: "Results",
+ value:
+ evaluation.status === "succeeded"
+ ? evaluation.current
+ ? "Current"
+ : "Outdated — product or article data changed"
+ : "Not completed",
+ },
+ ]
+ : []
+ }
+ />
+ },
+ { label: "Attempts", value: run.attempts },
+ { label: "Available at", value: },
+ { label: "Error", value: },
+ ]}
+ />
+
+
},
+ { label: "Created", value:
},
+ {
+ label: "Finished",
+ value: run.finished_at ?
: "—",
+ },
+ ]}
+ />
+
+ )}
+ {run && section === "related" && (
+
+
+ {pipeline?.operation === "sync" && (
+
+ )}
+ {pipeline?.product_id && (
+
+ )}
+ {evaluation && (
+ article.id}
+ columns={[
+ {
+ id: "title",
+ header: "Article",
+ accessorKey: "title",
+ cell: ({ row }) =>
+ linked(`/content/articles/${row.original.id}`, row.original.title),
+ },
+ { id: "type", header: "Type", accessorKey: "content_type" },
+ {
+ id: "match",
+ header: "Result",
+ accessorFn: (article) => {
+ const decision = evaluation.result?.decisions.find(
+ (d) => d.article_id === article.id,
+ );
+ return decision
+ ? decision.relevant
+ ? "Relevant"
+ : "No match"
+ : "Not evaluated";
+ },
+ },
+ ]}
+ empty="No articles in this evaluation."
+ />
+ )}
+
+ )}
+ {evaluation && section === "results" && }
+ {run && (section === "logs" || section === "details") && (
+
+ )}
+
+ );
+}
+
+function EvaluationResults({ run }: { run: PartnerEvaluationDetail }) {
+ return (
+
+ {run.status === "succeeded" && !run.current && (
+
+ These results are outdated because product or article data changed.
+
+ )}
+ {!run.result?.decisions.length && (
+
+ {run.status === "failed"
+ ? "Evaluation failed. See Details and Logs for the error."
+ : run.status === "succeeded"
+ ? "No article decisions were recorded."
+ : "Evaluation results will appear when this run completes."}
+
+ )}
+ {run.result?.decisions.map((decision) => {
+ const article = run.snapshot.articles.find((a) => a.id === decision.article_id);
+ const evidence = run.snapshot.product.evidence?.[decision.evidence_index];
+ return (
+
+ ),
+ },
+ { label: "Reason", value:
},
+ { label: "Technology", value:
},
+ { label: "Article evidence", value:
},
+ ...(evidence
+ ? [
+ {
+ label: "Product evidence",
+ value: (
+ <>
+
{evidence.quote}
+
+ >
+ ),
+ },
+ ]
+ : []),
+ ]}
+ />
+ );
+ })}
+
+ );
+}
diff --git a/apps/admin/src/components/organisms/partner-runs.tsx b/apps/admin/src/components/organisms/partner-runs.tsx
new file mode 100644
index 000000000..fc31c163c
--- /dev/null
+++ b/apps/admin/src/components/organisms/partner-runs.tsx
@@ -0,0 +1,294 @@
+"use client";
+
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { useCallback, useState } from "react";
+import { DataTable, type DataTableColumn } from "@/components/molecules/data-table";
+import { ResourceHeading } from "@devfeed/ui/resource-layout";
+import { SearchField, searchScope } from "@/components/molecules/search-field";
+import { Select } from "@/components/molecules/select";
+import { Button } from "@devfeed/ui/button";
+import { useRequest } from "@/lib/use-request";
+import { useTableQuery } from "@/lib/use-table-query";
+import { useRefreshInterval } from "@/lib/use-refresh-interval";
+import { adminPartnerPipelineList, adminPartnerEvaluationsList } from "@/lib/api/generated/admin";
+import {
+ partnerRunLabels,
+ partnerRunHref,
+ partnerRunPath,
+ partnerProductHref,
+ partnerOperation,
+ type PartnerRunKind,
+} from "@/lib/partner-runs";
+import { importConnectionHref, partnershipTrail } from "./partner-connections";
+
+type Run = {
+ id: string;
+ status: string;
+ product_id: string | null;
+ product_name: string | null;
+ created_at: string;
+ finished_at: string | null;
+ attempts: number;
+ error: string | null;
+ provider?: string | null;
+ operation?: string;
+ external_id?: string | null;
+ matches?: number;
+ articles?: number;
+};
+type RunPage = { items: Run[]; total: number; offset: number; limit: number };
+
+export function PartnerRuns({ kind }: { kind: PartnerRunKind }) {
+ const query = useTableQuery(`partner-${kind}`).toString();
+ const router = useRouter();
+ return (
+
+
+ router.replace(`${partnerRunPath(kind)}?${next}`, { scroll: false })}
+ />
+
+ );
+}
+
+export function RelatedPartnerRuns({
+ kind,
+ filters,
+ title,
+}: {
+ kind: PartnerRunKind;
+ filters: Record;
+ title?: string;
+}) {
+ const [query, setQuery] = useState("");
+ const params = new URLSearchParams(query);
+ Object.entries(filters).forEach(([key, value]) => params.set(key, value));
+ return (
+
+
+
{title ?? partnerRunLabels[kind]}
+
+ View full list
+
+
+
+
+ );
+}
+
+function RunTable({
+ kind,
+ query,
+ onQuery,
+ embedded = false,
+}: {
+ kind: PartnerRunKind;
+ query: string;
+ onQuery: (next: string) => void;
+ embedded?: boolean;
+}) {
+ const params = new URLSearchParams(query);
+ const bounded = (value: string | null, fallback: number, max: number) =>
+ value !== null && /^\d+$/.test(value) ? Math.min(max, Number(value)) : fallback;
+ const offset = bounded(params.get("offset"), 0, 100000000);
+ const limit = Math.max(1, bounded(params.get("limit"), 25, 100));
+ const sort = params.get("sort") ?? "-created_at";
+ const status = params.get("status") ?? "";
+ const operation = params.get("operation") ?? "";
+ const q = params.get("q") ?? "";
+ const provider = params.get("provider") || undefined;
+ const productId = params.get("product_id") || undefined;
+ const parentId = params.get("parent_id") || undefined;
+ const [revision, setRevision] = useState(0);
+ const interval = useRefreshInterval();
+ function change(values: Record) {
+ const next = new URLSearchParams(query);
+ next.set("offset", "0");
+ Object.entries(values).forEach(([key, value]) =>
+ value ? next.set(key, value) : next.delete(key),
+ );
+ onQuery(next.toString());
+ }
+ const load = useCallback(
+ async (signal: AbortSignal): Promise => {
+ const validStatus = ["queued", "running", "succeeded", "failed"].includes(status)
+ ? (status as "queued" | "running" | "succeeded" | "failed")
+ : undefined;
+ const common = {
+ q,
+ status: validStatus,
+ sort,
+ offset,
+ limit,
+ provider,
+ product_id: productId,
+ };
+ return kind === "pipeline"
+ ? adminPartnerPipelineList(
+ {
+ ...common,
+ parent_id: parentId,
+ operation: ["sync", "sync_product", "assess"].includes(operation)
+ ? (operation as "sync" | "sync_product" | "assess")
+ : undefined,
+ },
+ { signal },
+ )
+ : adminPartnerEvaluationsList(common, { signal });
+ },
+ [kind, q, status, sort, offset, limit, provider, productId, parentId, operation],
+ );
+ const result = useRequest(`partner-${kind}/${query}/${revision}`, load, interval * 1000);
+ const columns: DataTableColumn[] = [
+ {
+ id: "id",
+ header: "Run",
+ accessorKey: "id",
+ cell: ({ row }) => (
+
+ {row.original.id.slice(0, 8)}
+
+ ),
+ },
+ ...(kind === "pipeline"
+ ? [
+ {
+ id: "operation",
+ header: "Operation",
+ accessorFn: (row: Run) => partnerOperation(row.operation ?? ""),
+ },
+ {
+ id: "provider",
+ header: "Import connection",
+ cell: ({ row }: { row: { original: Run } }) =>
+ row.original.provider ? (
+
+ {row.original.provider}
+
+ ) : (
+ "Direct product"
+ ),
+ },
+ ]
+ : []),
+ {
+ id: "product",
+ header: "Product",
+ cell: ({ row }) =>
+ row.original.product_id ? (
+
+ {row.original.product_name ??
+ row.original.external_id ??
+ row.original.product_id.slice(0, 8)}
+
+ ) : (
+ (row.original.external_id ?? "—")
+ ),
+ },
+ { id: "status", header: "Status", accessorKey: "status", kind: "pill", enableSorting: true },
+ {
+ id: "attempts",
+ header: "Attempts",
+ accessorKey: "attempts",
+ kind: "number",
+ enableSorting: true,
+ },
+ ...(kind === "evaluations"
+ ? [{ id: "matches", header: "Matches", accessorKey: "matches", kind: "number" as const }]
+ : []),
+ {
+ id: "created_at",
+ header: "Created",
+ accessorKey: "created_at",
+ kind: "datetime",
+ enableSorting: true,
+ },
+ { id: "error", header: "Error", accessorKey: "error" },
+ ];
+ return (
+ ({ enableSorting: false, ...column }))}
+ getRowId={(row) => row.id}
+ columnChoices
+ loading={result.loading}
+ error={result.error}
+ onRetry={() => setRevision((n) => n + 1)}
+ sort={sort}
+ onSortChange={(next) => change({ sort: next })}
+ pagination={{ offset, limit, total: result.data?.total ?? 0, onChange: change }}
+ empty={`No ${partnerRunLabels[kind].toLowerCase()} found.`}
+ toolbar={
+ !embedded && (
+ <>
+ change({ q })}
+ />
+