Add newsletter recommendations
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user