Newsletter recommendation tweaks, add campaign history dialog
This commit is contained in:
@@ -0,0 +1,597 @@
|
||||
import { useState, useMemo } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { History, ChevronDown, ChevronRight, ChevronLeft, ExternalLink } from "lucide-react"
|
||||
import config from "@/config"
|
||||
|
||||
function useCampaignData(open: boolean) {
|
||||
const campaigns = useQuery<CampaignsResponse>({
|
||||
queryKey: ["newsletter-campaigns"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`${config.apiUrl}/newsletter/campaigns`)
|
||||
if (!res.ok) throw new Error("Failed to fetch campaigns")
|
||||
return res.json()
|
||||
},
|
||||
enabled: open,
|
||||
staleTime: 5 * 60_000,
|
||||
})
|
||||
|
||||
const products = useQuery<{ products: ProductAggregate[] }>({
|
||||
queryKey: ["newsletter-campaigns-products"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`${config.apiUrl}/newsletter/campaigns/products`)
|
||||
if (!res.ok) throw new Error("Failed to fetch")
|
||||
return res.json()
|
||||
},
|
||||
enabled: open,
|
||||
staleTime: 5 * 60_000,
|
||||
})
|
||||
|
||||
const links = useQuery<{ links: LinkAggregate[] }>({
|
||||
queryKey: ["newsletter-campaigns-links"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`${config.apiUrl}/newsletter/campaigns/links`)
|
||||
if (!res.ok) throw new Error("Failed to fetch")
|
||||
return res.json()
|
||||
},
|
||||
enabled: open,
|
||||
staleTime: 5 * 60_000,
|
||||
})
|
||||
|
||||
const brands = useQuery<{ brands: BrandAggregate[] }>({
|
||||
queryKey: ["newsletter-campaigns-brands"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`${config.apiUrl}/newsletter/campaigns/brands`)
|
||||
if (!res.ok) throw new Error("Failed to fetch")
|
||||
return res.json()
|
||||
},
|
||||
enabled: open,
|
||||
staleTime: 5 * 60_000,
|
||||
})
|
||||
|
||||
return { campaigns, products, brands, links }
|
||||
}
|
||||
|
||||
// ── Types ────────────────────────────────────────────
|
||||
|
||||
interface CampaignProduct {
|
||||
pid: number
|
||||
title: string
|
||||
sku: string
|
||||
brand: string | null
|
||||
line: string | null
|
||||
image: string | null
|
||||
product_url: string | null
|
||||
}
|
||||
|
||||
interface CampaignLink {
|
||||
link_url: string
|
||||
link_type: string
|
||||
}
|
||||
|
||||
interface Campaign {
|
||||
campaign_id: string
|
||||
campaign_name: string
|
||||
sent_at: string
|
||||
product_count: number
|
||||
products: CampaignProduct[]
|
||||
links: CampaignLink[]
|
||||
}
|
||||
|
||||
interface CampaignSummary {
|
||||
total_campaigns: number
|
||||
total_unique_products: number
|
||||
avg_products_per_campaign: number
|
||||
}
|
||||
|
||||
interface CampaignsResponse {
|
||||
campaigns: Campaign[]
|
||||
summary: CampaignSummary
|
||||
}
|
||||
|
||||
interface ProductAggregate {
|
||||
pid: number
|
||||
title: string
|
||||
sku: string
|
||||
brand: string
|
||||
image: string | null
|
||||
permalink: string | null
|
||||
times_featured: number
|
||||
first_featured_at: string
|
||||
last_featured_at: string
|
||||
days_since_featured: number
|
||||
featured_span_days: number
|
||||
avg_days_between_features: number | null
|
||||
campaigns: { campaign_id: string; campaign_name: string; sent_at: string }[]
|
||||
}
|
||||
|
||||
interface BrandAggregate {
|
||||
brand: string
|
||||
product_count: number
|
||||
times_featured: number
|
||||
first_featured_at: string
|
||||
last_featured_at: string
|
||||
days_since_featured: number
|
||||
avg_days_between_features: number | null
|
||||
campaigns: { campaign_id: string; campaign_name: string; sent_at: string }[]
|
||||
}
|
||||
|
||||
interface LinkAggregate {
|
||||
link_url: string
|
||||
link_type: string
|
||||
times_used: number
|
||||
first_used_at: string
|
||||
last_used_at: string
|
||||
days_since_used: number
|
||||
campaign_names: string[]
|
||||
}
|
||||
|
||||
// ── Campaign Row (expandable) ────────────────────────
|
||||
|
||||
function CampaignRow({ campaign }: { campaign: Campaign }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableRow
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<TableCell className="w-[30px]">
|
||||
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium text-sm">{campaign.campaign_name || campaign.campaign_id}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{campaign.sent_at ? new Date(campaign.sent_at).toLocaleDateString() : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm">{campaign.product_count}</TableCell>
|
||||
<TableCell className="text-right text-sm">{campaign.links.length}</TableCell>
|
||||
</TableRow>
|
||||
{expanded && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="p-0">
|
||||
<div className="bg-muted/30 p-3 space-y-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold mb-1.5">Products ({campaign.products.length})</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1.5" style={{ gridAutoFlow: "column", gridTemplateRows: `repeat(${Math.ceil(campaign.products.length / 2)}, minmax(0, auto))` }}>
|
||||
{campaign.products.map((p) => (
|
||||
<div key={p.pid} className="flex items-center gap-2 text-xs bg-background rounded px-2 py-1">
|
||||
{p.image ? (
|
||||
<img src={p.image} alt="" className="w-6 h-6 object-cover rounded shrink-0" />
|
||||
) : (
|
||||
<div className="w-6 h-6 bg-muted rounded shrink-0" />
|
||||
)}
|
||||
<span className="truncate flex-1">{p.title}</span>
|
||||
<span className="text-muted-foreground shrink-0">{p.pid}</span>
|
||||
{p.product_url && (
|
||||
<a href={p.product_url} target="_blank" rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{campaign.links.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-semibold mb-1.5">Links ({campaign.links.length})</p>
|
||||
<div className="space-y-1">
|
||||
{campaign.links.map((l, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-xs">
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0">{l.link_type || "other"}</Badge>
|
||||
<a href={l.link_url} target="_blank" rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:underline truncate"
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
{l.link_url}
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Product Row (expandable campaign list) ───────────
|
||||
|
||||
function ProductRow({ product }: { product: ProductAggregate }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableRow
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<TableCell className="w-[30px]">
|
||||
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2 max-w-[400px]">
|
||||
{product.image ? (
|
||||
<img src={product.image} alt="" className="w-7 h-7 object-cover rounded shrink-0" />
|
||||
) : (
|
||||
<div className="w-7 h-7 bg-muted rounded shrink-0" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{product.title}</p>
|
||||
<p className="text-xs text-muted-foreground">{product.sku}</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{product.brand}</TableCell>
|
||||
<TableCell className="text-right text-sm font-medium">{product.times_featured}×</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{product.first_featured_at ? new Date(product.first_featured_at).toLocaleDateString() : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{product.days_since_featured === 0 ? "Today" : `${product.days_since_featured}d ago`}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground text-right">
|
||||
{product.avg_days_between_features != null ? `${product.avg_days_between_features}d` : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="w-[30px]">
|
||||
{product.permalink && (
|
||||
<a href={product.permalink} target="_blank" rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{expanded && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="p-0">
|
||||
<div className="bg-muted/30 p-3">
|
||||
<p className="text-xs font-semibold mb-1.5">Campaigns ({product.campaigns.length})</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-1">
|
||||
{product.campaigns.map((c) => (
|
||||
<div key={c.campaign_id} className="flex items-center gap-2 text-xs bg-background rounded px-2 py-1">
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
{c.sent_at ? new Date(c.sent_at).toLocaleDateString() : "—"}
|
||||
</span>
|
||||
<span className="truncate">{c.campaign_name || c.campaign_id}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Skeleton loader ──────────────────────────────────
|
||||
|
||||
function TableSkeleton({ rows = 8 }: { rows?: number }) {
|
||||
return (
|
||||
<div className="p-4 space-y-3">
|
||||
{Array.from({ length: rows }).map((_, i) => <Skeleton key={i} className="h-8 w-full" />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Tab: Campaigns ───────────────────────────────────
|
||||
|
||||
function CampaignsTab({ data, isLoading }: { data: CampaignsResponse | undefined; isLoading: boolean }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Card key={i}><CardContent className="p-3"><Skeleton className="h-3 w-24 mb-1" /><Skeleton className="h-7 w-12" /></CardContent></Card>
|
||||
))}
|
||||
</div>
|
||||
) : data?.summary ? (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-3">
|
||||
<p className="text-xs text-muted-foreground">Total Campaigns</p>
|
||||
<p className="text-xl font-bold">{Number(data.summary.total_campaigns).toLocaleString()}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-3">
|
||||
<p className="text-xs text-muted-foreground">Unique Products Featured</p>
|
||||
<p className="text-xl font-bold">{Number(data.summary.total_unique_products).toLocaleString()}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-3">
|
||||
<p className="text-xs text-muted-foreground">Avg Products / Campaign</p>
|
||||
<p className="text-xl font-bold">{data.summary.avg_products_per_campaign}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex-1 overflow-auto rounded-md border max-h-[50vh]">
|
||||
{isLoading ? <TableSkeleton /> : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[30px]"></TableHead>
|
||||
<TableHead>Campaign</TableHead>
|
||||
<TableHead>Sent</TableHead>
|
||||
<TableHead className="text-right">Products</TableHead>
|
||||
<TableHead className="text-right">Links</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.campaigns.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">No campaigns found</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
data?.campaigns.map((c) => <CampaignRow key={c.campaign_id} campaign={c} />)
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Tab: Products ────────────────────────────────────
|
||||
|
||||
const PRODUCTS_PAGE_SIZE = 500
|
||||
|
||||
function ProductsTab({ data, isLoading }: { data: { products: ProductAggregate[] } | undefined; isLoading: boolean }) {
|
||||
const [page, setPage] = useState(1)
|
||||
const allProducts = data?.products ?? []
|
||||
const totalPages = Math.ceil(allProducts.length / PRODUCTS_PAGE_SIZE)
|
||||
const pageProducts = useMemo(
|
||||
() => allProducts.slice((page - 1) * PRODUCTS_PAGE_SIZE, page * PRODUCTS_PAGE_SIZE),
|
||||
[allProducts, page]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex-1 overflow-auto rounded-md border max-h-[55vh]">
|
||||
{isLoading ? <TableSkeleton /> : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[30px]"></TableHead>
|
||||
<TableHead>Product</TableHead>
|
||||
<TableHead>Brand</TableHead>
|
||||
<TableHead className="text-right">Featured</TableHead>
|
||||
<TableHead>First</TableHead>
|
||||
<TableHead>Last</TableHead>
|
||||
<TableHead className="text-right">Avg Gap</TableHead>
|
||||
<TableHead className="w-[30px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pageProducts.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center py-8 text-muted-foreground">No products found</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
pageProducts.map((p) => <ProductRow key={p.pid} product={p} />)
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{allProducts.length.toLocaleString()} products
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-sm">Page {page} of {totalPages}</span>
|
||||
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage(page + 1)}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Brand Row (expandable campaign list) ─────────────
|
||||
|
||||
function BrandRow({ brand }: { brand: BrandAggregate }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableRow
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<TableCell className="w-[30px]">
|
||||
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm font-medium">{brand.brand}</TableCell>
|
||||
<TableCell className="text-right text-sm">{brand.product_count}</TableCell>
|
||||
<TableCell className="text-right text-sm font-medium">{brand.times_featured}×</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{brand.first_featured_at ? new Date(brand.first_featured_at).toLocaleDateString() : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{brand.days_since_featured === 0 ? "Today" : `${brand.days_since_featured}d ago`}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground text-right">
|
||||
{brand.avg_days_between_features != null ? `${brand.avg_days_between_features}d` : "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{expanded && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="p-0">
|
||||
<div className="bg-muted/30 p-3">
|
||||
<p className="text-xs font-semibold mb-1.5">Campaigns ({brand.campaigns.length})</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-1">
|
||||
{brand.campaigns.map((c) => (
|
||||
<div key={c.campaign_id} className="flex items-center gap-2 text-xs bg-background rounded px-2 py-1">
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
{c.sent_at ? new Date(c.sent_at).toLocaleDateString() : "—"}
|
||||
</span>
|
||||
<span className="truncate">{c.campaign_name || c.campaign_id}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Tab: Brands ──────────────────────────────────────
|
||||
|
||||
function BrandsTab({ data, isLoading }: { data: { brands: BrandAggregate[] } | undefined; isLoading: boolean }) {
|
||||
return (
|
||||
<div className="flex-1 overflow-auto rounded-md border max-h-[55vh]">
|
||||
{isLoading ? <TableSkeleton /> : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[30px]"></TableHead>
|
||||
<TableHead>Brand</TableHead>
|
||||
<TableHead className="text-right">Products</TableHead>
|
||||
<TableHead className="text-right">Featured</TableHead>
|
||||
<TableHead>First</TableHead>
|
||||
<TableHead>Last</TableHead>
|
||||
<TableHead className="text-right">Avg Gap</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.brands.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">No brands found</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
data?.brands.map((b) => <BrandRow key={b.brand} brand={b} />)
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Tab: Links ───────────────────────────────────────
|
||||
|
||||
function LinksTab({ data, isLoading }: { data: { links: LinkAggregate[] } | undefined; isLoading: boolean }) {
|
||||
return (
|
||||
<div className="flex-1 overflow-auto rounded-md border max-h-[60vh]">
|
||||
{isLoading ? <TableSkeleton /> : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Link</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead className="text-right">Used</TableHead>
|
||||
<TableHead>First</TableHead>
|
||||
<TableHead>Last</TableHead>
|
||||
<TableHead className="text-right">Campaigns</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.links.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">No links found</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
data?.links.map((l, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell className="max-w-[500px]">
|
||||
<a href={l.link_url} target="_blank" rel="noopener noreferrer"
|
||||
className="text-sm text-blue-500 hover:underline truncate block">
|
||||
{l.link_url}
|
||||
</a>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0">{l.link_type || "other"}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm font-medium">{l.times_used}×</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{l.first_used_at ? new Date(l.first_used_at).toLocaleDateString() : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{l.days_since_used === 0 ? "Today" : `${l.days_since_used}d ago`}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm text-muted-foreground">
|
||||
{l.campaign_names?.length ?? 0}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main Dialog ──────────────────────────────────────
|
||||
|
||||
export function CampaignHistoryDialog() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const { campaigns, products, brands, links } = useCampaignData(open)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<History className="h-4 w-4 mr-2" />
|
||||
Campaign History
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-5xl max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Newsletter Campaign History</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs defaultValue="campaigns" className="">
|
||||
<TabsList>
|
||||
<TabsTrigger value="campaigns">Campaigns</TabsTrigger>
|
||||
<TabsTrigger value="products">Products</TabsTrigger>
|
||||
<TabsTrigger value="brands">Brands</TabsTrigger>
|
||||
<TabsTrigger value="links">Links</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="campaigns" forceMount className="flex-1 min-h-0 data-[state=inactive]:hidden">
|
||||
<CampaignsTab data={campaigns.data} isLoading={campaigns.isLoading} />
|
||||
</TabsContent>
|
||||
<TabsContent value="products" forceMount className="flex-1 min-h-0 data-[state=inactive]:hidden">
|
||||
<ProductsTab data={products.data} isLoading={products.isLoading} />
|
||||
</TabsContent>
|
||||
<TabsContent value="brands" forceMount className="flex-1 min-h-0 data-[state=inactive]:hidden">
|
||||
<BrandsTab data={brands.data} isLoading={brands.isLoading} />
|
||||
</TabsContent>
|
||||
<TabsContent value="links" forceMount className="flex-1 min-h-0 data-[state=inactive]:hidden">
|
||||
<LinksTab data={links.data} isLoading={links.isLoading} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Sparkles, RotateCcw, TrendingUp, Clock, CalendarClock, EyeOff, Info } from "lucide-react"
|
||||
import config from "@/config"
|
||||
|
||||
@@ -23,7 +24,23 @@ export function NewsletterStats() {
|
||||
},
|
||||
})
|
||||
|
||||
if (!data) return null
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="grid gap-4 grid-cols-2 md:grid-cols-3 xl:grid-cols-6">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-3.5 w-3.5 rounded" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-16 mt-1" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const stats = [
|
||||
{
|
||||
@@ -66,7 +83,7 @@ export function NewsletterStats() {
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-6">
|
||||
<div className="grid gap-4 grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||
{stats.map((s) => (
|
||||
<Card key={s.label}>
|
||||
<CardContent className="p-4">
|
||||
@@ -78,6 +95,7 @@ export function NewsletterStats() {
|
||||
<Info className="h-3 w-3 shrink-0 cursor-help opacity-50 hover:opacity-100 transition-opacity" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-[240px]">
|
||||
<p className="text-xs font-medium">{s.label}</p>
|
||||
<p>{s.tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { useState } from "react"
|
||||
import { useState, useMemo, useContext } from "react"
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from "@/components/ui/table"
|
||||
@@ -8,7 +8,9 @@ import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Tooltip, TooltipContent, TooltipProvider, TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { ChevronLeft, ChevronRight, ExternalLink, Layers } from "lucide-react"
|
||||
import { ChevronLeft, ChevronRight, ExternalLink, Layers, Copy, Check, ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { AuthContext } from "@/contexts/AuthContext"
|
||||
import config from "@/config"
|
||||
|
||||
interface Product {
|
||||
@@ -74,7 +76,7 @@ function FeaturedCell({ p }: { p: Product }) {
|
||||
const hasLineHistory = p.line && p.line_last_featured_at && !p.last_featured_at
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<div className="flex items-center justify-center gap-1.5">
|
||||
<span className="text-sm">{directCount}×</span>
|
||||
{hasLineHistory && (
|
||||
<TooltipProvider>
|
||||
@@ -108,7 +110,7 @@ function LastFeaturedCell({ p }: { p: Product }) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="flex items-center gap-1 text-blue-500">
|
||||
<TooltipTrigger className="flex items-center justify-center gap-1 text-blue-500">
|
||||
<Layers className="h-3 w-3" />
|
||||
<span>{lineLabel}</span>
|
||||
</TooltipTrigger>
|
||||
@@ -122,10 +124,124 @@ function LastFeaturedCell({ p }: { p: Product }) {
|
||||
}
|
||||
return <span>Never</span>
|
||||
}
|
||||
function CopyPidButton({ pid }: { pid: number }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(String(pid))
|
||||
toast.success(`Copied PID ${pid}`)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
}}
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="text-xs">{copied ? "Copied!" : "Copy product ID"}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
interface ScoreBreakdown {
|
||||
new_boost: number; preorder_boost: number; clearance_boost: number
|
||||
velocity_boost: number; back_in_stock_boost: number; interest_boost: number
|
||||
recency_adj: number; over_featured_adj: number; line_saturation_adj: number
|
||||
price_tier_adj: number; abc_boost: number; stock_penalty: number
|
||||
}
|
||||
|
||||
const SCORE_LABELS: Record<keyof ScoreBreakdown, string> = {
|
||||
new_boost: "New Product", preorder_boost: "Pre-Order", clearance_boost: "Clearance",
|
||||
velocity_boost: "Sales Velocity", back_in_stock_boost: "Back in Stock", interest_boost: "Interest",
|
||||
recency_adj: "Recency", over_featured_adj: "Over-Featured", line_saturation_adj: "Line Saturation",
|
||||
price_tier_adj: "Price Tier", abc_boost: "ABC Class", stock_penalty: "Stock"
|
||||
}
|
||||
|
||||
function ScoreBreakdownTooltip({ pid, score, children }: { pid: number; score: number; children: React.ReactNode }) {
|
||||
const [hovered, setHovered] = useState(false)
|
||||
const { data } = useQuery<ScoreBreakdown>({
|
||||
queryKey: ["score-breakdown", pid],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`${config.apiUrl}/newsletter/score-breakdown/${pid}`)
|
||||
if (!res.ok) throw new Error("Failed")
|
||||
return res.json()
|
||||
},
|
||||
enabled: hovered,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild onMouseEnter={() => setHovered(true)}>
|
||||
{children}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="p-0">
|
||||
<div className="p-2 min-w-[180px]">
|
||||
<p className="text-xs font-semibold mb-1.5 border-b pb-1">Score Breakdown: {score}</p>
|
||||
{data ? (
|
||||
<div className="space-y-0.5">
|
||||
{(Object.keys(SCORE_LABELS) as (keyof ScoreBreakdown)[]).map(k => {
|
||||
const v = Number(data[k])
|
||||
if (v === 0) return null
|
||||
return (
|
||||
<div key={k} className="flex justify-between text-xs gap-4">
|
||||
<span className="text-muted-foreground">{SCORE_LABELS[k]}</span>
|
||||
<span className={v > 0 ? "text-green-600" : "text-red-500"}>{v > 0 ? "+" : ""}{v}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">Loading…</p>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
type SortColumn = "score" | "brand" | "price" | "stock" | "sales_7d" | "sales_30d" | "times_featured" | "days_since_featured"
|
||||
type SortDirection = "asc" | "desc" | null
|
||||
interface SortState { column: SortColumn | null; direction: SortDirection }
|
||||
|
||||
function toggleSort(prev: SortState, column: SortColumn): SortState {
|
||||
if (prev.column !== column) return { column, direction: "asc" }
|
||||
if (prev.direction === "asc") return { column, direction: "desc" }
|
||||
return { column: null, direction: null }
|
||||
}
|
||||
|
||||
function SortableHeader({ label, column, sort, onSort, className }: {
|
||||
label: string; column: SortColumn; sort: SortState; onSort: (c: SortColumn) => void; className?: string
|
||||
}) {
|
||||
const active = sort.column === column
|
||||
return (
|
||||
<TableHead className={`${className ?? ""} cursor-pointer select-none`} onClick={() => onSort(column)}>
|
||||
<div className={`flex items-center gap-1 ${className?.includes("text-right") ? "justify-end" : className?.includes("text-center") ? "justify-center" : ""}`}>
|
||||
<span>{label}</span>
|
||||
{active && sort.direction === "asc" ? <ArrowUp className="h-3 w-3" /> :
|
||||
active && sort.direction === "desc" ? <ArrowDown className="h-3 w-3" /> :
|
||||
<ArrowUpDown className="h-3 w-3 opacity-30" />}
|
||||
</div>
|
||||
</TableHead>
|
||||
)
|
||||
}
|
||||
|
||||
export function RecommendationTable({ category }: RecommendationTableProps) {
|
||||
const { user } = useContext(AuthContext)
|
||||
const canDebug = user?.is_admin || user?.permissions?.includes("admin:debug")
|
||||
const [page, setPage] = useState(1)
|
||||
const limit = 50
|
||||
const [sort, setSort] = useState<SortState>({ column: null, direction: null })
|
||||
const limit = 100
|
||||
|
||||
const { data, isLoading } = useQuery<RecommendationResponse>({
|
||||
queryKey: ["newsletter-recommendations", category, page],
|
||||
@@ -138,31 +254,51 @@ export function RecommendationTable({ category }: RecommendationTableProps) {
|
||||
},
|
||||
})
|
||||
|
||||
const products = useMemo(() => {
|
||||
const list = data?.products ?? []
|
||||
if (!sort.column || !sort.direction) return list
|
||||
const col = sort.column
|
||||
const dir = sort.direction === "asc" ? 1 : -1
|
||||
return [...list].sort((a, b) => {
|
||||
let av: number, bv: number
|
||||
switch (col) {
|
||||
case "score": av = a.score; bv = b.score; break
|
||||
case "brand": return dir * (a.brand ?? "").localeCompare(b.brand ?? "")
|
||||
case "price": av = Number(a.is_daily_deal && a.deal_price ? a.deal_price : a.price); bv = Number(b.is_daily_deal && b.deal_price ? b.deal_price : b.price); break
|
||||
case "stock": av = a.current_stock ?? 0; bv = b.current_stock ?? 0; break
|
||||
case "sales_7d": av = a.sales_7d ?? 0; bv = b.sales_7d ?? 0; break
|
||||
case "sales_30d": av = a.sales_30d ?? 0; bv = b.sales_30d ?? 0; break
|
||||
case "times_featured": av = a.times_featured ?? 0; bv = b.times_featured ?? 0; break
|
||||
case "days_since_featured": av = a.effective_days_since_featured ?? 9999; bv = b.effective_days_since_featured ?? 9999; break
|
||||
default: return 0
|
||||
}
|
||||
return dir * (av - bv)
|
||||
})
|
||||
}, [data?.products, sort.column, sort.direction])
|
||||
const pagination = data?.pagination
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-center py-12 text-muted-foreground">Loading recommendations…</div>
|
||||
}
|
||||
|
||||
const products = data?.products ?? []
|
||||
const pagination = data?.pagination
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<Table className="">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px]">Score</TableHead>
|
||||
<SortableHeader label="Score" column="score" sort={sort} onSort={(c) => setSort(toggleSort(sort, c))} className="w-[50px]" />
|
||||
<TableHead className="w-[60px]">Image</TableHead>
|
||||
<TableHead>Product</TableHead>
|
||||
<TableHead>Brand</TableHead>
|
||||
<TableHead className="text-right">Price</TableHead>
|
||||
<TableHead className="text-right">Stock</TableHead>
|
||||
<TableHead className="text-right">7d Sales</TableHead>
|
||||
<TableHead className="text-right">30d Sales</TableHead>
|
||||
<SortableHeader label="Brand" column="brand" sort={sort} onSort={(c) => setSort(toggleSort(sort, c))} />
|
||||
<SortableHeader label="Price" column="price" sort={sort} onSort={(c) => setSort(toggleSort(sort, c))} className="text-center" />
|
||||
<SortableHeader label="Stock" column="stock" sort={sort} onSort={(c) => setSort(toggleSort(sort, c))} className="text-center" />
|
||||
<SortableHeader label="7d Sales" column="sales_7d" sort={sort} onSort={(c) => setSort(toggleSort(sort, c))} className="text-center" />
|
||||
<SortableHeader label="30d Sales" column="sales_30d" sort={sort} onSort={(c) => setSort(toggleSort(sort, c))} className="text-center" />
|
||||
<TableHead>Tags</TableHead>
|
||||
<TableHead className="text-right">Featured</TableHead>
|
||||
<TableHead>Last Featured</TableHead>
|
||||
<TableHead className="w-[40px]"></TableHead>
|
||||
<SortableHeader label="Featured" column="times_featured" sort={sort} onSort={(c) => setSort(toggleSort(sort, c))} className="text-center" />
|
||||
<SortableHeader label="Last Featured" column="days_since_featured" sort={sort} onSort={(c) => setSort(toggleSort(sort, c))} />
|
||||
<TableHead className="w-[50px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -176,13 +312,25 @@ export function RecommendationTable({ category }: RecommendationTableProps) {
|
||||
products.map((p) => (
|
||||
<TableRow key={p.pid}>
|
||||
<TableCell>
|
||||
<span className={`font-mono font-bold text-sm ${
|
||||
p.score >= 40 ? "text-green-600" :
|
||||
p.score >= 20 ? "text-yellow-600" :
|
||||
"text-muted-foreground"
|
||||
}`}>
|
||||
{p.score}
|
||||
</span>
|
||||
{canDebug ? (
|
||||
<ScoreBreakdownTooltip pid={p.pid} score={p.score}>
|
||||
<span className={`font-mono font-bold text-sm cursor-help ${
|
||||
p.score >= 40 ? "text-green-600" :
|
||||
p.score >= 20 ? "text-yellow-600" :
|
||||
"text-muted-foreground"
|
||||
}`}>
|
||||
{p.score}
|
||||
</span>
|
||||
</ScoreBreakdownTooltip>
|
||||
) : (
|
||||
<span className={`font-mono font-bold text-sm ${
|
||||
p.score >= 40 ? "text-green-600" :
|
||||
p.score >= 20 ? "text-yellow-600" :
|
||||
"text-muted-foreground"
|
||||
}`}>
|
||||
{p.score}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{p.image ? (
|
||||
@@ -192,16 +340,15 @@ export function RecommendationTable({ category }: RecommendationTableProps) {
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="max-w-[250px]">
|
||||
<p className="font-medium text-sm truncate">{p.title}</p>
|
||||
<p className="text-xs text-muted-foreground">{p.sku}</p>
|
||||
<div className="max-w-[400px]">
|
||||
<p className="font-medium text-sm line-clamp-2">{p.title}</p>
|
||||
{p.line && (
|
||||
<p className="text-[10px] text-muted-foreground/70 truncate">{p.line}</p>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{p.brand}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<TableCell className="text-center">
|
||||
<div>
|
||||
{p.is_daily_deal && p.deal_price ? (
|
||||
<>
|
||||
@@ -230,7 +377,7 @@ export function RecommendationTable({ category }: RecommendationTableProps) {
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<TableCell className="text-center">
|
||||
<span className={`text-sm ${p.current_stock <= 0 ? "text-red-500" : p.is_low_stock ? "text-yellow-600" : ""}`}>
|
||||
{p.current_stock ?? 0}
|
||||
</span>
|
||||
@@ -238,16 +385,16 @@ export function RecommendationTable({ category }: RecommendationTableProps) {
|
||||
<span className="text-xs text-blue-500 ml-1">(+{p.on_order_qty})</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm">{p.sales_7d ?? 0}</TableCell>
|
||||
<TableCell className="text-right text-sm">{p.sales_30d ?? 0}</TableCell>
|
||||
<TableCell className="text-center text-sm">{p.sales_7d ?? 0}</TableCell>
|
||||
<TableCell className="text-center text-sm">{p.sales_30d ?? 0}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{p.is_new && <Badge variant="default" className="text-[10px] px-1.5 py-0">New</Badge>}
|
||||
{p.is_preorder && <Badge variant="secondary" className="text-[10px] px-1.5 py-0">Pre-Order</Badge>}
|
||||
{p.is_clearance && <Badge variant="destructive" className="text-[10px] px-1.5 py-0">Clearance</Badge>}
|
||||
{p.is_new && <Badge variant="default" className="text-[10px] px-1.5 py-0 whitespace-nowrap">New</Badge>}
|
||||
{p.is_preorder && <Badge variant="secondary" className="text-[10px] px-1.5 py-0 whitespace-nowrap">Pre-Order</Badge>}
|
||||
{p.is_clearance && <Badge variant="destructive" className="text-[10px] px-1.5 py-0 whitespace-nowrap">Clearance</Badge>}
|
||||
{p.is_daily_deal && <Badge variant="destructive" className="text-[10px] px-1.5 py-0 bg-orange-500">Deal</Badge>}
|
||||
{p.is_back_in_stock && <Badge variant="outline" className="text-[10px] px-1.5 py-0">Back in Stock</Badge>}
|
||||
{p.is_low_stock && <Badge variant="outline" className="text-[10px] px-1.5 py-0 border-yellow-500">Low Stock</Badge>}
|
||||
{p.is_back_in_stock && <Badge variant="outline" className="text-[10px] px-1.5 py-0 whitespace-nowrap">Back in Stock</Badge>}
|
||||
{p.is_low_stock && <Badge variant="outline" className="text-[10px] px-1.5 py-0 border-yellow-500 whitespace-nowrap">Low Stock</Badge>}
|
||||
{(p.baskets > 0 || p.notifies > 0) && (
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 border-purple-500">
|
||||
{p.baskets > 0 ? `${p.baskets} 🛒` : ""}{p.baskets > 0 && p.notifies > 0 ? " " : ""}{p.notifies > 0 ? `${p.notifies} 🔔` : ""}
|
||||
@@ -255,19 +402,36 @@ export function RecommendationTable({ category }: RecommendationTableProps) {
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm">
|
||||
<TableCell className="text-center text-sm">
|
||||
<FeaturedCell p={p} />
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
<TableCell className="text-center text-sm text-muted-foreground">
|
||||
<LastFeaturedCell p={p} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{p.permalink && (
|
||||
<a href={p.permalink} target="_blank" rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground">
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<CopyPidButton pid={p.pid} />
|
||||
{p.permalink && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
asChild
|
||||
>
|
||||
<a href={p.permalink} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Open in shop
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useState } from "react"
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
|
||||
import { NewsletterStats } from "@/components/newsletter/NewsletterStats"
|
||||
import { RecommendationTable } from "@/components/newsletter/RecommendationTable"
|
||||
import { CampaignHistoryDialog } from "@/components/newsletter/CampaignHistoryDialog"
|
||||
|
||||
const CATEGORIES = [
|
||||
{ value: "all", label: "All Recommendations" },
|
||||
@@ -22,6 +23,7 @@ export function Newsletter() {
|
||||
<div className="flex-1 space-y-4 p-4 md:p-8 pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-3xl font-bold tracking-tight">Newsletter Recommendations</h2>
|
||||
<CampaignHistoryDialog />
|
||||
</div>
|
||||
|
||||
<NewsletterStats />
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user