Add newsletter recommendations

This commit is contained in:
2026-01-31 22:04:49 -05:00
parent 4372dc5e26
commit 450fd96e19
11 changed files with 1169 additions and 29 deletions
+10
View File
@@ -28,6 +28,7 @@ const Categories = lazy(() => import('./pages/Categories'));
const Brands = lazy(() => import('./pages/Brands'));
const PurchaseOrders = lazy(() => import('./pages/PurchaseOrders'));
const BlackFridayDashboard = lazy(() => import('./pages/BlackFridayDashboard'));
const Newsletter = lazy(() => import('./pages/Newsletter'));
// 2. Dashboard app - separate chunk
const Dashboard = lazy(() => import('./pages/Dashboard'));
@@ -215,6 +216,15 @@ function App() {
</Protected>
} />
{/* Newsletter recommendations */}
<Route path="/newsletter" element={
<Protected page="newsletter">
<Suspense fallback={<PageLoading />}>
<Newsletter />
</Suspense>
</Protected>
} />
{/* Dashboard app - separate chunk */}
<Route path="/dashboard" element={
<Protected page="dashboard">
@@ -14,6 +14,7 @@ import {
FileSearch,
ShoppingCart,
FilePenLine,
Mail,
} from "lucide-react";
import { IconCrystalBall } from "@tabler/icons-react";
import {
@@ -120,6 +121,12 @@ const toolsItems = [
icon: FilePenLine,
url: "/product-editor",
permission: "access:product_editor"
},
{
title: "Newsletter",
icon: Mail,
url: "/newsletter",
permission: "access:newsletter"
}
];
@@ -0,0 +1,92 @@
import { useQuery } from "@tanstack/react-query"
import { Card, CardContent } from "@/components/ui/card"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { Sparkles, RotateCcw, TrendingUp, Clock, CalendarClock, EyeOff, Info } from "lucide-react"
import config from "@/config"
interface Stats {
unfeatured_new: number
back_in_stock_ready: number
high_score_available: number
last_campaign_date: string
avg_days_since_featured: number
never_featured: number
}
export function NewsletterStats() {
const { data } = useQuery<Stats>({
queryKey: ["newsletter-stats"],
queryFn: async () => {
const res = await fetch(`${config.apiUrl}/newsletter/stats`)
if (!res.ok) throw new Error("Failed to fetch stats")
return res.json()
},
})
if (!data) return null
const stats = [
{
label: "Unfeatured New",
value: data.unfeatured_new?.toLocaleString() ?? "—",
icon: Sparkles,
tooltip: "New products (< 31 days) that haven't appeared in any newsletter yet. If this is high, prioritize new arrivals today.",
},
{
label: "Back in Stock Ready",
value: data.back_in_stock_ready?.toLocaleString() ?? "—",
icon: RotateCcw,
tooltip: "Restocked products that haven't been featured since their restock date. These are time-sensitive — customers are waiting and stock could sell through.",
},
{
label: "High Score Available",
value: data.high_score_available?.toLocaleString() ?? "—",
icon: TrendingUp,
tooltip: "Products scoring 40+ that haven't been featured in the last 2 days. Shows how deep your bench of strong picks is for today's send.",
},
{
label: "Last Campaign",
value: data.last_campaign_date ? new Date(data.last_campaign_date).toLocaleDateString() : "—",
icon: Clock,
tooltip: "Date of the most recent synced campaign. Useful to confirm your Klaviyo sync is up to date.",
},
{
label: "Avg Days Since Featured",
value: data.avg_days_since_featured ?? "—",
icon: CalendarClock,
tooltip: "Average days since in-stock products were last featured. If this is climbing, you're not cycling through enough of your catalog.",
},
{
label: "Never Featured",
value: data.never_featured?.toLocaleString() ?? "—",
icon: EyeOff,
tooltip: "Visible, in-stock products that have never appeared in any newsletter. Your untapped opportunity pool.",
},
]
return (
<TooltipProvider>
<div className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-6">
{stats.map((s) => (
<Card key={s.label}>
<CardContent className="p-4">
<div className="flex items-center gap-2 text-muted-foreground text-xs font-medium">
<s.icon className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{s.label}</span>
<Tooltip>
<TooltipTrigger asChild>
<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>{s.tooltip}</p>
</TooltipContent>
</Tooltip>
</div>
<p className="text-2xl font-bold mt-1">{s.value}</p>
</CardContent>
</Card>
))}
</div>
</TooltipProvider>
)
}
@@ -0,0 +1,307 @@
import { useQuery } from "@tanstack/react-query"
import { useState } from "react"
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Tooltip, TooltipContent, TooltipProvider, TooltipTrigger,
} from "@/components/ui/tooltip"
import { ChevronLeft, ChevronRight, ExternalLink, Layers } from "lucide-react"
import config from "@/config"
interface Product {
pid: number
title: string
sku: string
brand: string
vendor: string
price: number
regular_price: number
image: string
permalink: string
stock_quantity: number
preorder_count: number
sales_7d: number
sales_30d: number
revenue_30d: number
current_stock: number
on_order_qty: number
abc_class: string
line: string | null
times_featured: number | null
last_featured_at: string | null
days_since_featured: number | null
line_products_featured: number | null
line_total_features: number | null
line_last_featured_at: string | null
line_products_featured_30d: number | null
line_product_count: number | null
line_days_since_featured: number | null
effective_last_featured: string | null
effective_days_since_featured: number | null
age_days: number
score: number
is_new: boolean
is_preorder: boolean
is_clearance: boolean
discount_pct: number
is_low_stock: boolean
is_back_in_stock: boolean
is_daily_deal: boolean
deal_price: number | null
baskets: number
notifies: number
}
interface RecommendationResponse {
products: Product[]
pagination: {
total: number
pages: number
currentPage: number
limit: number
}
}
interface RecommendationTableProps {
category: string
}
function FeaturedCell({ p }: { p: Product }) {
const directCount = p.times_featured ?? 0
const hasLineHistory = p.line && p.line_last_featured_at && !p.last_featured_at
return (
<div className="flex items-center justify-end gap-1.5">
<span className="text-sm">{directCount}×</span>
{hasLineHistory && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Layers className="h-3.5 w-3.5 text-blue-500" />
</TooltipTrigger>
<TooltipContent side="left" className="max-w-[220px]">
<p className="text-xs font-medium">Line: {p.line}</p>
<p className="text-xs text-muted-foreground">
{p.line_products_featured} of {p.line_product_count} products featured
({p.line_products_featured_30d} in last 30d)
</p>
<p className="text-xs text-muted-foreground">
Line last featured {p.line_days_since_featured}d ago
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
)
}
function LastFeaturedCell({ p }: { p: Product }) {
if (p.last_featured_at) {
return <span>{p.days_since_featured === 0 ? "Today" : `${p.days_since_featured}d ago`}</span>
}
if (p.line_last_featured_at) {
const lineLabel = p.line_days_since_featured === 0 ? "Today" : `${p.line_days_since_featured}d ago`
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger className="flex items-center gap-1 text-blue-500">
<Layers className="h-3 w-3" />
<span>{lineLabel}</span>
</TooltipTrigger>
<TooltipContent side="left">
<p className="text-xs">Product never featured directly.</p>
<p className="text-xs">Line "{p.line}" was last featured {lineLabel.toLowerCase()}.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
return <span>Never</span>
}
export function RecommendationTable({ category }: RecommendationTableProps) {
const [page, setPage] = useState(1)
const limit = 50
const { data, isLoading } = useQuery<RecommendationResponse>({
queryKey: ["newsletter-recommendations", category, page],
queryFn: async () => {
const res = await fetch(
`${config.apiUrl}/newsletter/recommendations?category=${category}&page=${page}&limit=${limit}`
)
if (!res.ok) throw new Error("Failed to fetch recommendations")
return res.json()
},
})
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>
<TableHeader>
<TableRow>
<TableHead className="w-[50px]">Score</TableHead>
<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>
<TableHead>Tags</TableHead>
<TableHead className="text-right">Featured</TableHead>
<TableHead>Last Featured</TableHead>
<TableHead className="w-[40px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{products.length === 0 ? (
<TableRow>
<TableCell colSpan={12} className="text-center py-8 text-muted-foreground">
No products found for this category
</TableCell>
</TableRow>
) : (
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>
</TableCell>
<TableCell>
{p.image ? (
<img src={p.image} alt="" className="w-10 h-10 object-cover rounded" />
) : (
<div className="w-10 h-10 bg-muted rounded" />
)}
</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>
{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">
<div>
{p.is_daily_deal && p.deal_price ? (
<>
<span className="text-sm font-medium">${Number(p.deal_price).toFixed(2)}</span>
<div>
<span className="text-xs text-muted-foreground line-through">
${Number(p.price).toFixed(2)}
</span>
<span className="text-xs text-red-500 ml-1">
-{Math.round((1 - Number(p.deal_price) / Number(p.price)) * 100)}%
</span>
</div>
</>
) : (
<>
<span className="text-sm font-medium">${Number(p.price).toFixed(2)}</span>
{p.is_clearance && (
<div>
<span className="text-xs text-muted-foreground line-through">
${Number(p.regular_price).toFixed(2)}
</span>
<span className="text-xs text-red-500 ml-1">-{p.discount_pct}%</span>
</div>
)}
</>
)}
</div>
</TableCell>
<TableCell className="text-right">
<span className={`text-sm ${p.current_stock <= 0 ? "text-red-500" : p.is_low_stock ? "text-yellow-600" : ""}`}>
{p.current_stock ?? 0}
</span>
{p.on_order_qty > 0 && (
<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>
<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_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.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} 🔔` : ""}
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-right text-sm">
<FeaturedCell p={p} />
</TableCell>
<TableCell className="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>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{pagination && pagination.pages > 1 && (
<div className="flex items-center justify-between mt-4">
<p className="text-sm text-muted-foreground">
{pagination.total.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 {pagination.pages}
</span>
<Button
variant="outline" size="sm"
disabled={page >= pagination.pages}
onClick={() => setPage(page + 1)}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
)
}
+48
View File
@@ -0,0 +1,48 @@
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"
const CATEGORIES = [
{ value: "all", label: "All Recommendations" },
{ value: "new", label: "New Products" },
{ value: "preorder", label: "Pre-Orders" },
{ value: "bestsellers", label: "Bestsellers" },
{ value: "back_in_stock", label: "Back in Stock" },
{ value: "clearance", label: "Clearance" },
{ value: "daily_deals", label: "Daily Deals" },
{ value: "never_featured", label: "Never Featured" },
]
export function Newsletter() {
const [category, setCategory] = useState("all")
return (
<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>
</div>
<NewsletterStats />
<Tabs value={category} onValueChange={setCategory}>
<TabsList>
{CATEGORIES.map((c) => (
<TabsTrigger key={c.value} value={c.value}>
{c.label}
</TabsTrigger>
))}
</TabsList>
{CATEGORIES.map((c) => (
<TabsContent key={c.value} value={c.value}>
<RecommendationTable category={c.value} />
</TabsContent>
))}
</Tabs>
</div>
)
}
export default Newsletter