fix: filters, UI
This commit is contained in:
parent
23ab998311
commit
6765e2bbf4
@ -297,6 +297,29 @@ function productQuestionLabel(content: string): string {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function incrementProductQuestion(
|
||||||
|
rows: Map<string, { name: string; count: number }>,
|
||||||
|
name: string,
|
||||||
|
) {
|
||||||
|
const clean = name.trim()
|
||||||
|
if (!clean) return
|
||||||
|
rows.set(clean, {
|
||||||
|
name: clean,
|
||||||
|
count: (rows.get(clean)?.count ?? 0) + 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function cartEventTypes() {
|
||||||
|
return new Set([
|
||||||
|
"add_to_cart_clicked",
|
||||||
|
"upsell_clicked",
|
||||||
|
"cross_sell_clicked",
|
||||||
|
"cart_link_opened",
|
||||||
|
"cart_link_generated",
|
||||||
|
"cart_action_shown",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(req: Request) {
|
export async function GET(req: Request) {
|
||||||
try {
|
try {
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
@ -456,7 +479,7 @@ export async function GET(req: Request) {
|
|||||||
if (event.eventType === "ticket_created") row.ticketsCreated += 1
|
if (event.eventType === "ticket_created") row.ticketsCreated += 1
|
||||||
if (event.eventType === "upsell_shown") row.upsellShown += 1
|
if (event.eventType === "upsell_shown") row.upsellShown += 1
|
||||||
if (event.eventType === "upsell_clicked") row.upsellClicked += 1
|
if (event.eventType === "upsell_clicked") row.upsellClicked += 1
|
||||||
if (event.eventType === "add_to_cart_clicked" || event.eventType === "cart_link_opened") row.cartActions += 1
|
if (cartEventTypes().has(event.eventType)) row.cartActions += 1
|
||||||
if (event.sentiment === "positive") row.positiveSentiment += 1
|
if (event.sentiment === "positive") row.positiveSentiment += 1
|
||||||
if (event.sentiment === "neutral") row.neutralSentiment += 1
|
if (event.sentiment === "neutral") row.neutralSentiment += 1
|
||||||
if (event.sentiment === "negative") row.negativeSentiment += 1
|
if (event.sentiment === "negative") row.negativeSentiment += 1
|
||||||
@ -496,14 +519,18 @@ export async function GET(req: Request) {
|
|||||||
const bucket = classifyStorefrontCustomerQuery(message.content)
|
const bucket = classifyStorefrontCustomerQuery(message.content)
|
||||||
intentCounts[bucket] += 1
|
intentCounts[bucket] += 1
|
||||||
if (bucket === "Product Qs") {
|
if (bucket === "Product Qs") {
|
||||||
const name = productQuestionLabel(message.content)
|
incrementProductQuestion(productQuestionCounts, productQuestionLabel(message.content))
|
||||||
productQuestionCounts.set(name, {
|
|
||||||
name,
|
|
||||||
count: (productQuestionCounts.get(name)?.count ?? 0) + 1,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
pendingByConversation.set(message.conversationId, message.createdAt)
|
pendingByConversation.set(message.conversationId, message.createdAt)
|
||||||
} else if (message.senderType === "AI") {
|
} else if (message.senderType === "AI") {
|
||||||
|
for (const action of parseStorefrontChatActions(message.actions)) {
|
||||||
|
if (action.type === "add_to_cart" || action.type === "upsell_suggestion") {
|
||||||
|
incrementProductQuestion(productQuestionCounts, action.product.title)
|
||||||
|
}
|
||||||
|
if (action.type === "open_cart" && action.product?.title) {
|
||||||
|
incrementProductQuestion(productQuestionCounts, action.product.title)
|
||||||
|
}
|
||||||
|
}
|
||||||
const pendingAt = pendingByConversation.get(message.conversationId)
|
const pendingAt = pendingByConversation.get(message.conversationId)
|
||||||
if (!pendingAt) continue
|
if (!pendingAt) continue
|
||||||
const sec = (message.createdAt.getTime() - pendingAt.getTime()) / 1000
|
const sec = (message.createdAt.getTime() - pendingAt.getTime()) / 1000
|
||||||
@ -513,12 +540,18 @@ export async function GET(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const manualLatencyMinutes: number[] = []
|
const manualLatencyMinutes: number[] = []
|
||||||
for (let i = 0; i < chatMessages.length - 1; i++) {
|
const pendingManualByCounterparty = new Map<string, Date>()
|
||||||
const a = chatMessages[i]!
|
for (const message of chatMessages) {
|
||||||
const b = chatMessages[i + 1]!
|
if (message.receiverId === vendorUserId && message.senderId !== vendorUserId) {
|
||||||
if (a.receiverId === vendorUserId && a.senderId !== vendorUserId && b.senderId === vendorUserId) {
|
pendingManualByCounterparty.set(message.senderId, message.createdAt)
|
||||||
const min = (b.createdAt.getTime() - a.createdAt.getTime()) / 60_000
|
continue
|
||||||
if (min >= 0 && min <= 24 * 60) manualLatencyMinutes.push(min)
|
}
|
||||||
|
if (message.senderId === vendorUserId) {
|
||||||
|
const pendingAt = pendingManualByCounterparty.get(message.receiverId)
|
||||||
|
if (!pendingAt) continue
|
||||||
|
const min = (message.createdAt.getTime() - pendingAt.getTime()) / 60_000
|
||||||
|
if (min >= 0 && min <= 8 * 60) manualLatencyMinutes.push(min)
|
||||||
|
pendingManualByCounterparty.delete(message.receiverId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -531,12 +564,20 @@ export async function GET(req: Request) {
|
|||||||
const resolvedByAi = analyticsEvents.filter((event) => event.eventType === "resolved_by_ai").length
|
const resolvedByAi = analyticsEvents.filter((event) => event.eventType === "resolved_by_ai").length
|
||||||
const unresolvedQuestions = analyticsEvents.filter((event) => event.eventType === "unresolved_question").length
|
const unresolvedQuestions = analyticsEvents.filter((event) => event.eventType === "unresolved_question").length
|
||||||
const ticketsCreated = analyticsEvents.filter((event) => event.eventType === "ticket_created").length
|
const ticketsCreated = analyticsEvents.filter((event) => event.eventType === "ticket_created").length
|
||||||
const cartClicks = analyticsEvents.filter((event) =>
|
const cartTypes = cartEventTypes()
|
||||||
["add_to_cart_clicked", "upsell_clicked", "cross_sell_clicked"].includes(event.eventType),
|
const cartEvents = analyticsEvents.filter((event) => cartTypes.has(event.eventType))
|
||||||
).length
|
const cartClicks = cartEvents.length
|
||||||
const cartOpenCount = analyticsEvents.filter((event) => event.eventType === "cart_link_opened").length
|
const cartOpenCount = analyticsEvents.filter((event) => event.eventType === "cart_link_opened").length
|
||||||
const conversionBase = cartClicks || cartOpenCount
|
|
||||||
const directOrderCount = new Set(revenueAttribution.directRows.map((row) => row.orderId)).size
|
const directOrderCount = new Set(revenueAttribution.directRows.map((row) => row.orderId)).size
|
||||||
|
const cartConversationIds = new Set(
|
||||||
|
cartEvents.map((event) => event.conversationId).filter((id): id is string => Boolean(id)),
|
||||||
|
)
|
||||||
|
const cartAttributedOrderCount = new Set(
|
||||||
|
revenueAttribution.assistedRows
|
||||||
|
.filter((row) => cartConversationIds.has(row.conversationId))
|
||||||
|
.map((row) => row.orderId),
|
||||||
|
).size
|
||||||
|
const conversionBase = cartConversationIds.size || cartClicks
|
||||||
const currency = cachedOrders[0]?.currency ?? revenueAttribution.directRows[0]?.currency ?? "GBP"
|
const currency = cachedOrders[0]?.currency ?? revenueAttribution.directRows[0]?.currency ?? "GBP"
|
||||||
|
|
||||||
const sentimentTotals = {
|
const sentimentTotals = {
|
||||||
@ -549,7 +590,11 @@ export async function GET(req: Request) {
|
|||||||
const sentimentTotal = Object.values(sentimentTotals).reduce((sum, count) => sum + count, 0)
|
const sentimentTotal = Object.values(sentimentTotals).reduce((sum, count) => sum + count, 0)
|
||||||
const aiMedianSeconds = median(aiLatencySeconds)
|
const aiMedianSeconds = median(aiLatencySeconds)
|
||||||
const manualMedianMinutes = median(manualLatencyMinutes)
|
const manualMedianMinutes = median(manualLatencyMinutes)
|
||||||
const minutesSavedPerAnswer = Math.max(0, manualMedianMinutes - aiMedianSeconds / 60)
|
const manualBenchmarkMinutes = manualLatencyMinutes.length > 0 ? manualMedianMinutes : 3
|
||||||
|
const minutesSavedPerAnswer = Math.min(
|
||||||
|
10,
|
||||||
|
Math.max(0, manualBenchmarkMinutes - aiMedianSeconds / 60),
|
||||||
|
)
|
||||||
const hoursSavedEstimate = Math.round((minutesSavedPerAnswer * aiAnswers / 60) * 10) / 10
|
const hoursSavedEstimate = Math.round((minutesSavedPerAnswer * aiAnswers / 60) * 10) / 10
|
||||||
const topProductQuestions = Array.from(productQuestionCounts.values())
|
const topProductQuestions = Array.from(productQuestionCounts.values())
|
||||||
.sort((a, b) => b.count - a.count)
|
.sort((a, b) => b.count - a.count)
|
||||||
@ -573,12 +618,12 @@ export async function GET(req: Request) {
|
|||||||
directUpsellRevenue: money(revenueAttribution.directUpsellRevenue),
|
directUpsellRevenue: money(revenueAttribution.directUpsellRevenue),
|
||||||
directCrossSellRevenue: money(revenueAttribution.directCrossSellRevenue),
|
directCrossSellRevenue: money(revenueAttribution.directCrossSellRevenue),
|
||||||
aiAssistedRevenue: money(revenueAttribution.aiAssistedRevenue),
|
aiAssistedRevenue: money(revenueAttribution.aiAssistedRevenue),
|
||||||
aiCartConversionRate: pct(directOrderCount, conversionBase),
|
aiCartConversionRate: pct(cartAttributedOrderCount || directOrderCount, conversionBase),
|
||||||
averageAiInfluencedOrderValue:
|
averageAiInfluencedOrderValue:
|
||||||
revenueAttribution.assistedRows.length > 0
|
revenueAttribution.assistedRows.length > 0
|
||||||
? money(revenueAttribution.aiAssistedRevenue / revenueAttribution.assistedRows.length)
|
? money(revenueAttribution.aiAssistedRevenue / revenueAttribution.assistedRows.length)
|
||||||
: 0,
|
: 0,
|
||||||
pendingAttributionCount: Math.max(0, cartClicks - directOrderCount),
|
pendingAttributionCount: Math.max(0, conversionBase - (cartAttributedOrderCount || directOrderCount)),
|
||||||
directRows: revenueAttribution.directRows.slice(0, 12).map((row) => ({
|
directRows: revenueAttribution.directRows.slice(0, 12).map((row) => ({
|
||||||
...row,
|
...row,
|
||||||
createdAt: row.createdAt.toISOString(),
|
createdAt: row.createdAt.toISOString(),
|
||||||
@ -588,7 +633,7 @@ export async function GET(req: Request) {
|
|||||||
createdAt: row.createdAt.toISOString(),
|
createdAt: row.createdAt.toISOString(),
|
||||||
})),
|
})),
|
||||||
methodology:
|
methodology:
|
||||||
"Direct revenue only counts AI-clicked products later found in a matching order. Assisted revenue counts full matching orders after AI engagement.",
|
"Direct upsell/cross-sell revenue only counts clicked AI-suggested products later found in matching orders. Cart conversion counts cart-engaged conversations that later match a cached Shopify order.",
|
||||||
},
|
},
|
||||||
customerExperience: {
|
customerExperience: {
|
||||||
sentimentMix: {
|
sentimentMix: {
|
||||||
@ -613,7 +658,7 @@ export async function GET(req: Request) {
|
|||||||
resolvedByAi,
|
resolvedByAi,
|
||||||
ticketsCreated,
|
ticketsCreated,
|
||||||
aiMedianSeconds: Math.round(aiMedianSeconds),
|
aiMedianSeconds: Math.round(aiMedianSeconds),
|
||||||
manualMedianMinutes: Math.round(manualMedianMinutes * 10) / 10,
|
manualMedianMinutes: Math.round(manualBenchmarkMinutes * 10) / 10,
|
||||||
sampleAiPairs: aiLatencySeconds.length,
|
sampleAiPairs: aiLatencySeconds.length,
|
||||||
sampleManualPairs: manualLatencyMinutes.length,
|
sampleManualPairs: manualLatencyMinutes.length,
|
||||||
hoursSavedEstimate,
|
hoursSavedEstimate,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { format, startOfMonth, subMonths } from "date-fns"
|
import { format, startOfDay, startOfMonth, subDays, subMonths } from "date-fns"
|
||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import { getVendorActiveStore } from "@/lib/dashboard/vendorActiveStore"
|
import { getVendorActiveStore } from "@/lib/dashboard/vendorActiveStore"
|
||||||
@ -8,17 +8,51 @@ const ASSUMED_NET_MARGIN = 0.34
|
|||||||
const PAYMENT_RATE = 0.019
|
const PAYMENT_RATE = 0.019
|
||||||
const PLATFORM_RATE = 0.024
|
const PLATFORM_RATE = 0.024
|
||||||
|
|
||||||
function monthKeys(monthCount: number): string[] {
|
type DashboardRange = "7days" | "30days" | "1year"
|
||||||
const keys: string[] = []
|
|
||||||
for (let i = monthCount - 1; i >= 0; i--) {
|
function parseRange(input: string | null): DashboardRange {
|
||||||
keys.push(format(startOfMonth(subMonths(new Date(), i)), "yyyy-MM"))
|
if (input === "30days" || input === "1year" || input === "7days") return input
|
||||||
}
|
return "7days"
|
||||||
return keys
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function monthLabel(isoMonth: string): string {
|
function rangeWindow(range: DashboardRange, now = new Date()) {
|
||||||
const [y, m] = isoMonth.split("-").map(Number)
|
if (range === "1year") {
|
||||||
return format(new Date(y!, (m ?? 1) - 1, 1), "MMM")
|
const since = startOfMonth(subMonths(now, 11))
|
||||||
|
return {
|
||||||
|
since,
|
||||||
|
previousSince: startOfMonth(subMonths(now, 23)),
|
||||||
|
bucketCount: 12,
|
||||||
|
bucketKey: (date: Date) => format(startOfMonth(date), "yyyy-MM"),
|
||||||
|
bucketLabel: (key: string) => {
|
||||||
|
const [y, m] = key.split("-").map(Number)
|
||||||
|
return format(new Date(y!, (m ?? 1) - 1, 1), "MMM")
|
||||||
|
},
|
||||||
|
previousLabel: "prior year",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const days = range === "30days" ? 30 : 7
|
||||||
|
const since = subDays(startOfDay(now), days - 1)
|
||||||
|
return {
|
||||||
|
since,
|
||||||
|
previousSince: subDays(since, days),
|
||||||
|
bucketCount: days,
|
||||||
|
bucketKey: (date: Date) => format(startOfDay(date), "yyyy-MM-dd"),
|
||||||
|
bucketLabel: (key: string) => format(new Date(`${key}T00:00:00`), "MMM d"),
|
||||||
|
previousLabel: range === "30days" ? "prior 30 days" : "prior 7 days",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rangeKeys(config: ReturnType<typeof rangeWindow>): string[] {
|
||||||
|
const keys: string[] = []
|
||||||
|
for (let i = config.bucketCount - 1; i >= 0; i--) {
|
||||||
|
const d =
|
||||||
|
config.bucketCount === 12
|
||||||
|
? startOfMonth(subMonths(new Date(), i))
|
||||||
|
: subDays(startOfDay(new Date()), i)
|
||||||
|
keys.push(config.bucketKey(d))
|
||||||
|
}
|
||||||
|
return keys
|
||||||
}
|
}
|
||||||
|
|
||||||
type LineItemLike = {
|
type LineItemLike = {
|
||||||
@ -33,7 +67,7 @@ function parseLineItems(raw: unknown): LineItemLike[] {
|
|||||||
return raw as LineItemLike[]
|
return raw as LineItemLike[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET(req: Request) {
|
||||||
try {
|
try {
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
@ -48,12 +82,17 @@ export async function GET() {
|
|||||||
return NextResponse.json({ message: "No active store" }, { status: 400 })
|
return NextResponse.json({ message: "No active store" }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const since = startOfMonth(subMonths(new Date(), 5))
|
const { searchParams } = new URL(req.url)
|
||||||
|
const range = parseRange(searchParams.get("range"))
|
||||||
|
const config = rangeWindow(range)
|
||||||
|
|
||||||
const orders = await prisma.cachedOrder.findMany({
|
const orders = await prisma.cachedOrder.findMany({
|
||||||
where: {
|
where: {
|
||||||
storeId: store.id,
|
storeId: store.id,
|
||||||
OR: [{ shopifyCreatedAt: { gte: since } }, { shopifyCreatedAt: null, createdAt: { gte: since } }],
|
OR: [
|
||||||
|
{ shopifyCreatedAt: { gte: config.previousSince } },
|
||||||
|
{ shopifyCreatedAt: null, createdAt: { gte: config.previousSince } },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
totalPrice: true,
|
totalPrice: true,
|
||||||
@ -65,12 +104,14 @@ export async function GET() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const keys = monthKeys(6)
|
const keys = rangeKeys(config)
|
||||||
const grossByMonth: Record<string, number> = Object.fromEntries(keys.map((k) => [k, 0]))
|
const grossByMonth: Record<string, number> = Object.fromEntries(keys.map((k) => [k, 0]))
|
||||||
const refundByMonth: Record<string, number> = Object.fromEntries(keys.map((k) => [k, 0]))
|
const refundByMonth: Record<string, number> = Object.fromEntries(keys.map((k) => [k, 0]))
|
||||||
|
|
||||||
let gross = 0
|
let gross = 0
|
||||||
let refunds = 0
|
let refunds = 0
|
||||||
|
let previousGross = 0
|
||||||
|
let previousRefunds = 0
|
||||||
|
|
||||||
type ProductAgg = {
|
type ProductAgg = {
|
||||||
title: string
|
title: string
|
||||||
@ -89,17 +130,25 @@ export async function GET() {
|
|||||||
const fs = (o.financialStatus || "").toLowerCase()
|
const fs = (o.financialStatus || "").toLowerCase()
|
||||||
const isRefundLike = fs.includes("refund") || fs.includes("voided")
|
const isRefundLike = fs.includes("refund") || fs.includes("voided")
|
||||||
const d = o.shopifyCreatedAt ?? o.createdAt
|
const d = o.shopifyCreatedAt ?? o.createdAt
|
||||||
const mk = format(startOfMonth(d), "yyyy-MM")
|
const isCurrentPeriod = d >= config.since
|
||||||
|
const isPreviousPeriod = d >= config.previousSince && d < config.since
|
||||||
|
const mk = config.bucketKey(d)
|
||||||
|
|
||||||
if (isRefundLike) {
|
if (isCurrentPeriod) {
|
||||||
refunds += Math.abs(amt)
|
if (isRefundLike) {
|
||||||
if (mk in refundByMonth) refundByMonth[mk] += Math.abs(amt)
|
refunds += Math.abs(amt)
|
||||||
} else {
|
if (mk in refundByMonth) refundByMonth[mk] += Math.abs(amt)
|
||||||
gross += amt
|
} else {
|
||||||
if (mk in grossByMonth) grossByMonth[mk] += amt
|
gross += amt
|
||||||
|
if (mk in grossByMonth) grossByMonth[mk] += amt
|
||||||
|
}
|
||||||
|
} else if (isPreviousPeriod) {
|
||||||
|
if (isRefundLike) previousRefunds += Math.abs(amt)
|
||||||
|
else previousGross += amt
|
||||||
}
|
}
|
||||||
|
|
||||||
const firstHalf = d < midpoint
|
const firstHalf = d < midpoint
|
||||||
|
if (!isCurrentPeriod) continue
|
||||||
for (const li of parseLineItems(o.lineItems)) {
|
for (const li of parseLineItems(o.lineItems)) {
|
||||||
const title = (li.title || li.name || "Product").trim() || "Product"
|
const title = (li.title || li.name || "Product").trim() || "Product"
|
||||||
const qty = typeof li.quantity === "number" ? li.quantity : parseInt(String(li.quantity ?? 1), 10) || 1
|
const qty = typeof li.quantity === "number" ? li.quantity : parseInt(String(li.quantity ?? 1), 10) || 1
|
||||||
@ -122,10 +171,19 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const netProfitEstimate = Math.max(0, gross * ASSUMED_NET_MARGIN - refunds * 0.5)
|
const netProfitEstimate = Math.max(0, gross * ASSUMED_NET_MARGIN - refunds * 0.5)
|
||||||
const orderCount = orders.filter((o) => !(o.financialStatus || "").toLowerCase().includes("refund")).length
|
const orderCount = orders.filter((o) => {
|
||||||
|
const d = o.shopifyCreatedAt ?? o.createdAt
|
||||||
|
return d >= config.since && !(o.financialStatus || "").toLowerCase().includes("refund")
|
||||||
|
}).length
|
||||||
const estimatedFees = gross * (PAYMENT_RATE + PLATFORM_RATE) + refunds * 0.02 + orderCount * 0.25
|
const estimatedFees = gross * (PAYMENT_RATE + PLATFORM_RATE) + refunds * 0.02 + orderCount * 0.25
|
||||||
const avgMarginPct =
|
const avgMarginPct =
|
||||||
gross > 0 ? Math.round(((netProfitEstimate + refunds * 0.5) / gross) * 1000) / 10 : 0
|
gross > 0 ? Math.round(((netProfitEstimate + refunds * 0.5) / gross) * 1000) / 10 : 0
|
||||||
|
const previousNetProfitEstimate = Math.max(0, previousGross * ASSUMED_NET_MARGIN - previousRefunds * 0.5)
|
||||||
|
const previousFees = previousGross * (PAYMENT_RATE + PLATFORM_RATE) + previousRefunds * 0.02
|
||||||
|
const previousAvgMarginPct =
|
||||||
|
previousGross > 0
|
||||||
|
? Math.round(((previousNetProfitEstimate + previousRefunds * 0.5) / previousGross) * 1000) / 10
|
||||||
|
: 0
|
||||||
|
|
||||||
const sparkFromRecord = (rec: Record<string, number>) =>
|
const sparkFromRecord = (rec: Record<string, number>) =>
|
||||||
keys.map((k) => Math.round((rec[k] ?? 0) * 100) / 100)
|
keys.map((k) => Math.round((rec[k] ?? 0) * 100) / 100)
|
||||||
@ -144,9 +202,9 @@ export async function GET() {
|
|||||||
) / 100,
|
) / 100,
|
||||||
)
|
)
|
||||||
|
|
||||||
function pctChange(series: number[]): number {
|
function pctChangeValue(current: number, previous: number): number {
|
||||||
const a = series[series.length - 2] ?? 0
|
const a = previous
|
||||||
const b = series[series.length - 1] ?? 0
|
const b = current
|
||||||
if (a === 0) return b > 0 ? 100 : 0
|
if (a === 0) return b > 0 ? 100 : 0
|
||||||
return Math.round(((b - a) / a) * 1000) / 10
|
return Math.round(((b - a) / a) * 1000) / 10
|
||||||
}
|
}
|
||||||
@ -156,21 +214,21 @@ export async function GET() {
|
|||||||
key: "netProfit",
|
key: "netProfit",
|
||||||
label: "Est. net profit",
|
label: "Est. net profit",
|
||||||
value: netProfitEstimate,
|
value: netProfitEstimate,
|
||||||
changePct: pctChange(profitSpark),
|
changePct: pctChangeValue(netProfitEstimate, previousNetProfitEstimate),
|
||||||
sparkline: profitSpark,
|
sparkline: profitSpark,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "grossRevenue",
|
key: "grossRevenue",
|
||||||
label: "Gross revenue",
|
label: "Gross revenue",
|
||||||
value: gross,
|
value: gross,
|
||||||
changePct: pctChange(grossSpark),
|
changePct: pctChangeValue(gross, previousGross),
|
||||||
sparkline: grossSpark,
|
sparkline: grossSpark,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "fees",
|
key: "fees",
|
||||||
label: "Est. fees",
|
label: "Est. fees",
|
||||||
value: estimatedFees,
|
value: estimatedFees,
|
||||||
changePct: pctChange(feeSpark),
|
changePct: pctChangeValue(estimatedFees, previousFees),
|
||||||
sparkline: feeSpark,
|
sparkline: feeSpark,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -178,13 +236,13 @@ export async function GET() {
|
|||||||
label: "Avg. margin (est.)",
|
label: "Avg. margin (est.)",
|
||||||
value: avgMarginPct,
|
value: avgMarginPct,
|
||||||
suffix: "%",
|
suffix: "%",
|
||||||
changePct: avgMarginPct > 30 ? 1.2 : -0.8,
|
changePct: pctChangeValue(avgMarginPct, previousAvgMarginPct),
|
||||||
sparkline: keys.map((_, i) => avgMarginPct + (i % 3) - 1),
|
sparkline: keys.map((_, i) => avgMarginPct + (i % 3) - 1),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const monthlyProfitBars = keys.map((k) => ({
|
const monthlyProfitBars = keys.map((k) => ({
|
||||||
month: monthLabel(k),
|
month: config.bucketLabel(k),
|
||||||
profit: Math.round((grossByMonth[k] ?? 0) * ASSUMED_NET_MARGIN * 100) / 100,
|
profit: Math.round((grossByMonth[k] ?? 0) * ASSUMED_NET_MARGIN * 100) / 100,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@ -240,6 +298,8 @@ export async function GET() {
|
|||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
currency,
|
currency,
|
||||||
|
range,
|
||||||
|
comparisonLabel: config.previousLabel,
|
||||||
disclaimer:
|
disclaimer:
|
||||||
"Financial figures include estimates (payment + platform fees, assumed ~34% net margin). Connect fuller accounting data for audited numbers.",
|
"Financial figures include estimates (payment + platform fees, assumed ~34% net margin). Connect fuller accounting data for audited numbers.",
|
||||||
kpis,
|
kpis,
|
||||||
@ -248,7 +308,11 @@ export async function GET() {
|
|||||||
feeTotal: Math.round(feeDonut.reduce((s, x) => s + x.value, 0) * 100) / 100,
|
feeTotal: Math.round(feeDonut.reduce((s, x) => s + x.value, 0) * 100) / 100,
|
||||||
productMargins: productRows,
|
productMargins: productRows,
|
||||||
aiInsight,
|
aiInsight,
|
||||||
totals: { gross, refunds, orders: orderCount },
|
totals: {
|
||||||
|
gross,
|
||||||
|
refunds,
|
||||||
|
orders: orderCount,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[business-metrics]", e)
|
console.error("[business-metrics]", e)
|
||||||
|
|||||||
@ -199,7 +199,7 @@ export default function AiAnalyticsPage() {
|
|||||||
}, [data?.customerExperience.sentimentMix.totals])
|
}, [data?.customerExperience.sentimentMix.totals])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 md:space-y-8 max-w-[1400px] mx-auto">
|
<div className="w-full max-w-none space-y-5 md:space-y-7">
|
||||||
<div className="flex flex-col xl:flex-row xl:items-end xl:justify-between gap-4">
|
<div className="flex flex-col xl:flex-row xl:items-end xl:justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
@ -212,14 +212,14 @@ export default function AiAnalyticsPage() {
|
|||||||
Outcome metrics for revenue, customer experience, automation, and demand signals from AI-assisted storefront journeys.
|
Outcome metrics for revenue, customer experience, automation, and demand signals from AI-assisted storefront journeys.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="-mx-1 flex gap-2 overflow-x-auto px-1 pb-1 sm:mx-0 sm:flex-wrap sm:overflow-visible sm:px-0 sm:pb-0">
|
||||||
{RANGE_OPTIONS.map((option) => (
|
{RANGE_OPTIONS.map((option) => (
|
||||||
<button
|
<button
|
||||||
key={option.value}
|
key={option.value}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setRange(option.value)}
|
onClick={() => setRange(option.value)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-xl border px-3 py-2 text-xs font-black transition",
|
"shrink-0 rounded-xl border px-3 py-2 text-xs font-black transition",
|
||||||
range === option.value
|
range === option.value
|
||||||
? isLight
|
? isLight
|
||||||
? "bg-indigo-600 border-indigo-600 text-white"
|
? "bg-indigo-600 border-indigo-600 text-white"
|
||||||
@ -249,7 +249,7 @@ export default function AiAnalyticsPage() {
|
|||||||
|
|
||||||
{!loading && data && (
|
{!loading && data && (
|
||||||
<>
|
<>
|
||||||
<section className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
|
<section className="grid grid-cols-1 sm:grid-cols-2 2xl:grid-cols-4 gap-3 md:gap-4">
|
||||||
<MetricCard
|
<MetricCard
|
||||||
title="Direct AI upsell revenue"
|
title="Direct AI upsell revenue"
|
||||||
value={formatMoney(data.revenueImpact.directUpsellRevenue, data.revenueImpact.currency)}
|
value={formatMoney(data.revenueImpact.directUpsellRevenue, data.revenueImpact.currency)}
|
||||||
@ -280,8 +280,8 @@ export default function AiAnalyticsPage() {
|
|||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="grid grid-cols-1 xl:grid-cols-3 gap-4 md:gap-6">
|
<section className="grid grid-cols-1 2xl:grid-cols-3 gap-4 md:gap-5">
|
||||||
<div className={cn("xl:col-span-2 rounded-2xl border p-4 md:p-6", isLight ? "bg-white border-slate-100 shadow-sm" : "bg-white/5 border-white/10")}>
|
<div className={cn("2xl:col-span-2 rounded-2xl border p-4 md:p-5", isLight ? "bg-white border-slate-100 shadow-sm" : "bg-white/5 border-white/10")}>
|
||||||
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-2 mb-5">
|
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-2 mb-5">
|
||||||
<div>
|
<div>
|
||||||
<h2 className={cn("text-lg font-black", isLight ? "text-slate-900" : "text-white")}>Revenue Impact Timeline</h2>
|
<h2 className={cn("text-lg font-black", isLight ? "text-slate-900" : "text-white")}>Revenue Impact Timeline</h2>
|
||||||
@ -294,7 +294,8 @@ export default function AiAnalyticsPage() {
|
|||||||
{timeline.length === 0 ? (
|
{timeline.length === 0 ? (
|
||||||
<EmptyState isLight={isLight}>Not enough data yet to draw a revenue timeline.</EmptyState>
|
<EmptyState isLight={isLight}>Not enough data yet to draw a revenue timeline.</EmptyState>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-[300px] w-full min-w-0">
|
<div className="w-full overflow-x-auto">
|
||||||
|
<div className="h-[260px] min-w-[620px] md:h-[300px] md:min-w-0">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<AreaChart data={timeline} margin={{ top: 8, right: 8, left: -12, bottom: 0 }}>
|
<AreaChart data={timeline} margin={{ top: 8, right: 8, left: -12, bottom: 0 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={gridStroke} />
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={gridStroke} />
|
||||||
@ -307,11 +308,12 @@ export default function AiAnalyticsPage() {
|
|||||||
<Area type="monotone" dataKey="directCrossSellRevenue" name="Direct cross-sell" stackId="1" stroke="#06b6d4" fill="#06b6d4" fillOpacity={0.3} />
|
<Area type="monotone" dataKey="directCrossSellRevenue" name="Direct cross-sell" stackId="1" stroke="#06b6d4" fill="#06b6d4" fillOpacity={0.3} />
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={cn("rounded-2xl border p-4 md:p-6", isLight ? "bg-white border-slate-100 shadow-sm" : "bg-white/5 border-white/10")}>
|
<div className={cn("rounded-2xl border p-4 md:p-5", isLight ? "bg-white border-slate-100 shadow-sm" : "bg-white/5 border-white/10")}>
|
||||||
<h2 className={cn("text-lg font-black", isLight ? "text-slate-900" : "text-white")}>Customer Sentiment</h2>
|
<h2 className={cn("text-lg font-black", isLight ? "text-slate-900" : "text-white")}>Customer Sentiment</h2>
|
||||||
<p className={cn("text-xs mt-1 mb-5", isLight ? "text-slate-500" : "text-slate-400")}>
|
<p className={cn("text-xs mt-1 mb-5", isLight ? "text-slate-500" : "text-slate-400")}>
|
||||||
Conservative sentiment from storefront customer messages. Unknown is kept out of the chart.
|
Conservative sentiment from storefront customer messages. Unknown is kept out of the chart.
|
||||||
@ -319,7 +321,7 @@ export default function AiAnalyticsPage() {
|
|||||||
{sentimentBars.every((row) => row.count === 0) ? (
|
{sentimentBars.every((row) => row.count === 0) ? (
|
||||||
<EmptyState isLight={isLight}>Not enough sentiment data yet.</EmptyState>
|
<EmptyState isLight={isLight}>Not enough sentiment data yet.</EmptyState>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-[240px] w-full min-w-0">
|
<div className="h-[220px] w-full min-w-0 sm:h-[240px]">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={sentimentBars} margin={{ top: 8, right: 8, left: -18, bottom: 0 }}>
|
<BarChart data={sentimentBars} margin={{ top: 8, right: 8, left: -18, bottom: 0 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={gridStroke} />
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={gridStroke} />
|
||||||
@ -338,13 +340,13 @@ export default function AiAnalyticsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="grid grid-cols-1 lg:grid-cols-2 gap-4 md:gap-6">
|
<section className="grid grid-cols-1 xl:grid-cols-2 gap-4 md:gap-5">
|
||||||
<div className={cn("rounded-2xl border p-4 md:p-6", isLight ? "bg-white border-slate-100 shadow-sm" : "bg-white/5 border-white/10")}>
|
<div className={cn("rounded-2xl border p-4 md:p-5", isLight ? "bg-white border-slate-100 shadow-sm" : "bg-white/5 border-white/10")}>
|
||||||
<h2 className={cn("text-lg font-black", isLight ? "text-slate-900" : "text-white")}>Automation Outcomes</h2>
|
<h2 className={cn("text-lg font-black", isLight ? "text-slate-900" : "text-white")}>Automation Outcomes</h2>
|
||||||
<p className={cn("text-xs mt-1 mb-5", isLight ? "text-slate-500" : "text-slate-400")}>
|
<p className={cn("text-xs mt-1 mb-5", isLight ? "text-slate-500" : "text-slate-400")}>
|
||||||
Shows the operational outcomes owners care about: handled questions, AI resolution, tickets, and response speed.
|
Shows the operational outcomes owners care about: handled questions, AI resolution, tickets, and response speed.
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<MetricCard title="Questions handled" value={data.automationOutcomes.questionsAnswered.toLocaleString()} helper="Customer questions in the selected window." accent={isLight ? "text-slate-900" : "text-white"} isLight={isLight} />
|
<MetricCard title="Questions handled" value={data.automationOutcomes.questionsAnswered.toLocaleString()} helper="Customer questions in the selected window." accent={isLight ? "text-slate-900" : "text-white"} isLight={isLight} />
|
||||||
<MetricCard title="Resolved by AI" value={formatPercent(data.automationOutcomes.resolutionRate)} helper={`${data.automationOutcomes.resolvedByAi.toLocaleString()} resolved events.`} accent={isLight ? "text-emerald-700" : "text-emerald-400"} isLight={isLight} />
|
<MetricCard title="Resolved by AI" value={formatPercent(data.automationOutcomes.resolutionRate)} helper={`${data.automationOutcomes.resolvedByAi.toLocaleString()} resolved events.`} accent={isLight ? "text-emerald-700" : "text-emerald-400"} isLight={isLight} />
|
||||||
<MetricCard title="Tickets created" value={data.automationOutcomes.ticketsCreated.toLocaleString()} helper="Escalations sent to human support." accent={isLight ? "text-rose-700" : "text-rose-300"} isLight={isLight} />
|
<MetricCard title="Tickets created" value={data.automationOutcomes.ticketsCreated.toLocaleString()} helper="Escalations sent to human support." accent={isLight ? "text-rose-700" : "text-rose-300"} isLight={isLight} />
|
||||||
@ -352,12 +354,13 @@ export default function AiAnalyticsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={cn("rounded-2xl border p-4 md:p-6", isLight ? "bg-white border-slate-100 shadow-sm" : "bg-white/5 border-white/10")}>
|
<div className={cn("rounded-2xl border p-4 md:p-5", isLight ? "bg-white border-slate-100 shadow-sm" : "bg-white/5 border-white/10")}>
|
||||||
<h2 className={cn("text-lg font-black", isLight ? "text-slate-900" : "text-white")}>Questions And Resolutions</h2>
|
<h2 className={cn("text-lg font-black", isLight ? "text-slate-900" : "text-white")}>Questions And Resolutions</h2>
|
||||||
<p className={cn("text-xs mt-1 mb-5", isLight ? "text-slate-500" : "text-slate-400")}>
|
<p className={cn("text-xs mt-1 mb-5", isLight ? "text-slate-500" : "text-slate-400")}>
|
||||||
Timeline of demand, AI resolution, unresolved questions, and support tickets.
|
Timeline of demand, AI resolution, unresolved questions, and support tickets.
|
||||||
</p>
|
</p>
|
||||||
<div className="h-[260px] w-full min-w-0">
|
<div className="w-full overflow-x-auto">
|
||||||
|
<div className="h-[240px] min-w-[620px] md:h-[260px] md:min-w-0">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<LineChart data={timeline} margin={{ top: 8, right: 8, left: -18, bottom: 0 }}>
|
<LineChart data={timeline} margin={{ top: 8, right: 8, left: -18, bottom: 0 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={gridStroke} />
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={gridStroke} />
|
||||||
@ -371,12 +374,13 @@ export default function AiAnalyticsPage() {
|
|||||||
<Line type="monotone" dataKey="ticketsCreated" name="Tickets" stroke="#ef4444" strokeWidth={2} dot={false} />
|
<Line type="monotone" dataKey="ticketsCreated" name="Tickets" stroke="#ef4444" strokeWidth={2} dot={false} />
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="grid grid-cols-1 lg:grid-cols-2 gap-4 md:gap-6">
|
<section className="grid grid-cols-1 xl:grid-cols-2 gap-4 md:gap-5">
|
||||||
<div className={cn("rounded-2xl border p-4 md:p-6", isLight ? "bg-white border-slate-100 shadow-sm" : "bg-white/5 border-white/10")}>
|
<div className={cn("rounded-2xl border p-4 md:p-5", isLight ? "bg-white border-slate-100 shadow-sm" : "bg-white/5 border-white/10")}>
|
||||||
<h2 className={cn("text-lg font-black", isLight ? "text-slate-900" : "text-white")}>Growth Signals</h2>
|
<h2 className={cn("text-lg font-black", isLight ? "text-slate-900" : "text-white")}>Growth Signals</h2>
|
||||||
<p className={cn("text-xs mt-1 mb-4", isLight ? "text-slate-500" : "text-slate-400")}>
|
<p className={cn("text-xs mt-1 mb-4", isLight ? "text-slate-500" : "text-slate-400")}>
|
||||||
Product curiosity and repeated requests that can guide buying, merchandising, and product-page content.
|
Product curiosity and repeated requests that can guide buying, merchandising, and product-page content.
|
||||||
@ -395,12 +399,12 @@ export default function AiAnalyticsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={cn("rounded-2xl border p-4 md:p-6", isLight ? "bg-white border-slate-100 shadow-sm" : "bg-white/5 border-white/10")}>
|
<div className={cn("rounded-2xl border p-4 md:p-5", isLight ? "bg-white border-slate-100 shadow-sm" : "bg-white/5 border-white/10")}>
|
||||||
<h2 className={cn("text-lg font-black", isLight ? "text-slate-900" : "text-white")}>Attribution Health</h2>
|
<h2 className={cn("text-lg font-black", isLight ? "text-slate-900" : "text-white")}>Attribution Health</h2>
|
||||||
<p className={cn("text-xs mt-1 mb-4", isLight ? "text-slate-500" : "text-slate-400")}>
|
<p className={cn("text-xs mt-1 mb-4", isLight ? "text-slate-500" : "text-slate-400")}>
|
||||||
Conservative matching keeps revenue accurate. Pending attribution means cart interest exists but no matching order has been cached yet.
|
Conservative matching keeps revenue accurate. Pending attribution means cart interest exists but no matching order has been cached yet.
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<MetricCard title="Cart conversion" value={formatPercent(data.revenueImpact.aiCartConversionRate)} helper="Orders matched to AI cart actions." accent={isLight ? "text-emerald-700" : "text-emerald-400"} isLight={isLight} />
|
<MetricCard title="Cart conversion" value={formatPercent(data.revenueImpact.aiCartConversionRate)} helper="Orders matched to AI cart actions." accent={isLight ? "text-emerald-700" : "text-emerald-400"} isLight={isLight} />
|
||||||
<MetricCard title="Pending attribution" value={data.revenueImpact.pendingAttributionCount.toLocaleString()} helper="Clicks awaiting a matching Shopify order." accent={isLight ? "text-amber-700" : "text-amber-300"} isLight={isLight} />
|
<MetricCard title="Pending attribution" value={data.revenueImpact.pendingAttributionCount.toLocaleString()} helper="Clicks awaiting a matching Shopify order." accent={isLight ? "text-amber-700" : "text-amber-300"} isLight={isLight} />
|
||||||
<MetricCard title="Cross-sell revenue" value={formatMoney(data.revenueImpact.directCrossSellRevenue, data.revenueImpact.currency)} helper="Shown separately from upsell revenue." accent={isLight ? "text-cyan-700" : "text-cyan-300"} isLight={isLight} />
|
<MetricCard title="Cross-sell revenue" value={formatMoney(data.revenueImpact.directCrossSellRevenue, data.revenueImpact.currency)} helper="Shown separately from upsell revenue." accent={isLight ? "text-cyan-700" : "text-cyan-300"} isLight={isLight} />
|
||||||
|
|||||||
@ -74,7 +74,7 @@ export default async function DashboardLayout({
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||||
<PageTransition className="p-4 md:p-8 overflow-y-auto custom-scrollbar" data-lenis-prevent>
|
<PageTransition className="p-3 sm:p-4 lg:p-6 2xl:p-7 overflow-y-auto custom-scrollbar" data-lenis-prevent>
|
||||||
<div className="flex-1 flex flex-col min-h-0">
|
<div className="flex-1 flex flex-col min-h-0">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -41,6 +41,8 @@ type TimeRange = "7days" | "30days" | "1year"
|
|||||||
|
|
||||||
type BusinessMetricsPayload = {
|
type BusinessMetricsPayload = {
|
||||||
currency: string
|
currency: string
|
||||||
|
range?: TimeRange
|
||||||
|
comparisonLabel?: string
|
||||||
disclaimer: string
|
disclaimer: string
|
||||||
kpis: {
|
kpis: {
|
||||||
key: string
|
key: string
|
||||||
@ -74,6 +76,21 @@ function formatMoney(amount: number, currency: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sinceForRange(range: TimeRange) {
|
||||||
|
const now = new Date()
|
||||||
|
if (range === "1year") {
|
||||||
|
return new Date(now.getFullYear(), now.getMonth() - 11, 1)
|
||||||
|
}
|
||||||
|
const days = range === "30days" ? 30 : 7
|
||||||
|
return new Date(now.getFullYear(), now.getMonth(), now.getDate() - (days - 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
function orderDate(order: { created_at?: string; shopifyCreatedAt?: string; createdAt?: string }) {
|
||||||
|
const raw = order.created_at ?? order.shopifyCreatedAt ?? order.createdAt ?? ""
|
||||||
|
const date = new Date(raw)
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date
|
||||||
|
}
|
||||||
|
|
||||||
function MiniSpark({
|
function MiniSpark({
|
||||||
data,
|
data,
|
||||||
color,
|
color,
|
||||||
@ -133,7 +150,7 @@ export default function DashboardPage() {
|
|||||||
])
|
])
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const bizRes = await fetch("/api/dashboard/business-metrics")
|
const bizRes = await fetch(`/api/dashboard/business-metrics?range=${encodeURIComponent(timeRange)}`)
|
||||||
const j = await bizRes.json().catch(() => null)
|
const j = await bizRes.json().catch(() => null)
|
||||||
if (bizRes.ok && j && typeof j === "object" && !("message" in j && typeof j.message === "string")) {
|
if (bizRes.ok && j && typeof j === "object" && !("message" in j && typeof j.message === "string")) {
|
||||||
setBusiness(j as BusinessMetricsPayload)
|
setBusiness(j as BusinessMetricsPayload)
|
||||||
@ -148,8 +165,13 @@ export default function DashboardPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const orders = ordersData.orders || []
|
const orders = ordersData.orders || []
|
||||||
|
const since = sinceForRange(timeRange)
|
||||||
|
const filteredOrders = orders.filter((order: { created_at?: string; shopifyCreatedAt?: string; createdAt?: string }) => {
|
||||||
|
const d = orderDate(order)
|
||||||
|
return d ? d >= since : false
|
||||||
|
})
|
||||||
const totalSales =
|
const totalSales =
|
||||||
orders.reduce((sum: number, order: { total_price?: string }) => sum + parseFloat(order.total_price ?? "0"), 0) || 0
|
filteredOrders.reduce((sum: number, order: { total_price?: string }) => sum + parseFloat(order.total_price ?? "0"), 0) || 0
|
||||||
|
|
||||||
const generateDateRange = (range: TimeRange) => {
|
const generateDateRange = (range: TimeRange) => {
|
||||||
const dates = []
|
const dates = []
|
||||||
@ -173,13 +195,14 @@ export default function DashboardPage() {
|
|||||||
const rangeLabels = generateDateRange(timeRange)
|
const rangeLabels = generateDateRange(timeRange)
|
||||||
const salesByDate: Record<string, number> = {}
|
const salesByDate: Record<string, number> = {}
|
||||||
|
|
||||||
orders.forEach((order: { created_at?: string; total_price?: string }) => {
|
filteredOrders.forEach((order: { created_at?: string; shopifyCreatedAt?: string; createdAt?: string; total_price?: string }) => {
|
||||||
const orderDate = new Date(order.created_at ?? "")
|
const createdAt = orderDate(order)
|
||||||
|
if (!createdAt) return
|
||||||
let label = ""
|
let label = ""
|
||||||
if (timeRange === "1year") {
|
if (timeRange === "1year") {
|
||||||
label = orderDate.toLocaleDateString("en-US", { month: "short", year: "2-digit" })
|
label = createdAt.toLocaleDateString("en-US", { month: "short", year: "2-digit" })
|
||||||
} else {
|
} else {
|
||||||
label = orderDate.toLocaleDateString("en-US", { month: "short", day: "numeric" })
|
label = createdAt.toLocaleDateString("en-US", { month: "short", day: "numeric" })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rangeLabels.includes(label)) {
|
if (rangeLabels.includes(label)) {
|
||||||
@ -196,7 +219,7 @@ export default function DashboardPage() {
|
|||||||
setSnapshot({
|
setSnapshot({
|
||||||
totalSales,
|
totalSales,
|
||||||
products: productsData.products?.length ?? 0,
|
products: productsData.products?.length ?? 0,
|
||||||
orders: orders.length,
|
orders: filteredOrders.length,
|
||||||
customers: customersData.customers?.length ?? 0,
|
customers: customersData.customers?.length ?? 0,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -224,8 +247,8 @@ export default function DashboardPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 md:space-y-8">
|
<div className="w-full max-w-none space-y-5 md:space-y-7">
|
||||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
|
<div className="flex flex-col xl:flex-row xl:items-center xl:justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className={cn("text-2xl md:text-3xl font-extrabold tracking-tight", isLight ? "text-slate-900" : "text-white")}>
|
<h1 className={cn("text-2xl md:text-3xl font-extrabold tracking-tight", isLight ? "text-slate-900" : "text-white")}>
|
||||||
Overview
|
Overview
|
||||||
@ -272,13 +295,13 @@ export default function DashboardPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex w-full flex-col gap-3 sm:w-auto sm:flex-row sm:items-center">
|
||||||
<div className="relative group">
|
<div className="relative group w-full sm:w-auto">
|
||||||
<select
|
<select
|
||||||
value={timeRange}
|
value={timeRange}
|
||||||
onChange={(e) => setTimeRange(e.target.value as TimeRange)}
|
onChange={(e) => setTimeRange(e.target.value as TimeRange)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"appearance-none h-11 pl-10 pr-10 rounded-xl font-bold border transition-all text-sm cursor-pointer focus:outline-none focus:ring-2 focus:ring-indigo-500/20",
|
"w-full appearance-none h-11 pl-10 pr-10 rounded-xl font-bold border transition-all text-sm cursor-pointer focus:outline-none focus:ring-2 focus:ring-indigo-500/20 sm:w-auto",
|
||||||
isLight
|
isLight
|
||||||
? "bg-white border-slate-200 text-slate-700 hover:border-slate-300 shadow-sm"
|
? "bg-white border-slate-200 text-slate-700 hover:border-slate-300 shadow-sm"
|
||||||
: "bg-white/10 border-white/10 text-white hover:bg-white/15 backdrop-blur-sm",
|
: "bg-white/10 border-white/10 text-white hover:bg-white/15 backdrop-blur-sm",
|
||||||
@ -301,7 +324,7 @@ export default function DashboardPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-11 px-4 md:px-6 rounded-xl font-bold flex items-center justify-center gap-2 border transition-all text-sm md:text-base shrink-0",
|
"h-11 w-full px-4 md:px-6 rounded-xl font-bold flex items-center justify-center gap-2 border transition-all text-sm md:text-base shrink-0 sm:w-auto",
|
||||||
isLight
|
isLight
|
||||||
? "bg-indigo-600 text-white border-indigo-700 hover:bg-indigo-700 shadow-sm"
|
? "bg-indigo-600 text-white border-indigo-700 hover:bg-indigo-700 shadow-sm"
|
||||||
: "bg-white/10 text-white border-white/10 hover:bg-white/15 backdrop-blur-sm",
|
: "bg-white/10 text-white border-white/10 hover:bg-white/15 backdrop-blur-sm",
|
||||||
@ -313,7 +336,7 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Financial KPI row — Image 2 style */}
|
{/* Financial KPI row — Image 2 style */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4 md:gap-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 2xl:grid-cols-4 gap-3 md:gap-4">
|
||||||
{(business?.kpis ?? []).map((kpi) => (
|
{(business?.kpis ?? []).map((kpi) => (
|
||||||
<div
|
<div
|
||||||
key={kpi.key}
|
key={kpi.key}
|
||||||
@ -343,7 +366,7 @@ export default function DashboardPage() {
|
|||||||
>
|
>
|
||||||
<TrendingUp className={cn("w-3.5 h-3.5", kpi.changePct < 0 && "rotate-180")} />
|
<TrendingUp className={cn("w-3.5 h-3.5", kpi.changePct < 0 && "rotate-180")} />
|
||||||
{kpi.changePct >= 0 ? "+" : ""}
|
{kpi.changePct >= 0 ? "+" : ""}
|
||||||
{kpi.changePct}% vs prior month
|
{kpi.changePct}% vs {business?.comparisonLabel ?? "prior period"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -363,10 +386,10 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
{/* Profit bars + fee donut */}
|
{/* Profit bars + fee donut */}
|
||||||
{business && (
|
{business && (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 md:gap-6">
|
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4 md:gap-5">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-4 md:p-6 rounded-xl md:rounded-2xl border min-h-[300px]",
|
"p-4 md:p-5 rounded-xl md:rounded-2xl border min-h-[300px]",
|
||||||
isLight ? "bg-white border-slate-100 shadow-sm" : "bg-white/5 border-white/10 backdrop-blur-xl",
|
isLight ? "bg-white border-slate-100 shadow-sm" : "bg-white/5 border-white/10 backdrop-blur-xl",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@ -374,7 +397,8 @@ export default function DashboardPage() {
|
|||||||
<p className={cn("text-xs mt-1 mb-4", isLight ? "text-slate-500" : "text-slate-400")}>
|
<p className={cn("text-xs mt-1 mb-4", isLight ? "text-slate-500" : "text-slate-400")}>
|
||||||
Based on gross revenue × assumed net margin ({business.disclaimer.slice(0, 80)}…)
|
Based on gross revenue × assumed net margin ({business.disclaimer.slice(0, 80)}…)
|
||||||
</p>
|
</p>
|
||||||
<div className="h-[220px] w-full min-w-0">
|
<div className="w-full overflow-x-auto">
|
||||||
|
<div className="h-[220px] min-w-[560px] md:min-w-0">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={business.monthlyProfitBars} margin={{ top: 8, right: 8, left: -18, bottom: 0 }}>
|
<BarChart data={business.monthlyProfitBars} margin={{ top: 8, right: 8, left: -18, bottom: 0 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={gridStroke} />
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={gridStroke} />
|
||||||
@ -392,12 +416,13 @@ export default function DashboardPage() {
|
|||||||
<Bar dataKey="profit" fill="#22c55e" radius={[6, 6, 0, 0]} />
|
<Bar dataKey="profit" fill="#22c55e" radius={[6, 6, 0, 0]} />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-4 md:p-6 rounded-xl md:rounded-2xl border min-h-[300px]",
|
"p-4 md:p-5 rounded-xl md:rounded-2xl border min-h-[300px]",
|
||||||
isLight ? "bg-white border-slate-100 shadow-sm" : "bg-white/5 border-white/10 backdrop-blur-xl",
|
isLight ? "bg-white border-slate-100 shadow-sm" : "bg-white/5 border-white/10 backdrop-blur-xl",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@ -437,7 +462,7 @@ export default function DashboardPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Snapshot icons row — compact */}
|
{/* Snapshot icons row — compact */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-3 md:gap-4">
|
||||||
{[
|
{[
|
||||||
{ label: "Live revenue (period)", value: formatMoney(snapshot.totalSales, currency), icon: TrendingUp },
|
{ label: "Live revenue (period)", value: formatMoney(snapshot.totalSales, currency), icon: TrendingUp },
|
||||||
{ label: "Catalog size", value: String(snapshot.products), icon: Package },
|
{ label: "Catalog size", value: String(snapshot.products), icon: Package },
|
||||||
@ -473,7 +498,7 @@ export default function DashboardPage() {
|
|||||||
<div className={cn("px-4 md:px-6 py-4 border-b flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2", isLight ? "border-slate-100" : "border-white/10")}>
|
<div className={cn("px-4 md:px-6 py-4 border-b flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2", isLight ? "border-slate-100" : "border-white/10")}>
|
||||||
<h3 className={cn("text-lg font-bold", isLight ? "text-slate-900" : "text-white")}>Product-level margins (est.)</h3>
|
<h3 className={cn("text-lg font-bold", isLight ? "text-slate-900" : "text-white")}>Product-level margins (est.)</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="table-horizontal-scroll">
|
<div className="table-horizontal-scroll overflow-x-auto">
|
||||||
<table className="w-full text-sm min-w-[520px]">
|
<table className="w-full text-sm min-w-[520px]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className={cn("text-left text-[10px] uppercase font-bold tracking-wider", isLight ? "bg-slate-50 text-slate-500" : "bg-white/[0.06] text-slate-400")}>
|
<tr className={cn("text-left text-[10px] uppercase font-bold tracking-wider", isLight ? "bg-slate-50 text-slate-500" : "bg-white/[0.06] text-slate-400")}>
|
||||||
@ -523,25 +548,25 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-4 md:p-8 rounded-xl md:rounded-2xl border transition-all",
|
"p-4 md:p-5 rounded-xl md:rounded-2xl border transition-all",
|
||||||
isLight ? "bg-white border-slate-100 shadow-sm" : "bg-white/5 border-white/10 backdrop-blur-xl",
|
isLight ? "bg-white border-slate-100 shadow-sm" : "bg-white/5 border-white/10 backdrop-blur-xl",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-6 md:mb-8">
|
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-5 md:mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h3 className={cn("text-lg md:text-xl font-bold font-display", isLight ? "text-slate-900" : "text-white")}>
|
<h3 className={cn("text-lg md:text-xl font-bold font-display", isLight ? "text-slate-900" : "text-white")}>
|
||||||
Recent Sales Trend
|
Recent Sales Trend
|
||||||
</h3>
|
</h3>
|
||||||
<p className={cn("text-xs mt-1", isLight ? "text-slate-500" : "text-slate-400")}>Revenue analysis over time</p>
|
<p className={cn("text-xs mt-1", isLight ? "text-slate-500" : "text-slate-400")}>Revenue analysis over time</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 bg-slate-100 dark:bg-white/5 p-1 rounded-lg w-fit">
|
<div className="flex w-full items-center gap-2 overflow-x-auto rounded-lg bg-slate-100 p-1 dark:bg-white/5 md:w-fit md:overflow-visible">
|
||||||
{(["7days", "30days", "1year"] as const).map((r) => (
|
{(["7days", "30days", "1year"] as const).map((r) => (
|
||||||
<button
|
<button
|
||||||
key={r}
|
key={r}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setTimeRange(r)}
|
onClick={() => setTimeRange(r)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-3 py-1.5 rounded-md text-[10px] font-bold uppercase tracking-wider transition-all",
|
"shrink-0 px-3 py-1.5 rounded-md text-[10px] font-bold uppercase tracking-wider transition-all",
|
||||||
timeRange === r
|
timeRange === r
|
||||||
? "bg-white dark:bg-indigo-600 text-indigo-600 dark:text-white shadow-sm"
|
? "bg-white dark:bg-indigo-600 text-indigo-600 dark:text-white shadow-sm"
|
||||||
: "text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-white",
|
: "text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-white",
|
||||||
@ -553,7 +578,8 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="h-[250px] md:h-[350px] w-full min-w-0">
|
<div className="w-full overflow-x-auto">
|
||||||
|
<div className="h-[250px] min-w-[620px] md:h-[350px] md:min-w-0">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="w-full h-full flex items-center justify-center rounded-2xl bg-slate-50 dark:bg-white/5 border border-slate-100 dark:border-white/5">
|
<div className="w-full h-full flex items-center justify-center rounded-2xl bg-slate-50 dark:bg-white/5 border border-slate-100 dark:border-white/5">
|
||||||
<Loader2 className="w-8 h-8 animate-spin text-indigo-400" />
|
<Loader2 className="w-8 h-8 animate-spin text-indigo-400" />
|
||||||
@ -614,6 +640,7 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -15,7 +15,14 @@ export function classifyStorefrontCustomerQuery(content: string): StorefrontInte
|
|||||||
if (/\b(return|refund|exchange|wrong item|damaged|faulty)\b/.test(t)) {
|
if (/\b(return|refund|exchange|wrong item|damaged|faulty)\b/.test(t)) {
|
||||||
return "Returns"
|
return "Returns"
|
||||||
}
|
}
|
||||||
if (/\b(product|sku|size|colour|color|material|fit|in stock|availability|price)\b/.test(t)) {
|
if (
|
||||||
|
/\b(product|products|sku|size|sizes|colour|color|material|fit|fits|in stock|stock|available|availability|price|cost|buy|purchase|cart|variant|variants)\b/.test(
|
||||||
|
t,
|
||||||
|
) ||
|
||||||
|
/\b(shoe|shoes|sandal|sandals|boot|boots|sneaker|sneakers|slipper|slippers|sock|socks|shirt|shirts|dress|dresses|earbud|earbuds|case|cases)\b/.test(
|
||||||
|
t,
|
||||||
|
)
|
||||||
|
) {
|
||||||
return "Product Qs"
|
return "Product Qs"
|
||||||
}
|
}
|
||||||
if (/\b(shipping|delivery|courier|dispatch|address change|postcode|zip)\b/.test(t)) {
|
if (/\b(shipping|delivery|courier|dispatch|address change|postcode|zip)\b/.test(t)) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user