3 Commits

Author SHA1 Message Date
matt cde8148196 Add customer service dashboard component 2026-07-18 12:09:56 -04:00
matt e9894c893b Full dashboard restyle and redesign 2026-07-18 09:49:10 -04:00
matt 9bdde05d9d Toast refactor, hts tweaks 2026-07-17 15:19:57 -04:00
58 changed files with 4385 additions and 3823 deletions
@@ -0,0 +1,30 @@
// FreeScout customer-service report router. Read-only — authenticated-only is
// sufficient (same stance as Google/Typeform); no requirePermission() gate.
import express from 'express';
import { FreescoutService } from '../../services/freescout/freescout.service.js';
const ALLOWED_DAYS = new Set([7, 14, 30, 90, 180, 365]);
export function createFreescoutRouter({ redis, freescoutPool, phonePool }) {
const router = express.Router();
const freescout = new FreescoutService(redis, freescoutPool, phonePool);
router.get('/report', async (req, res) => {
const days = Number(req.query.days) || 30;
if (!ALLOWED_DAYS.has(days)) {
return res.status(400).json({
error: 'Invalid days parameter',
details: `days must be one of ${[...ALLOWED_DAYS].join(', ')}`,
});
}
try {
res.json(await freescout.getReport(days));
} catch (error) {
console.error('FreeScout report error:', { days, error: error.message });
res.status(500).json({ error: 'Failed to build FreeScout report', details: error.message });
}
});
return router;
}
@@ -0,0 +1,37 @@
// Smoke test for the FreeScout CS report service, bypassing HTTP auth.
// Run on the server: cd /var/www/inventory/dashboard && node scripts/test-freescout-report.mjs [days]
import { config as loadEnv } from 'dotenv';
import { createPool } from '../../shared/db/pg.js';
import { FreescoutService } from '../services/freescout/freescout.service.js';
loadEnv({ path: new URL('../.env', import.meta.url).pathname });
const days = Number(process.argv[2]) || 30;
const freescoutPool = createPool('FREESCOUT_DB', {
user: process.env.FREESCOUT_DB_USERNAME,
database: process.env.FREESCOUT_DB_DATABASE,
max: 2,
});
const phonePool = process.env.ACOT_PHONE_DB_HOST
? createPool('ACOT_PHONE_DB', {
user: process.env.ACOT_PHONE_DB_USERNAME,
database: process.env.ACOT_PHONE_DB_DATABASE,
max: 2,
})
: null;
// Stub redis in "end" status so the service skips caching entirely.
const redisStub = { status: 'end' };
const service = new FreescoutService(redisStub, freescoutPool, phonePool);
const t0 = Date.now();
try {
const report = await service.getReport(days);
console.log(JSON.stringify(report, null, 2));
console.error(`\nOK — ${days}d report in ${Date.now() - t0}ms`);
} finally {
await freescoutPool.end();
await phonePool?.end();
}
+28
View File
@@ -7,6 +7,7 @@
// /api/meta/* → routes/meta (was meta-server :3005)
// /api/dashboard-analytics/* → routes/google (was google-server :3007 via Caddy /api/analytics rewrite)
// /api/typeform/* → routes/typeform (was typeform-server :3008)
// /api/freescout/* → routes/freescout (FreeScout + acot_phone CS report, new)
//
// Shared infrastructure (Phase 2 + Phase 6):
// - shared/auth/middleware.js authenticate() guards /api/* (Phase 6.1/6.2 — second line of defense)
@@ -39,6 +40,7 @@ import { createKlaviyoRouter } from './routes/klaviyo/index.js';
import { createMetaRouter } from './routes/meta/index.js';
import { createGoogleRouter } from './routes/google/index.js';
import { createTypeformRouter } from './routes/typeform/index.js';
import { createFreescoutRouter } from './routes/freescout/index.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -76,6 +78,25 @@ const pool = createPool('DB');
// if Redis is temporarily unavailable, and aligns with shared/db/redis.js defaults.
const redis = createRedis();
// Read-only pools for the FreeScout CS section. The .env keys use USERNAME /
// DATABASE where createPool() reads USER / NAME, hence the overrides. Both are
// optional: without FREESCOUT_DB_* the route isn't mounted; without
// ACOT_PHONE_DB_* the report's phone block is null.
const freescoutPool = process.env.FREESCOUT_DB_HOST
? createPool('FREESCOUT_DB', {
user: process.env.FREESCOUT_DB_USERNAME,
database: process.env.FREESCOUT_DB_DATABASE,
max: 5,
})
: null;
const phonePool = process.env.ACOT_PHONE_DB_HOST
? createPool('ACOT_PHONE_DB', {
user: process.env.ACOT_PHONE_DB_USERNAME,
database: process.env.ACOT_PHONE_DB_DATABASE,
max: 3,
})
: null;
app.use(requestLog());
app.use(cors(corsOptions));
app.use(express.json({ limit: '10mb' }));
@@ -91,6 +112,11 @@ app.use('/api/meta', createMetaRouter());
// Caddy can drop the rewrite — see Caddyfile.proposed.
app.use('/api/dashboard-analytics', createGoogleRouter({ redis }));
app.use('/api/typeform', createTypeformRouter({ redis }));
if (freescoutPool) {
app.use('/api/freescout', createFreescoutRouter({ redis, freescoutPool, phonePool }));
} else {
logger.warn('FREESCOUT_DB_* not set — /api/freescout not mounted');
}
app.get('/health', (req, res) => {
res.json({
@@ -118,6 +144,8 @@ const shutdown = async (signal) => {
server.close();
try { await redis.quit(); } catch { /* ignore */ }
try { await pool.end(); } catch { /* ignore */ }
try { await freescoutPool?.end(); } catch { /* ignore */ }
try { await phonePool?.end(); } catch { /* ignore */ }
process.exit(0);
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
@@ -0,0 +1,409 @@
// FreeScout customer-service report — direct read-only queries against the
// freescout and acot_phone Postgres databases (same instance as inventory_db).
// FreeScout's Reports module has no JSON API (its /reports/ajax needs an agent
// session cookie and returns rendered Blade HTML), so we reproduce its formulas
// from Modules/Reports/Http/Controllers/ReportsController.php instead.
//
// Timezone: freescout.* timestamp columns are NAIVE literals in America/New_York
// (Laravel APP_TZ). Every comparison re-anchors them with AT TIME ZONE
// 'America/New_York'; day bucketing then converts to America/Chicago business
// days per shared/business-time. acot_phone columns are proper timestamptz.
//
// First-response / resolution times are NOT recomputed from threads — FreeScout's
// `freescout:reports-collect-data` cron precomputes them into conversations.meta
// under the "rpt" key (frt / rst / rnt / rtr / rfr, seconds). We read that JSON.
import { DateTime } from 'luxon';
import { BUSINESS_TZ } from '../../../shared/business-time/index.js';
// Enum values from freescout app/Conversation.php and app/Thread.php, and
// Modules/SatRatings (rating: 1=great 2=okay 3=bad).
const CONV_STATE_PUBLISHED = 2;
const CONV_STATUS_CLOSED = 3;
const CONV_STATUS_SPAM = 4;
const CONV_TYPES = { 1: 'email', 2: 'phone', 3: 'chat' };
const THREAD_TYPE_CUSTOMER = 1;
const THREAD_TYPE_MESSAGE = 2;
// FreeScout's report-table time buckets (Reports module getTimeTablePattern),
// used for the first-response-time histogram. Thresholds in seconds; SQL
// width_bucket() returns the index into this list.
const FRT_BUCKET_LABELS = ['<15m', '15-30m', '30-60m', '1-2h', '2-3h', '3-6h', '6-12h', '12-24h', '1-2d', '>2d'];
const FRT_BUCKET_THRESHOLDS = [900, 1800, 3600, 7200, 10800, 21600, 43200, 86400, 172800];
// Re-anchor a naive America/New_York timestamp column as timestamptz.
const ny = (col) => `(${col} AT TIME ZONE 'America/New_York')`;
// Bucket it to an America/Chicago business date.
const nyBizDate = (col) => `(${ny(col)} AT TIME ZONE '${BUSINESS_TZ}')::date`;
const num = (v) => (v === null || v === undefined ? null : Number(v));
export class FreescoutService {
constructor(redis, freescoutPool, phonePool = null) {
if (!redis) throw new Error('FreescoutService requires an ioredis client');
if (!freescoutPool) throw new Error('FreescoutService requires the freescout pg Pool');
this.redis = redis;
this.fs = freescoutPool;
this.phone = phonePool; // optional — phone card hidden when absent
}
get _redisReady() {
return this.redis.status === 'ready' || this.redis.status === 'connect';
}
async _cacheGet(key) {
if (!this._redisReady) return null;
try {
const raw = await this.redis.get(key);
return raw ? JSON.parse(raw) : null;
} catch (err) {
console.warn('[Freescout] cache get failed:', err.message);
return null;
}
}
async _cacheSet(key, value, ttlSec) {
if (!this._redisReady) return;
try {
await this.redis.setex(key, ttlSec, JSON.stringify(value));
} catch (err) {
console.warn('[Freescout] cache set failed:', err.message);
}
}
async getReport(days) {
const cacheKey = `freescout:report:v2:${days}`;
const cached = await this._cacheGet(cacheKey);
if (cached) return cached;
const todayStart = DateTime.now().setZone(BUSINESS_TZ).startOf('day');
const end = todayStart.plus({ days: 1 }); // exclusive — current period includes today-so-far
const start = todayStart.minus({ days: days - 1 });
const prevEnd = start;
const prevStart = start.minus({ days });
const cur = [start.toISO(), end.toISO()];
const prev = [prevStart.toISO(), prevEnd.toISO()];
const [
overview, overviewPrev,
times, timesPrev,
satisfaction, satisfactionPrev,
timeseries, channels, agents,
frtDistribution,
phone, phonePrev,
] = await Promise.all([
this._overview(cur), this._overview(prev),
this._responseTimes(cur), this._responseTimes(prev),
this._satisfaction(cur), this._satisfaction(prev),
this._timeseries(cur, start, todayStart),
this._channels(cur),
this._agents(cur),
this._frtDistribution(cur),
this._phoneStats(cur), this._phoneStats(prev),
]);
const report = {
range: {
days,
start: start.toISODate(),
end: todayStart.toISODate(),
prevStart: prevStart.toISODate(),
prevEnd: prevEnd.minus({ days: 1 }).toISODate(),
},
overview: { current: overview, previous: overviewPrev },
responseTimes: { current: times, previous: timesPrev },
satisfaction: { current: satisfaction, previous: satisfactionPrev },
timeseries,
channels,
agents,
frtDistribution,
phone: phone ? { current: phone, previous: phonePrev } : null,
};
await this._cacheSet(cacheKey, report, 300);
return report;
}
// Headline counts. Formulas mirror ReportsController: newConversations =
// countNewConv, messagesReceived = countMessages (customer threads),
// repliesSent = countRepliesSent, customersHelped = countCustomersHelped.
// "resolved" uses conversations.closed_at (simpler than the module's
// closing-thread reconstruction, same intent: closes that happened in range).
async _overview([from, to]) {
const { rows } = await this.fs.query(
`SELECT
(SELECT count(*) FROM conversations
WHERE state = ${CONV_STATE_PUBLISHED} AND status <> ${CONV_STATUS_SPAM}
AND ${ny('created_at')} >= $1 AND ${ny('created_at')} < $2) AS new_conversations,
(SELECT count(*) FROM conversations
WHERE state = ${CONV_STATE_PUBLISHED} AND status = ${CONV_STATUS_CLOSED}
AND closed_at IS NOT NULL
AND ${ny('closed_at')} >= $1 AND ${ny('closed_at')} < $2) AS resolved,
(SELECT count(*) FROM threads
WHERE type = ${THREAD_TYPE_CUSTOMER}
AND ${ny('created_at')} >= $1 AND ${ny('created_at')} < $2) AS messages_received,
(SELECT count(*) FROM threads
WHERE type = ${THREAD_TYPE_MESSAGE} AND state = ${CONV_STATE_PUBLISHED}
AND created_by_user_id IS NOT NULL
AND ${ny('created_at')} >= $1 AND ${ny('created_at')} < $2) AS replies_sent,
(SELECT count(DISTINCT c.customer_id)
FROM threads t JOIN conversations c ON c.id = t.conversation_id
WHERE t.type = ${THREAD_TYPE_MESSAGE} AND t.state = ${CONV_STATE_PUBLISHED}
AND t.created_by_user_id IS NOT NULL
AND ${ny('t.created_at')} >= $1 AND ${ny('t.created_at')} < $2) AS customers_helped`,
[from, to],
);
const r = rows[0];
return {
newConversations: num(r.new_conversations),
resolved: num(r.resolved),
messagesReceived: num(r.messages_received),
repliesSent: num(r.replies_sent),
customersHelped: num(r.customers_helped),
};
}
// Precomputed per-conversation metrics from conversations.meta->rpt (seconds):
// frt = first response time, rnt = resolution time, rfr = resolved on first reply.
async _responseTimes([from, to]) {
const { rows } = await this.fs.query(
`SELECT count(frt) AS frt_count,
round(avg(frt)) AS frt_avg,
round(percentile_cont(0.5) WITHIN GROUP (ORDER BY frt)) AS frt_median,
count(rnt) AS rnt_count,
round(avg(rnt)) AS rnt_avg,
round(percentile_cont(0.5) WITHIN GROUP (ORDER BY rnt)) AS rnt_median,
count(*) FILTER (WHERE rfr) AS rfr_count
FROM (
SELECT nullif(meta::json #>> '{rpt,frt}', '')::numeric AS frt,
nullif(meta::json #>> '{rpt,rnt}', '')::numeric AS rnt,
(meta::json #>> '{rpt,rfr}') IN ('1', 'true') AS rfr
FROM conversations
WHERE state = ${CONV_STATE_PUBLISHED} AND status <> ${CONV_STATUS_SPAM}
AND meta LIKE '{%' AND meta LIKE '%"rpt"%'
AND ${ny('created_at')} >= $1 AND ${ny('created_at')} < $2
) rpt`,
[from, to],
);
const r = rows[0];
return {
firstResponse: { count: num(r.frt_count), avgSec: num(r.frt_avg), medianSec: num(r.frt_median) },
resolution: { count: num(r.rnt_count), avgSec: num(r.rnt_avg), medianSec: num(r.rnt_median) },
resolvedFirstReply: num(r.rfr_count),
};
}
// Histogram of first-response times (current period only), zero-filled over
// FreeScout's bucket boundaries.
async _frtDistribution([from, to]) {
const { rows } = await this.fs.query(
`SELECT width_bucket(frt, ARRAY[${FRT_BUCKET_THRESHOLDS.join(',')}]::numeric[]) AS bucket,
count(*) AS n
FROM (
SELECT nullif(meta::json #>> '{rpt,frt}', '')::numeric AS frt
FROM conversations
WHERE state = ${CONV_STATE_PUBLISHED} AND status <> ${CONV_STATUS_SPAM}
AND meta LIKE '{%' AND meta LIKE '%"rpt"%'
AND ${ny('created_at')} >= $1 AND ${ny('created_at')} < $2
) rpt
WHERE frt IS NOT NULL
GROUP BY 1`,
[from, to],
);
const counts = new Array(FRT_BUCKET_LABELS.length).fill(0);
for (const r of rows) counts[Number(r.bucket)] = Number(r.n);
return FRT_BUCKET_LABELS.map((label, i) => ({ bucket: label, count: counts[i] }));
}
// Ratings live on the rated reply thread (threads.rating, SatRatings module).
// Score formula per calcSatisfactionScore: ceil(great% - bad%).
async _satisfaction([from, to]) {
const { rows } = await this.fs.query(
`SELECT count(*) FILTER (WHERE rating = 1) AS great,
count(*) FILTER (WHERE rating = 2) AS okay,
count(*) FILTER (WHERE rating = 3) AS bad
FROM threads
WHERE rating IS NOT NULL AND rating > 0
AND ${ny('created_at')} >= $1 AND ${ny('created_at')} < $2`,
[from, to],
);
const great = num(rows[0].great);
const okay = num(rows[0].okay);
const bad = num(rows[0].bad);
const total = great + okay + bad;
return {
great,
okay,
bad,
total,
score: total ? Math.ceil((great * 100) / total - (bad * 100) / total) : null,
};
}
// Per-business-day new / resolved / customer messages, zero-filled.
async _timeseries([from, to], start, todayStart) {
const [news, closes, msgs] = await Promise.all([
this.fs.query(
// ::text so pg returns 'YYYY-MM-DD' strings — the driver's DATE→JS Date
// parsing is zone-ambiguous and can shift the day
`SELECT ${nyBizDate('created_at')}::text AS day, count(*) AS n
FROM conversations
WHERE state = ${CONV_STATE_PUBLISHED} AND status <> ${CONV_STATUS_SPAM}
AND ${ny('created_at')} >= $1 AND ${ny('created_at')} < $2
GROUP BY 1`,
[from, to],
),
this.fs.query(
`SELECT ${nyBizDate('closed_at')}::text AS day, count(*) AS n
FROM conversations
WHERE state = ${CONV_STATE_PUBLISHED} AND status = ${CONV_STATUS_CLOSED}
AND closed_at IS NOT NULL
AND ${ny('closed_at')} >= $1 AND ${ny('closed_at')} < $2
GROUP BY 1`,
[from, to],
),
this.fs.query(
`SELECT ${nyBizDate('created_at')}::text AS day, count(*) AS n
FROM threads
WHERE type = ${THREAD_TYPE_CUSTOMER}
AND ${ny('created_at')} >= $1 AND ${ny('created_at')} < $2
GROUP BY 1`,
[from, to],
),
]);
const toMap = (res) => new Map(res.rows.map((row) => [row.day, Number(row.n)]));
const newsM = toMap(news);
const closesM = toMap(closes);
const msgsM = toMap(msgs);
const series = [];
for (let d = start; d <= todayStart; d = d.plus({ days: 1 })) {
const key = d.toISODate();
series.push({
date: key,
newConversations: newsM.get(key) ?? 0,
resolved: closesM.get(key) ?? 0,
messages: msgsM.get(key) ?? 0,
});
}
return series;
}
async _channels([from, to]) {
const { rows } = await this.fs.query(
`SELECT type, count(*) AS n
FROM conversations
WHERE state = ${CONV_STATE_PUBLISHED} AND status <> ${CONV_STATUS_SPAM}
AND ${ny('created_at')} >= $1 AND ${ny('created_at')} < $2
GROUP BY type`,
[from, to],
);
const channels = { email: 0, phone: 0, chat: 0, other: 0 };
for (const r of rows) {
channels[CONV_TYPES[r.type] ?? 'other'] += Number(r.n);
}
return channels;
}
// Per-agent table: replies + customers helped + ratings keyed off the reply
// thread's author; closes keyed off conversations.closed_by_user_id.
async _agents([from, to]) {
const [replies, ratings, closes, users] = await Promise.all([
this.fs.query(
`SELECT t.created_by_user_id AS user_id,
count(*) AS replies,
count(DISTINCT c.customer_id) AS customers_helped
FROM threads t JOIN conversations c ON c.id = t.conversation_id
WHERE t.type = ${THREAD_TYPE_MESSAGE} AND t.state = ${CONV_STATE_PUBLISHED}
AND t.created_by_user_id IS NOT NULL
AND ${ny('t.created_at')} >= $1 AND ${ny('t.created_at')} < $2
GROUP BY 1`,
[from, to],
),
this.fs.query(
`SELECT created_by_user_id AS user_id,
count(*) FILTER (WHERE rating = 1) AS great,
count(*) FILTER (WHERE rating = 2) AS okay,
count(*) FILTER (WHERE rating = 3) AS bad
FROM threads
WHERE rating IS NOT NULL AND rating > 0 AND created_by_user_id IS NOT NULL
AND ${ny('created_at')} >= $1 AND ${ny('created_at')} < $2
GROUP BY 1`,
[from, to],
),
this.fs.query(
`SELECT closed_by_user_id AS user_id, count(*) AS closed
FROM conversations
WHERE state = ${CONV_STATE_PUBLISHED} AND status = ${CONV_STATUS_CLOSED}
AND closed_at IS NOT NULL AND closed_by_user_id IS NOT NULL
AND ${ny('closed_at')} >= $1 AND ${ny('closed_at')} < $2
GROUP BY 1`,
[from, to],
),
// type=1 excludes the Workflows robot user
this.fs.query(`SELECT id, first_name, last_name FROM users WHERE type = 1`),
]);
const agents = new Map();
const entry = (id) => {
if (!agents.has(id)) {
agents.set(id, {
id, name: null, replies: 0, customersHelped: 0, closed: 0,
great: 0, okay: 0, bad: 0, satScore: null,
});
}
return agents.get(id);
};
for (const r of replies.rows) {
const a = entry(r.user_id);
a.replies = Number(r.replies);
a.customersHelped = Number(r.customers_helped);
}
for (const r of closes.rows) entry(r.user_id).closed = Number(r.closed);
for (const r of ratings.rows) {
const a = entry(r.user_id);
a.great = Number(r.great);
a.okay = Number(r.okay);
a.bad = Number(r.bad);
const total = a.great + a.okay + a.bad;
if (total) a.satScore = Math.ceil((a.great * 100) / total - (a.bad * 100) / total);
}
const names = new Map(users.rows.map((u) => [u.id, `${u.first_name} ${u.last_name}`.trim()]));
return [...agents.values()]
.filter((a) => names.has(a.id)) // drop deleted/robot authors
.map((a) => ({ ...a, name: names.get(a.id) }))
.sort((a, b) => b.replies - a.replies);
}
// acot_phone bridge DB (timestamptz — no NY re-anchoring). Inbound calls always
// get answered_at set by the PBX, so "missed" is the voicemail count, not
// answered_at IS NULL.
async _phoneStats([from, to]) {
if (!this.phone) return null;
const [calls, vms] = await Promise.all([
this.phone.query(
`SELECT count(*) AS total,
count(*) FILTER (WHERE direction = 'inbound') AS inbound,
count(*) FILTER (WHERE direction = 'outbound') AS outbound,
round(avg(duration_seconds) FILTER (WHERE answered_at IS NOT NULL)) AS avg_duration_sec
FROM calls
WHERE started_at >= $1 AND started_at < $2`,
[from, to],
),
this.phone.query(
`SELECT count(*) AS voicemails FROM voicemails WHERE created_at >= $1 AND created_at < $2`,
[from, to],
),
]);
const c = calls.rows[0];
return {
total: num(c.total),
inbound: num(c.inbound),
outbound: num(c.outbound),
avgDurationSec: num(c.avg_duration_sec),
voicemails: num(vms.rows[0].voicemails),
};
}
}
-35
View File
@@ -31,7 +31,6 @@
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.6",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.1.6",
@@ -2434,40 +2433,6 @@
}
}
},
"node_modules/@radix-ui/react-toast": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.6.tgz",
"integrity": "sha512-gN4dpuIVKEgpLn1z5FhzT9mYRUitbfZq9XqN/7kkBMUgFTzTG8x/KszWJugJXHcwxckY8xcKDZPz7kG3o6DsUA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.1",
"@radix-ui/react-collection": "1.1.2",
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-dismissable-layer": "1.1.5",
"@radix-ui/react-portal": "1.1.4",
"@radix-ui/react-presence": "1.1.2",
"@radix-ui/react-primitive": "2.0.2",
"@radix-ui/react-use-callback-ref": "1.1.0",
"@radix-ui/react-use-controllable-state": "1.1.0",
"@radix-ui/react-use-layout-effect": "1.1.0",
"@radix-ui/react-visually-hidden": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-toggle": {
"version": "1.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz",
-1
View File
@@ -35,7 +35,6 @@
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.6",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.1.6",
@@ -16,7 +16,6 @@ import {
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from "recharts";
import { TrendingUp } from "lucide-react";
@@ -31,12 +30,15 @@ import {
DashboardChartTooltip,
ChartSkeleton,
DashboardEmptyState,
MetricPill,
LegendChip,
PILL_TRIGGER_CLASS,
} from "@/components/dashboard/shared";
// Note: Using ChartSkeleton from @/components/dashboard/shared
const SkeletonStats = () => (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2">
{[...Array(4)].map((_, i) => (
<DashboardStatCardSkeleton key={i} size="compact" hasIcon={false} hasSubtitle />
))}
@@ -48,19 +50,19 @@ const SkeletonStats = () => (
// Add color constants
const METRIC_COLORS = {
activeUsers: {
color: "#8b5cf6",
color: "#6b5fc7",
className: "text-purple-600 dark:text-purple-400",
},
newUsers: {
color: "#10b981",
color: "#0f8f77",
className: "text-emerald-600 dark:text-emerald-400",
},
pageViews: {
color: "#f59e0b",
color: "#a87a24",
className: "text-amber-600 dark:text-amber-400",
},
conversions: {
color: "#3b82f6",
color: "#e06a4e",
className: "text-blue-600 dark:text-blue-400",
},
};
@@ -163,7 +165,7 @@ export const AnalyticsDashboard = () => {
// Time selector for DashboardSectionHeader
const timeSelector = (
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger className="w-[130px] h-9">
<SelectTrigger className={PILL_TRIGGER_CLASS}>
<SelectValue placeholder="Select range" />
</SelectTrigger>
<SelectContent>
@@ -179,7 +181,7 @@ export const AnalyticsDashboard = () => {
const headerActions = !loading ? (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" className="h-9">
<Button variant="outline" size="sm" className="h-8 rounded-full border-[#e7e5e1] text-xs font-semibold text-[#2b2925] shadow-none hover:bg-[#faf9f7]">
Details
</Button>
</DialogTrigger>
@@ -191,8 +193,9 @@ export const AnalyticsDashboard = () => {
{Object.entries(metrics).map(([key, value]) => (
<Button
key={key}
variant={value ? "default" : "outline"}
variant="ghost"
size="sm"
className={`h-7 rounded-full px-3 text-xs font-medium ${value ? "bg-[#f0eeea] text-[#2b2925] hover:bg-[#eae7e2]" : "text-[#7c7870] hover:bg-[#f5f3f0] hover:text-[#2b2925]"}`}
onClick={() =>
setMetrics((prev) => ({
...prev,
@@ -270,20 +273,21 @@ export const AnalyticsDashboard = () => {
};
return (
<Card className={`w-full ${CARD_STYLES.base}`}>
<Card className={`w-full h-full ${CARD_STYLES.base}`}>
<DashboardSectionHeader
title="Analytics Overview"
size="large"
loading={loading}
timeSelector={timeSelector}
actions={headerActions}
/>
<CardContent className="p-6 pt-0 space-y-4">
<CardContent className="p-4 pt-3 space-y-3">
{/* Stats cards */}
{loading ? (
<SkeletonStats />
) : summaryStats ? (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 dashboard-stagger">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2 dashboard-stagger">
<DashboardStatCard
title="Active Users"
value={summaryStats.totals.activeUsers.toLocaleString()}
@@ -319,66 +323,36 @@ export const AnalyticsDashboard = () => {
</div>
) : null}
{/* Metric toggles */}
<div className="flex items-center flex-col sm:flex-row gap-0 sm:gap-4">
<div className="flex flex-wrap gap-1">
<Button
variant={metrics.activeUsers ? "default" : "outline"}
size="sm"
className="font-medium"
onClick={() =>
setMetrics((prev) => ({
...prev,
activeUsers: !prev.activeUsers,
}))
}
>
<span className="hidden sm:inline">Active Users</span>
<span className="sm:hidden">Active</span>
</Button>
<Button
variant={metrics.newUsers ? "default" : "outline"}
size="sm"
className="font-medium"
onClick={() =>
setMetrics((prev) => ({
...prev,
newUsers: !prev.newUsers,
}))
}
>
<span className="hidden sm:inline">New Users</span>
<span className="sm:hidden">New</span>
</Button>
<Button
variant={metrics.pageViews ? "default" : "outline"}
size="sm"
className="font-medium"
onClick={() =>
setMetrics((prev) => ({
...prev,
pageViews: !prev.pageViews,
}))
}
>
<span className="hidden sm:inline">Page Views</span>
<span className="sm:hidden">Views</span>
</Button>
<Button
variant={metrics.conversions ? "default" : "outline"}
size="sm"
className="font-medium"
onClick={() =>
setMetrics((prev) => ({
...prev,
conversions: !prev.conversions,
}))
}
>
<span className="hidden sm:inline">Conversions</span>
<span className="sm:hidden">Conv.</span>
</Button>
</div>
{/* Metric toggle pills — the pill dot doubles as the series legend */}
<div className="flex flex-wrap items-center gap-0.5">
<MetricPill
active={metrics.activeUsers}
color={METRIC_COLORS.activeUsers.color}
onClick={() => setMetrics((prev) => ({ ...prev, activeUsers: !prev.activeUsers }))}
>
Active users
</MetricPill>
<MetricPill
active={metrics.newUsers}
color={METRIC_COLORS.newUsers.color}
onClick={() => setMetrics((prev) => ({ ...prev, newUsers: !prev.newUsers }))}
>
New users
</MetricPill>
<MetricPill
active={metrics.pageViews}
color={METRIC_COLORS.pageViews.color}
onClick={() => setMetrics((prev) => ({ ...prev, pageViews: !prev.pageViews }))}
>
Page views
</MetricPill>
<MetricPill
active={metrics.conversions}
color={METRIC_COLORS.conversions.color}
onClick={() => setMetrics((prev) => ({ ...prev, conversions: !prev.conversions }))}
>
Conversions
</MetricPill>
</div>
{loading ? (
<ChartSkeleton height="default" withCard={false} />
@@ -389,11 +363,11 @@ export const AnalyticsDashboard = () => {
description="Try selecting a different time range"
/>
) : (
<div className={`h-[400px] mt-4 ${CARD_STYLES.base} rounded-lg p-0 relative`}>
<div className="h-[340px] p-0 relative">
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={data}
margin={{ top: 5, right: -30, left: -5, bottom: 5 }}
margin={{ top: 8, right: 0, left: -8, bottom: 0 }}
>
<CartesianGrid
strokeDasharray="3 3"
@@ -424,7 +398,6 @@ export const AnalyticsDashboard = () => {
/>
}
/>
<Legend />
{metrics.activeUsers && (
<Line
yAxisId="left"
@@ -0,0 +1,628 @@
import { useEffect, useMemo, useState } from "react";
import apiClient from "@/utils/apiClient";
import { Card, CardContent } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Bar,
BarChart,
CartesianGrid,
ComposedChart,
LabelList,
Line,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import type { TooltipProps } from "recharts";
import { MessagesSquare, TrendingUp } from "lucide-react";
import { CARD_STYLES, STUDIO_COLORS } from "@/lib/dashboard/designTokens";
import {
DashboardSectionHeader,
DashboardStatCard,
DashboardStatCardSkeleton,
ChartSkeleton,
DashboardEmptyState,
DashboardErrorState,
TOOLTIP_STYLES,
MetricPill,
QuietStat,
QUIET_STRIP_CLASS,
} from "@/components/dashboard/shared";
import { BusinessRangeSelect } from "@/components/dashboard/shared/BusinessRangeSelect";
type PeriodPair<T> = { current: T; previous: T };
type OverviewStats = {
newConversations: number;
resolved: number;
messagesReceived: number;
repliesSent: number;
customersHelped: number;
};
type ResponseTimeStats = {
firstResponse: { count: number; avgSec: number | null; medianSec: number | null };
resolution: { count: number; avgSec: number | null; medianSec: number | null };
resolvedFirstReply: number;
};
type SatisfactionStats = {
great: number;
okay: number;
bad: number;
total: number;
score: number | null;
};
type TimeseriesPoint = {
date: string;
newConversations: number;
resolved: number;
messages: number;
};
type AgentEntry = {
id: number;
name: string;
replies: number;
customersHelped: number;
closed: number;
great: number;
okay: number;
bad: number;
satScore: number | null;
};
type PhoneStats = {
total: number;
inbound: number;
outbound: number;
avgDurationSec: number | null;
voicemails: number;
};
type FrtBucket = { bucket: string; count: number };
type FreescoutReport = {
range: { days: number; start: string; end: string; prevStart: string; prevEnd: string };
overview: PeriodPair<OverviewStats>;
responseTimes: PeriodPair<ResponseTimeStats>;
satisfaction: PeriodPair<SatisfactionStats>;
timeseries: TimeseriesPoint[];
channels: { email: number; phone: number; chat: number; other: number };
agents: AgentEntry[];
frtDistribution: FrtBucket[];
phone: PeriodPair<PhoneStats> | null;
};
type ChartSeriesKey = "newConversations" | "resolved" | "messages";
// Sorbet Studio data hues, deliberately teal-led: Operations leads with a violet
// area, so this section leads with teal bars to keep the two visually distinct.
// Triple passes the CVD/contrast validator (ΔE ≥ 27).
const chartColors: Record<ChartSeriesKey, string> = {
newConversations: STUDIO_COLORS.teal,
resolved: STUDIO_COLORS.violet,
messages: STUDIO_COLORS.amber,
};
const SERIES_LABELS: Record<ChartSeriesKey, string> = {
newConversations: "New",
resolved: "Resolved",
messages: "Messages",
};
const RANGE_OPTIONS = [
{ value: "7", label: "Last 7 Days" },
{ value: "30", label: "Last 30 Days" },
{ value: "90", label: "Last 90 Days" },
{ value: "180", label: "Last 180 Days" },
{ value: "365", label: "Last Year" },
];
const REFRESH_MS = 10 * 60 * 1000;
const formatNumber = (value: number) =>
Number.isFinite(value) ? value.toLocaleString("en-US") : "0";
// Compact duration: 45s, 12m, 3.5h, 2.1d
const formatSeconds = (seconds: number | null | undefined): string => {
if (seconds == null || !Number.isFinite(seconds)) return "—";
if (seconds < 60) return `${Math.round(seconds)}s`;
if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
if (seconds < 86400) return `${(seconds / 3600).toFixed(1)}h`;
return `${(seconds / 86400).toFixed(1)}d`;
};
const trendPct = (current: number, previous: number): number | null =>
previous > 0 ? ((current - previous) / previous) * 100 : null;
const formatDayLabel = (date: string) =>
new Date(`${date}T00:00:00`).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
const CustomerServiceDashboard = () => {
const [days, setDays] = useState("30");
const [data, setData] = useState<FreescoutReport | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [metrics, setMetrics] = useState<Record<ChartSeriesKey, boolean>>({
newConversations: true,
resolved: true,
messages: false,
});
useEffect(() => {
let cancelled = false;
const fetchReport = async (isRefresh = false) => {
if (!isRefresh) {
setLoading(true);
setError(null);
}
try {
const response = await apiClient.get<FreescoutReport>("/api/freescout/report", {
params: { days },
});
if (!cancelled) setData(response.data);
} catch (err: unknown) {
if (!cancelled && !isRefresh) {
const maybe = err as { response?: { data?: { error?: string } }; message?: string };
setError(maybe.response?.data?.error ?? maybe.message ?? "Request failed");
}
} finally {
if (!cancelled && !isRefresh) setLoading(false);
}
};
void fetchReport();
const interval = setInterval(() => void fetchReport(true), REFRESH_MS);
return () => {
cancelled = true;
clearInterval(interval);
};
}, [days]);
const toggleMetric = (key: ChartSeriesKey) =>
setMetrics((prev) => ({ ...prev, [key]: !prev[key] }));
const hasActiveMetrics = Object.values(metrics).some(Boolean);
const chartData = useMemo(
() =>
(data?.timeseries ?? []).map((point) => ({
...point,
label: formatDayLabel(point.date),
})),
[data]
);
const cards = useMemo(() => {
if (!data) return [];
const { overview, responseTimes, satisfaction } = data;
const cur = overview.current;
const prev = overview.previous;
const rt = responseTimes.current;
const sat = satisfaction.current;
const satPrev = satisfaction.previous;
const rfrPct = rt.resolution.count > 0
? Math.round((rt.resolvedFirstReply * 100) / rt.resolution.count)
: null;
return [
{
key: "new",
title: "New Conversations",
value: formatNumber(cur.newConversations),
subtitle: `${formatNumber(cur.messagesReceived)} messages received`,
trend: trendPct(cur.newConversations, prev.newConversations),
moreIsBetter: true,
suffix: "%",
},
{
key: "resolved",
title: "Resolved",
value: formatNumber(cur.resolved),
subtitle: rfrPct != null ? `${rfrPct}% on first reply` : undefined,
trend: trendPct(cur.resolved, prev.resolved),
moreIsBetter: true,
suffix: "%",
},
{
key: "frt",
title: "Median First Response",
value: formatSeconds(rt.firstResponse.medianSec),
subtitle: `avg ${formatSeconds(rt.firstResponse.avgSec)}`,
// No trend pill: chat + workflow auto-replies produce sub-minute medians
// that make percent deltas absurd (e.g. 2s → 5m reads as +14,850%)
trend: null,
moreIsBetter: false,
suffix: "%",
},
{
key: "satisfaction",
title: "Satisfaction",
value: sat.score != null ? `${sat.score}%` : "—",
subtitle: sat.total > 0
? `${formatNumber(sat.total)} ratings · ${sat.great} great · ${sat.bad} bad`
: "No ratings in period",
trend:
sat.score != null && satPrev.score != null ? sat.score - satPrev.score : null,
moreIsBetter: true,
suffix: " pts",
},
];
}, [data]);
const hasData = (data?.overview.current.newConversations ?? 0) > 0
|| (data?.overview.current.messagesReceived ?? 0) > 0;
const headerActions = !error ? (
<BusinessRangeSelect value={days} onValueChange={setDays} options={RANGE_OPTIONS} />
) : null;
return (
<Card className={`w-full h-full ${CARD_STYLES.elevated}`}>
<DashboardSectionHeader title="Customer Service" size="large" actions={headerActions} />
<CardContent className="p-4 pt-3 space-y-3">
{!error && (
loading ? (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2 w-full">
{Array.from({ length: 4 }).map((_, index) => (
<DashboardStatCardSkeleton key={index} hasSubtitle />
))}
</div>
) : (
cards.length > 0 && (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2 w-full dashboard-stagger">
{cards.map((card) => (
<DashboardStatCard
key={card.key}
title={card.title}
value={card.value}
subtitle={card.subtitle}
trend={
card.trend != null && Number.isFinite(card.trend)
? {
value: card.trend,
moreIsBetter: card.moreIsBetter,
suffix: card.suffix,
}
: undefined
}
/>
))}
</div>
)
)
)}
{!error && !loading && (
<div className="flex flex-wrap items-center gap-0.5">
{(Object.keys(SERIES_LABELS) as ChartSeriesKey[]).map((key) => (
<MetricPill
key={key}
active={metrics[key]}
color={chartColors[key]}
onClick={() => toggleMetric(key)}
>
{SERIES_LABELS[key]}
</MetricPill>
))}
</div>
)}
{loading ? (
<div className="flex flex-col lg:flex-row gap-3">
<div className="w-full lg:w-[55%]">
<ChartSkeleton type="area" height="md" withCard={false} />
</div>
<div className="w-full lg:w-[45%]">
<div className="h-[280px] rounded-xl border border-[#efede9] bg-[#faf9f7] animate-pulse" />
</div>
</div>
) : error ? (
<DashboardErrorState
error={`Failed to load customer service data: ${error}`}
className="mx-0 my-0"
/>
) : !hasData ? (
<DashboardEmptyState
icon={MessagesSquare}
title="No conversation data"
description="Try selecting a different time range"
/>
) : (
<>
<div className="flex flex-col lg:flex-row gap-3">
<div className={`h-[280px] w-full lg:w-[55%] ${CARD_STYLES.subtle} p-0 relative`}>
{!hasActiveMetrics ? (
<DashboardEmptyState
icon={TrendingUp}
title="No metrics selected"
description="Select at least one metric to visualize."
/>
) : (
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={chartData}
margin={{ top: 8, right: 0, left: -8, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="label"
className="text-xs text-muted-foreground"
tick={{ fill: "currentColor" }}
minTickGap={24}
/>
<YAxis
tickFormatter={(value: number) => formatNumber(value)}
className="text-xs text-muted-foreground"
tick={{ fill: "currentColor" }}
allowDecimals={false}
/>
<Tooltip content={<ConversationsTooltip />} />
{metrics.newConversations && (
<Bar
dataKey="newConversations"
name={SERIES_LABELS.newConversations}
fill={chartColors.newConversations}
fillOpacity={0.75}
radius={[6, 6, 0, 0]}
maxBarSize={18}
/>
)}
{metrics.resolved && (
<Line
type="monotone"
dataKey="resolved"
name={SERIES_LABELS.resolved}
stroke={chartColors.resolved}
strokeWidth={2}
dot={false}
activeDot={{ r: 4 }}
/>
)}
{metrics.messages && (
<Line
type="monotone"
dataKey="messages"
name={SERIES_LABELS.messages}
stroke={chartColors.messages}
strokeWidth={2}
strokeDasharray="5 3"
dot={false}
activeDot={{ r: 4 }}
/>
)}
</ComposedChart>
</ResponsiveContainer>
)}
</div>
<div className="w-full lg:w-[45%]">
<AgentLeaderboard agents={data?.agents ?? []} />
</div>
</div>
<FrtHistogram
buckets={data?.frtDistribution ?? []}
medianSec={data?.responseTimes.current.firstResponse.medianSec ?? null}
/>
<SecondaryStats data={data!} />
</>
)}
</CardContent>
</Card>
);
};
const ConversationsTooltip = ({ active, payload, label }: TooltipProps<number, string>) => {
if (!active || !payload?.length) return null;
return (
<div className={TOOLTIP_STYLES.container}>
<p className={TOOLTIP_STYLES.header}>{label}</p>
<div className={TOOLTIP_STYLES.content}>
{payload.map((entry, index) => (
<div key={index} className={TOOLTIP_STYLES.row}>
<div className={TOOLTIP_STYLES.rowLabel}>
<span
className={TOOLTIP_STYLES.dot}
style={{ backgroundColor: entry.stroke || entry.color || "#888" }}
/>
<span className={TOOLTIP_STYLES.name}>{entry.name}</span>
</div>
<span className={TOOLTIP_STYLES.value}>
{entry.value != null ? formatNumber(entry.value as number) : "—"}
</span>
</div>
))}
</div>
</div>
);
};
// How-fast-do-we-answer histogram over FreeScout's report buckets. Single-series,
// so no legend; direct count labels on the bars carry the values.
function FrtHistogram({
buckets,
medianSec,
}: {
buckets: FrtBucket[];
medianSec: number | null;
}) {
const total = buckets.reduce((sum, b) => sum + b.count, 0);
if (total === 0) return null;
return (
<div className={`${CARD_STYLES.subtle} px-3 pb-1 pt-2.5`}>
<div className="flex items-baseline justify-between px-1">
<span className="text-[10.5px] font-semibold uppercase tracking-wide text-[#a09b92]">
First Response Time
</span>
<span className="text-[10.5px] text-[#7c7870]">
median {formatSeconds(medianSec)} · {formatNumber(total)} conversations
</span>
</div>
<div className="h-[120px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={buckets} margin={{ top: 18, right: 4, left: 4, bottom: 0 }}>
<XAxis
dataKey="bucket"
interval={0}
tickLine={false}
axisLine={false}
tick={{ fill: "#7c7870", fontSize: 10 }}
/>
<YAxis hide />
<Bar
dataKey="count"
fill={STUDIO_COLORS.violet}
fillOpacity={0.8}
radius={[6, 6, 0, 0]}
maxBarSize={44}
minPointSize={3}
>
<LabelList
dataKey="count"
position="top"
formatter={(value: number) => (value > 0 ? formatNumber(value) : "")}
style={{ fill: "#7c7870", fontSize: 10 }}
/>
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</div>
);
}
const TABLE_HEAD_CLASS =
"h-8 px-2 text-[10.5px] font-semibold uppercase tracking-wide text-[#a09b92]";
function AgentLeaderboard({ agents }: { agents: AgentEntry[] }) {
if (agents.length === 0) {
return (
<div className="h-[280px] rounded-xl border border-[#efede9] bg-[#faf9f7] flex items-center justify-center">
<p className="text-sm text-muted-foreground">No agent activity</p>
</div>
);
}
return (
<div className="h-[280px] rounded-xl border border-[#efede9] bg-[#faf9f7] overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto">
<Table>
<TableHeader className="sticky top-0 z-10 bg-[#faf9f7]">
<TableRow className="hover:bg-transparent">
<TableHead className={`${TABLE_HEAD_CLASS} w-8`} />
<TableHead className={TABLE_HEAD_CLASS}>Agent</TableHead>
<TableHead className={`${TABLE_HEAD_CLASS} text-right`}>Replies</TableHead>
<TableHead className={`${TABLE_HEAD_CLASS} text-right`}>Closed</TableHead>
<TableHead className={`${TABLE_HEAD_CLASS} text-right`}>Helped</TableHead>
<TableHead className={`${TABLE_HEAD_CLASS} text-right`}>CSAT</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{agents.map((agent, index) => (
<TableRow key={agent.id}>
<TableCell className="py-1.5 px-2 w-8 text-xs text-muted-foreground text-center">
{index + 1}
</TableCell>
<TableCell className="py-1.5 px-2 text-xs font-medium truncate max-w-[110px]">
{agent.name}
</TableCell>
<TableCell className="py-1.5 px-2 text-xs text-right font-medium">
{formatNumber(agent.replies)}
</TableCell>
<TableCell className="py-1.5 px-2 text-xs text-right text-muted-foreground">
{agent.closed > 0 ? formatNumber(agent.closed) : "—"}
</TableCell>
<TableCell className="py-1.5 px-2 text-xs text-right text-muted-foreground">
{agent.customersHelped > 0 ? formatNumber(agent.customersHelped) : "—"}
</TableCell>
<TableCell className="py-1.5 px-2 text-xs text-right">
{agent.satScore != null ? (
<span
className={
agent.satScore >= 80
? "text-emerald-600 font-medium"
: agent.satScore >= 50
? "text-amber-600 font-medium"
: "text-red-600 font-medium"
}
>
{agent.satScore}%
</span>
) : (
<span className="text-muted-foreground"></span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
);
}
function SecondaryStats({ data }: { data: FreescoutReport }) {
const { overview, responseTimes, channels, phone, range } = data;
const cur = overview.current;
const totalChannels = channels.email + channels.chat + channels.phone + channels.other;
const pct = (n: number) =>
totalChannels > 0 ? `${Math.round((n * 100) / totalChannels)}%` : "0%";
const phoneCur = phone?.current ?? null;
const phonePrev = phone?.previous ?? null;
return (
<div className="space-y-2">
<div className={`${QUIET_STRIP_CLASS} grid-cols-2 sm:grid-cols-3 lg:grid-cols-6`}>
<QuietStat label="Replies Sent" value={formatNumber(cur.repliesSent)} />
<QuietStat label="Customers Helped" value={formatNumber(cur.customersHelped)} />
<QuietStat
label="Median Resolution"
value={formatSeconds(responseTimes.current.resolution.medianSec)}
sub={`avg ${formatSeconds(responseTimes.current.resolution.avgSec)}`}
/>
<QuietStat label="Email" value={formatNumber(channels.email)} sub={pct(channels.email)} />
<QuietStat label="Chat" value={formatNumber(channels.chat)} sub={pct(channels.chat)} />
<QuietStat label="Phone" value={formatNumber(channels.phone)} sub={pct(channels.phone)} />
</div>
{phoneCur && (
<div className={`${QUIET_STRIP_CLASS} grid-cols-2 sm:grid-cols-4`}>
<QuietStat
label="Calls"
value={formatNumber(phoneCur.total)}
sub={`${formatNumber(phoneCur.inbound)} in · ${formatNumber(phoneCur.outbound)} out`}
/>
<QuietStat
label="Calls / Day"
value={(phoneCur.total / range.days).toFixed(1)}
sub={phonePrev ? `${(phonePrev.total / range.days).toFixed(1)} prior period` : undefined}
/>
<QuietStat label="Avg Call" value={formatSeconds(phoneCur.avgDurationSec)} />
<QuietStat label="Voicemails" value={formatNumber(phoneCur.voicemails)} />
</div>
)}
</div>
);
}
export default CustomerServiceDashboard;
+185 -566
View File
@@ -73,36 +73,12 @@ const EVENT_ICONS = {
};
const EVENT_TYPES = {
[METRIC_IDS.PLACED_ORDER]: {
label: "Order Placed",
color: "bg-green-500 dark:bg-green-600",
textColor: "text-green-600 dark:text-green-400",
},
[METRIC_IDS.SHIPPED_ORDER]: {
label: "Order Shipped",
color: "bg-blue-500 dark:bg-blue-600",
textColor: "text-blue-600 dark:text-blue-400",
},
[METRIC_IDS.ACCOUNT_CREATED]: {
label: "New Account",
color: "bg-purple-500 dark:bg-purple-600",
textColor: "text-purple-600 dark:text-purple-400",
},
[METRIC_IDS.CANCELED_ORDER]: {
label: "Order Canceled",
color: "bg-red-500 dark:bg-red-600",
textColor: "text-red-600 dark:text-red-400",
},
[METRIC_IDS.PAYMENT_REFUNDED]: {
label: "Payment Refunded",
color: "bg-orange-500 dark:bg-orange-600",
textColor: "text-orange-600 dark:text-orange-400",
},
[METRIC_IDS.NEW_BLOG_POST]: {
label: "New Blog Post",
color: "bg-indigo-500 dark:bg-indigo-600",
textColor: "text-indigo-600 dark:text-indigo-400",
},
[METRIC_IDS.PLACED_ORDER]: { label: "Order Placed" },
[METRIC_IDS.SHIPPED_ORDER]: { label: "Order Shipped" },
[METRIC_IDS.ACCOUNT_CREATED]: { label: "New Account" },
[METRIC_IDS.CANCELED_ORDER]: { label: "Order Canceled" },
[METRIC_IDS.PAYMENT_REFUNDED]: { label: "Payment Refunded" },
[METRIC_IDS.NEW_BLOG_POST]: { label: "New Blog Post" },
};
// Helper Functions
@@ -146,28 +122,28 @@ const formatShipMethodSimple = (method) => {
// Loading State Component
const LoadingState = () => (
<div className="divide-y divide-border/50">
<div className="divide-y divide-[#f5f3f0]">
{[...Array(8)].map((_, i) => (
<div key={i} className="flex items-center gap-3 p-4 hover:bg-muted/50 transition-colors">
<div key={i} className="flex items-center gap-3 px-3 py-3">
<div className="shrink-0">
<Skeleton className="h-10 w-10 rounded-full bg-muted" />
<Skeleton className="h-8 w-8 rounded-md bg-[#f0eeea]" />
</div>
<div className="flex-1 min-w-0 space-y-2">
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-24 bg-muted rounded-sm" />
<Skeleton className="h-4 w-24 bg-[#f0eeea] rounded-sm" />
</div>
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-48 bg-muted rounded-sm" />
<Skeleton className="h-4 w-48 bg-[#f0eeea] rounded-sm" />
</div>
<div className="flex gap-1.5 items-center flex-wrap">
<Skeleton className="h-5 w-16 bg-muted rounded-md" />
<Skeleton className="h-5 w-20 bg-muted rounded-md" />
<Skeleton className="h-5 w-14 bg-muted rounded-md" />
<Skeleton className="h-5 w-16 bg-[#f0eeea] rounded-md" />
<Skeleton className="h-5 w-20 bg-[#f0eeea] rounded-md" />
<Skeleton className="h-5 w-14 bg-[#f0eeea] rounded-md" />
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Skeleton className="h-4 w-16 bg-muted rounded-sm" />
<Skeleton className="h-4 w-4 bg-muted rounded-full" />
<Skeleton className="h-4 w-16 bg-[#f0eeea] rounded-sm" />
<Skeleton className="h-4 w-4 bg-[#f0eeea] rounded-full" />
</div>
</div>
))}
@@ -177,10 +153,10 @@ const LoadingState = () => (
// Empty State Component
const EmptyState = () => (
<div className="h-full flex flex-col items-center justify-center py-16 px-4 text-center">
<div className="bg-muted rounded-full p-3 mb-4">
<div className="bg-[#f0eeea] rounded-full p-3 mb-4">
<Activity className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-lg font-medium text-foreground mb-2">
<h3 className="text-sm font-medium text-foreground mb-1">
No activity yet today
</h3>
<p className={`${TYPOGRAPHY.cardDescription} max-w-sm`}>
@@ -193,37 +169,37 @@ const EmptyState = () => (
const OrderStatusTags = ({ details }) => (
<div className="flex flex-wrap gap-2">
{details.HasPreorder && (
<span className="px-2 py-1 bg-blue-100 dark:bg-blue-900/20 text-blue-800 dark:text-blue-300 rounded-full text-xs cursor-help">
<span className="px-2 py-1 bg-[#f5f3f0] text-[#7c7870] rounded-full text-xs font-medium cursor-help">
Includes Pre-order
</span>
)}
{details.LocalPickup && (
<span className="px-2 py-1 bg-purple-100 dark:bg-purple-900/20 text-purple-800 dark:text-purple-300 rounded-full text-xs cursor-help">
<span className="px-2 py-1 bg-[#f5f3f0] text-[#7c7870] rounded-full text-xs font-medium cursor-help">
Local Pickup
</span>
)}
{details.IsOnHold && (
<span className="px-2 py-1 bg-yellow-100 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300 rounded-full text-xs cursor-help">
<span className="px-2 py-1 bg-[#f5f3f0] text-[#7c7870] rounded-full text-xs font-medium cursor-help">
On Hold
</span>
)}
{details.HasDigiItem && (
<span className="px-2 py-1 bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300 rounded-full text-xs cursor-help">
<span className="px-2 py-1 bg-[#f5f3f0] text-[#7c7870] rounded-full text-xs font-medium cursor-help">
Digital Items
</span>
)}
{details.HasNotions && (
<span className="px-2 py-1 bg-pink-100 dark:bg-pink-900/20 text-pink-800 dark:text-pink-300 rounded-full text-xs cursor-help">
<span className="px-2 py-1 bg-[#f5f3f0] text-[#7c7870] rounded-full text-xs font-medium cursor-help">
Includes Notions
</span>
)}
{details.HasDigitalGC && (
<span className="px-2 py-1 bg-indigo-100 dark:bg-indigo-900/20 text-indigo-800 dark:text-indigo-300 rounded-full text-xs cursor-help">
<span className="px-2 py-1 bg-[#f5f3f0] text-[#7c7870] rounded-full text-xs font-medium cursor-help">
Gift Card
</span>
)}
{details.StillOwes && (
<span className="px-2 py-1 bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300 rounded-full text-xs cursor-help">
<span className="px-2 py-1 bg-[#f5f3f0] text-[#7c7870] rounded-full text-xs font-medium cursor-help">
Payment Due
</span>
)}
@@ -231,7 +207,7 @@ const OrderStatusTags = ({ details }) => (
);
const ProductCard = ({ product }) => (
<div className="p-3 bg-muted/50 rounded-lg mb-3 hover:bg-muted transition-colors">
<div className="p-3 bg-[#faf9f7] rounded-xl mb-3 hover:bg-[#f0eeea] transition-colors">
<div className="flex items-start space-x-3">
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-2">
@@ -239,7 +215,7 @@ const ProductCard = ({ product }) => (
{product.ProductName || "Unnamed Product"}
</p>
{product.ItemStatus === "Pre-Order" && (
<span className="px-2 py-0.5 bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 rounded-full text-xs cursor-help">
<span className="px-2 py-0.5 bg-[#eae6f6] text-[#4a3f9e] rounded-full text-xs cursor-help">
Pre-order
</span>
)}
@@ -280,28 +256,28 @@ const PromotionalInfo = ({ details }) => {
if (!details?.PromosUsedReg?.length && !details?.PointsDiscount) return null;
return (
<div className="mt-4 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
<h4 className="text-sm font-medium text-green-800 dark:text-green-400 mb-2">
<div className="mt-4 p-3 bg-[#e6f3ee] rounded-xl">
<h4 className="text-sm font-semibold text-[#237a4d] mb-2">
<span className="cursor-help">Savings Applied</span>
</h4>
<div className="space-y-2">
{Array.isArray(details.PromosUsedReg) &&
details.PromosUsedReg.map(([code, amount], index) => (
<div key={index} className="flex justify-between text-sm">
<span className="font-mono text-green-700 dark:text-green-300 cursor-help">
<span className="font-mono text-[#237a4d] cursor-help">
{code}
</span>
<span className="font-medium text-green-700 dark:text-green-300 cursor-help">
<span className="font-medium text-[#237a4d] cursor-help">
-{formatCurrency(amount)}
</span>
</div>
))}
{details.PointsDiscount > 0 && (
<div className="flex justify-between text-sm">
<span className="text-green-700 dark:text-green-300 cursor-help">
<span className="text-[#237a4d] cursor-help">
Points Discount
</span>
<span className="font-medium text-green-700 dark:text-green-300 cursor-help">
<span className="font-medium text-[#237a4d] cursor-help">
-{formatCurrency(details.PointsDiscount)}
</span>
</div>
@@ -312,7 +288,7 @@ const PromotionalInfo = ({ details }) => {
};
const OrderSummary = ({ details }) => (
<div className="bg-muted/50 p-4 rounded-lg space-y-3">
<div className="bg-[#faf9f7] p-4 rounded-xl space-y-3">
<div className="grid grid-cols-2 gap-4">
<div>
<h4 className="text-sm font-medium text-muted-foreground mb-2 cursor-help">
@@ -326,13 +302,13 @@ const OrderSummary = ({ details }) => (
<span>{formatCurrency(details.Subtotal)}</span>
</div>
{details.PointsDiscount > 0 && (
<div className="flex justify-between text-green-600">
<div className="flex justify-between text-[#237a4d]">
<span className="cursor-help">Points Discount</span>
<span>-{formatCurrency(details.PointsDiscount)}</span>
</div>
)}
{details.TotalDiscounts > 0 && (
<div className="flex justify-between text-green-600">
<div className="flex justify-between text-[#237a4d]">
<span className="cursor-help">Discounts</span>
<span>-{formatCurrency(details.TotalDiscounts)}</span>
</div>
@@ -358,12 +334,12 @@ const OrderSummary = ({ details }) => (
</div>
</div>
<div className="pt-3 border-t border-border">
<div className="pt-3 border-t border-[#f0eeea]">
<div className="flex justify-between items-center">
<div>
<span className="text-sm font-medium">Total</span>
{details.TotalSavings > 0 && (
<div className="text-xs text-green-600 cursor-help">
<div className="text-xs text-[#237a4d] cursor-help">
You saved {formatCurrency(details.TotalSavings)}
</div>
)}
@@ -407,7 +383,7 @@ const ShippingInfo = ({ details }) => (
<div className="font-medium cursor-help">
{formatShipMethod(details.ShipMethod)}
</div>
<div className="font-mono text-blue-600 dark:text-blue-400 cursor-pointer hover:underline">
<div className="font-mono text-[#3f7fae] cursor-pointer hover:underline">
{details.TrackingNumber}
</div>
</div>
@@ -425,9 +401,9 @@ const EventDialog = ({ event, children, scale }) => {
const dialogInner = (
<>
<DialogHeader className="border-b border-border px-6 py-4">
<DialogHeader className="border-b border-[#f0eeea] px-6 py-4">
<div className="flex items-center gap-2 mb-2">
{Icon && <Icon className={`h-5 w-5 ${eventType.textColor}`} />}
{Icon && <Icon className="h-5 w-5 text-[#7c7870]" />}
<DialogTitle className="text-lg font-semibold">{eventType.label}</DialogTitle>
</div>
<div className="flex items-center justify-between">
@@ -484,7 +460,7 @@ const EventDialog = ({ event, children, scale }) => {
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
{details.IsOnHold && (
<Badge variant="secondary" className="bg-blue-100 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300">
<Badge variant="secondary" className="bg-[#eae6f6] text-[#4a3f9e]">
On Hold
</Badge>
)}
@@ -546,14 +522,14 @@ const EventDialog = ({ event, children, scale }) => {
<span className="font-medium">{formatCurrency(details.SalesTax)}</span>
</div>
{details.PointsDiscount > 0 && (
<div className="flex justify-between text-sm text-green-600 dark:text-green-400">
<div className="flex justify-between text-sm text-[#237a4d]">
<span>Points Discount</span>
<span className="font-medium">-{formatCurrency(details.PointsDiscount)}</span>
</div>
)}
{Array.isArray(details.PromosUsedReg) &&
details.PromosUsedReg.map(([code, amount], i) => (
<div key={i} className="flex justify-between text-sm text-green-600 dark:text-green-400">
<div key={i} className="flex justify-between text-sm text-[#237a4d]">
<span>{code}</span>
<span className="font-medium">-{formatCurrency(amount)}</span>
</div>
@@ -581,7 +557,7 @@ const EventDialog = ({ event, children, scale }) => {
<img
src={item.ImgThumb}
alt={item.ProductName}
className="w-16 h-16 object-cover rounded bg-muted"
className="w-16 h-16 object-cover rounded-lg bg-[#faf9f7]"
/>
)}
<div className="flex-1 min-w-0">
@@ -592,7 +568,7 @@ const EventDialog = ({ event, children, scale }) => {
{item.Quantity}x @ {formatCurrency(item.ItemPrice)}
</p>
</div>
<p className="text-sm font-medium text-green-600 dark:text-green-400 shrink-0">
<p className="text-sm font-medium text-[#237a4d] shrink-0">
{formatCurrency(item.RowTotal)}
</p>
</div>
@@ -627,7 +603,7 @@ const EventDialog = ({ event, children, scale }) => {
{event.event_properties?.ShippedBy && (
<>
<span className="text-sm text-muted-foreground"> </span>
<span className="text-sm font-medium text-blue-600 dark:text-blue-400">Shipped by {event.event_properties.ShippedBy}</span>
<span className="text-sm font-medium text-[#3f7fae]">Shipped by {event.event_properties.ShippedBy}</span>
</>
)}
</div>
@@ -706,7 +682,7 @@ const EventDialog = ({ event, children, scale }) => {
<img
src={item.ImgThumb}
alt={item.ProductName}
className="w-16 h-16 object-cover rounded bg-muted"
className="w-16 h-16 object-cover rounded-lg bg-[#faf9f7]"
/>
)}
<div className="flex-1 min-w-0">
@@ -784,7 +760,7 @@ const EventDialog = ({ event, children, scale }) => {
href={details.url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 dark:text-blue-400 hover:underline inline-flex items-center gap-1"
className="text-sm text-[#3f7fae] hover:underline inline-flex items-center gap-1"
>
Read More
<ChevronRight className="h-4 w-4" />
@@ -802,8 +778,8 @@ const EventDialog = ({ event, children, scale }) => {
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className={scale
? "w-[80vw] h-[80vh] max-w-none p-0 overflow-hidden"
: "max-w-2xl max-h-[85vh] overflow-hidden flex flex-col"
? "w-[80vw] h-[80vh] max-w-none p-0 overflow-hidden rounded-[14px] border-[#e7e5e1]"
: "max-w-2xl max-h-[85vh] overflow-hidden flex flex-col rounded-[14px] border-[#e7e5e1]"
}>
{scale ? (
<div
@@ -820,221 +796,115 @@ const EventDialog = ({ event, children, scale }) => {
export { EventDialog };
const EventCard = ({ event }) => {
const eventType = EVENT_TYPES[event.metric_id] || {
label: "Unknown Event",
color: "bg-slate-500",
textColor: "text-muted-foreground",
};
// Studio feed row: type is a colored dot, details collapse to one muted line,
// order flags get a single amber emphasis instead of a rainbow of badges.
const EVENT_DOTS = {
[METRIC_IDS.PLACED_ORDER]: "#2e8f5b",
[METRIC_IDS.SHIPPED_ORDER]: "#3f7fae",
[METRIC_IDS.ACCOUNT_CREATED]: "#6b5fc7",
[METRIC_IDS.CANCELED_ORDER]: "#b3503f",
[METRIC_IDS.PAYMENT_REFUNDED]: "#c9973d",
[METRIC_IDS.NEW_BLOG_POST]: "#a09b92",
};
const Icon = EVENT_ICONS[event.metric_id] || Package;
const EventCard = ({ event }) => {
const eventType = EVENT_TYPES[event.metric_id] || { label: "Event" };
const details = event.event_properties || {};
const datetime = event.attributes?.datetime || event.datetime || event.event_properties?.datetime;
const timestamp = datetime ? new Date(datetime) : null;
const isValidDate = timestamp && !isNaN(timestamp.getTime());
const dot = EVENT_DOTS[event.metric_id] || "#a09b92";
let name = "";
let parts = [];
let flags = [];
switch (event.metric_id) {
case METRIC_IDS.PLACED_ORDER:
name = toTitleCase(details.ShippingName);
parts = [`#${details.OrderId}`, formatCurrency(details.TotalAmount)];
flags = [
details.IsOnHold && "On Hold",
details.OnHoldReleased && "Hold Released",
details.StillOwes && "Owes",
details.LocalPickup && "Local",
details.HasPreorder && "Pre-order",
details.HasNotions && "Notions",
(details.OnlyDigitalGC || details.HasDigitalGC) && "eGift Card",
(details.HasDigiItem || details.OnlyDigiItem) && "Digital",
].filter(Boolean);
break;
case METRIC_IDS.SHIPPED_ORDER:
name = toTitleCase(details.ShippingName);
parts = [
`#${details.OrderId}`,
formatShipMethodSimple(details.ShipMethod),
details.ShippedBy && `by ${details.ShippedBy}`,
].filter(Boolean);
break;
case METRIC_IDS.ACCOUNT_CREATED:
name =
details.FirstName && details.LastName
? `${toTitleCase(details.FirstName)} ${toTitleCase(details.LastName)}`
: "New customer";
parts = [details.EmailAddress].filter(Boolean);
break;
case METRIC_IDS.CANCELED_ORDER:
name = toTitleCase(details.ShippingName);
parts = [
`#${details.OrderId}`,
formatCurrency(details.TotalAmount),
details.CancelReason,
].filter(Boolean);
break;
case METRIC_IDS.PAYMENT_REFUNDED:
name = toTitleCase(details.ShippingName);
parts = [
`#${details.FromOrder}`,
formatCurrency(details.PaymentAmount),
details.PaymentName && `via ${details.PaymentName}`,
].filter(Boolean);
break;
case METRIC_IDS.NEW_BLOG_POST:
name = details.title;
parts = [details.description].filter(Boolean);
break;
default:
break;
}
return (
<EventDialog event={event}>
<button className="w-full focus:outline-none text-left">
<div className="flex items-center gap-3 p-4 hover:bg-muted/50 transition-colors border-b border-border/50 last:border-b-0">
<div className={`shrink-0 w-10 h-10 rounded-full ${eventType.color} bg-opacity-10 dark:bg-opacity-20 flex items-center justify-center`}>
<Icon className="h-5 w-5 text-foreground" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center">
<span className={`${eventType.textColor} text-sm font-medium`}>
{eventType.label}
<button className="w-full text-left focus:outline-none focus-visible:bg-[#faf9f7]">
<div className="flex items-start gap-2.5 border-b border-[#f5f3f0] px-4 py-2.5 transition-colors last:border-b-0 hover:bg-[#faf9f7]">
<span
className="mt-[7px] inline-block h-2 w-2 shrink-0 rounded-full"
style={{ background: dot }}
/>
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<span className="truncate text-[13px] font-semibold text-[#2b2925]">
{name || eventType.label}
</span>
{isValidDate && (
<time
className="shrink-0 text-[11px] text-[#a09b92]"
dateTime={timestamp.toISOString()}
>
{format(timestamp, "h:mm a")}
</time>
)}
</div>
<div className="truncate text-[11.5px] text-[#7c7870]">
{eventType.label}
{parts.map((part, i) => (
<span key={i}> · {part}</span>
))}
{flags.length > 0 && (
<span className="font-semibold text-[#a87a24]"> · {flags.join(" · ")}</span>
)}
</div>
{event.metric_id === METRIC_IDS.PLACED_ORDER && (
<>
<div className="mt-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground">
{toTitleCase(details.ShippingName)}
</span>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="text-sm text-muted-foreground">
#{details.OrderId}
</span>
<span className="text-sm text-muted-foreground"></span>
<span className="font-medium text-green-600 dark:text-green-400">
{formatCurrency(details.TotalAmount)}
</span>
</div>
</div>
<div className="flex gap-1.5 items-center flex-wrap mt-2">
{details.IsOnHold && (
<Badge
variant="secondary"
className="bg-blue-100 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 text-xs py-0"
>
On Hold
</Badge>
)}
{details.OnHoldReleased && (
<Badge
variant="secondary"
className="bg-green-100 dark:bg-green-900/20 text-green-700 dark:text-green-300 text-xs py-0"
>
Hold Released
</Badge>
)}
{details.StillOwes && (
<Badge
variant="secondary"
className="bg-red-100 dark:bg-red-900/20 text-red-700 dark:text-red-300 text-xs py-0"
>
Owes
</Badge>
)}
{details.LocalPickup && (
<Badge
variant="secondary"
className="bg-green-100 dark:bg-green-900/20 text-green-700 dark:text-green-300 text-xs py-0"
>
Local
</Badge>
)}
{details.HasPreorder && (
<Badge
variant="secondary"
className="bg-purple-100 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300 text-xs py-0"
>
Pre-order
</Badge>
)}
{details.HasNotions && (
<Badge
variant="secondary"
className="bg-yellow-100 dark:bg-yellow-900/20 text-yellow-700 dark:text-yellow-300 text-xs py-0"
>
Notions
</Badge>
)}
{(details.OnlyDigitalGC || details.HasDigitalGC) && (
<Badge
variant="secondary"
className="bg-pink-100 dark:bg-pink-900/20 text-pink-700 dark:text-pink-300 text-xs py-0"
>
eGift Card
</Badge>
)}
{(details.HasDigiItem || details.OnlyDigiItem) && (
<Badge
variant="secondary"
className="bg-indigo-100 dark:bg-indigo-900/20 text-indigo-700 dark:text-indigo-300 text-xs py-0"
>
Digital
</Badge>
)}
</div>
</>
)}
{event.metric_id === METRIC_IDS.SHIPPED_ORDER && (
<>
<div className="mt-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground">
{toTitleCase(details.ShippingName)}
</span>
<span className="text-sm text-muted-foreground"></span>
<span className="text-sm text-muted-foreground">
#{details.OrderId}
</span>
</div>
<div className="text-sm text-muted-foreground">
{formatShipMethodSimple(details.ShipMethod)}
{event.event_properties?.ShippedBy && (
<>
<span className="text-sm text-muted-foreground"> </span>
<span className="text-sm font-medium text-blue-600 dark:text-blue-400">Shipped by {event.event_properties.ShippedBy}</span>
</>
)}
</div>
</div>
</>
)}
{event.metric_id === METRIC_IDS.ACCOUNT_CREATED && (
<div className="mt-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground">
{details.FirstName && details.LastName
? `${toTitleCase(details.FirstName)} ${toTitleCase(
details.LastName
)}`
: "New Customer"}
</span>
</div>
<div className="text-sm text-muted-foreground">
{details.EmailAddress}
</div>
</div>
)}
{event.metric_id === METRIC_IDS.CANCELED_ORDER && (
<div className="mt-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground">
{toTitleCase(details.ShippingName)}
</span>
<span className="text-sm text-muted-foreground"></span>
<span className="text-sm text-muted-foreground">
#{details.OrderId}
</span>
</div>
<div className="text-sm text-muted-foreground">
{formatCurrency(details.TotalAmount)} {details.CancelReason}
</div>
</div>
)}
{event.metric_id === METRIC_IDS.PAYMENT_REFUNDED && (
<div className="mt-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground">
{toTitleCase(details.ShippingName)}
</span>
<span className="text-sm text-muted-foreground"></span>
<span className="text-sm text-muted-foreground">
#{details.FromOrder}
</span>
</div>
<div className="text-sm text-muted-foreground">
{formatCurrency(details.PaymentAmount)} via{" "}
{details.PaymentName}
</div>
</div>
)}
{event.metric_id === METRIC_IDS.NEW_BLOG_POST && (
<div className="mt-1">
<div className="text-sm font-medium text-foreground">
{details.title}
</div>
<div className="text-sm text-muted-foreground line-clamp-1">
{details.description}
</div>
</div>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{isValidDate && (
<time
className="text-sm text-muted-foreground"
dateTime={timestamp.toISOString()}
>
{format(timestamp, "h:mm a")}
</time>
)}
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</div>
</div>
</button>
@@ -1248,302 +1118,51 @@ const EventFeed = ({
});
};
const EventTypeTooltipContent = () => (
<div className="grid gap-2">
<div className="flex items-center justify-between gap-4">
<span className="flex items-center gap-2">
<Package className="h-4 w-4" />
Orders
</span>
<Badge variant="secondary" className="bg-muted">
{counts.eventTypes[METRIC_IDS.PLACED_ORDER].toLocaleString()}
</Badge>
</div>
<div className="flex items-center justify-between gap-4">
<span className="flex items-center gap-2">
<Truck className="h-4 w-4" />
Shipments
</span>
<Badge variant="secondary" className="bg-muted">
{counts.eventTypes[METRIC_IDS.SHIPPED_ORDER].toLocaleString()}
</Badge>
</div>
<div className="flex items-center justify-between gap-4">
<span className="flex items-center gap-2">
<UserPlus className="h-4 w-4" />
Accounts
</span>
<Badge variant="secondary" className="bg-muted">
{counts.eventTypes[METRIC_IDS.ACCOUNT_CREATED].toLocaleString()}
</Badge>
</div>
<div className="flex items-center justify-between gap-4">
<span className="flex items-center gap-2">
<XCircle className="h-4 w-4" />
Cancellations
</span>
<Badge variant="secondary" className="bg-muted">
{counts.eventTypes[METRIC_IDS.CANCELED_ORDER].toLocaleString()}
</Badge>
</div>
<div className="flex items-center justify-between gap-4">
<span className="flex items-center gap-2">
<DollarSign className="h-4 w-4" />
Refunds
</span>
<Badge variant="secondary" className="bg-muted">
{counts.eventTypes[METRIC_IDS.PAYMENT_REFUNDED].toLocaleString()}
</Badge>
</div>
<div className="flex items-center justify-between gap-4">
<span className="flex items-center gap-2">
<FileText className="h-4 w-4" />
Blog Posts
</span>
<Badge variant="secondary" className="bg-muted">
{counts.eventTypes[METRIC_IDS.NEW_BLOG_POST].toLocaleString()}
</Badge>
</div>
</div>
);
const TYPE_FILTERS = [
{ id: METRIC_IDS.PLACED_ORDER, label: "Orders" },
{ id: METRIC_IDS.SHIPPED_ORDER, label: "Shipped" },
{ id: METRIC_IDS.ACCOUNT_CREATED, label: "Accounts" },
{ id: METRIC_IDS.CANCELED_ORDER, label: "Canceled" },
{ id: METRIC_IDS.PAYMENT_REFUNDED, label: "Refunds" },
{ id: METRIC_IDS.NEW_BLOG_POST, label: "Blog" },
];
return (
<Card className={`flex flex-col h-full ${CARD_STYLES.base} w-full`}>
<CardHeader className="p-6 pb-2">
<div className="flex justify-between items-start">
<div>
<CardTitle className={TYPOGRAPHY.sectionTitle}>{title}</CardTitle>
{lastUpdate && (
<CardDescription className={TYPOGRAPHY.cardDescription}>
Last updated {format(lastUpdate, "h:mm a")}
</CardDescription>
)}
</div>
{!error && (
<div className="flex flex-wrap gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={activeEventTypes[METRIC_IDS.PLACED_ORDER] ? "default" : "outline"}
size="sm"
onClick={() => handleEventTypeClick(METRIC_IDS.PLACED_ORDER)}
className="h-8 w-8 p-0 rounded-md"
>
<Package className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<EventTypeTooltipContent />
</TooltipContent>
</Tooltip>
</TooltipProvider>
<CardHeader className="space-y-2 border-b border-[#f0eeea] px-4 pb-3.5 pt-4">
<CardTitle className="text-[12.5px] font-semibold tracking-tight text-[#2b2925]">
{title}
</CardTitle>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={activeEventTypes[METRIC_IDS.SHIPPED_ORDER] ? "default" : "outline"}
size="sm"
onClick={() => handleEventTypeClick(METRIC_IDS.SHIPPED_ORDER)}
className="h-8 w-8 p-0 rounded-md"
>
<Truck className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<EventTypeTooltipContent />
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={activeEventTypes[METRIC_IDS.ACCOUNT_CREATED] ? "default" : "outline"}
size="sm"
onClick={() => handleEventTypeClick(METRIC_IDS.ACCOUNT_CREATED)}
className="h-8 w-8 p-0 rounded-md"
>
<UserPlus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<EventTypeTooltipContent />
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={activeEventTypes[METRIC_IDS.CANCELED_ORDER] ? "default" : "outline"}
size="sm"
onClick={() => handleEventTypeClick(METRIC_IDS.CANCELED_ORDER)}
className="h-8 w-8 p-0 rounded-md"
>
<XCircle className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<EventTypeTooltipContent />
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={activeEventTypes[METRIC_IDS.PAYMENT_REFUNDED] ? "default" : "outline"}
size="sm"
onClick={() => handleEventTypeClick(METRIC_IDS.PAYMENT_REFUNDED)}
className="h-8 w-8 p-0 rounded-md"
>
<DollarSign className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<EventTypeTooltipContent />
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={activeEventTypes[METRIC_IDS.NEW_BLOG_POST] ? "default" : "outline"}
size="sm"
onClick={() => handleEventTypeClick(METRIC_IDS.NEW_BLOG_POST)}
className="h-8 w-8 p-0 rounded-md"
>
<FileText className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<EventTypeTooltipContent />
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
</div>
{/* Order Property Filters - update styling */}
{/* Event-type filter pills — dot doubles as the type legend */}
{!error && (
<div className="flex flex-wrap gap-2 justify-center mt-4 pt-1">
{counts.orderProperties.hasPreorder > 0 && (
<Badge
variant="secondary"
onClick={() => handleOrderPropertyClick('hasPreorder')}
className={`${
orderFilters.hasPreorder
? 'bg-purple-800 text-purple-200 hover:bg-purple-700'
: 'bg-purple-100 dark:bg-purple-900/20 text-purple-800 dark:text-purple-300 hover:bg-purple-100 dark:hover:bg-purple-900/20'
} cursor-pointer rounded-md`}
<div className="flex flex-wrap items-center gap-1">
{TYPE_FILTERS.map(({ id, label }) => (
<button
key={id}
type="button"
onClick={() => handleEventTypeClick(id)}
className={`flex h-7 shrink-0 items-center gap-1.5 rounded-full px-2.5 text-[11px] font-medium transition-colors ${
activeEventTypes[id]
? "bg-[#f0eeea] text-[#2b2925] hover:bg-[#eae7e2]"
: "text-[#a09b92] hover:bg-[#f5f3f0] hover:text-[#2b2925]"
}`}
>
Pre-order ({counts.orderProperties.hasPreorder})
</Badge>
)}
{counts.orderProperties.localPickup > 0 && (
<Badge
variant="secondary"
onClick={() => handleOrderPropertyClick('localPickup')}
className={`${
orderFilters.localPickup
? 'bg-green-800 text-green-200 hover:bg-green-700'
: 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300 hover:bg-green-100 dark:hover:bg-green-900/20'
} cursor-pointer rounded-md`}
>
Local ({counts.orderProperties.localPickup})
</Badge>
)}
{counts.orderProperties.isOnHold > 0 && (
<Badge
variant="secondary"
onClick={() => handleOrderPropertyClick('isOnHold')}
className={`${
orderFilters.isOnHold
? 'bg-blue-800 text-blue-200 hover:bg-blue-700'
: 'bg-blue-100 dark:bg-blue-900/20 text-blue-800 dark:text-blue-300 hover:bg-blue-100 dark:hover:bg-blue-900/20'
} cursor-pointer rounded-md`}
>
On Hold ({counts.orderProperties.isOnHold})
</Badge>
)}
{counts.orderProperties.onHoldReleased > 0 && (
<Badge
variant="secondary"
onClick={() => handleOrderPropertyClick('onHoldReleased')}
className={`${
orderFilters.onHoldReleased
? 'bg-green-800 text-green-200 hover:bg-green-700'
: 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300 hover:bg-green-100 dark:hover:bg-green-900/20'
} cursor-pointer rounded-md`}
>
Hold Released ({counts.orderProperties.onHoldReleased})
</Badge>
)}
{counts.orderProperties.hasDigiItem > 0 && (
<Badge
variant="secondary"
onClick={() => handleOrderPropertyClick('hasDigiItem')}
className={`${
orderFilters.hasDigiItem
? 'bg-indigo-800 text-indigo-200 hover:bg-indigo-700'
: 'bg-indigo-100 dark:bg-indigo-900/20 text-indigo-800 dark:text-indigo-300 hover:bg-indigo-100 dark:hover:bg-indigo-900/20'
} cursor-pointer rounded-md`}
>
Digital ({counts.orderProperties.hasDigiItem})
</Badge>
)}
{counts.orderProperties.hasNotions > 0 && (
<Badge
variant="secondary"
onClick={() => handleOrderPropertyClick('hasNotions')}
className={`${
orderFilters.hasNotions
? 'bg-yellow-800 text-yellow-200 hover:bg-yellow-700'
: 'bg-yellow-100 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300 hover:bg-yellow-100 dark:hover:bg-yellow-900/20'
} cursor-pointer rounded-md`}
>
Notions ({counts.orderProperties.hasNotions})
</Badge>
)}
{counts.orderProperties.hasGiftCard > 0 && (
<Badge
variant="secondary"
onClick={() => handleOrderPropertyClick('hasGiftCard')}
className={`${
orderFilters.hasGiftCard
? 'bg-pink-800 text-pink-200 hover:bg-pink-700'
: 'bg-pink-100 dark:bg-pink-900/20 text-pink-800 dark:text-pink-300 hover:bg-pink-100 dark:hover:bg-pink-900/20'
} cursor-pointer rounded-md`}
>
eGift Card ({counts.orderProperties.hasGiftCard})
</Badge>
)}
{counts.orderProperties.stillOwes > 0 && (
<Badge
variant="secondary"
onClick={() => handleOrderPropertyClick('stillOwes')}
className={`${
orderFilters.stillOwes
? 'bg-red-800 text-red-200 hover:bg-red-700'
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300 hover:bg-red-100 dark:hover:bg-red-900/20'
} cursor-pointer rounded-md`}
>
Owes ({counts.orderProperties.stillOwes})
</Badge>
)}
<span
className="inline-block h-1.5 w-1.5 rounded-full"
style={{ background: activeEventTypes[id] ? EVENT_DOTS[id] : "#d8d4cd" }}
/>
{label}
{counts.eventTypes[id] > 0 && (
<span className="tabular-nums text-[#a09b92]">{counts.eventTypes[id]}</span>
)}
</button>
))}
</div>
)}
</CardHeader>
<CardContent className="px-0 pb-6 pt-0 md:px-6 flex-1 overflow-hidden -mt-2">
<CardContent className="px-0 pb-4 pt-0 md:px-4 flex-1 overflow-hidden">
<ScrollArea className="h-full">
{loading && !events.length ? (
<LoadingState />
@@ -1552,7 +1171,7 @@ const EventFeed = ({
) : !filteredEvents || filteredEvents.length === 0 ? (
<EmptyState />
) : (
<div className="divide-y divide-border/50">
<div>
{filteredEvents.map((event) => (
<EventCard key={event.id} event={event} />
))}
@@ -29,7 +29,6 @@ import {
Area,
CartesianGrid,
ComposedChart,
Legend,
Line,
ResponsiveContainer,
Tooltip,
@@ -52,7 +51,7 @@ import {
DashboardEmptyState,
DashboardErrorState,
TOOLTIP_STYLES,
METRIC_COLORS,
FINANCIAL_COLORS,
} from "@/components/dashboard/shared";
type ComparisonValue = {
@@ -126,13 +125,13 @@ type ChartPoint = {
isFuture: boolean;
};
// Chart colors mapped from semantic METRIC_COLORS tokens
// Chart colors from the semantic FINANCIAL_COLORS tokens (Sorbet Studio)
const chartColors: Record<ChartSeriesKey, string> = {
income: METRIC_COLORS.orders, // Blue - revenue/income streams
cogs: METRIC_COLORS.expense, // Orange - costs/expenses
cogsPercentage: "#f97316", // Orange-500 - slightly brighter for percentage line
profit: METRIC_COLORS.profit, // Green - profit metrics
margin: METRIC_COLORS.aov, // Violet - percentage/derived metrics
income: FINANCIAL_COLORS.income, // Coral - revenue/income streams
cogs: FINANCIAL_COLORS.expense, // Studio amber - costs/expenses
cogsPercentage: FINANCIAL_COLORS.expense, // Same amber — the dashed stroke carries the distinction
profit: FINANCIAL_COLORS.profit, // Teal - profit metrics
margin: FINANCIAL_COLORS.margin, // Violet - percentage/derived metrics
};
const SERIES_LABELS: Record<ChartSeriesKey, string> = {
@@ -1109,7 +1108,7 @@ const FinancialOverview = () => {
<>
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" className="h-9" disabled={loading || !detailRows.length}>
<Button variant="outline" size="sm" className="h-8 rounded-full border-[#e7e5e1] text-xs font-semibold text-[#2b2925] shadow-none hover:bg-[#faf9f7]" disabled={loading || !detailRows.length}>
Details
</Button>
</DialogTrigger>
@@ -1123,8 +1122,9 @@ const FinancialOverview = () => {
{SERIES_DEFINITIONS.map((series) => (
<Button
key={series.key}
variant={metrics[series.key] ? "default" : "outline"}
variant="ghost"
size="sm"
className={`h-7 rounded-full px-3 text-xs font-medium ${metrics[series.key] ? "bg-[#f0eeea] text-[#2b2925] hover:bg-[#eae7e2]" : "text-[#7c7870] hover:bg-[#f5f3f0] hover:text-[#2b2925]"}`}
onClick={() => toggleMetric(series.key)}
>
{series.label}
@@ -1241,7 +1241,7 @@ const FinancialOverview = () => {
actions={headerActions}
/>
<CardContent className="p-6 pt-0 space-y-4">
<CardContent className="p-4 pt-3 space-y-3">
{/* Show stats only if not in error state */}
{!error && (
loading ? (
@@ -1251,47 +1251,52 @@ const FinancialOverview = () => {
)
)}
{/* Show metric toggles only if not in error state */}
{/* Metric toggle pills — the pill dot doubles as the series legend */}
{!error && (
<div className="flex items-center flex-col sm:flex-row gap-0 sm:gap-4">
<div className="flex flex-wrap gap-1">
{SERIES_DEFINITIONS.map((series) => (
<Button
key={series.key}
variant={metrics[series.key] ? "default" : "outline"}
size="sm"
onClick={() => toggleMetric(series.key)}
>
{series.label}
</Button>
))}
</div>
<Separator
orientation="vertical"
className="h-6 hidden sm:block"
/>
<Separator
orientation="horizontal"
className="sm:hidden w-20 my-2"
/>
<div className="flex items-center gap-2">
<div className="text-sm text-muted-foreground">Group By:</div>
<Select value={groupBy} onValueChange={handleGroupByChange}>
<SelectTrigger className="w-[110px]">
<SelectValue placeholder="Group By" />
</SelectTrigger>
<SelectContent>
{GROUP_BY_CHOICES.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-wrap items-center gap-0.5">
{SERIES_DEFINITIONS.map((series) => (
<button
key={series.key}
type="button"
onClick={() => toggleMetric(series.key)}
className={
"flex h-7 shrink-0 items-center gap-1.5 rounded-full px-2.5 text-xs font-medium transition-colors " +
(metrics[series.key]
? "bg-[#f0eeea] text-[#2b2925] hover:bg-[#eae7e2]"
: "text-[#7c7870] hover:bg-[#f5f3f0] hover:text-[#2b2925]")
}
>
<span
className="inline-block h-2 w-2 rounded-full"
style={
series.key === "cogsPercentage"
? {
background: "transparent",
border: `1.5px dashed ${metrics[series.key] ? chartColors.cogsPercentage : "#d8d4cd"}`,
}
: {
background: metrics[series.key]
? chartColors[series.key as ChartSeriesKey]
: "#d8d4cd",
}
}
/>
{series.label}
</button>
))}
<span className="mx-1.5 h-4 w-px bg-[#eceae5]" />
<Select value={groupBy} onValueChange={handleGroupByChange}>
<SelectTrigger className="h-8 w-auto gap-1.5 rounded-full border-[#e7e5e1] bg-white px-3.5 text-xs font-semibold text-[#2b2925] shadow-none hover:bg-[#faf9f7]">
<SelectValue placeholder="Group by" />
</SelectTrigger>
<SelectContent className="rounded-xl">
{GROUP_BY_CHOICES.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{loading ? (
@@ -1308,7 +1313,7 @@ const FinancialOverview = () => {
/>
) : (
<>
<div className={`h-[400px] mt-4 ${CARD_STYLES.base} rounded-lg p-0 relative`}>
<div className={`h-[340px] ${CARD_STYLES.subtle} p-0 relative`}>
{!hasActiveMetrics ? (
<DashboardEmptyState
icon={TrendingUp}
@@ -1317,7 +1322,7 @@ const FinancialOverview = () => {
/>
) : (
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={chartData} margin={{ top: 5, right: -25, left: 15, bottom: 5 }}>
<ComposedChart data={chartData} margin={{ top: 8, right: 0, left: -8, bottom: 0 }}>
<defs>
<linearGradient id="financialCogs" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={chartColors.cogs} stopOpacity={0.8} />
@@ -1354,7 +1359,6 @@ const FinancialOverview = () => {
/>
)}
<Tooltip content={<FinancialTooltip />} />
<Legend formatter={(value: string) => SERIES_LABELS[value as ChartSeriesKey] ?? value} />
{/* Stacked areas showing revenue breakdown */}
{metrics.cogs ? (
<Area
@@ -1456,7 +1460,7 @@ function FinancialStatGrid({
cards: FinancialStatCardConfig[];
}) {
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4 w-full dashboard-stagger">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2 w-full dashboard-stagger">
{cards.map((card) => (
<DashboardStatCard
key={card.key}
@@ -1482,7 +1486,7 @@ function FinancialStatGrid({
function SkeletonStats() {
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4 w-full">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2 w-full">
{Array.from({ length: 4 }).map((_, index) => (
<DashboardStatCardSkeleton key={index} hasIcon hasSubtitle />
))}
+30 -368
View File
@@ -1,376 +1,38 @@
import React, { useState, useEffect } from "react";
import { Card, CardContent } from "@/components/ui/card";
import {
Calendar,
Clock,
Sun,
Cloud,
CloudRain,
CloudDrizzle,
CloudSnow,
CloudLightning,
CloudFog,
CloudSun,
CircleAlert,
Tornado,
Haze,
Moon,
Monitor,
Wind,
Droplets,
ThermometerSun,
ThermometerSnowflake,
Sunrise,
Sunset,
AlertTriangle,
Umbrella,
} from "lucide-react";
import { useScroll } from "@/contexts/DashboardScrollContext";
import { useTheme } from "@/components/dashboard/theme/ThemeProvider";
import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { CARD_STYLES } from "@/lib/dashboard/designTokens";
import React from "react";
const CraftsIcon = () => (
<svg viewBox="0 0 2687 3338" className="w-6 h-6" aria-hidden="true">
<path
fill="white"
d="M911.230469 1807.75C974.730469 1695.5 849.919922 1700.659912 783.610352 1791.25C645.830078 1979.439941 874.950195 2120.310059 1112.429688 2058.800049C1201.44043 2035.72998 1278.759766 2003.080078 1344.580078 1964.159912C1385.389648 1940.040039 1380.900391 1926.060059 1344.580078 1935.139893C1294.040039 1947.800049 1261.69043 1953.73999 1177.700195 1966.97998C1084.719727 1981.669922 832.790039 1984.22998 911.230469 1807.75M1046.799805 1631.389893C1135.280273 1670.419922 1139.650391 1624.129883 1056.980469 1562.070068C925.150391 1463.110107 787.360352 1446.379883 661.950195 1478.280029C265.379883 1579.179932 67.740234 2077.050049 144.099609 2448.399902C357.860352 3487.689941 1934.570313 3457.959961 2143.030273 2467.540039C2204.700195 2174.439941 2141.950195 1852.780029 1917.990234 1665.149902C1773.219727 1543.870117 1575.009766 1536.659912 1403.599609 1591.72998C1380.639648 1599.110107 1381.410156 1616.610107 1403.599609 1612.379883C1571.25 1596.040039 1750.790039 1606 1856.75 1745.280029C2038.769531 1984.459961 2052.570313 2274.080078 1974.629883 2511.209961C1739.610352 3226.25 640.719727 3226.540039 401.719727 2479.26001C308.040039 2186.350098 400.299805 1788.800049 690 1639.100098C785.830078 1589.590088 907.040039 1569.709961 1046.799805 1631.389893Z"
/>
<path
fill="white"
d="M1270.089844 1727.72998C1292.240234 1431.47998 1284.94043 952.430176 1257.849609 717.390137C1235.679688 525.310059 1166.200195 416.189941 1093.629883 349.390137C1157.620117 313.180176 1354.129883 485.680176 1447.830078 603.350098C1790.870117 1034.100098 2235.580078 915.060059 2523.480469 721.129883C2569.120117 680.51001 2592.900391 654.030029 2523.480469 651.339844C2260.400391 615.330078 2115 463.060059 1947.530273 293.890137C1672.870117 16.459961 1143.719727 162.169922 1033.969727 303.040039C999.339844 280.299805 966.849609 265 941.709961 252.419922C787.139648 175.160156 670.049805 223.580078 871.780273 341.569824C962.599609 394.689941 1089.849609 483.48999 1168.230469 799.589844C1222.370117 1018.040039 1230.009766 1423.919922 1242.360352 1728.379883C1247 1761.850098 1264.799805 1759.629883 1270.089844 1727.72998"
/>
</svg>
);
const formatDate = (date) =>
date.toLocaleDateString("en-US", {
weekday: "long",
month: "long",
day: "numeric",
});
const Header = () => {
const [currentTime, setCurrentTime] = useState(new Date());
const [weather, setWeather] = useState(null);
const [forecast, setForecast] = useState(null);
const { isStuck } = useScroll();
const { theme, systemTheme, toggleTheme, setTheme } = useTheme();
useEffect(() => {
const timer = setInterval(() => {
setCurrentTime(new Date());
}, 1000);
return () => clearInterval(timer);
}, []);
useEffect(() => {
const fetchWeatherData = async () => {
try {
const API_KEY = import.meta.env.VITE_OPENWEATHER_API_KEY;
const [weatherResponse, forecastResponse] = await Promise.all([
fetch(
`https://api.openweathermap.org/data/2.5/weather?lat=43.63507&lon=-84.18995&appid=${API_KEY}&units=imperial`
),
fetch(
`https://api.openweathermap.org/data/2.5/forecast?lat=43.63507&lon=-84.18995&appid=${API_KEY}&units=imperial`
)
]);
const weatherData = await weatherResponse.json();
const forecastData = await forecastResponse.json();
setWeather(weatherData);
// Process forecast data to get daily forecasts
const dailyForecasts = forecastData.list.reduce((acc, item) => {
const date = new Date(item.dt * 1000).toLocaleDateString();
if (!acc[date]) {
acc[date] = {
...item,
precipitation: item.rain?.['3h'] || item.snow?.['3h'] || 0,
pop: item.pop * 100
};
}
return acc;
}, {});
setForecast(Object.values(dailyForecasts).slice(0, 5));
} catch (error) {
console.error("Error fetching weather:", error);
}
};
fetchWeatherData();
const weatherTimer = setInterval(fetchWeatherData, 300000);
return () => clearInterval(weatherTimer);
}, []);
const getWeatherIcon = (weatherCode, currentTime, small = false) => {
if (!weatherCode) return <CircleAlert className={cn(small ? "w-6 h-6" : "w-7 h-7", "text-red-500")} />;
const code = parseInt(weatherCode, 10);
const iconProps = small ? "w-6 h-6" : "w-7 h-7";
switch (true) {
case code >= 200 && code < 300:
return <CloudLightning className={cn(iconProps, "text-gray-700")} />;
case code >= 300 && code < 500:
return <CloudDrizzle className={cn(iconProps, "text-blue-600")} />;
case code >= 500 && code < 600:
return <CloudRain className={cn(iconProps, "text-blue-600")} />;
case code >= 600 && code < 700:
return <CloudSnow className={cn(iconProps, "text-blue-400")} />;
case code >= 700 && code < 721:
return <CloudFog className={cn(iconProps, "text-gray-600")} />;
case code === 721:
return <Haze className={cn(iconProps, "text-gray-700")} />;
case code >= 722 && code < 781:
return <CloudFog className={cn(iconProps, "text-gray-600")} />;
case code === 781:
return <Tornado className={cn(iconProps, "text-gray-700")} />;
case code === 800:
return currentTime.getHours() >= 6 && currentTime.getHours() < 18 ? (
<Sun className={cn(iconProps, "text-yellow-500")} />
) : (
<Moon className={cn(iconProps, "text-gray-300")} />
);
case code >= 800 && code < 803:
return <CloudSun className={cn(iconProps, "text-gray-600")} />;
case code >= 803:
return <Cloud className={cn(iconProps, "text-gray-600")} />;
default:
return <CircleAlert className={cn(iconProps, "text-red-500")} />;
}
};
const formatTime = (timestamp) => {
if (!timestamp) return '--:--';
const date = new Date(timestamp * 1000);
return date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
});
};
const WeatherDetails = () => (
<div className="space-y-4 p-3">
<div className="grid grid-cols-3 gap-2">
<Card className="p-2">
<div className="flex items-center gap-1">
<ThermometerSun className="w-5 h-5 text-orange-500" />
<div className="flex flex-col">
<span className="text-xs text-muted-foreground">High</span>
<span className="text-sm font-bold">{Math.round(weather.main.temp_max)}°F</span>
</div>
</div>
</Card>
<Card className="p-2">
<div className="flex items-center gap-1">
<ThermometerSnowflake className="w-5 h-5 text-blue-500" />
<div className="flex flex-col">
<span className="text-xs text-muted-foreground">Low</span>
<span className="text-sm font-bold">{Math.round(weather.main.temp_min)}°F</span>
</div>
</div>
</Card>
<Card className="p-2">
<div className="flex items-center gap-1">
<Droplets className="w-5 h-5 text-blue-400" />
<div className="flex flex-col">
<span className="text-xs text-muted-foreground">Humidity</span>
<span className="text-sm font-bold">{weather.main.humidity}%</span>
</div>
</div>
</Card>
<Card className="p-2">
<div className="flex items-center gap-1">
<Wind className="w-5 h-5 text-gray-500" />
<div className="flex flex-col">
<span className="text-xs text-muted-foreground">Wind</span>
<span className="text-sm font-bold">{Math.round(weather.wind.speed)} mph</span>
</div>
</div>
</Card>
<Card className="p-2">
<div className="flex items-center gap-1">
<Sunrise className="w-5 h-5 text-yellow-500" />
<div className="flex flex-col">
<span className="text-xs text-muted-foreground">Sunrise</span>
<span className="text-sm font-bold">{formatTime(weather.sys?.sunrise)}</span>
</div>
</div>
</Card>
<Card className="p-2">
<div className="flex items-center gap-1">
<Sunset className="w-5 h-5 text-orange-400" />
<div className="flex flex-col">
<span className="text-xs text-muted-foreground">Sunset</span>
<span className="text-sm font-bold">{formatTime(weather.sys?.sunset)}</span>
</div>
</div>
</Card>
</div>
{forecast && (
<div>
<div className="grid grid-cols-5 gap-2">
{forecast.map((day, index) => (
<Card key={index} className="p-2">
<div className="flex flex-col items-center gap-1">
<span className="text-sm font-medium">
{new Date(day.dt * 1000).toLocaleDateString('en-US', { weekday: 'short' })}
</span>
{getWeatherIcon(day.weather[0].id, new Date(day.dt * 1000), true)}
<div className="flex justify-center gap-1 items-baseline w-full">
<span className="text-sm font-bold">
{Math.round(day.main.temp_max)}°
</span>
<span className="text-xs text-muted-foreground">
{Math.round(day.main.temp_min)}°
</span>
</div>
<div className="flex flex-col items-center gap-1 w-full pt-1">
{day.rain?.['3h'] > 0 && (
<div className="flex items-center gap-1">
<CloudRain className="w-3 h-3 text-blue-400" />
<span className="text-xs">{day.rain['3h'].toFixed(2)}"</span>
</div>
)}
{day.snow?.['3h'] > 0 && (
<div className="flex items-center gap-1">
<CloudSnow className="w-3 h-3 text-blue-400" />
<span className="text-xs">{day.snow['3h'].toFixed(2)}"</span>
</div>
)}
{!day.rain?.['3h'] && !day.snow?.['3h'] && (
<div className="flex items-center gap-1">
<Umbrella className="w-3 h-3 text-gray-400" />
<span className="text-xs">0"</span>
</div>
)}
</div>
</div>
</Card>
))}
</div>
</div>
)}
</div>
);
const formatDate = (date) =>
date.toLocaleDateString("en-US", {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
});
const formatTimeDisplay = (date) => {
const hours = date.getHours();
const minutes = String(date.getMinutes()).padStart(2, "0");
const seconds = String(date.getSeconds()).padStart(2, "0");
const period = hours >= 12 ? "PM" : "AM";
const displayHours = hours % 12 || 12;
return `${displayHours}:${minutes}:${seconds} ${period}`;
};
const greeting = (date) => {
const hour = date.getHours();
if (hour < 12) return "Good morning";
if (hour < 17) return "Good afternoon";
return "Good evening";
};
const Header = ({ right = null }) => {
const now = new Date();
return (
<Card
className={cn(
`w-full ${CARD_STYLES.solid} shadow-sm`,
isStuck ? "rounded-b-lg border-b-1" : "border-b-0 rounded-b-none"
)}
>
<CardContent className="p-4">
<div className="flex flex-col justify-between lg:flex-row items-center sm:items-center flex-wrap">
<div className="flex items-center space-x-4">
<div className="flex space-x-2">
<div
onClick={toggleTheme}
className={cn(
"bg-gradient-to-r from-blue-500 to-blue-600 p-3 rounded-lg shadow-md cursor-pointer hover:opacity-90 transition-opacity",
theme === "light" && "ring-1 ring-yellow-300",
theme === "dark" && "ring-1 ring-purple-300",
"ring-offset-2 ring-offset-white dark:ring-offset-gray-900"
)}
>
<CraftsIcon />
</div>
</div>
<div>
<h1 className="text-2xl font-bold bg-gradient-to-r from-blue-600 to-blue-400 bg-clip-text text-transparent">
ACOT Dashboard
</h1>
</div>
</div>
<div className="flex items-left sm:items-center justify-center flex-wrap mt-2 sm:mt-0">
{weather?.main && (
<>
<div className="flex-col items-center text-center">
<Popover>
<PopoverTrigger asChild>
<div className="items-center justify-center space-x-2 rounded-lg px-4 hidden sm:flex cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors p-2">
{getWeatherIcon(weather.weather[0]?.id, currentTime)}
<div>
<p className="text-xl font-bold tracking-tight dark:text-gray-100">
{Math.round(weather.main.temp)}° F
</p>
</div>
{weather.alerts && (
<AlertTriangle className="w-5 h-5 text-red-500 ml-1" />
)}
</div>
</PopoverTrigger>
<PopoverContent className="w-[450px]" align="end" side="bottom" sideOffset={5}>
{weather.alerts && (
<Alert variant="warning" className="mb-3">
<AlertTriangle className="h-3 w-3" />
<AlertDescription className="text-xs">
{weather.alerts[0].event}
</AlertDescription>
</Alert>
)}
<WeatherDetails />
</PopoverContent>
</Popover>
</div>
</>
)}
<div className="h-10 w-px bg-gradient-to-b from-gray-200 to-gray-200 dark:from-gray-700 dark:to-gray-700 hidden sm:block"></div>
<div className="flex items-center space-x-1 sm:space-x-3 rounded-lg px-4 py-2">
<Calendar className="w-5 h-5 text-green-500 shrink-0" />
<div>
<p className="text-sm sm:text-xl font-bold tracking-tight p-0 dark:text-gray-100">
{formatDate(currentTime)}
</p>
</div>
</div>
<div className="h-10 w-px bg-gradient-to-b from-gray-200 to-gray-200 dark:from-gray-700 dark:to-gray-700 hidden sm:block"></div>
<div className="flex items-center space-x-1 sm:space-x-3 rounded-lg px-4 py-2">
<Clock className="w-5 h-5 text-blue-500 shrink-0" />
<div>
<p className="text-md sm:text-xl font-bold tracking-tight tabular-nums dark:text-gray-100 mr-2">
{formatTimeDisplay(currentTime)}
</p>
</div>
</div>
</div>
<header className="px-3 pt-5 sm:px-4 lg:px-5">
<div className="flex items-center justify-between gap-4 pb-1">
<div className="min-w-0">
<h1 className="text-lg font-bold tracking-tight text-[#2b2925]">
{greeting(now)}
</h1>
<p className="hidden text-xs text-[#7c7870] sm:block">
Here's how the shop is doing
</p>
</div>
</CardContent>
</Card>
<div className="flex shrink-0 items-center gap-3">
{right}
<span className="text-xs text-[#7c7870]">{formatDate(now)}</span>
</div>
</div>
</header>
);
};
@@ -49,18 +49,18 @@ const MetricCellContent = ({
if (isSMS && hideForSMS) {
return (
<div className="text-center">
<div className="text-muted-foreground text-lg font-semibold">N/A</div>
<div className="text-muted-foreground text-sm">-</div>
<div className="text-[13px] font-bold text-[#d8d4cd]">N/A</div>
<div className="text-[10.5px] text-[#a09b92]">-</div>
</div>
);
}
return (
<div className="text-center">
<div className="text-blue-600 dark:text-blue-400 text-lg font-semibold">
<div className="text-[13px] font-bold tabular-nums text-[#2b2925]">
{isMonetary ? formatCurrency(value) : formatRate(value, isSMS, hideForSMS)}
</div>
<div className="text-muted-foreground text-sm">
<div className="text-[10.5px] text-[#a09b92]">
{count?.toLocaleString() || 0} {count === 1 ? "recipient" : "recipients"}
{showConversionRate &&
totalRecipients > 0 &&
@@ -245,21 +245,6 @@ const KlaviyoCampaigns = ({ className }) => {
/>
),
},
{
key: "click_to_open_rate",
header: "CTR",
align: "center",
sortable: true,
render: (_, campaign) => (
<MetricCellContent
value={campaign.stats.click_to_open_rate}
count={campaign.stats.clicks_unique}
totalRecipients={campaign.stats.opens_unique}
isSMS={campaign.channel === 'sms'}
hideForSMS={true}
/>
),
},
{
key: "conversion_value",
header: "Orders",
@@ -284,11 +269,11 @@ const KlaviyoCampaigns = ({ className }) => {
<DashboardSectionHeader
title="Klaviyo Campaigns"
loading={true}
compact
size="large"
actions={<div className="w-[200px]" />}
timeSelector={<div className="w-[130px]" />}
/>
<CardContent className="overflow-y-auto pl-4 max-h-[400px] mb-4">
<CardContent className="overflow-y-auto p-4 pt-3 max-h-[400px]">
<TableSkeleton rows={15} columns={6} variant="detailed" />
</CardContent>
</Card>
@@ -301,60 +286,58 @@ const KlaviyoCampaigns = ({ className }) => {
<DashboardErrorState
title="Failed to load campaigns"
message={error}
className="mx-6 mt-4"
className="mx-4 mt-3"
/>
)}
<DashboardSectionHeader
title="Klaviyo Campaigns"
compact
size="large"
actions={
<div className="flex gap-1 items-center">
<Button
variant={selectedChannels.email ? "default" : "outline"}
size="sm"
onClick={() => setSelectedChannels(prev => {
if (prev.email && Object.values(prev).filter(Boolean).length === 1) {
return { email: true, sms: true, blog: true };
}
return { email: true, sms: false, blog: false };
})}
>
<Mail className="h-4 w-4" />
<span className="hidden sm:inline">Email</span>
</Button>
<Button
variant={selectedChannels.sms ? "default" : "outline"}
size="sm"
onClick={() => setSelectedChannels(prev => {
if (prev.sms && Object.values(prev).filter(Boolean).length === 1) {
return { email: true, sms: true, blog: true };
}
return { email: false, sms: true, blog: false };
})}
>
<MessageSquare className="h-4 w-4" />
<span className="hidden sm:inline">SMS</span>
</Button>
<Button
variant={selectedChannels.blog ? "default" : "outline"}
size="sm"
onClick={() => setSelectedChannels(prev => {
if (prev.blog && Object.values(prev).filter(Boolean).length === 1) {
return { email: true, sms: true, blog: true };
}
return { email: false, sms: false, blog: true };
})}
>
<BookOpen className="h-4 w-4" />
<span className="hidden sm:inline">Blog</span>
</Button>
<div className="flex items-center gap-0.5">
{/* When all channels are on, "active" reads as the default state so
all three highlight; clicking one isolates it, clicking again
resets to all. Only the isolated channel highlights then. */}
{(() => {
const allOn = selectedChannels.email && selectedChannels.sms && selectedChannels.blog;
const toggle = (channel) =>
setSelectedChannels((prev) => {
const onlyThis = prev[channel] && Object.values(prev).filter(Boolean).length === 1;
if (onlyThis) return { email: true, sms: true, blog: true };
return {
email: channel === "email",
sms: channel === "sms",
blog: channel === "blog",
};
});
return [
{ key: "email", label: "Email" },
{ key: "sms", label: "SMS" },
{ key: "blog", label: "Blog" },
].map(({ key, label }) => {
const active = !allOn && selectedChannels[key];
return (
<button
key={key}
type="button"
onClick={() => toggle(key)}
className={`h-7 shrink-0 rounded-full px-2.5 text-xs font-medium transition-colors ${
active
? "bg-[#f0eeea] text-[#2b2925] hover:bg-[#eae7e2]"
: "text-[#a09b92] hover:bg-[#f5f3f0] hover:text-[#2b2925]"
}`}
>
{label}
</button>
);
});
})()}
</div>
}
timeSelector={
<BusinessRangeSelect value={selectedTimeRange} onValueChange={setSelectedTimeRange} />
}
/>
<CardContent className="pl-4 mb-4">
<CardContent className="p-4 pt-3">
<DashboardTable
columns={columns}
data={filteredCampaigns}
@@ -1,6 +1,6 @@
import React, { useState, useEffect } from "react";
import { apiFetch } from "@/utils/api";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Card, CardContent } from "@/components/ui/card";
import {
Select,
SelectContent,
@@ -28,6 +28,9 @@ import {
DashboardErrorState,
DashboardTable,
TableSkeleton,
QuietStat,
QUIET_STRIP_CLASS,
PILL_TRIGGER_CLASS,
} from "@/components/dashboard/shared";
// Helper functions for formatting
@@ -59,11 +62,11 @@ const MetricCellContent = ({ value, label, sublabel, isMonetary = false, isPerce
return (
<div className="text-center">
<div className="text-blue-600 dark:text-blue-400 text-lg font-semibold">
<div className="text-[13px] font-bold tabular-nums text-[#2b2925]">
{formattedValue}
</div>
{(label || sublabel) && (
<div className="text-muted-foreground text-sm">
<div className="text-[10.5px] text-[#a09b92]">
{label || sublabel}
</div>
)}
@@ -385,28 +388,6 @@ const MetaCampaigns = () => {
/>
),
},
{
key: "impressions",
header: "Impressions",
align: "center",
sortable: true,
render: (_, campaign) => (
<MetricCellContent value={campaign.metrics.impressions} />
),
},
{
key: "cpm",
header: "CPM",
align: "center",
sortable: true,
render: (_, campaign) => (
<MetricCellContent
value={campaign.metrics.cpm}
isMonetary
decimalPlaces={2}
/>
),
},
{
key: "ctr",
header: "CTR",
@@ -447,15 +428,6 @@ const MetaCampaigns = () => {
/>
),
},
{
key: "engagements",
header: "Engagements",
align: "center",
sortable: true,
render: (_, campaign) => (
<MetricCellContent value={campaign.metrics.totalPostEngagements} />
),
},
];
if (loading) {
@@ -464,17 +436,15 @@ const MetaCampaigns = () => {
<DashboardSectionHeader
title="Meta Ads Performance"
loading={true}
compact
size="large"
timeSelector={<div className="w-[130px]" />}
/>
<CardHeader className="pt-0 pb-2">
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4">
<CardContent className="p-4 pt-3 space-y-3">
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-2">
{[...Array(12)].map((_, i) => (
<DashboardStatCardSkeleton key={i} size="compact" />
))}
</div>
</CardHeader>
<CardContent className="p-4">
<TableSkeleton rows={5} columns={9} variant="detailed" />
</CardContent>
</Card>
@@ -498,10 +468,10 @@ const MetaCampaigns = () => {
<Card className={`h-full ${CARD_STYLES.base}`}>
<DashboardSectionHeader
title="Meta Ads Performance"
compact
size="large"
timeSelector={
<Select value={timeframe} onValueChange={setTimeframe}>
<SelectTrigger className="w-[130px] bg-background">
<SelectTrigger className={PILL_TRIGGER_CLASS}>
<SelectValue placeholder="Select range" />
</SelectTrigger>
<SelectContent>
@@ -514,96 +484,24 @@ const MetaCampaigns = () => {
</Select>
}
/>
<CardHeader className="pt-0 pb-2">
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4 dashboard-stagger">
<DashboardStatCard
title="Active Campaigns"
value={formatNumber(summaryMetrics?.totalCampaigns)}
icon={Target}
iconColor="purple"
size="compact"
/>
<DashboardStatCard
title="Total Spend"
value={formatCurrency(summaryMetrics?.totalSpend, 0)}
icon={DollarSign}
iconColor="green"
size="compact"
/>
<DashboardStatCard
title="Total Reach"
value={formatNumber(summaryMetrics?.totalReach)}
icon={Users}
iconColor="blue"
size="compact"
/>
<DashboardStatCard
title="Total Impressions"
value={formatNumber(summaryMetrics?.totalImpressions)}
icon={Eye}
iconColor="indigo"
size="compact"
/>
<DashboardStatCard
title="Avg Frequency"
value={formatNumber(summaryMetrics?.avgFrequency, 2)}
icon={Repeat}
iconColor="cyan"
size="compact"
/>
<DashboardStatCard
title="Total Engagements"
value={formatNumber(summaryMetrics?.totalPostEngagements)}
icon={MessageCircle}
iconColor="pink"
size="compact"
/>
<DashboardStatCard
title="Avg CPM"
value={formatCurrency(summaryMetrics?.avgCpm, 2)}
icon={DollarSign}
iconColor="emerald"
size="compact"
/>
<DashboardStatCard
title="Avg CTR"
value={formatPercent(summaryMetrics?.avgCtr, 2)}
icon={BarChart}
iconColor="orange"
size="compact"
/>
<DashboardStatCard
title="Avg CPC"
value={formatCurrency(summaryMetrics?.avgCpc, 2)}
icon={MousePointer}
iconColor="rose"
size="compact"
/>
<DashboardStatCard
title="Total Link Clicks"
value={formatNumber(summaryMetrics?.totalLinkClicks)}
icon={MousePointer}
iconColor="amber"
size="compact"
/>
<DashboardStatCard
title="Total Purchases"
value={formatNumber(summaryMetrics?.totalPurchases)}
icon={ShoppingCart}
iconColor="teal"
size="compact"
/>
<DashboardStatCard
title="Purchase Value"
value={formatCurrency(summaryMetrics?.totalPurchaseValue, 0)}
icon={DollarSign}
iconColor="lime"
size="compact"
/>
<CardContent className="p-4 pt-3 space-y-3">
{/* Summary metrics as a quiet strip — same deemphasized treatment as
the secondary stats on the Overview section */}
<div className={`${QUIET_STRIP_CLASS} grid-cols-2 sm:grid-cols-3 lg:grid-cols-6`}>
<QuietStat label="Campaigns" value={formatNumber(summaryMetrics?.totalCampaigns)} />
<QuietStat label="Spend" value={formatCurrency(summaryMetrics?.totalSpend, 0)} />
<QuietStat label="Reach" value={formatNumber(summaryMetrics?.totalReach)} />
<QuietStat label="Impressions" value={formatNumber(summaryMetrics?.totalImpressions)} />
<QuietStat label="Frequency" value={formatNumber(summaryMetrics?.avgFrequency, 2)} />
<QuietStat label="Engagements" value={formatNumber(summaryMetrics?.totalPostEngagements)} />
<QuietStat label="CPM" value={formatCurrency(summaryMetrics?.avgCpm, 2)} />
<QuietStat label="CTR" value={formatPercent(summaryMetrics?.avgCtr, 2)} />
<QuietStat label="CPC" value={formatCurrency(summaryMetrics?.avgCpc, 2)} />
<QuietStat label="Link clicks" value={formatNumber(summaryMetrics?.totalLinkClicks)} />
<QuietStat label="Purchases" value={formatNumber(summaryMetrics?.totalPurchases)} />
<QuietStat label="Purchase value" value={formatCurrency(summaryMetrics?.totalPurchaseValue, 0)} />
</div>
</CardHeader>
<CardContent className="pl-4 mb-4">
<DashboardTable
columns={columns}
data={sortedCampaigns}
+97 -252
View File
@@ -1,282 +1,127 @@
import React, { useState, useEffect, useRef, useContext, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { useScroll } from "@/contexts/DashboardScrollContext";
import { ArrowUpToLine } from "lucide-react";
import React, { useContext, useEffect, useMemo, useRef, useState } from "react";
import { ArrowUp } from "lucide-react";
import { AuthContext } from "@/contexts/AuthContext";
import { useScroll } from "@/contexts/DashboardScrollContext";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
const ALL_SECTIONS = [
{ id: "stats", label: "Overview", permission: "dashboard:stats" },
{ id: "financial", label: "Financial", permission: "dashboard:financial" },
{ id: "sales", label: "Sales", permission: "dashboard:sales" },
{ id: "feed", label: "Events", permission: "dashboard:feed" },
{ id: "operations-metrics", label: "Operations", permission: "dashboard:operations" },
{ id: "payroll-metrics", label: "Payroll", permission: "dashboard:payroll" },
{ id: "products", label: "Products", permission: "dashboard:products" },
{ id: "campaigns", label: "Klaviyo", permission: "dashboard:campaigns" },
{ id: "analytics", label: "Analytics", permission: "dashboard:analytics" },
{ id: "user-behavior", label: "Behavior", permission: "dashboard:user_behavior" },
{ id: "meta-campaigns", label: "Meta Ads", permission: "dashboard:meta_campaigns" },
{ id: "typeform", label: "Surveys", permission: "dashboard:typeform" },
{ id: "customer-service", label: "Support", permission: "dashboard:customer_service" },
];
const Navigation = () => {
const [activeSections, setActiveSections] = useState([]);
const [activeSection, setActiveSection] = useState(null);
const { isStuck, scrollContainerRef, scrollToSection } = useScroll();
const { user } = useContext(AuthContext);
const navRef = useRef(null);
const buttonRefs = useRef({});
const scrollContainerRef2 = useRef(null);
const [shouldAutoScroll, setShouldAutoScroll] = useState(true);
const lastScrollLeft = useRef(0);
const lastScrollTop = useRef(0);
// Define all possible sections with their permission requirements
const allSections = [
{ id: "stats", label: "Statistics", permission: "dashboard:stats" },
{ id: "realtime", label: "Realtime", permission: "dashboard:realtime" },
{ id: "financial", label: "Financial", permission: "dashboard:financial" },
{ id: "payroll-metrics", label: "Payroll", permission: "dashboard:payroll" },
{ id: "operations-metrics", label: "Operations", permission: "dashboard:operations" },
{ id: "feed", label: "Event Feed", permission: "dashboard:feed" },
{ id: "sales", label: "Sales Chart", permission: "dashboard:sales" },
{ id: "products", label: "Top Products", permission: "dashboard:products" },
{ id: "campaigns", label: "Campaigns", permission: "dashboard:campaigns" },
{ id: "analytics", label: "Analytics", permission: "dashboard:analytics" },
{ id: "user-behavior", label: "User Behavior", permission: "dashboard:user_behavior" },
{ id: "meta-campaigns", label: "Meta Ads", permission: "dashboard:meta_campaigns" },
{ id: "typeform", label: "Customer Surveys", permission: "dashboard:typeform" },
];
// Filter sections based on user permissions
const baseSections = useMemo(() => {
const sections = useMemo(() => {
if (!user) return [];
// Admins see all sections
if (user.is_admin) return allSections;
// Filter sections based on user permissions
return allSections.filter(section =>
user.permissions && user.permissions.includes(section.permission)
if (user.is_admin) return ALL_SECTIONS;
return ALL_SECTIONS.filter((section) =>
user.permissions?.includes(section.permission)
);
}, [user]);
const sortSections = (sections) => {
const isMediumScreen = window.matchMedia(
"(min-width: 768px) and (max-width: 1023px)"
).matches;
useEffect(() => {
const container = scrollContainerRef.current;
if (!container || !sections.length) return undefined;
return [...sections].sort((a, b) => {
const aOrder = a.order
? isMediumScreen
? a.order.md
: a.order.default
: 0;
const bOrder = b.order
? isMediumScreen
? b.order.md
: b.order.default
: 0;
const updateActiveSection = () => {
const containerTop = container.getBoundingClientRect().top;
let current = sections[0]?.id ?? null;
if (aOrder && bOrder) {
return aOrder - bOrder;
}
return 0;
sections.forEach(({ id }) => {
const element = document.getElementById(id);
if (element && element.getBoundingClientRect().top - containerTop <= 140) {
current = id;
}
});
setActiveSection(current);
};
container.addEventListener("scroll", updateActiveSection, { passive: true });
updateActiveSection();
return () => container.removeEventListener("scroll", updateActiveSection);
}, [scrollContainerRef, sections]);
useEffect(() => {
const activeButton = activeSection && buttonRefs.current[activeSection];
activeButton?.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "center",
});
};
const sections = sortSections(baseSections);
}, [activeSection]);
const scrollToTop = () => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTo({
top: 0,
behavior: "smooth",
});
} else {
window.scrollTo({
top: 0,
behavior: "smooth",
});
}
scrollContainerRef.current?.scrollTo({ top: 0, behavior: "smooth" });
};
const handleSectionClick = (sectionId, responsiveIds) => {
scrollToSection(sectionId);
};
// Track horizontal scroll position changes
useEffect(() => {
const container = scrollContainerRef.current;
if (!container) return;
const handleButtonBarScroll = () => {
if (Math.abs(container.scrollLeft - lastScrollLeft.current) > 5) {
setShouldAutoScroll(false);
}
lastScrollLeft.current = container.scrollLeft;
};
container.addEventListener("scroll", handleButtonBarScroll);
return () => container.removeEventListener("scroll", handleButtonBarScroll);
}, []);
// Handle page scroll and active sections
useEffect(() => {
const handlePageScroll = (e) => {
const scrollTop = e?.target?.scrollTop || window.pageYOffset || document.documentElement.scrollTop;
if (Math.abs(scrollTop - lastScrollTop.current) > 5) {
setShouldAutoScroll(true);
lastScrollTop.current = scrollTop;
} else {
return;
}
const activeIds = [];
const viewportHeight = window.innerHeight;
const threshold = viewportHeight * 0.5;
const container = scrollContainerRef.current;
sections.forEach((section) => {
if (section.responsiveIds) {
const visibleId = section.responsiveIds.find((id) => {
const element = document.getElementById(id);
if (!element) return false;
const style = window.getComputedStyle(element);
if (style.display === "none") return false;
if (container) {
// For container-based scrolling
const rect = element.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const relativeTop = rect.top - containerRect.top;
const relativeBottom = rect.bottom - containerRect.top;
return (
relativeTop < containerRect.height - threshold &&
relativeBottom > threshold
);
} else {
// For window-based scrolling
const rect = element.getBoundingClientRect();
return (
rect.top < viewportHeight - threshold && rect.bottom > threshold
);
}
});
if (visibleId) {
activeIds.push(section.id);
}
} else {
const element = document.getElementById(section.id);
if (element) {
if (container) {
// For container-based scrolling
const rect = element.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const relativeTop = rect.top - containerRect.top;
const relativeBottom = rect.bottom - containerRect.top;
if (
relativeTop < containerRect.height - threshold &&
relativeBottom > threshold
) {
activeIds.push(section.id);
}
} else {
// For window-based scrolling
const rect = element.getBoundingClientRect();
if (
rect.top < viewportHeight - threshold &&
rect.bottom > threshold
) {
activeIds.push(section.id);
}
}
}
}
});
setActiveSections(activeIds);
if (shouldAutoScroll && activeIds.length > 0) {
const firstActiveButton = buttonRefs.current[activeIds[0]];
if (firstActiveButton && scrollContainerRef2.current) {
scrollContainerRef2.current.scrollTo({
left:
firstActiveButton.offsetLeft -
scrollContainerRef2.current.offsetWidth / 2 +
firstActiveButton.offsetWidth / 2,
behavior: "auto",
});
}
}
};
// Attach to container or window
const container = scrollContainerRef.current;
if (container) {
container.addEventListener("scroll", handlePageScroll);
handlePageScroll({ target: container });
} else {
window.addEventListener("scroll", handlePageScroll);
handlePageScroll();
}
return () => {
if (container) {
container.removeEventListener("scroll", handlePageScroll);
} else {
window.removeEventListener("scroll", handlePageScroll);
}
};
}, [sections, shouldAutoScroll, scrollContainerRef]);
return (
<div
className={cn(
"sticky z-50 px-4 transition-all duration-200",
isStuck ? "top-1 sm:top-2 md:top-4 rounded-lg" : "rounded-t-none"
"sticky top-0 z-40 px-3 sm:px-4 lg:px-5",
isStuck && "bg-[#f8f7f5]/90 backdrop-blur-lg"
)}
>
<Card
<div
className={cn(
"w-full bg-background transition-all duration-200",
isStuck
? "rounded-lg mt-2 shadow-md"
: "shadow-sm rounded-t-none border-t-0 -mt-6 pb-2"
"flex min-w-0 items-center border-b border-[#eceae5] transition-shadow",
isStuck && "shadow-[0_8px_16px_-16px_rgba(43,41,37,0.35)]"
)}
>
<CardContent className="py-2 px-4">
<div className="grid grid-cols-[1fr_auto] items-center min-w-0 relative">
<div
ref={scrollContainerRef2}
className="overflow-x-auto no-scrollbar min-w-0 -mx-1 px-1 touch-pan-x overscroll-y-contain pr-12"
<div
ref={navRef}
className="no-scrollbar flex min-w-0 flex-1 gap-0.5 overflow-x-auto py-2"
>
{sections.map(({ id, label }) => (
<Button
key={id}
ref={(element) => {
buttonRefs.current[id] = element;
}}
variant="ghost"
size="sm"
className={cn(
"h-7 shrink-0 rounded-full px-3 text-xs font-medium text-[#7c7870]",
"hover:bg-[#f0eeea] hover:text-[#2b2925]",
activeSection === id &&
"bg-[#2b2925] text-white hover:bg-[#2b2925] hover:text-white"
)}
onClick={() => scrollToSection(id)}
>
<div className="flex flex-nowrap space-x-1">
{sections.map(({ id, label, responsiveIds }) => (
<Button
key={id}
ref={(el) => (buttonRefs.current[id] = el)}
variant={activeSections.includes(id) ? "default" : "ghost"}
size="sm"
className={cn(
"whitespace-nowrap flex-shrink-0 px-1 md:px-3 py-2 transition-all duration-200",
activeSections.includes(id) &&
"bg-blue-100 dark:bg-blue-900/70 text-primary dark:text-blue-100 shadow-sm hover:bg-blue-100 dark:hover:bg-blue-900/70 md:hover:bg-blue-200 dark:md:hover:bg-blue-900",
!activeSections.includes(id) &&
"hover:bg-blue-100 dark:hover:bg-blue-900/40 md:hover:bg-blue-50 dark:md:hover:bg-blue-900/20 hover:text-primary dark:hover:text-blue-100 dark:text-gray-400",
"focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-ring focus-visible:ring-offset-background",
"disabled:pointer-events-none disabled:opacity-50"
)}
onClick={() => handleSectionClick(id, responsiveIds)}
>
{label}
</Button>
))}
</div>
</div>
<div className="absolute -right-2.5 top-0 bottom-0 flex items-center bg-background pl-1 pr-0">
<Button
variant="icon"
size="sm"
className={cn(
"flex-shrink-0 h-10 w-10 p-0 hover:bg-blue-100 dark:hover:bg-blue-900/40",
isStuck ? "" : "hidden"
)}
onClick={scrollToTop}
>
<ArrowUpToLine className="h-4 w-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
{label}
</Button>
))}
</div>
{isStuck && (
<Button
variant="ghost"
size="icon"
className="ml-2 h-7 w-7 shrink-0"
onClick={scrollToTop}
aria-label="Scroll to top"
>
<ArrowUp className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
);
};
@@ -1,7 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import { acotService } from "@/services/dashboard/acotService";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
@@ -9,7 +8,6 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import {
Table,
TableBody,
@@ -22,7 +20,6 @@ import {
Area,
CartesianGrid,
ComposedChart,
Legend,
Line,
ResponsiveContainer,
Tooltip,
@@ -35,7 +32,7 @@ import PeriodSelectionPopover, {
type QuickPreset,
} from "@/components/dashboard/PeriodSelectionPopover";
import type { CustomPeriod, NaturalLanguagePeriodResult } from "@/utils/naturalLanguagePeriod";
import { CARD_STYLES } from "@/lib/dashboard/designTokens";
import { CARD_STYLES, STUDIO_COLORS } from "@/lib/dashboard/designTokens";
import {
DashboardSectionHeader,
DashboardStatCard,
@@ -44,7 +41,8 @@ import {
DashboardEmptyState,
DashboardErrorState,
TOOLTIP_STYLES,
METRIC_COLORS,
MetricPill,
PILL_TRIGGER_CLASS,
} from "@/components/dashboard/shared";
import { Tooltip as TooltipUI, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { toDateOnly } from "@/utils/businessTime";
@@ -127,11 +125,13 @@ type ChartPoint = {
tooltipLabel: string;
};
// Sorbet Studio quad — pieces series stay dashed so hue is never the only
// separator between the picked/shipped pairs
const chartColors: Record<ChartSeriesKey, string> = {
ordersPicked: METRIC_COLORS.orders,
piecesPicked: METRIC_COLORS.aov,
ordersShipped: METRIC_COLORS.profit,
piecesShipped: METRIC_COLORS.secondary,
ordersPicked: STUDIO_COLORS.violet,
piecesPicked: STUDIO_COLORS.amber,
ordersShipped: STUDIO_COLORS.teal,
piecesShipped: STUDIO_COLORS.coral,
};
const SERIES_LABELS: Record<ChartSeriesKey, string> = {
@@ -598,7 +598,7 @@ const OperationsMetrics = () => {
actions={headerActions}
/>
<CardContent className="p-6 pt-0">
<CardContent className="p-4 pt-3 space-y-3">
{!error && (
loading ? (
<SkeletonStats />
@@ -608,43 +608,35 @@ const OperationsMetrics = () => {
)}
{!error && (
<div className="flex items-center flex-col sm:flex-row gap-0 sm:gap-4">
<div className="flex flex-wrap gap-1">
{SERIES_DEFINITIONS.map((series) => (
<Button
key={series.key}
variant={metrics[series.key] ? "default" : "outline"}
size="sm"
onClick={() => toggleMetric(series.key)}
>
{series.label}
</Button>
))}
</div>
<Separator orientation="vertical" className="h-6 hidden sm:block" />
<Separator orientation="horizontal" className="sm:hidden w-20 my-2" />
<div className="flex items-center gap-2">
<div className="text-sm text-muted-foreground">Group:</div>
<Select value={groupBy} onValueChange={handleGroupByChange}>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="Group By" />
</SelectTrigger>
<SelectContent>
{GROUP_BY_CHOICES.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-wrap items-center gap-0.5">
{SERIES_DEFINITIONS.map((series) => (
<MetricPill
key={series.key}
active={metrics[series.key]}
color={chartColors[series.key]}
onClick={() => toggleMetric(series.key)}
>
{series.label}
</MetricPill>
))}
<span className="mx-1.5 h-4 w-px bg-[#eceae5]" />
<Select value={groupBy} onValueChange={handleGroupByChange}>
<SelectTrigger className={PILL_TRIGGER_CLASS}>
<SelectValue placeholder="Group by" />
</SelectTrigger>
<SelectContent className="rounded-xl">
{GROUP_BY_CHOICES.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{loading ? (
<div className="flex flex-col lg:flex-row gap-6 mt-4">
<div className="flex flex-col lg:flex-row gap-3">
<div className="w-full lg:w-[45%]">
<LeaderboardTableSkeleton />
</div>
@@ -661,14 +653,14 @@ const OperationsMetrics = () => {
description="Try selecting a different time range"
/>
) : (
<div className="flex flex-col lg:flex-row gap-6 mt-4">
<div className="flex flex-col lg:flex-row gap-3">
<div className="w-full lg:w-[45%]">
<OperationsLeaderboard
picking={data?.byEmployee?.picking ?? []}
shipping={data?.byEmployee?.shipping ?? []}
/>
</div>
<div className={`h-[300px] w-full lg:w-[55%] ${CARD_STYLES.base} rounded-lg p-0 relative`}>
<div className={`h-[280px] w-full lg:w-[55%] ${CARD_STYLES.subtle} p-0 relative`}>
{!hasActiveMetrics ? (
<DashboardEmptyState
icon={TrendingUp}
@@ -677,7 +669,7 @@ const OperationsMetrics = () => {
/>
) : (
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={chartData} margin={{ top: 5, right: 15, left: 15, bottom: 5 }}>
<ComposedChart data={chartData} margin={{ top: 8, right: 0, left: -8, bottom: 0 }}>
<defs>
<linearGradient id="operationsOrdersPicked" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={chartColors.ordersPicked} stopOpacity={0.8} />
@@ -706,7 +698,6 @@ const OperationsMetrics = () => {
/>
)}
<Tooltip content={<OperationsTooltip />} />
<Legend formatter={(value: string) => SERIES_LABELS[value as ChartSeriesKey] ?? value} />
{metrics.ordersPicked && (
<Area
@@ -791,7 +782,7 @@ const ICON_MAP = {
function OperationsStatGrid({ cards }: { cards: OperationsStatCardConfig[] }) {
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4 w-full dashboard-stagger">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2 w-full dashboard-stagger">
{cards.map((card) => (
<DashboardStatCard
key={card.key}
@@ -817,7 +808,7 @@ function OperationsStatGrid({ cards }: { cards: OperationsStatCardConfig[] }) {
function SkeletonStats() {
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4 w-full">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2 w-full">
{Array.from({ length: 4 }).map((_, index) => (
<DashboardStatCardSkeleton key={index} hasIcon hasSubtitle />
))}
@@ -868,8 +859,8 @@ const OperationsTooltip = ({ active, payload, label }: TooltipProps<number, stri
function LeaderboardTableSkeleton() {
return (
<div className={`h-[280px] rounded-lg border ${CARD_STYLES.base} overflow-hidden`}>
<div className="p-3 border-b bg-muted/30">
<div className="h-[280px] overflow-hidden rounded-xl border border-[#efede9] bg-[#faf9f7]">
<div className="p-3 border-b border-[#f0eeea]">
<div className="h-4 w-32 bg-muted rounded animate-pulse" />
</div>
<div className="p-2 space-y-2">
@@ -954,26 +945,23 @@ function OperationsLeaderboard({
if (leaderboard.length === 0) {
return (
<div className={`h-[300px] rounded-lg border ${CARD_STYLES.base} flex items-center justify-center`}>
<div className="h-[280px] rounded-xl border border-[#efede9] bg-[#faf9f7] flex items-center justify-center">
<p className="text-sm text-muted-foreground">No employee data</p>
</div>
);
}
return (
<div className={`h-[300px] rounded-lg border ${CARD_STYLES.base} overflow-hidden flex flex-col`}>
<div className="p-3 border-b bg-muted/30 flex-none">
<h4 className="text-sm font-medium">Top Performers</h4>
</div>
<div className="h-[280px] rounded-xl border border-[#efede9] bg-[#faf9f7] overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto">
<Table>
<TableHeader>
<TableHeader className="sticky top-0 z-10 bg-[#faf9f7]">
<TableRow className="hover:bg-transparent">
<TableHead className="h-8 text-xs px-2 w-8" />
<TableHead className="h-8 text-xs px-2">Name</TableHead>
<TableHead className="h-8 text-xs px-2 text-right">Picked</TableHead>
<TableHead className="h-8 text-xs px-2 text-right">Shipped</TableHead>
<TableHead className="h-8 text-xs px-2 text-right">
<TableHead className="h-8 px-2 text-[10.5px] font-semibold uppercase tracking-wide text-[#a09b92] w-8" />
<TableHead className="h-8 px-2 text-[10.5px] font-semibold uppercase tracking-wide text-[#a09b92]">Name</TableHead>
<TableHead className="h-8 px-2 text-[10.5px] font-semibold uppercase tracking-wide text-[#a09b92] text-right">Picked</TableHead>
<TableHead className="h-8 px-2 text-[10.5px] font-semibold uppercase tracking-wide text-[#a09b92] text-right">Shipped</TableHead>
<TableHead className="h-8 px-2 text-[10.5px] font-semibold uppercase tracking-wide text-[#a09b92] text-right">
<TooltipUI>
<TooltipTrigger asChild>
<span className="cursor-help">Hours</span>
@@ -983,7 +971,7 @@ function OperationsLeaderboard({
</TooltipContent>
</TooltipUI>
</TableHead>
<TableHead className="h-8 text-xs px-2 text-right">Speed</TableHead>
<TableHead className="h-8 px-2 text-[10.5px] font-semibold uppercase tracking-wide text-[#a09b92] text-right">Speed</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -20,9 +20,9 @@ import {
import {
Bar,
CartesianGrid,
Legend,
Line,
ComposedChart,
Rectangle,
ResponsiveContainer,
Tooltip,
XAxis,
@@ -30,7 +30,7 @@ import {
} from "recharts";
import type { TooltipProps } from "recharts";
import { Clock, Users, AlertTriangle, ChevronLeft, ChevronRight, Calendar, TrendingUp } from "lucide-react";
import { CARD_STYLES } from "@/lib/dashboard/designTokens";
import { CARD_STYLES, STUDIO_COLORS } from "@/lib/dashboard/designTokens";
import {
DashboardSectionHeader,
DashboardStatCard,
@@ -39,7 +39,8 @@ import {
DashboardEmptyState,
DashboardErrorState,
TOOLTIP_STYLES,
METRIC_COLORS,
LegendChip,
PILL_TRIGGER_CLASS,
} from "@/components/dashboard/shared";
type ComparisonValue = {
@@ -123,13 +124,24 @@ const PERIOD_COUNT_OPTIONS: { value: PeriodCountOption; label: string }[] = [
{ value: 12, label: "12 periods" },
];
// Sorbet Studio hues: violet+amber stacked segments (amber doubles as the
// "hot" overtime tone), teal FTE line on the right axis. `hours` was unused.
const chartColors = {
regular: METRIC_COLORS.orders,
overtime: METRIC_COLORS.expense,
hours: METRIC_COLORS.profit,
fte: METRIC_COLORS.secondary,
regular: STUDIO_COLORS.violet,
overtime: STUDIO_COLORS.amber,
fte: STUDIO_COLORS.teal,
};
// Rounds only the segment that tops the stack: overtime when present,
// otherwise regular (a fixed radius on both would notch every boundary).
const BAR_RADIUS: [number, number, number, number] = [6, 6, 0, 0];
const OvertimeBarShape = (props: any) => <Rectangle {...props} radius={BAR_RADIUS} />;
const RegularBarShape = (props: any) => (
<Rectangle {...props} radius={props.payload?.overtime > 0 ? undefined : BAR_RADIUS} />
);
const formatNumber = (value: number, decimals = 0) => {
if (!Number.isFinite(value)) return "0";
return value.toLocaleString("en-US", {
@@ -454,7 +466,7 @@ const PayrollMetrics = () => {
}}
disabled={loading}
>
<SelectTrigger className="h-9 w-[110px]">
<SelectTrigger className={PILL_TRIGGER_CLASS}>
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -470,7 +482,7 @@ const PayrollMetrics = () => {
<Button
variant="outline"
size="icon"
className="h-9 w-9"
className="h-8 w-8 rounded-full border-[#e7e5e1] shadow-none text-[#7c7870] hover:bg-[#faf9f7] hover:text-[#2b2925]"
onClick={() => navigatePeriod("prev")}
disabled={loading}
>
@@ -478,7 +490,7 @@ const PayrollMetrics = () => {
</Button>
<Button
variant="outline"
className="h-9 px-3 min-w-[120px]"
className="h-8 min-w-[110px] rounded-full border-[#e7e5e1] px-3.5 text-xs font-semibold text-[#2b2925] shadow-none hover:bg-[#faf9f7]"
onClick={goToCurrentPeriod}
disabled={loading || isAtCurrentPeriod}
>
@@ -488,7 +500,7 @@ const PayrollMetrics = () => {
<Button
variant="outline"
size="icon"
className="h-9 w-9"
className="h-8 w-8 rounded-full border-[#e7e5e1] shadow-none text-[#7c7870] hover:bg-[#faf9f7] hover:text-[#2b2925]"
onClick={() => navigatePeriod("next")}
disabled={loading || isAtCurrentPeriod}
>
@@ -506,7 +518,7 @@ const PayrollMetrics = () => {
actions={headerActions}
/>
<CardContent className="p-6 pt-0">
<CardContent className="p-4 pt-3 space-y-3">
{!error && (
loading ? (
<SkeletonStats />
@@ -516,7 +528,7 @@ const PayrollMetrics = () => {
)}
{loading ? (
<div className="flex flex-col lg:flex-row gap-6">
<div className="flex flex-col lg:flex-row gap-3">
<div className="w-full lg:w-[65%]">
<ChartSkeleton type="bar" height="md" withCard={false} />
</div>
@@ -533,10 +545,18 @@ const PayrollMetrics = () => {
description="Try selecting a different pay period"
/>
) : (
<div className="flex flex-col lg:flex-row gap-6">
<div className={`h-[300px] w-full lg:w-[65%] ${CARD_STYLES.base} rounded-lg p-0 relative`}>
<div className="flex flex-col lg:flex-row gap-3">
<div className="w-full lg:w-[65%]">
<div className="flex flex-wrap items-center gap-0.5 pb-1">
<LegendChip color={chartColors.regular}>Regular hours</LegendChip>
<LegendChip color={chartColors.overtime}>Overtime</LegendChip>
<LegendChip color={chartColors.fte} dashed>
FTE
</LegendChip>
</div>
<div className="h-[256px] relative">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={chartData} margin={{ top: 20, right: 20, left: 20, bottom: 5 }}>
<ComposedChart data={chartData} margin={{ top: 8, right: 0, left: -8, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="label"
@@ -561,13 +581,13 @@ const PayrollMetrics = () => {
tick={{ fill: "currentColor" }}
/>
<Tooltip content={<PayrollTrendTooltip />} />
<Legend />
<Bar
yAxisId="hours"
dataKey="regular"
name="Regular Hours"
stackId="hours"
fill={chartColors.regular}
shape={RegularBarShape}
/>
<Bar
yAxisId="hours"
@@ -575,6 +595,7 @@ const PayrollMetrics = () => {
name="Overtime"
stackId="hours"
fill={chartColors.overtime}
shape={OvertimeBarShape}
/>
<Line
yAxisId="fte"
@@ -587,6 +608,7 @@ const PayrollMetrics = () => {
/>
</ComposedChart>
</ResponsiveContainer>
</div>
</div>
<div className="w-full lg:w-[35%]">
<PayrollEmployeeSummary
@@ -623,7 +645,7 @@ const ICON_MAP = {
function PayrollStatGrid({ cards }: { cards: PayrollStatCardConfig[] }) {
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4 w-full dashboard-stagger">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2 w-full dashboard-stagger">
{cards.map((card) => (
<DashboardStatCard
key={card.key}
@@ -649,7 +671,7 @@ function PayrollStatGrid({ cards }: { cards: PayrollStatCardConfig[] }) {
function SkeletonStats() {
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4 w-full">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2 w-full">
{Array.from({ length: 4 }).map((_, index) => (
<DashboardStatCardSkeleton key={index} hasIcon hasSubtitle />
))}
@@ -719,8 +741,8 @@ const PayrollTrendTooltip = ({ active, payload, label }: TooltipProps<number, st
function EmployeeTableSkeleton() {
return (
<div className={`h-[300px] rounded-lg border ${CARD_STYLES.base} overflow-hidden`}>
<div className="p-3 border-b bg-muted/30">
<div className="h-[280px] rounded-xl border border-[#efede9] bg-[#faf9f7] overflow-hidden">
<div className="p-3 border-b border-[#f0eeea]">
<div className="h-4 w-32 bg-muted rounded animate-pulse" />
</div>
<div className="p-2">
@@ -769,41 +791,31 @@ function PayrollEmployeeSummary({
if (sortedEmployees.length === 0) {
return (
<div className={`h-[300px] rounded-lg border ${CARD_STYLES.base} flex items-center justify-center`}>
<div className="h-[280px] rounded-xl border border-[#efede9] bg-[#faf9f7] flex items-center justify-center">
<p className="text-sm text-muted-foreground">No employee data</p>
</div>
);
}
const periodLabel = periodCount === 1
? "1 period"
: `${periodCount} periods`;
return (
<div className={`h-[300px] rounded-lg border ${CARD_STYLES.base} overflow-hidden flex flex-col`}>
<div className="p-3 border-b bg-muted/30 flex-none">
<h4 className="text-sm font-medium">
Employee Summary
<span className="text-muted-foreground font-normal ml-1">({periodLabel})</span>
</h4>
</div>
<div className="h-[280px] rounded-xl border border-[#efede9] bg-[#faf9f7] overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto">
<Table>
<TableHeader>
<TableHeader className="sticky top-0 z-10 bg-[#faf9f7]">
<TableRow className="hover:bg-transparent">
<TableHead className="h-8 text-xs px-3">Name</TableHead>
<TableHead className="h-8 px-3 text-[10.5px] font-semibold uppercase tracking-wide text-[#a09b92]">Name</TableHead>
{hasWeekData ? (
<>
<TableHead className="h-8 text-xs px-2 text-right">Wk 1</TableHead>
<TableHead className="h-8 text-xs px-2 text-right">Wk 2</TableHead>
<TableHead className="h-8 px-2 text-right text-[10.5px] font-semibold uppercase tracking-wide text-[#a09b92]">Wk 1</TableHead>
<TableHead className="h-8 px-2 text-right text-[10.5px] font-semibold uppercase tracking-wide text-[#a09b92]">Wk 2</TableHead>
</>
) : (
<TableHead className="h-8 text-xs px-2 text-right">Regular</TableHead>
<TableHead className="h-8 px-2 text-right text-[10.5px] font-semibold uppercase tracking-wide text-[#a09b92]">Regular</TableHead>
)}
<TableHead className="h-8 text-xs px-2 text-right">Total</TableHead>
<TableHead className="h-8 text-xs px-2 text-right">OT</TableHead>
<TableHead className="h-8 px-2 text-right text-[10.5px] font-semibold uppercase tracking-wide text-[#a09b92]">Total</TableHead>
<TableHead className="h-8 px-2 text-right text-[10.5px] font-semibold uppercase tracking-wide text-[#a09b92]">OT</TableHead>
{periodCount > 1 && (
<TableHead className="h-8 text-xs px-2 text-right">Avg/Per</TableHead>
<TableHead className="h-8 px-2 text-right text-[10.5px] font-semibold uppercase tracking-wide text-[#a09b92]">Avg/Per</TableHead>
)}
</TableRow>
</TableHeader>
@@ -98,61 +98,70 @@ const PeriodSelectionPopover = ({
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
<Button variant="outline" className="h-9">
<Button
variant="outline"
className="h-8 gap-1.5 rounded-full border-[#e7e5e1] bg-white px-3.5 text-xs font-semibold text-[#2b2925] shadow-none hover:bg-[#faf9f7]"
>
{selectedLabel}
<ChevronDown className="w-4 h-4 text-muted-foreground" />
<ChevronDown className="h-3.5 w-3.5 text-[#a09b92]" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-96 p-4" align="end">
<PopoverContent className="w-96 rounded-[14px] border-[#e7e5e1] p-4 shadow-lg" align="end">
<div className="space-y-4">
<div className="text-sm font-medium">Select Time Period</div>
<div className="text-[10.5px] font-semibold uppercase tracking-wide text-[#a09b92]">
Time period
</div>
<div className="grid grid-cols-3 gap-2">
<Button
variant={isLast30DaysActive ? "default" : "outline"}
variant="outline"
size="sm"
onClick={() => handleQuickSelect("last30days")}
className="h-8 text-xs"
className={`h-8 rounded-full text-xs shadow-none ${
isLast30DaysActive
? "border-[#2b2925] bg-[#2b2925] text-white hover:bg-[#2b2925]/90 hover:text-white"
: "border-[#e7e5e1] text-[#2b2925] hover:bg-[#faf9f7]"
}`}
>
Last 30 Days
</Button>
<Button
variant="outline"
size="sm"
className="h-8 rounded-full border-[#e7e5e1] text-xs text-[#2b2925] shadow-none hover:bg-[#faf9f7]"
onClick={() => handleQuickSelect("thisMonth")}
className="h-8 text-xs"
>
This Month
</Button>
<Button
variant="outline"
size="sm"
className="h-8 rounded-full border-[#e7e5e1] text-xs text-[#2b2925] shadow-none hover:bg-[#faf9f7]"
onClick={() => handleQuickSelect("lastMonth")}
className="h-8 text-xs"
>
Last Month
</Button>
<Button
variant="outline"
size="sm"
className="h-8 rounded-full border-[#e7e5e1] text-xs text-[#2b2925] shadow-none hover:bg-[#faf9f7]"
onClick={() => handleQuickSelect("thisQuarter")}
className="h-8 text-xs"
>
This Quarter
</Button>
<Button
variant="outline"
size="sm"
className="h-8 rounded-full border-[#e7e5e1] text-xs text-[#2b2925] shadow-none hover:bg-[#faf9f7]"
onClick={() => handleQuickSelect("lastQuarter")}
className="h-8 text-xs"
>
Last Quarter
</Button>
<Button
variant="outline"
size="sm"
className="h-8 rounded-full border-[#e7e5e1] text-xs text-[#2b2925] shadow-none hover:bg-[#faf9f7]"
onClick={() => handleQuickSelect("thisYear")}
className="h-8 text-xs"
>
This Year
</Button>
@@ -161,26 +170,26 @@ const PeriodSelectionPopover = ({
<Separator />
<div className="space-y-3">
<div className="text-xs text-muted-foreground">Or enter a custom period:</div>
<div className="text-xs text-[#7c7870]">Or enter a custom period:</div>
<div className="relative">
<Input
value={inputValue}
onChange={(event) => handleInputChange(event.target.value)}
onKeyDown={handleKeyDown}
className="h-8 text-sm"
className="h-8 rounded-full border-[#e7e5e1] px-3.5 text-sm shadow-none"
/>
{inputValue && (
<div className="mt-2 ml-3">
{preview.label ? (
<div className="flex items-center gap-2 text-xs">
<span className="font-medium text-green-600 dark:text-green-400">
<span className="font-medium text-[#237a4d]">
{preview.label}
</span>
</div>
) : (
<div className="text-xs text-amber-600 dark:text-amber-400">
<div className="text-xs text-[#a87a24]">
Not recognized
</div>
)}
@@ -7,7 +7,7 @@ import {
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Lock, Delete } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { toast } from "sonner";
const MAX_ATTEMPTS = 3;
const LOCKOUT_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds
@@ -27,8 +27,6 @@ const PinProtection = ({ onSuccess }) => {
}
return 0;
});
const { toast } = useToast();
useEffect(() => {
let timer;
if (lockoutTime > 0) {
@@ -59,34 +57,25 @@ const PinProtection = ({ onSuccess }) => {
if (newAttempts >= MAX_ATTEMPTS) {
setLockoutTime(LOCKOUT_DURATION);
toast({
title: "Too many attempts",
toast.error("Too many attempts", {
description: `Please try again in ${Math.ceil(LOCKOUT_DURATION / 60000)} minutes`,
variant: "destructive",
});
setPin("");
return;
}
if (value === "892312") {
toast({
title: "Success",
description: "PIN accepted",
});
toast.success("PIN accepted");
// Reset attempts on success
setAttempts(0);
localStorage.removeItem('pinAttempts');
localStorage.removeItem('lastAttemptTime');
onSuccess();
} else {
toast({
title: "Error",
description: `Incorrect PIN. ${MAX_ATTEMPTS - newAttempts} attempts remaining`,
variant: "destructive",
});
toast.error(`Incorrect PIN. ${MAX_ATTEMPTS - newAttempts} attempts remaining`);
setPin("");
}
}, [attempts, lockoutTime, onSuccess, toast]);
}, [attempts, lockoutTime, onSuccess]);
const handleKeyPress = (value) => {
if (pin.length < 6) {
+168 -333
View File
@@ -1,376 +1,211 @@
import React, { useState, useEffect } from "react";
import axios from "axios";
import { acotService } from "@/services/dashboard/acotService";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Loader2, ArrowUpDown, AlertCircle, Package, Settings2, Search, X } from "lucide-react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Package, Search, X } from "lucide-react";
import { BusinessRangeSelect } from "@/components/dashboard/shared/BusinessRangeSelect";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { CARD_STYLES, TYPOGRAPHY } from "@/lib/dashboard/designTokens";
import { CARD_STYLES } from "@/lib/dashboard/designTokens";
import { DashboardEmptyState, DashboardErrorState } from "@/components/dashboard/shared";
/**
* Top Sellers ranked product list (Sorbet Studio).
* Rank badges cycle the pastel tile fills; revenue is the bold right column.
*/
const RANK_FILLS = ["#fbe7de", "#ddf0ea", "#faf0d7", "#eae6f6"];
const SORTS = [
{ key: "totalQuantity", label: "Sold" },
{ key: "totalRevenue", label: "Revenue" },
];
const ProductGrid = ({
timeRange = "today",
onTimeRangeChange,
title = "Top Products",
description
title = "Top sellers",
}) => {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [selectedTimeRange, setSelectedTimeRange] = useState(timeRange);
const [sorting, setSorting] = useState({
column: "totalQuantity",
direction: "desc",
});
const [sortKey, setSortKey] = useState("totalQuantity");
const [searchQuery, setSearchQuery] = useState("");
const [isSearchVisible, setIsSearchVisible] = useState(false);
useEffect(() => {
fetchProducts();
let cancelled = false;
(async () => {
try {
setLoading(true);
setError(null);
const response = await acotService.getProducts({ timeRange: selectedTimeRange });
if (!cancelled) setProducts(response.stats.products.list || []);
} catch (err) {
console.error("Error fetching products:", err);
if (!cancelled) setError(err.message);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [selectedTimeRange]);
const fetchProducts = async () => {
try {
setLoading(true);
setError(null);
const response = await acotService.getProducts({ timeRange: selectedTimeRange });
setProducts(response.stats.products.list || []);
} catch (error) {
console.error("Error fetching products:", error);
setError(error.message);
} finally {
setLoading(false);
}
};
const handleTimeRangeChange = (value) => {
setSelectedTimeRange(value);
if (onTimeRangeChange) {
onTimeRangeChange(value);
}
if (onTimeRangeChange) onTimeRangeChange(value);
};
const handleSort = (column) => {
setSorting((prev) => ({
column,
direction:
prev.column === column && prev.direction === "desc" ? "asc" : "desc",
}));
};
const sortedProducts = [...products].sort((a, b) => {
const direction = sorting.direction === "desc" ? -1 : 1;
const aValue = a[sorting.column];
const bValue = b[sorting.column];
if (typeof aValue === "number") {
return (aValue - bValue) * direction;
}
return String(aValue).localeCompare(String(bValue)) * direction;
});
const filteredProducts = sortedProducts.filter(product =>
const ranked = [...products].sort((a, b) => (b[sortKey] || 0) - (a[sortKey] || 0));
const filtered = ranked.filter((product) =>
product.name.toLowerCase().includes(searchQuery.toLowerCase())
);
const SkeletonProduct = () => (
<tr className="hover:bg-muted/50 transition-colors">
<td className="p-1 align-middle w-[50px]">
<Skeleton className="h-[50px] w-[50px] rounded bg-muted" />
</td>
<td className="p-1 align-middle min-w-[200px]">
<div className="flex flex-col gap-1.5">
<Skeleton className="h-4 w-[180px] bg-muted rounded-sm" />
<Skeleton className="h-3 w-[140px] bg-muted rounded-sm" />
</div>
</td>
<td className="p-1 align-middle text-center">
<Skeleton className="h-4 w-8 mx-auto bg-muted rounded-sm" />
</td>
<td className="p-1 align-middle text-center">
<Skeleton className="h-4 w-16 mx-auto bg-muted rounded-sm" />
</td>
<td className="p-1 align-middle text-center">
<Skeleton className="h-4 w-8 mx-auto bg-muted rounded-sm" />
</td>
</tr>
);
const LoadingState = () => (
<div className="h-full">
<div className="overflow-y-auto h-full">
<table className="w-full">
<thead>
<tr className="hover:bg-transparent">
<th className="p-1.5 text-left font-medium sticky top-0 bg-card z-10 w-[50px] min-w-[50px] border-b border-border/50" />
<th className="p-1.5 text-left font-medium sticky top-0 bg-card z-10 min-w-[200px] border-b border-border/50">
<Button
variant="ghost"
className="w-full p-2 justify-start h-8 pointer-events-none"
disabled
>
<Skeleton className="h-4 w-16 bg-muted rounded-sm" />
</Button>
</th>
<th className="p-1.5 font-medium text-center sticky top-0 bg-card z-10 border-b border-border/50">
<Button
variant="ghost"
className="w-full p-2 justify-center h-8 pointer-events-none"
disabled
>
<Skeleton className="h-4 w-12 bg-muted rounded-sm" />
</Button>
</th>
<th className="p-1.5 font-medium text-center sticky top-0 bg-card z-10 border-b border-border/50">
<Button
variant="ghost"
className="w-full p-2 justify-center h-8 pointer-events-none"
disabled
>
<Skeleton className="h-4 w-12 bg-muted rounded-sm" />
</Button>
</th>
<th className="p-1.5 font-medium text-center sticky top-0 bg-card z-10 border-b border-border/50">
<Button
variant="ghost"
className="w-full p-2 justify-center h-8 pointer-events-none"
disabled
>
<Skeleton className="h-4 w-16 bg-muted rounded-sm" />
</Button>
</th>
</tr>
</thead>
<tbody className="divide-y divide-border/50">
{[...Array(20)].map((_, i) => (
<SkeletonProduct key={i} />
))}
</tbody>
</table>
</div>
</div>
);
if (loading) {
return (
<Card className={`flex flex-col h-full ${CARD_STYLES.base}`}>
<CardHeader className="p-6 pb-4">
<div className="flex flex-col gap-4">
<div className="flex justify-between items-start">
<div>
<CardTitle className={TYPOGRAPHY.sectionTitle}>
<Skeleton className="h-6 w-32 bg-muted rounded-sm" />
</CardTitle>
{description && (
<CardDescription className="mt-1">
<Skeleton className="h-4 w-48 bg-muted rounded-sm" />
</CardDescription>
)}
</div>
<div className="flex items-center gap-2">
<Skeleton className="h-9 w-9 bg-muted rounded-sm" />
<Skeleton className="h-9 w-[130px] bg-muted rounded-sm" />
</div>
</div>
</div>
</CardHeader>
<CardContent className="p-6 pt-0 flex-1 overflow-hidden -mt-1">
<div className="h-full">
<LoadingState />
</div>
</CardContent>
</Card>
);
}
return (
<Card className={`flex flex-col h-full ${CARD_STYLES.base}`}>
<CardHeader className="p-6 pb-4">
<div className="flex flex-col gap-4">
<div className="flex justify-between items-start">
<div>
<CardTitle className={TYPOGRAPHY.sectionTitle}>{title}</CardTitle>
{description && (
<CardDescription className={`mt-1 ${TYPOGRAPHY.cardDescription}`}>{description}</CardDescription>
)}
</div>
<div className="flex items-center gap-2">
{!error && (
<Button
variant="outline"
size="icon"
onClick={() => setIsSearchVisible(!isSearchVisible)}
<CardHeader className="border-b border-[#f0eeea] px-4 py-3">
<div className="flex items-center justify-between gap-3">
<CardTitle className="text-[12.5px] font-semibold tracking-tight text-[#2b2925]">
{title}
</CardTitle>
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-0.5">
{SORTS.map(({ key, label }) => (
<button
key={key}
type="button"
onClick={() => setSortKey(key)}
className={cn(
"h-9 w-9",
isSearchVisible && "bg-muted"
"h-7 rounded-full px-2.5 text-xs font-medium transition-colors",
sortKey === key
? "bg-[#f0eeea] text-[#2b2925]"
: "text-[#a09b92] hover:bg-[#f5f3f0] hover:text-[#2b2925]"
)}
>
<Search className="h-4 w-4" />
</Button>
)}
<BusinessRangeSelect
value={selectedTimeRange}
onValueChange={handleTimeRangeChange}
/>
{label}
</button>
))}
</div>
{!error && (
<Button
variant="ghost"
size="icon"
onClick={() => setIsSearchVisible(!isSearchVisible)}
className={cn(
"h-8 w-8 rounded-full text-[#7c7870] hover:bg-[#f5f3f0]",
isSearchVisible && "bg-[#f0eeea] text-[#2b2925]"
)}
>
<Search className="h-3.5 w-3.5" />
</Button>
)}
<BusinessRangeSelect
value={selectedTimeRange}
onValueChange={handleTimeRangeChange}
/>
</div>
{isSearchVisible && !error && (
<div className="relative w-full">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search products..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 pr-9 h-9 w-full"
autoFocus
/>
{searchQuery && (
<Button
variant="ghost"
size="icon"
className="absolute right-1 top-1 h-7 w-7"
onClick={() => setSearchQuery("")}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
)}
</div>
{isSearchVisible && !error && (
<div className="relative w-full pt-1.5">
<Search className="absolute left-3 top-[15px] h-3.5 w-3.5 text-[#a09b92]" />
<Input
placeholder="Search products…"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 w-full rounded-full border-[#e7e5e1] pl-9 pr-9 text-xs shadow-none"
autoFocus
/>
{searchQuery && (
<Button
variant="ghost"
size="icon"
className="absolute right-1 top-[7px] h-7 w-7 rounded-full"
onClick={() => setSearchQuery("")}
>
<X className="h-3.5 w-3.5" />
</Button>
)}
</div>
)}
</CardHeader>
<CardContent className="p-6 pt-0 flex-1 overflow-hidden -mt-1">
<div className="h-full">
{error ? (
<DashboardErrorState error={`Failed to load products: ${error}`} className="mx-0 my-0" />
) : !products?.length ? (
<DashboardEmptyState
icon={Package}
title="No product data available"
description="Try selecting a different time range"
height="sm"
/>
) : (
<div className="h-full">
<div className="overflow-y-auto h-full">
<table className="w-full">
<thead>
<tr className="hover:bg-transparent">
<th className="p-1 text-left font-medium sticky top-0 bg-card z-10 h-[50px] min-h-[50px] w-[50px] min-w-[35px] border-b border-border/50" />
<th className="p-1 text-left font-medium sticky top-0 bg-card z-10 border-b border-border/50">
<Button
variant={sorting.column === "name" ? "default" : "ghost"}
onClick={() => handleSort("name")}
className="w-full p-2 justify-start h-8"
>
Product
</Button>
</th>
<th className="p-1 font-medium text-center sticky top-0 bg-card z-10 border-b border-border/50">
<Button
variant={sorting.column === "totalQuantity" ? "default" : "ghost"}
onClick={() => handleSort("totalQuantity")}
className="w-full p-2 justify-center h-8"
>
Sold
</Button>
</th>
<th className="p-1 font-medium text-center sticky top-0 bg-card z-10 border-b border-border/50">
<Button
variant={sorting.column === "totalRevenue" ? "default" : "ghost"}
onClick={() => handleSort("totalRevenue")}
className="w-full p-2 justify-center h-8"
>
Rev
</Button>
</th>
<th className="p-1 font-medium text-center sticky top-0 bg-card z-10 border-b border-border/50">
<Button
variant={sorting.column === "orderCount" ? "default" : "ghost"}
onClick={() => handleSort("orderCount")}
className="w-full p-2 justify-center h-8"
>
Orders
</Button>
</th>
</tr>
</thead>
<tbody className="divide-y divide-border/50">
{filteredProducts.map((product) => (
<tr
key={product.id}
className="hover:bg-muted/50 transition-colors"
>
<td className="p-1 align-middle w-[50px]">
{product.ImgThumb && (
<img
src={product.ImgThumb}
alt=""
width={50}
height={50}
className="rounded bg-muted w-[50px] h-[50px] object-contain"
onError={(e) => (e.target.style.display = "none")}
/>
)}
</td>
<td className="p-1 align-middle min-w-[200px]">
<div className="flex flex-col min-w-0">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<a
href={`https://backend.acherryontop.com/product/${product.id}`}
target="_blank"
rel="noopener noreferrer"
className="text-sm hover:underline line-clamp-2 text-foreground"
>
{product.name}
</a>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[300px]">
<p>{product.name}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</td>
<td className="p-1 align-middle text-center text-sm font-medium text-foreground">
{product.totalQuantity}
</td>
<td className="p-1 align-middle text-center text-emerald-600 dark:text-emerald-400 text-sm font-medium">
${product.totalRevenue.toFixed(2)}
</td>
<td className="p-1 align-middle text-center text-muted-foreground text-sm">
{product.orderCount}
</td>
</tr>
))}
</tbody>
</table>
<CardContent className="flex-1 overflow-hidden p-0">
{loading ? (
<div>
{[...Array(8)].map((_, i) => (
<div
key={i}
className="flex items-center gap-3 border-b border-[#f5f3f0] px-4 py-2.5 last:border-b-0"
>
<div className="h-7 w-7 animate-pulse rounded-lg bg-[#f0eeea]" />
<div className="h-9 w-9 animate-pulse rounded-lg bg-[#f5f3f0]" />
<div className="flex-1">
<div className="h-3.5 w-3/4 animate-pulse rounded bg-[#f0eeea]" />
<div className="mt-1.5 h-3 w-24 animate-pulse rounded bg-[#f5f3f0]" />
</div>
<div className="h-4 w-14 animate-pulse rounded bg-[#f0eeea]" />
</div>
</div>
)}
</div>
))}
</div>
) : error ? (
<DashboardErrorState error={`Failed to load products: ${error}`} className="m-4" />
) : !filtered.length ? (
<DashboardEmptyState
icon={Package}
title="No product data available"
description="Try selecting a different time range"
height="sm"
/>
) : (
<div className="h-full overflow-y-auto">
{filtered.map((product, index) => (
<a
key={product.id}
href={`https://backend.acherryontop.com/product/${product.id}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 border-b border-[#f5f3f0] px-4 py-2 transition-colors last:border-b-0 hover:bg-[#faf9f7]"
>
<span
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-xs font-bold tabular-nums text-[#2b2925]"
style={{
background: index < 4 ? RANK_FILLS[index] : "#f5f3f0",
color: index < 4 ? "#2b2925" : "#a09b92",
}}
>
{index + 1}
</span>
{product.ImgThumb ? (
<img
src={product.ImgThumb}
alt=""
width={36}
height={36}
className="h-9 w-9 shrink-0 rounded-lg bg-[#faf9f7] object-contain"
onError={(e) => (e.target.style.visibility = "hidden")}
/>
) : (
<span className="h-9 w-9 shrink-0 rounded-lg bg-[#faf9f7]" />
)}
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-[#2b2925]">
{product.name}
</div>
<div className="text-[11px] text-[#7c7870]">
{product.totalQuantity} sold · {product.orderCount}{" "}
{product.orderCount === 1 ? "order" : "orders"}
</div>
</div>
<span className="shrink-0 text-[13px] font-bold tabular-nums text-[#2b2925]">
${product.totalRevenue.toFixed(2)}
</span>
</a>
))}
</div>
)}
</CardContent>
</Card>
);
@@ -8,23 +8,20 @@ import {
YAxis,
Tooltip,
ResponsiveContainer,
PieChart,
Pie,
Cell,
} from "recharts";
import {
Tooltip as UITooltip,
TooltipContent,
TooltipTrigger,
TooltipProvider,
} from "@/components/ui/tooltip";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { format } from "date-fns";
import { Radio, Users } from "lucide-react";
// Import shared components and tokens
import {
DashboardChartTooltip,
DashboardSectionHeader,
DashboardStatCard,
DashboardTable,
StatCardSkeleton,
ChartSkeleton,
TableSkeleton,
DashboardErrorState,
@@ -36,15 +33,15 @@ import {
// Realtime-specific colors using the standardized palette
const REALTIME_COLORS = {
activeUsers: {
color: METRIC_COLORS.aov, // Purple
className: "text-chart-aov",
},
pages: {
color: METRIC_COLORS.revenue, // Emerald
color: METRIC_COLORS.revenue, // Studio coral realtime is a headline metric
className: "text-chart-revenue",
},
pages: {
color: METRIC_COLORS.orders, // Studio violet
className: "text-chart-orders",
},
sources: {
color: METRIC_COLORS.comparison, // Amber
color: METRIC_COLORS.comparison, // Studio teal
className: "text-chart-comparison",
},
};
@@ -52,10 +49,6 @@ const REALTIME_COLORS = {
// Export for backwards compatibility
export { REALTIME_COLORS as METRIC_COLORS };
export const SkeletonSummaryCard = () => (
<StatCardSkeleton size="default" hasIcon={false} hasSubtitle />
);
export const SkeletonBarChart = () => (
<ChartSkeleton type="bar" height="sm" withCard={false} />
);
@@ -192,6 +185,87 @@ export const QuotaInfo = ({ tokenQuota }) => {
);
};
// Traffic-sources donut mirrors the Device Usage tab in UserBehavior:
// ring with a center total and a labeled legend column (no on-slice labels).
const SOURCE_COLORS = ["#e06a4e", "#6b5fc7", "#0f8f77", "#a87a24", "#3f7fae", "#c94f76"];
const SourcesDonut = ({ sources, loading }) => {
if (loading) {
return (
<div className="flex h-full items-center gap-6">
<div className="h-40 w-40 shrink-0 animate-pulse rounded-full bg-[#f0eeea]" />
<div className="flex-1 space-y-2.5">
{[...Array(4)].map((_, i) => (
<div key={i} className="h-3.5 w-full animate-pulse rounded bg-[#f0eeea]" />
))}
</div>
</div>
);
}
const top = [...(sources || [])]
.sort((a, b) => b.activeUsers - a.activeUsers)
.slice(0, 6);
const total = top.reduce((sum, s) => sum + s.activeUsers, 0);
if (!total) {
return (
<div className="flex h-full items-center justify-center text-sm text-[#a09b92]">
No active sources
</div>
);
}
return (
<div className="flex h-full items-center gap-4 sm:gap-6">
<div className="relative h-40 w-40 shrink-0">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={top}
dataKey="activeUsers"
nameKey="source"
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={76}
paddingAngle={2}
stroke="#ffffff"
strokeWidth={2}
labelLine={false}
>
{top.map((entry, index) => (
<Cell key={entry.source} fill={SOURCE_COLORS[index % SOURCE_COLORS.length]} />
))}
</Pie>
</PieChart>
</ResponsiveContainer>
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
<span className="text-[19px] font-bold tabular-nums text-[#2b2925]">{total}</span>
<span className="text-[10px] font-semibold text-[#a09b92]">active</span>
</div>
</div>
<div className="min-w-0 flex-1 space-y-2 overflow-y-auto dashboard-scroll" style={{ maxHeight: 176 }}>
{top.map((entry, index) => (
<div key={entry.source} className="flex items-center gap-2.5">
<span
className="h-2 w-2 shrink-0 rounded-full"
style={{ background: SOURCE_COLORS[index % SOURCE_COLORS.length] }}
/>
<span className="min-w-0 flex-1 truncate text-xs font-medium text-[#2b2925]">
{entry.source}
</span>
<span className="text-xs tabular-nums text-[#7c7870]">{entry.activeUsers}</span>
<span className="w-10 text-right text-xs font-bold tabular-nums text-[#2b2925]">
{Math.round((entry.activeUsers / total) * 100)}%
</span>
</div>
))}
</div>
</div>
);
};
// Custom tooltip for the realtime chart
const RealtimeTooltip = ({ active, payload }) => {
if (active && payload && payload.length) {
@@ -342,43 +416,29 @@ export const RealtimeAnalytics = () => {
{
key: "path",
header: "Page",
// GA4's realtime API only exposes unifiedScreenName (page TITLE), not
// pagePath so these can't be linked, unlike the 30-day Top Pages table.
render: (value) => <span className="font-medium text-foreground">{value}</span>,
},
{
key: "activeUsers",
header: "Active Users",
align: "right",
width: "w-[110px] min-w-[110px] whitespace-nowrap",
render: (value) => <span className={REALTIME_COLORS.pages.className}>{value}</span>,
},
];
// Column definitions for sources table
const sourcesColumns = [
{
key: "source",
header: "Source",
render: (value) => <span className="font-medium text-foreground">{value}</span>,
},
{
key: "activeUsers",
header: "Active Users",
align: "right",
render: (value) => <span className={REALTIME_COLORS.sources.className}>{value}</span>,
},
];
if (loading && !basicData && !detailedData) {
return (
<Card className={`${CARD_STYLES.base} h-full`}>
<DashboardSectionHeader title="Real-Time Analytics" className="pb-2" />
<DashboardSectionHeader title="Real-Time Analytics" size="large" />
<CardContent className="p-6 pt-0">
<div className="grid grid-cols-2 gap-4 mt-1 mb-3">
<SkeletonSummaryCard />
<SkeletonSummaryCard />
</div>
<CardContent className="p-4 pt-3">
<div className="mb-3 h-[74px] animate-pulse rounded-xl bg-[#f0eeea]" />
<div className="space-y-4">
<div className="space-y-3">
<div className="flex gap-2">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-8 w-20 bg-muted animate-pulse rounded-md" />
@@ -395,57 +455,55 @@ export const RealtimeAnalytics = () => {
<Card className={`${CARD_STYLES.base} h-full`}>
<DashboardSectionHeader
title="Real-Time Analytics"
className="pb-2"
size="large"
actions={
<TooltipProvider>
<UITooltip>
<TooltipTrigger>
<div className={TYPOGRAPHY.label}>
Last updated:{" "}
{basicData.lastUpdated && format(new Date(basicData.lastUpdated), "h:mm a")}
</div>
</TooltipTrigger>
<TooltipContent className="p-3">
<QuotaInfo tokenQuota={basicData.tokenQuota} />
</TooltipContent>
</UITooltip>
</TooltipProvider>
<span className={TYPOGRAPHY.label}>
Updated{" "}
{basicData.lastUpdated && format(new Date(basicData.lastUpdated), "h:mm a")}
</span>
}
/>
<CardContent className="p-6 pt-0">
<CardContent className="p-4 pt-3">
{error && (
<DashboardErrorState error={error} className="mx-0 mb-4" />
)}
<div className="grid grid-cols-2 gap-4 mt-1 mb-3 dashboard-stagger">
<DashboardStatCard
title="Last 30 minutes"
subtitle="Active users"
value={basicData.last30MinUsers}
size="large"
/>
<DashboardStatCard
title="Last 5 minutes"
subtitle="Active users"
value={basicData.last5MinUsers}
size="large"
/>
<div className="mb-3 grid grid-cols-[1fr_auto] items-center gap-4 rounded-xl bg-[#e6f3ee] px-4 py-3">
<div className="flex items-center gap-3">
<div className="relative flex h-9 w-9 items-center justify-center rounded-full bg-white">
<Radio className="h-4 w-4 text-[#1f8a67]" />
<span className="absolute right-0 top-0 h-2 w-2 rounded-full bg-[#1f8a67] ring-2 ring-[#e6f3ee]" />
</div>
<div>
<p className="text-[11px] font-semibold text-[#2b2925]/60">Active now</p>
<p className="text-3xl font-bold leading-none tracking-tight tabular-nums text-[#2b2925]">
{basicData.last5MinUsers}
</p>
</div>
</div>
<div className="border-l border-[#cfe5dc] pl-4 text-right">
<div className="flex items-center justify-end gap-1.5 text-muted-foreground">
<Users className="h-3.5 w-3.5" />
<span className="text-[11px]">30 minutes</span>
</div>
<p className="text-lg font-semibold tabular-nums">{basicData.last30MinUsers}</p>
</div>
</div>
<Tabs defaultValue="activity" className="w-full">
<TabsList className="mb-4">
<TabsList className="mb-3 h-8">
<TabsTrigger value="activity">Activity</TabsTrigger>
<TabsTrigger value="pages">Current Pages</TabsTrigger>
<TabsTrigger value="sources">Traffic Sources</TabsTrigger>
</TabsList>
<TabsContent value="activity">
<div className="h-[235px] rounded-lg">
<div className="h-[176px] rounded-lg">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={basicData.byMinute}
margin={{ top: 5, right: 5, left: -35, bottom: -5 }}
margin={{ top: 8, right: 0, left: -28, bottom: -5 }}
>
<XAxis
dataKey="minute"
@@ -471,28 +529,20 @@ export const RealtimeAnalytics = () => {
</TabsContent>
<TabsContent value="pages">
<div className="h-[230px]">
<div className="h-[188px] overflow-y-auto dashboard-scroll">
<DashboardTable
columns={pagesColumns}
data={detailedData.currentPages}
loading={loading}
getRowKey={(page, index) => `${page.path}-${index}`}
maxHeight="sm"
compact
/>
</div>
</TabsContent>
<TabsContent value="sources">
<div className="h-[230px]">
<DashboardTable
columns={sourcesColumns}
data={detailedData.sources}
loading={loading}
getRowKey={(source, index) => `${source.source}-${index}`}
maxHeight="sm"
compact
/>
<div className="h-[188px]">
<SourcesDonut sources={detailedData.sources} loading={loading} />
</div>
</TabsContent>
</Tabs>
@@ -501,4 +551,60 @@ export const RealtimeAnalytics = () => {
);
};
/**
* RealtimeChip compact live-visitors pill for the page header. The full
* realtime panel (chart + pages + sources tabs) lives behind a popover, per
* the Studio design: realtime is ambient chrome, not a standing section.
*/
export const RealtimeChip = () => {
const [count, setCount] = useState(null);
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const response = await apiFetch("/api/dashboard-analytics/realtime/basic", {
credentials: "include",
});
if (!response.ok) return;
const result = await response.json();
if (!cancelled) setCount(processBasicData(result.data).last5MinUsers);
} catch {
// ambient chip stay quiet on errors
}
};
load();
const t = setInterval(load, 30000);
return () => {
cancelled = true;
clearInterval(t);
};
}, []);
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="flex h-8 items-center gap-2 rounded-full bg-[#ddf0ea] px-3.5 text-xs font-semibold text-[#2b2925] transition-[filter] hover:brightness-[1.03]"
>
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full rounded-full bg-[#1f8a67] opacity-60 motion-safe:animate-ping" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-[#1f8a67]" />
</span>
{count != null ? `${count} on site` : "Live"}
</button>
</PopoverTrigger>
<PopoverContent
align="end"
sideOffset={8}
className="w-[460px] max-w-[92vw] rounded-[14px] border-0 p-0 shadow-xl"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<RealtimeAnalytics />
</PopoverContent>
</Popover>
);
};
export default RealtimeAnalytics;
+127 -199
View File
@@ -32,7 +32,7 @@ import {
} from "@/components/ui/dialog";
import { Skeleton } from "@/components/ui/skeleton";
import { Separator } from "@/components/ui/separator";
import { CARD_STYLES } from "@/lib/dashboard/designTokens";
import { CARD_STYLES, STUDIO_COLORS } from "@/lib/dashboard/designTokens";
import {
DashboardSectionHeader,
DashboardStatCard,
@@ -41,18 +41,19 @@ import {
ChartSkeleton,
DashboardEmptyState,
DashboardErrorState,
METRIC_COLORS,
MetricPill,
} from "@/components/dashboard/shared";
// Chart color mapping using semantic tokens
// Chart color mapping Sorbet Studio series hues (prev-* stay dashed in the
// chart; the lighter prev tones are only legal alongside that dash)
const CHART_COLORS = {
revenue: METRIC_COLORS.revenue,
prevRevenue: METRIC_COLORS.comparison,
orders: METRIC_COLORS.orders,
prevOrders: METRIC_COLORS.secondary,
aov: METRIC_COLORS.aov,
prevAov: METRIC_COLORS.comparison,
movingAverage: METRIC_COLORS.comparison,
revenue: STUDIO_COLORS.coral,
prevRevenue: STUDIO_COLORS.teal,
orders: STUDIO_COLORS.violet,
prevOrders: "#9c92de",
aov: STUDIO_COLORS.amber,
prevAov: "#c9a45b",
movingAverage: "#a8a29a",
};
// Move formatCurrency to top and export it
@@ -224,105 +225,56 @@ const calculateSummaryStats = (data = []) => {
};
};
// Add memoized SummaryStats component
// Compact inline totals replaces the old four-card summary row. The hero
// tiles above the fold already carry the headline numbers; this line just
// anchors the chart to its period totals.
const SummaryStats = memo(({ stats = {}, projection = null, projectionLoading = false }) => {
const {
totalRevenue = 0,
totalOrders = 0,
avgOrderValue = 0,
bestDay = null,
prevRevenue = 0,
prevOrders = 0,
prevAvgOrderValue = 0,
periodProgress = 100
periodProgress = 100,
} = stats;
// Calculate projected values when period is incomplete
const currentRevenue = periodProgress < 100 ? (projection?.projectedRevenue || totalRevenue) : totalRevenue;
const revenueTrend = currentRevenue >= prevRevenue ? "up" : "down";
const revenueDiff = Math.abs(currentRevenue - prevRevenue);
const revenuePercentage = (revenueDiff / prevRevenue) * 100;
const currentRevenue =
periodProgress < 100 ? projection?.projectedRevenue || totalRevenue : totalRevenue;
const currentOrders =
periodProgress < 100 ? projection?.projectedOrders || totalOrders : totalOrders;
const showTrends = !(projectionLoading && periodProgress < 100);
const revPct = prevRevenue > 0 ? ((currentRevenue - prevRevenue) / prevRevenue) * 100 : null;
const ordPct = prevOrders > 0 ? ((currentOrders - prevOrders) / prevOrders) * 100 : null;
// Calculate order trends
const currentOrders = periodProgress < 100 ? (projection?.projectedOrders || totalOrders) : totalOrders;
const ordersTrend = currentOrders >= prevOrders ? "up" : "down";
const ordersDiff = Math.abs(currentOrders - prevOrders);
const ordersPercentage = (ordersDiff / prevOrders) * 100;
// Calculate AOV trends
const currentAOV = currentOrders ? currentRevenue / currentOrders : avgOrderValue;
const aovTrend = currentAOV >= prevAvgOrderValue ? "up" : "down";
const aovDiff = Math.abs(currentAOV - prevAvgOrderValue);
const aovPercentage = (aovDiff / prevAvgOrderValue) * 100;
// Convert trend direction to numeric value for DashboardStatCard
const getNumericTrend = (trendDir, percentage) => {
if (projectionLoading && periodProgress < 100) return undefined;
if (!isFinite(percentage)) return undefined;
return trendDir === "up" ? percentage : -percentage;
const Delta = ({ pct }) => {
if (!showTrends || pct == null || !isFinite(pct) || Math.abs(pct) < 0.1) return null;
return (
<span className={pct > 0 ? "text-[#237a4d]" : "text-[#b3503f]"}>
{" "}
{pct > 0 ? "↑" : "↓"}
{Math.abs(pct).toFixed(1)}%
</span>
);
};
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4 dashboard-stagger w-full">
<DashboardStatCard
title="Total Revenue"
value={formatCurrency(totalRevenue, false)}
subtitle={
periodProgress < 100
? `Projected: ${formatCurrency(projection?.projectedRevenue || totalRevenue, false)}`
: `Previous: ${formatCurrency(prevRevenue, false)}`
}
trend={getNumericTrend(revenueTrend, revenuePercentage) !== undefined
? { value: getNumericTrend(revenueTrend, revenuePercentage) }
: undefined}
tooltip="Total revenue for the selected period"
size="compact"
/>
<DashboardStatCard
title="Total Orders"
value={totalOrders.toLocaleString()}
subtitle={
periodProgress < 100
? `Projected: ${(projection?.projectedOrders || totalOrders).toLocaleString()}`
: `Previous: ${prevOrders.toLocaleString()}`
}
trend={getNumericTrend(ordersTrend, ordersPercentage) !== undefined
? { value: getNumericTrend(ordersTrend, ordersPercentage) }
: undefined}
tooltip="Total number of orders for the selected period"
size="compact"
/>
<DashboardStatCard
title="AOV"
value={formatCurrency(avgOrderValue)}
subtitle={
periodProgress < 100
? `Projected: ${formatCurrency(currentAOV)}`
: `Previous: ${formatCurrency(prevAvgOrderValue)}`
}
trend={getNumericTrend(aovTrend, aovPercentage) !== undefined
? { value: getNumericTrend(aovTrend, aovPercentage) }
: undefined}
tooltip="Average value per order for the selected period"
size="compact"
/>
<DashboardStatCard
title="Best Day"
value={formatCurrency(bestDay?.revenue || 0, false)}
subtitle={
bestDay?.timestamp
? `${new Date(bestDay.timestamp).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})} - ${bestDay.orders} orders`
: "No data"
}
tooltip="Day with highest revenue in the selected period"
size="compact"
/>
<div className="text-xs text-[#7c7870]">
Revenue{" "}
<span className="font-bold tabular-nums text-[#2b2925]">
{formatCurrency(totalRevenue, false)}
</span>
<Delta pct={revPct} />
<span className="mx-1.5 text-[#d8d4cd]">·</span>
Orders{" "}
<span className="font-bold tabular-nums text-[#2b2925]">
{totalOrders.toLocaleString()}
</span>
<Delta pct={ordPct} />
<span className="mx-1.5 text-[#d8d4cd]">·</span>
AOV{" "}
<span className="font-bold tabular-nums text-[#2b2925]">
{formatCurrency(avgOrderValue)}
</span>
</div>
);
});
@@ -332,14 +284,10 @@ SummaryStats.displayName = "SummaryStats";
// Note: Using ChartSkeleton and TableSkeleton from @/components/dashboard/shared
const SkeletonStats = () => (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4 max-w-3xl">
{[...Array(4)].map((_, i) => (
<DashboardStatCardSkeleton key={i} size="compact" hasIcon={false} hasSubtitle />
))}
</div>
<div className="h-4 w-64 animate-pulse rounded bg-[#f0eeea]" />
);
const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
const SalesChart = ({ timeRange = "last30days", title = "Sales" }) => {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
@@ -466,11 +414,15 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
const headerActions = !error ? (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" className="h-9">
<Button
variant="outline"
size="sm"
className="h-8 rounded-full border-[#e7e5e1] text-xs font-semibold text-[#2b2925] shadow-none hover:bg-[#faf9f7]"
>
Details
</Button>
</DialogTrigger>
<DialogContent className="p-4 max-w-[95vw] w-fit max-h-[85vh] overflow-hidden flex flex-col bg-card">
<DialogContent className="p-4 max-w-[95vw] w-fit max-h-[85vh] overflow-hidden flex flex-col rounded-[14px] border-[#e7e5e1] bg-white">
<DialogHeader className="flex-none">
<DialogTitle className="text-foreground">
Daily Details
@@ -478,8 +430,9 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
<div className="flex items-center justify-center gap-2 pt-4">
<div className="flex flex-wrap gap-1">
<Button
variant={metrics.revenue ? "default" : "outline"}
variant="ghost"
size="sm"
className={`h-7 rounded-full px-3 text-xs font-medium ${metrics.revenue ? "bg-[#f0eeea] text-[#2b2925] hover:bg-[#eae7e2]" : "text-[#7c7870] hover:bg-[#f5f3f0] hover:text-[#2b2925]"}`}
onClick={() =>
setMetrics((prev) => ({
...prev,
@@ -490,8 +443,9 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
Revenue
</Button>
<Button
variant={metrics.orders ? "default" : "outline"}
variant="ghost"
size="sm"
className={`h-7 rounded-full px-3 text-xs font-medium ${metrics.orders ? "bg-[#f0eeea] text-[#2b2925] hover:bg-[#eae7e2]" : "text-[#7c7870] hover:bg-[#f5f3f0] hover:text-[#2b2925]"}`}
onClick={() =>
setMetrics((prev) => ({
...prev,
@@ -503,9 +457,10 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
</Button>
<Button
variant={
metrics.movingAverage ? "default" : "outline"
metrics.movingAverage ? "secondary" : "ghost"
}
size="sm"
className="h-7 text-xs"
onClick={() =>
setMetrics((prev) => ({
...prev,
@@ -517,9 +472,10 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
</Button>
<Button
variant={
metrics.avgOrderValue ? "default" : "outline"
metrics.avgOrderValue ? "secondary" : "ghost"
}
size="sm"
className="h-7 text-xs"
onClick={() =>
setMetrics((prev) => ({
...prev,
@@ -534,8 +490,9 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
<Separator orientation="vertical" className="h-6" />
<Button
variant={metrics.showPrevious ? "default" : "outline"}
variant="ghost"
size="sm"
className={`h-7 rounded-full px-3 text-xs font-medium ${metrics.showPrevious ? "bg-[#f0eeea] text-[#2b2925] hover:bg-[#eae7e2]" : "text-[#7c7870] hover:bg-[#f5f3f0] hover:text-[#2b2925]"}`}
onClick={() =>
setMetrics((prev) => ({
...prev,
@@ -661,11 +618,12 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
<Card className={`w-full ${CARD_STYLES.elevated}`}>
<DashboardSectionHeader
title={title}
size="large"
timeSelector={timeSelector}
actions={headerActions}
/>
<CardContent className="p-6 pt-0 space-y-4">
<CardContent className="p-4 pt-3 space-y-3">
{/* Show stats only if not in error state */}
{!error && (
loading ? (
@@ -679,81 +637,47 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
)
)}
{/* Show metric toggles only if not in error state */}
{/* Metric toggle pills — the pill dot doubles as the series legend */}
{!error && (
<div className="flex items-center flex-col sm:flex-row gap-0 sm:gap-4">
<div className="flex flex-wrap gap-1">
<Button
variant={metrics.revenue ? "default" : "outline"}
size="sm"
onClick={() =>
setMetrics((prev) => ({
...prev,
revenue: !prev.revenue,
}))
}
>
Revenue
</Button>
<Button
variant={metrics.orders ? "default" : "outline"}
size="sm"
onClick={() =>
setMetrics((prev) => ({
...prev,
orders: !prev.orders,
}))
}
>
Orders
</Button>
<Button
variant={metrics.movingAverage ? "default" : "outline"}
size="sm"
onClick={() =>
setMetrics((prev) => ({
...prev,
movingAverage: !prev.movingAverage,
}))
}
>
7-Day Avg
</Button>
<Button
variant={metrics.avgOrderValue ? "default" : "outline"}
size="sm"
onClick={() =>
setMetrics((prev) => ({
...prev,
avgOrderValue: !prev.avgOrderValue,
}))
}
>
AOV
</Button>
</div>
<Separator
orientation="vertical"
className="h-6 hidden sm:block"
/>
<Separator
orientation="horizontal"
className="sm:hidden w-20 my-2"
/>
<Button
variant={metrics.showPrevious ? "default" : "outline"}
size="sm"
onClick={() =>
setMetrics((prev) => ({
...prev,
showPrevious: !prev.showPrevious,
}))
}
<div className="flex flex-wrap items-center gap-0.5">
<MetricPill
active={metrics.revenue}
color={CHART_COLORS.revenue}
onClick={() => setMetrics((prev) => ({ ...prev, revenue: !prev.revenue }))}
>
Compare Prev Period
</Button>
Revenue
</MetricPill>
<MetricPill
active={metrics.orders}
color={CHART_COLORS.orders}
onClick={() => setMetrics((prev) => ({ ...prev, orders: !prev.orders }))}
>
Orders
</MetricPill>
<MetricPill
active={metrics.avgOrderValue}
color={CHART_COLORS.aov}
onClick={() => setMetrics((prev) => ({ ...prev, avgOrderValue: !prev.avgOrderValue }))}
>
AOV
</MetricPill>
<MetricPill
active={metrics.movingAverage}
color={CHART_COLORS.movingAverage}
dashed
onClick={() => setMetrics((prev) => ({ ...prev, movingAverage: !prev.movingAverage }))}
>
7-day avg
</MetricPill>
<span className="mx-1.5 h-4 w-px bg-[#eceae5]" />
<MetricPill
active={metrics.showPrevious}
color={CHART_COLORS.prevRevenue}
dashed
onClick={() => setMetrics((prev) => ({ ...prev, showPrevious: !prev.showPrevious }))}
>
vs previous
</MetricPill>
</div>
)}
{loading ? (
@@ -774,46 +698,50 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
/>
) : (
<>
<div className="h-[400px] mt-4 bg-card rounded-lg p-0 relative">
<div className="h-[340px] relative">
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={data}
margin={{ top: 5, right: -30, left: -5, bottom: 5 }}
margin={{ top: 8, right: 0, left: -8, bottom: 0 }}
>
<CartesianGrid
strokeDasharray="3 3"
className="stroke-muted"
strokeDasharray="3 4"
stroke="#efede9"
vertical={false}
/>
<XAxis
dataKey="timestamp"
tickFormatter={formatXAxis}
className="text-xs text-muted-foreground"
tick={{ fill: "currentColor" }}
tick={{ fill: "#a09b92", fontSize: 11 }}
tickLine={false}
axisLine={{ stroke: "#eceae5" }}
/>
<YAxis
yAxisId="revenue"
tickFormatter={(value) => formatCurrency(value, false)}
className="text-xs text-muted-foreground"
tick={{ fill: "currentColor" }}
tick={{ fill: "#a09b92", fontSize: 11 }}
tickLine={false}
axisLine={false}
/>
<YAxis
yAxisId="orders"
orientation="right"
tickFormatter={(value) => value.toLocaleString()}
className="text-xs text-muted-foreground"
tick={{ fill: "currentColor" }}
tick={{ fill: "#a09b92", fontSize: 11 }}
tickLine={false}
axisLine={false}
/>
<Tooltip content={<DashboardChartTooltip valueFormatter={salesValueFormatter} labelFormatter={salesLabelFormatter} />} />
<Legend />
<ReferenceLine
y={averageRevenue}
yAxisId="revenue"
stroke="currentColor"
strokeDasharray="3 3"
stroke="#d8d4cd"
strokeDasharray="3 4"
label={{
value: `Avg Revenue: ${formatCurrency(averageRevenue)}`,
fill: "currentColor",
fontSize: 12,
value: `avg ${formatCurrency(averageRevenue, false)}`,
fill: "#a09b92",
fontSize: 10,
position: "insideBottomRight",
}}
/>
{metrics.revenue && (
+226 -230
View File
@@ -28,8 +28,6 @@ import {
// Import Tooltip from recharts with alias to avoid naming conflict
import { Tooltip as RechartsTooltip } from "recharts";
import {
DollarSign,
ShoppingCart,
Package,
Clock,
Map,
@@ -39,11 +37,11 @@ import {
TrendingUp,
AlertCircle,
RefreshCcw,
CircleDollarSign,
MapPin,
} from "lucide-react";
import { DateTime } from "luxon";
import { CARD_STYLES } from "@/lib/dashboard/designTokens";
import { CARD_STYLES, STUDIO_COLORS } from "@/lib/dashboard/designTokens";
import StudioStatTile from "@/components/dashboard/studio/StudioStatTile";
import {
DashboardStatCard,
DashboardStatCardSkeleton,
@@ -52,6 +50,7 @@ import {
DashboardEmptyState,
DashboardErrorState,
TOOLTIP_STYLES,
QuietStat,
} from "@/components/dashboard/shared";
import {
Table,
@@ -90,7 +89,7 @@ const TimeSeriesChart = ({
data,
dataKey,
name,
color = "hsl(var(--primary))",
color = "#e06a4e",
type = "line",
valueFormatter = (value) => value,
height = 400,
@@ -110,7 +109,7 @@ const TimeSeriesChart = ({
data={data}
margin={{ top: 10, right: 20, left: -20, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<CartesianGrid strokeDasharray="3 4" stroke="#efede9" vertical={false} />
<XAxis
dataKey="timestamp"
tickFormatter={(value) => DateTime.fromISO(value).toFormat("LLL d")}
@@ -161,7 +160,7 @@ const TimeSeriesChart = ({
const DetailDialog = ({ open, onOpenChange, title, children }) => (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto rounded-[14px] border-[#e7e5e1]">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
@@ -196,7 +195,7 @@ const RevenueDetails = ({ data }) => {
data={chartData}
dataKey="revenue"
name="Revenue"
color="hsl(142.1 76.2% 36.3%)"
color="#e06a4e"
valueFormatter={(value) => formatCurrency(value)}
height={400}
/>
@@ -217,7 +216,7 @@ const OrdersDetails = ({ data }) => {
data={data}
dataKey="orders"
name="Orders"
color="hsl(221.2 83.2% 53.3%)"
color="#6b5fc7"
/>
{data[0]?.hourlyOrders && (
<div className="mt-8">
@@ -230,7 +229,7 @@ const OrdersDetails = ({ data }) => {
dataKey="orders"
name="Orders"
type="bar"
color="hsl(221.2 83.2% 53.3%)"
color="#6b5fc7"
/>
</div>
)}
@@ -252,7 +251,7 @@ const AverageOrderDetails = ({ data }) => {
data={data}
dataKey="averageOrderValue"
name="Average Order Value"
color="hsl(262.1 83.3% 57.8%)"
color="#a87a24"
valueFormatter={(value) => formatCurrency(value)}
/>
);
@@ -293,7 +292,7 @@ const CancellationsDetails = ({ data }) => {
data={timeSeriesData}
dataKey="total"
name="Cancellation Amount"
color="hsl(0 84.2% 60.2%)"
color="#b3503f"
valueFormatter={(value) => formatCurrency(value)}
/>
</div>
@@ -305,7 +304,7 @@ const CancellationsDetails = ({ data }) => {
dataKey="count"
name="Cancellation Count"
type="bar"
color="hsl(0 84.2% 60.2%)"
color="#b3503f"
/>
</div>
@@ -629,7 +628,7 @@ const PeakHourDetails = ({ data }) => {
data={hourlyData}
margin={{ top: 10, right: 30, left: 20, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<CartesianGrid strokeDasharray="3 4" stroke="#efede9" vertical={false} />
<XAxis
dataKey="timestamp"
tickFormatter={(hour) => {
@@ -833,7 +832,7 @@ const OrderRangeDetails = ({ data }) => {
data={timeSeriesData}
dataKey="average"
name="Average Order Value"
color="hsl(142.1 76.2% 36.3%)"
color="#e06a4e"
valueFormatter={(value) => formatCurrency(value)}
/>
</div>
@@ -881,7 +880,7 @@ const OrderRangeDetails = ({ data }) => {
data={formattedDistributionData}
margin={{ top: 10, right: 20, left: -20, bottom: 40 }}
>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<CartesianGrid strokeDasharray="3 4" stroke="#efede9" vertical={false} />
<XAxis
dataKey="range"
angle={-45}
@@ -976,6 +975,7 @@ const MemoizedCancellationsDetails = memo(CancellationsDetails);
// Note: Using ChartSkeleton and TableSkeleton from @/components/dashboard/shared
// Quiet secondary stat deemphasized strip item for the less-watched metrics
const StatCards = ({
timeRange: initialTimeRange = "today",
startDate,
@@ -1316,6 +1316,89 @@ const StatCards = ({
return () => clearInterval(interval);
}, [timeRange]);
// 30-day daily series for the hero-tile sparklines. Context stays 30d
// regardless of the selected range a one-point "today" series has no shape.
const [sparkRows, setSparkRows] = useState(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const cached = getCacheData("last30days", "hero_sparklines");
if (cached) {
setSparkRows(cached);
return;
}
const response = await acotService.getStatsDetails({
timeRange: "last30days",
metric: "revenue",
daily: true,
});
if (cancelled) return;
const rows = Array.isArray(response.stats) ? response.stats : [];
setCacheData("last30days", "hero_sparklines", rows);
setSparkRows(rows);
} catch {
// sparklines are optional garnish tiles render fine without them
}
})();
return () => {
cancelled = true;
};
}, [getCacheData, setCacheData]);
// Financials for the Gross Profit hero tile (selected range) and its
// 30-day sparkline. Both cached; the tile degrades to an em dash on failure.
const [profitData, setProfitData] = useState(null);
useEffect(() => {
if (timeRange === "custom") {
setProfitData(null);
return undefined;
}
let cancelled = false;
(async () => {
try {
const cached = getCacheData(timeRange, "hero_financials");
if (cached) {
setProfitData(cached);
return;
}
const response = await acotService.getFinancials({ timeRange });
if (cancelled) return;
setCacheData(timeRange, "hero_financials", response);
setProfitData(response);
} catch {
// tile shows an em dash without financials
}
})();
return () => {
cancelled = true;
};
}, [timeRange, getCacheData, setCacheData]);
const [profitSparkRows, setProfitSparkRows] = useState(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const cached = getCacheData("last30days", "hero_profit_spark");
if (cached) {
setProfitSparkRows(cached);
return;
}
const response = await acotService.getFinancials({ timeRange: "last30days" });
if (cancelled) return;
const rows = Array.isArray(response?.trend) ? response.trend : [];
setCacheData("last30days", "hero_profit_spark", rows);
setProfitSparkRows(rows);
} catch {
// sparkline is optional garnish
}
})();
return () => {
cancelled = true;
};
}, [getCacheData, setCacheData]);
// Fetch detail data when a metric is selected (if not already cached)
useEffect(() => {
if (!selectedMetric) return;
@@ -1414,69 +1497,42 @@ const StatCards = ({
if (loading && !stats) {
return (
<Card className={`w-full ${CARD_STYLES.base}`}>
<CardHeader className="p-6 pb-2">
<div className="flex flex-col space-y-2">
<div className="flex justify-between items-start">
<div>
<CardTitle className="text-xl font-semibold text-foreground">
{title}
</CardTitle>
{description && (
<CardDescription className="mt-1 text-muted-foreground">
{description}
</CardDescription>
)}
</div>
<div className="flex items-center gap-4">
<Skeleton className="h-4 w-32 bg-muted rounded-sm" />
<Skeleton className="h-9 w-[130px] bg-muted rounded-md" />
</div>
<div className="w-full">
<div className="mb-2 flex items-center justify-between gap-3 px-0.5">
<Skeleton className="h-4 w-28 rounded bg-[#f0eeea]" />
<Skeleton className="h-8 w-24 rounded-full bg-[#f0eeea]" />
</div>
<div className="grid grid-cols-2 xl:grid-cols-[1.35fr_1fr_1fr_1fr] gap-2.5">
{[...Array(4)].map((_, i) => (
<div
key={i}
className="h-[132px] animate-pulse rounded-[14px]"
style={{ background: ["#fbe7de", "#ddf0ea", "#faf0d7", "#eae6f6"][i] }}
/>
))}
</div>
<div className="mt-2.5 grid grid-cols-2 gap-px overflow-hidden rounded-[14px] border border-[#e7e5e1] bg-[#f0eeea] sm:grid-cols-3 lg:grid-cols-6">
{[...Array(6)].map((_, i) => (
<div key={i} className="bg-white px-3.5 py-2.5">
<div className="h-3 w-16 animate-pulse rounded bg-[#f0eeea]" />
<div className="mt-1.5 h-4 w-12 animate-pulse rounded bg-[#f5f3f0]" />
</div>
</div>
</CardHeader>
<CardContent className="p-6 pt-0 space-y-4">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-3 2xl:grid-cols-4 gap-2 md:gap-3">
{[...Array(12)].map((_, i) => (
<DashboardStatCardSkeleton key={i} />
))}
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
}
if (error) {
return (
<Card className={`w-full h-full ${CARD_STYLES.base}`}>
<CardHeader className="p-6 pb-4">
<div className="flex flex-col space-y-2">
<div className="flex justify-between items-start">
<div>
<CardTitle className="text-xl font-semibold text-foreground">
{title}
</CardTitle>
{description && (
<CardDescription className="mt-1 text-muted-foreground">
{description}
</CardDescription>
)}
</div>
<div className="flex items-center gap-4">
{lastUpdate && !loading && (
<span className="text-sm text-muted-foreground">
Last updated: {lastUpdate.toFormat("hh:mm a")}
</span>
)}
<BusinessRangeSelect value={timeRange} onValueChange={setTimeRange} />
</div>
</div>
</div>
</CardHeader>
<CardContent className="p-6 pt-0">
<div className="w-full">
<div className="mb-2 flex items-center justify-end px-0.5">
<BusinessRangeSelect value={timeRange} onValueChange={setTimeRange} />
</div>
<div className={`${CARD_STYLES.base} p-4`}>
<DashboardErrorState error={`Failed to load stats: ${error}`} className="mx-0 my-0" />
</CardContent>
</Card>
</div>
</div>
);
}
@@ -1487,56 +1543,43 @@ const StatCards = ({
const aovTrend = calculateAOVTrend();
const isSingleDay = ["today", "yesterday"].includes(timeRange);
const profitTotals = profitData?.totals;
const prevProfitTotal = profitData?.previousTotals?.profit;
// Mid-period, compare a PROJECTED end-of-period profit against the previous
// period (same as revenue/orders) raw partial-day profit vs a full prior
// day would read as a huge drop until midnight. Projection assumes the
// current margin holds: profit × (projectedRevenue / revenue so far).
const profitTrendPct = (() => {
if (!profitTotals || !(prevProfitTotal > 0)) return null;
if (stats?.periodProgress < 100) {
const projRevForTrend = projection?.projectedRevenue || stats.projectedRevenue;
if (!(stats.revenue > 0) || !projRevForTrend) return null;
const projectedProfit = profitTotals.profit * (projRevForTrend / stats.revenue);
return ((projectedProfit - prevProfitTotal) / prevProfitTotal) * 100;
}
return (
profitData?.comparison?.profit?.percentage ??
((profitTotals.profit - prevProfitTotal) / prevProfitTotal) * 100
);
})();
const sparkProfit = profitSparkRows?.map((d) => Number(d.profit) || 0);
const sparkRevenue = sparkRows?.map((d) => Number(d.revenue) || 0);
const sparkOrders = sparkRows?.map((d) => Number(d.orders) || 0);
const sparkAov = sparkRows?.map((d) =>
Number(d.orders) > 0 ? (Number(d.revenue) || 0) / Number(d.orders) : 0
);
return (
<Card className={`w-full ${CARD_STYLES.base}`}>
<CardHeader className="p-6">
<div className="flex flex-col space-y-2">
<div className="flex justify-between items-start">
<div>
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100 flex items-baseline gap-1">
{title}
{lastUpdate && !loading && (
<div className="text-xs text-muted-foreground font-medium">
Last updated {lastUpdate.toFormat("h:mm a")}
{projection?.confidence > 0 && !projectionLoading && (
<TooltipProvider>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<span className="ml-1 text-muted-foreground">
(
<span
style={{
color: `rgb(${255 * (1 - projection.confidence)}, ${
255 * projection.confidence
}, 0)`,
}}
>
{Math.round(projection.confidence * 100)}%
</span>
)
</span>
</TooltipTrigger>
<TooltipContent className="max-w-[250px]">
<p>Confidence level of revenue projection based on historical data patterns</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
)}
</CardTitle>
</div>
<div className="flex items-center gap-4">
<BusinessRangeSelect value={timeRange} onValueChange={setTimeRange} />
</div>
</div>
</div>
</CardHeader>
<CardContent className="p-6 pt-0">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-3 2xl:grid-cols-4 gap-2 md:gap-3 dashboard-stagger">
<DashboardStatCard
<div className="w-full">
{/* Slim control row — no card chrome, the tiles speak for themselves */}
<div className="mb-2 flex items-center justify-end px-0.5">
<BusinessRangeSelect value={timeRange} onValueChange={setTimeRange} />
</div>
<div>
{/* Hero tiles — Sorbet Studio pastel identities with 30d sparklines */}
<div className="grid grid-cols-2 xl:grid-cols-[1.35fr_1fr_1fr_1fr] gap-2.5 dashboard-stagger">
<StudioStatTile
title="Revenue"
value={formatCurrency(stats?.revenue || 0)}
subtitle={
@@ -1561,13 +1604,14 @@ const StatCards = ({
? { value: revenueTrend.trend === "up" ? revenueTrend.value : -revenueTrend.value, moreIsBetter: true }
: undefined
}
icon={DollarSign}
iconColor="green"
tone="peach"
featured
spark={sparkRevenue}
onClick={() => setSelectedMetric("revenue")}
loading={loading || !stats}
/>
<DashboardStatCard
<StudioStatTile
title="Orders"
value={stats?.orderCount}
subtitle={`${stats?.itemCount} total items`}
@@ -1578,150 +1622,102 @@ const StatCards = ({
? { value: orderTrend.trend === "up" ? orderTrend.value : -orderTrend.value, moreIsBetter: true }
: undefined
}
icon={ShoppingCart}
iconColor="blue"
tone="mint"
spark={sparkOrders}
onClick={() => setSelectedMetric("orders")}
loading={loading || !stats}
/>
<DashboardStatCard
<StudioStatTile
title="AOV"
value={stats?.averageOrderValue?.toFixed(2)}
valuePrefix="$"
subtitle={`${stats?.averageItemsPerOrder?.toFixed(1)} items per order`}
trend={aovTrend?.value ? { value: aovTrend.trend === "up" ? aovTrend.value : -aovTrend.value, moreIsBetter: true } : undefined}
icon={CircleDollarSign}
iconColor="purple"
tone="lemon"
spark={sparkAov}
onClick={() => setSelectedMetric("average_order")}
loading={loading || !stats}
/>
<DashboardStatCard
title="Brands"
value={stats?.brands?.total || 0}
subtitle={`${stats?.categories?.total || 0} categories`}
icon={Tags}
iconColor="indigo"
onClick={() => setSelectedMetric("brands_categories")}
<StudioStatTile
title="Gross Profit"
value={profitTotals ? formatCurrency(profitTotals.profit) : "—"}
subtitle={
profitTotals && Number.isFinite(profitTotals.margin)
? `${profitTotals.margin.toFixed(1)}% margin`
: undefined
}
trend={
projectionLoading && stats?.periodProgress < 100
? undefined
: profitTrendPct != null && isFinite(profitTrendPct)
? { value: profitTrendPct, moreIsBetter: true }
: undefined
}
tone="lilac"
spark={sparkProfit}
loading={loading || !stats}
/>
</div>
<DashboardStatCard
title="Shipped Orders"
value={stats?.shipping?.shippedCount || 0}
subtitle={`${stats?.shipping?.locations?.total || 0} locations`}
icon={Package}
iconColor="teal"
onClick={() => setSelectedMetric("shipping")}
loading={loading || !stats}
/>
<DashboardStatCard
title="Pre-Orders"
value={
{/* Quiet strip the less-watched stats, deemphasized but still tappable.
(Brands and Order Range were retired in the Studio redesign.) */}
<div className="mt-2.5 grid grid-cols-2 gap-px overflow-hidden rounded-[14px] border border-[#e7e5e1] bg-[#f0eeea] sm:grid-cols-3 lg:grid-cols-6">
<QuietStat
label="Pre-orders"
value={`${
stats?.orderCount > 0
? ((stats?.orderTypes?.preOrders?.count / stats?.orderCount) * 100).toFixed(1)
: "0"
}
valueSuffix="%"
subtitle={`${stats?.orderTypes?.preOrders?.count || 0} orders`}
icon={Clock}
iconColor="yellow"
}%`}
sub={`${stats?.orderTypes?.preOrders?.count || 0} orders`}
onClick={() => setSelectedMetric("pre_orders")}
loading={loading || !stats}
/>
<DashboardStatCard
title="Local Pickup"
value={
<QuietStat
label="Local pickup"
value={`${
stats?.orderCount > 0
? ((stats?.orderTypes?.localPickup?.count / stats?.orderCount) * 100).toFixed(1)
: "0"
}
valueSuffix="%"
subtitle={`${stats?.orderTypes?.localPickup?.count || 0} orders`}
icon={Map}
iconColor="cyan"
}%`}
sub={`${stats?.orderTypes?.localPickup?.count || 0} orders`}
onClick={() => setSelectedMetric("local_pickup")}
loading={loading || !stats}
/>
<DashboardStatCard
title="On Hold"
value={
<QuietStat
label="On hold"
value={`${
stats?.orderCount > 0
? ((stats?.orderTypes?.heldItems?.count / stats?.orderCount) * 100).toFixed(1)
: "0"
}
valueSuffix="%"
subtitle={`${stats?.orderTypes?.heldItems?.count || 0} orders`}
icon={AlertCircle}
iconColor="red"
}%`}
sub={`${stats?.orderTypes?.heldItems?.count || 0} orders`}
onClick={() => setSelectedMetric("on_hold")}
loading={loading || !stats}
/>
{isSingleDay ? (
<DashboardStatCard
title="Peak Hour"
value={stats?.peakOrderHour?.displayHour || "N/A"}
subtitle={stats?.peakOrderHour ? `${stats?.peakOrderHour.count} orders` : undefined}
icon={Clock}
iconColor="pink"
onClick={() => setSelectedMetric("peak_hour")}
loading={loading || !stats}
/>
) : (
<DashboardStatCard
title="Best Revenue Day"
value={stats?.bestRevenueDay?.amount?.toFixed(2) || "N/A"}
valuePrefix="$"
subtitle={
stats?.bestRevenueDay?.displayDate
? new Date(stats?.bestRevenueDay.displayDate).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})
: "N/A"
}
icon={TrendingUp}
iconColor="emerald"
loading={loading || !stats}
/>
)}
<DashboardStatCard
title="Refunds"
value={stats?.refunds?.total?.toFixed(2) || "0.00"}
valuePrefix="$"
subtitle={`${stats?.refunds?.count || 0} refund orders`}
icon={RefreshCcw}
iconColor="orange"
<QuietStat
label="Shipped orders"
value={(stats?.shipping?.shippedCount || 0).toLocaleString()}
sub={`${stats?.shipping?.locations?.total || 0} locations`}
onClick={() => setSelectedMetric("shipping")}
loading={loading || !stats}
/>
<QuietStat
label="Refunds"
value={`$${stats?.refunds?.total?.toFixed(2) || "0.00"}`}
sub={`${stats?.refunds?.count || 0} orders`}
onClick={() => setSelectedMetric("refunds")}
loading={loading || !stats}
/>
<DashboardStatCard
title="Cancellations"
value={stats?.canceledOrders?.total?.toFixed(2) || "0.00"}
valuePrefix="$"
subtitle={`${stats?.canceledOrders?.count || 0} canceled orders`}
icon={XCircle}
iconColor="rose"
<QuietStat
label="Cancellations"
value={`$${stats?.canceledOrders?.total?.toFixed(2) || "0.00"}`}
sub={`${stats?.canceledOrders?.count || 0} orders`}
onClick={() => setSelectedMetric("cancellations")}
loading={loading || !stats}
/>
<DashboardStatCard
title="Order Range"
value={stats?.orderValueRange?.largest?.toFixed(2) || "0.00"}
valuePrefix="$"
subtitle={`Min: $${stats?.orderValueRange?.smallest?.toFixed(2) || "0.00"}`}
icon={TrendingUp}
iconColor="violet"
onClick={() => setSelectedMetric("order_range")}
loading={loading || !stats}
/>
</div>
<DetailDialog
@@ -1738,8 +1734,8 @@ const StatCards = ({
>
{getDetailComponent()}
</DetailDialog>
</CardContent>
</Card>
</div>
</div>
);
};
@@ -42,13 +42,13 @@ const FORM_NAMES = {
};
const ResponseFeed = ({ responses, title, renderSummary }) => (
<Card>
<DashboardSectionHeader title={title} compact />
<CardContent>
<Card className={`h-full ${CARD_STYLES.subtle} shadow-none`}>
<DashboardSectionHeader title={title} compact accent={false} />
<CardContent className="p-0">
<ScrollArea className="h-[400px]">
<div className="divide-y divide-border/50">
<div className="divide-y divide-[#f5f3f0]">
{responses.items.map((response) => (
<div key={response.token} className="p-4">
<div key={response.token} className="p-3 transition-colors hover:bg-[#faf9f7]">
{renderSummary(response)}
</div>
))}
@@ -73,12 +73,12 @@ const ProductRelevanceFeed = ({ responses }) => (
{response.hidden?.email ? (
<a
href={`https://backend.acherryontop.com/search/?search_for=customers&search=${response.hidden.email}`}
className="text-sm font-medium text-foreground hover:underline"
className="text-[13px] font-semibold text-[#2b2925] hover:underline"
>
{response.hidden?.name || "Anonymous"}
</a>
) : (
<span className="text-sm font-medium text-foreground">
<span className="text-[13px] font-semibold text-[#2b2925]">
{response.hidden?.name || "Anonymous"}
</span>
)}
@@ -87,14 +87,14 @@ const ProductRelevanceFeed = ({ responses }) => (
</DashboardBadge>
</div>
<time
className="text-xs text-muted-foreground"
className="text-[11px] text-[#a09b92]"
dateTime={response.submitted_at}
>
{format(new Date(response.submitted_at), "MMM d")}
</time>
</div>
{textAnswer && (
<div className="text-sm text-muted-foreground">"{textAnswer}"</div>
<div className="text-[11.5px] text-[#7c7870]">"{textAnswer}"</div>
)}
</div>
);
@@ -122,12 +122,12 @@ const WinbackFeed = ({ responses }) => (
{response.hidden?.email ? (
<a
href={`https://backend.acherryontop.com/search/?search_for=customers&search=${response.hidden.email}`}
className="text-sm font-medium text-foreground hover:underline"
className="text-[13px] font-semibold text-[#2b2925] hover:underline"
>
{response.hidden?.name || "Anonymous"}
</a>
) : (
<span className="text-sm font-medium text-foreground">
<span className="text-[13px] font-semibold text-[#2b2925]">
{response.hidden?.name || "Anonymous"}
</span>
)}
@@ -150,7 +150,7 @@ const WinbackFeed = ({ responses }) => (
</DashboardBadge>
</div>
<time
className="text-xs text-muted-foreground"
className="text-[11px] text-[#a09b92]"
dateTime={response.submitted_at}
>
{format(new Date(response.submitted_at), "MMM d")}
@@ -169,7 +169,7 @@ const WinbackFeed = ({ responses }) => (
)}
</div>
{feedbackAnswer?.text && (
<div className="text-sm text-muted-foreground">
<div className="text-[11.5px] text-[#7c7870]">
{feedbackAnswer.text}
</div>
)}
@@ -368,56 +368,51 @@ const TypeformDashboard = () => {
<Card className={CARD_STYLES.base}>
<DashboardSectionHeader
title="Customer Surveys"
lastUpdated={newestResponse ? new Date(newestResponse) : null}
lastUpdatedFormat={(date) => `Newest response: ${format(date, "MMM d, h:mm a")}`}
className="pb-0"
size="large"
/>
<CardContent className="space-y-4">
<CardContent className="p-4 pt-3 space-y-3">
{loading ? (
<div className="space-y-4">
<div className="space-y-3">
<ChartSkeleton height="md" withCard={false} />
<TableSkeleton rows={5} columns={3} />
</div>
) : (
<>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-6">
<Card className="bg-card">
<CardHeader className="p-6">
<div className="flex items-baseline justify-between">
<CardTitle className="text-lg font-semibold">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
<Card className="rounded-[12px] border-[#efede9] shadow-none">
<CardHeader className="p-4 pb-3">
<div className="flex items-baseline justify-between gap-2">
<CardTitle className="text-[12.5px] font-semibold tracking-tight text-[#2b2925]">
How likely are you to place another order with us?
</CardTitle>
<span
className={`text-2xl font-bold ${
className={`text-lg font-bold tabular-nums ${
metrics.winback.averageRating <= 1
? "text-red-600 dark:text-red-500"
? "text-[#b3503f]"
: metrics.winback.averageRating <= 2
? "text-orange-600 dark:text-orange-500"
? "text-[#c9973d]"
: metrics.winback.averageRating <= 3
? "text-yellow-600 dark:text-yellow-500"
? "text-[#b39b2e]"
: metrics.winback.averageRating <= 4
? "text-lime-600 dark:text-lime-500"
: "text-green-600 dark:text-green-500"
? "text-[#7fa653]"
: "text-[#2e8f5b]"
}`}
>
{metrics.winback.averageRating}
<span className="text-base font-normal text-muted-foreground">
<span className="text-xs font-normal text-muted-foreground">
/5 avg
</span>
</span>
</div>
</CardHeader>
<CardContent>
<div className="h-[200px]">
<CardContent className="p-4 pt-0">
<div className="h-[132px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={likelihoodCounts}
margin={{ top: 0, right: 10, left: -20, bottom: -25 }}
margin={{ top: 4, right: 0, left: 0, bottom: -25 }}
>
<CartesianGrid
strokeDasharray="3 3"
className="stroke-muted"
/>
<CartesianGrid strokeDasharray="3 4" stroke="#efede9" vertical={false} />
<XAxis
dataKey="rating"
tickFormatter={(value) => {
@@ -430,9 +425,16 @@ const TypeformDashboard = () => {
textAnchor="middle"
interval={0}
height={50}
className="text-muted-foreground text-xs md:text-sm"
tick={{ fill: "#a09b92", fontSize: 11 }}
tickLine={false}
axisLine={{ stroke: "#eceae5" }}
/>
<YAxis
width={36}
tick={{ fill: "#a09b92", fontSize: 11 }}
tickLine={false}
axisLine={false}
/>
<YAxis className="text-muted-foreground text-xs md:text-sm" />
<Tooltip
content={
<SimpleTooltip
@@ -441,20 +443,20 @@ const TypeformDashboard = () => {
/>
}
/>
<Bar dataKey="count">
<Bar dataKey="count" radius={[6, 6, 0, 0]}>
{likelihoodCounts.map((_, index) => (
<Cell
key={`cell-${index}`}
fill={
index === 0
? "#ef4444" // red
? "#b3503f" // red
: index === 1
? "#f97316" // orange
? "#c9973d" // orange
: index === 2
? "#eab308" // yellow
? "#d4b445" // yellow
: index === 3
? "#84cc16" // lime
: "#10b981" // green
? "#7fa653" // lime
: "#2e8f5b" // green
}
/>
))}
@@ -464,98 +466,40 @@ const TypeformDashboard = () => {
</div>
</CardContent>
</Card>
<Card className="bg-card">
<CardHeader className="p-6">
<div className="flex items-baseline justify-between gap-2">
<CardTitle className="text-lg font-semibold">
Were the suggested products in this email relevant to you?
</CardTitle>
<div className="flex flex-col items-end">
<span className="text-2xl font-bold text-green-600 dark:text-green-500">
{metrics.productRelevance.yesPercentage}% Relevant
</span>
</div>
</div>
<Card className="rounded-[12px] border-[#efede9] shadow-none">
<CardHeader className="p-4 pb-3">
<CardTitle className="text-[12.5px] font-semibold tracking-tight text-[#2b2925]">
Were the suggested products in this email relevant to you?
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[100px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={[
{
yes: metrics.productRelevance.yesCount,
no: metrics.productRelevance.noCount,
total:
metrics.productRelevance.yesCount +
metrics.productRelevance.noCount,
},
]}
layout="vertical"
stackOffset="expand"
margin={{ top: 0, right: 0, left: -20, bottom: 0 }}
>
<XAxis type="number" hide domain={[0, 1]} />
<YAxis type="category" hide />
<Tooltip
cursor={false}
content={({ payload }) => {
if (payload && payload.length) {
const yesCount = payload[0].payload.yes;
const noCount = payload[0].payload.no;
const total = yesCount + noCount;
const yesPercent = Math.round((yesCount / total) * 100);
const noPercent = Math.round((noCount / total) * 100);
return (
<div className={TOOLTIP_STYLES.container}>
<div className={TOOLTIP_STYLES.content}>
<div className={TOOLTIP_STYLES.row}>
<div className={TOOLTIP_STYLES.rowLabel}>
<span className={TOOLTIP_STYLES.dot} style={{ backgroundColor: '#10b981' }} />
<span className={TOOLTIP_STYLES.name}>Yes</span>
</div>
<span className={TOOLTIP_STYLES.value}>{yesCount} ({yesPercent}%)</span>
</div>
<div className={TOOLTIP_STYLES.row}>
<div className={TOOLTIP_STYLES.rowLabel}>
<span className={TOOLTIP_STYLES.dot} style={{ backgroundColor: '#ef4444' }} />
<span className={TOOLTIP_STYLES.name}>No</span>
</div>
<span className={TOOLTIP_STYLES.value}>{noCount} ({noPercent}%)</span>
</div>
</div>
</div>
);
}
return null;
}}
/>
<Bar
dataKey="yes"
stackId="stack"
fill="#10b981"
radius={[0, 0, 0, 0]}
<CardContent className="p-4 pt-0">
{/* CSS proportion bar guarantees matching rounded ends on
both segments (recharts stacked-bar radius rounds unreliably) */}
{(() => {
const yes = metrics.productRelevance.yesCount;
const no = metrics.productRelevance.noCount;
const total = yes + no || 1;
const yesPct = Math.round((yes / total) * 100);
return (
<div className="flex h-[52px] w-full gap-1 overflow-hidden rounded-xl">
<div
className="flex items-center justify-center rounded-l-xl bg-[#2e8f5b] text-sm font-bold text-white"
style={{ width: `${(yes / total) * 100}%` }}
title={`Yes: ${yes.toLocaleString()} (${yesPct}%)`}
>
<text
x="50%"
y="50%"
textAnchor="middle"
fill="#fff"
fontSize={14}
fontWeight="bold"
>
{metrics.productRelevance.yesPercentage}%
</text>
</Bar>
<Bar
dataKey="no"
stackId="stack"
fill="#ef4444"
radius={[0, 0, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
</div>
<div className="flex justify-between mt-2 text-md font-semibold mx-1 text-muted-foreground">
{yesPct >= 12 && `${yesPct}%`}
</div>
<div
className="flex items-center justify-center rounded-r-xl bg-[#b3503f] text-sm font-bold text-white"
style={{ width: `${(no / total) * 100}%` }}
title={`No: ${no.toLocaleString()} (${100 - yesPct}%)`}
>
{100 - yesPct >= 12 && `${100 - yesPct}%`}
</div>
</div>
);
})()}
<div className="mt-2 flex justify-between px-1 text-xs font-medium text-[#7c7870]">
<div>Yes: {metrics.productRelevance.yesCount}</div>
<div>No: {metrics.productRelevance.noCount}</div>
</div>
@@ -563,11 +507,11 @@ const TypeformDashboard = () => {
</Card>
</div>
<div className="grid grid-cols-2 lg:grid-cols-12 gap-4">
<div className="grid grid-cols-2 lg:grid-cols-12 gap-3">
<div className="col-span-4 lg:col-span-12 xl:col-span-4">
<Card className="bg-card h-full">
<DashboardSectionHeader title="Reasons for Not Ordering" compact />
<CardContent>
<Card className="rounded-[12px] border-[#efede9] shadow-none h-full">
<DashboardSectionHeader title="Reasons for Not Ordering" compact accent={false} />
<CardContent className="p-4 pt-3">
<DashboardTable
columns={reasonsColumns}
data={metrics?.winback?.reasons || []}
@@ -24,6 +24,7 @@ import {
TableSkeleton,
ChartSkeleton,
TOOLTIP_STYLES,
PILL_TRIGGER_CLASS,
} from "@/components/dashboard/shared";
export const UserBehaviorDashboard = () => {
@@ -136,7 +137,19 @@ export const UserBehaviorDashboard = () => {
{
key: "path",
header: "Page Path",
render: (value) => <span className="font-medium text-foreground">{value}</span>,
render: (value) =>
typeof value === "string" && value.startsWith("/") ? (
<a
href={`https://www.acherryontop.com${value}`}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-foreground hover:underline"
>
{value}
</a>
) : (
<span className="font-medium text-foreground">{value}</span>
),
},
{
key: "pageViews",
@@ -163,28 +176,28 @@ export const UserBehaviorDashboard = () => {
{
key: "source",
header: "Source",
width: "w-[35%] min-w-[120px]",
render: (value) => <span className="font-medium text-foreground break-words max-w-[160px]">{value}</span>,
width: "w-[40%]",
render: (value) => <span className="font-medium text-foreground break-all">{value}</span>,
},
{
key: "sessions",
header: "Sessions",
align: "right",
width: "w-[20%] min-w-[80px]",
width: "w-[19%] whitespace-nowrap",
render: (value) => <span className="text-muted-foreground whitespace-nowrap">{value.toLocaleString()}</span>,
},
{
key: "conversions",
header: "Conv.",
align: "right",
width: "w-[20%] min-w-[80px]",
width: "w-[17%] whitespace-nowrap",
render: (value) => <span className="text-muted-foreground whitespace-nowrap">{value.toLocaleString()}</span>,
},
{
key: "conversionRate",
header: "Conv. Rate",
align: "right",
width: "w-[25%] min-w-[80px]",
width: "w-[24%] whitespace-nowrap",
render: (_, row) => (
<span className="text-muted-foreground whitespace-nowrap">
{((row.conversions / row.sessions) * 100).toFixed(1)}%
@@ -195,30 +208,30 @@ export const UserBehaviorDashboard = () => {
if (loading) {
return (
<Card className={`${CARD_STYLES.base} h-full`}>
<Card className={`${CARD_STYLES.base} flex h-full flex-col`}>
<DashboardSectionHeader
title="User Behavior Analysis"
loading={true}
size="large"
timeSelector={<div className="w-36" />}
/>
<CardContent className="p-6 pt-0">
<CardContent className="p-4 pt-3">
<Tabs defaultValue="pages" className="w-full">
<TabsList className="mb-4">
<TabsList className="mb-3 h-8 w-fit self-start">
<TabsTrigger value="pages" disabled>Top Pages</TabsTrigger>
<TabsTrigger value="sources" disabled>Traffic Sources</TabsTrigger>
<TabsTrigger value="devices" disabled>Device Usage</TabsTrigger>
</TabsList>
<TabsContent value="pages" className="mt-4 space-y-2">
<TabsContent value="pages" className="mt-3 space-y-2">
<TableSkeleton rows={15} columns={4} />
</TabsContent>
<TabsContent value="sources" className="mt-4 space-y-2">
<TabsContent value="sources" className="mt-3 space-y-2">
<TableSkeleton rows={12} columns={4} />
</TabsContent>
<TabsContent value="devices" className="mt-4 space-y-2">
<TabsContent value="devices" className="mt-3 space-y-2">
<ChartSkeleton type="pie" height="sm" withCard={false} />
</TabsContent>
</Tabs>
@@ -228,9 +241,9 @@ export const UserBehaviorDashboard = () => {
}
const COLORS = {
desktop: "#8b5cf6", // Purple
mobile: "#10b981", // Green
tablet: "#f59e0b", // Yellow
desktop: "#6b5fc7", // Purple
mobile: "#0f8f77", // Green
tablet: "#a87a24", // Yellow
};
const deviceData = data?.data?.pageData?.deviceData || [];
@@ -270,13 +283,13 @@ export const UserBehaviorDashboard = () => {
};
return (
<Card className={`${CARD_STYLES.base} h-full`}>
<Card className={`${CARD_STYLES.base} flex h-full flex-col`}>
<DashboardSectionHeader
title="User Behavior Analysis"
size="large"
timeSelector={
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger className="w-36 h-9">
<SelectTrigger className={PILL_TRIGGER_CLASS}>
<SelectValue>
{timeRange === "7" && "Last 7 days"}
{timeRange === "14" && "Last 14 days"}
@@ -293,67 +306,108 @@ export const UserBehaviorDashboard = () => {
</Select>
}
/>
<CardContent className="p-6 pt-0">
<Tabs defaultValue="pages" className="w-full">
<TabsList className="mb-4">
<CardContent className="flex min-h-0 flex-1 flex-col p-4 pt-3">
{/* Fixed-height tab body so the card doesn't grow/shrink per tab; the
tables scroll internally instead. On xl the card fills the Analytics
panel beside it (absolute-fill in Dashboard.tsx). */}
<Tabs defaultValue="pages" className="flex min-h-0 w-full flex-1 flex-col">
<TabsList className="mb-3 h-8 w-fit shrink-0 self-start">
<TabsTrigger value="pages">Top Pages</TabsTrigger>
<TabsTrigger value="sources">Traffic Sources</TabsTrigger>
<TabsTrigger value="devices">Device Usage</TabsTrigger>
</TabsList>
<TabsContent value="pages" className="mt-4 space-y-2">
<TabsContent value="pages" className="mt-0 min-h-0 flex-1 overflow-y-auto dashboard-scroll max-h-[420px] xl:max-h-none">
<DashboardTable
columns={pagesColumns}
data={data?.data?.pageData?.pageData || []}
getRowKey={(page, index) => `${page.path}-${index}`}
maxHeight="xl"
compact
/>
</TabsContent>
<TabsContent value="sources" className="mt-4 space-y-2">
<TabsContent value="sources" className="mt-0 min-h-0 flex-1 overflow-y-auto dashboard-scroll max-h-[420px] xl:max-h-none">
<DashboardTable
columns={sourcesColumns}
data={data?.data?.sourceData || []}
getRowKey={(source, index) => `${source.source}-${index}`}
maxHeight="xl"
compact
/>
</TabsContent>
<TabsContent value="devices" className="mt-4 space-y-2">
<div className={`h-60 ${CARD_STYLES.base} rounded-lg p-4`}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={deviceData}
dataKey="pageViews"
nameKey="device"
cx="50%"
cy="50%"
outerRadius={80}
labelLine={false}
label={({ name, percent }) =>
`${name} ${(percent * 100).toFixed(1)}%`
}
>
{deviceData.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={COLORS[entry.device.toLowerCase()]}
{/* NOTE: TabsContent must not carry a bare display class (e.g. `flex`)
it would override Radix's [hidden] attribute and render the
inactive panel into the layout. Center via the inner wrapper. */}
<TabsContent value="devices" className="mt-0 min-h-0 flex-1">
{/* Studio donut: slice gaps + center total + labeled rows (no on-slice labels) */}
<div className="flex h-full min-h-[280px] w-full flex-col items-center justify-center gap-4 py-2 sm:flex-row sm:gap-10">
<div className="relative h-48 w-48 shrink-0">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={deviceData}
dataKey="pageViews"
nameKey="device"
cx="50%"
cy="50%"
innerRadius={58}
outerRadius={88}
paddingAngle={2}
stroke="#ffffff"
strokeWidth={2}
labelLine={false}
>
{deviceData.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={COLORS[entry.device.toLowerCase()]}
/>
))}
</Pie>
<Tooltip
content={
<DashboardChartTooltip
labelFormatter={(_, payload) => payload?.[0]?.payload?.device || ""}
itemRenderer={deviceTooltipRenderer}
/>
}
/>
</PieChart>
</ResponsiveContainer>
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
<span className="text-[20px] font-bold tabular-nums text-[#2b2925]">
{totalViews >= 1000
? `${(totalViews / 1000).toFixed(1)}K`
: totalViews.toLocaleString()}
</span>
<span className="text-[10.5px] font-semibold text-[#a09b92]">views</span>
</div>
</div>
<div className="w-full max-w-[280px] space-y-2.5">
{deviceData.map((entry) => {
const pct =
totalViews > 0
? ((entry.pageViews / totalViews) * 100).toFixed(1)
: "0.0";
return (
<div key={entry.device} className="flex items-center gap-2.5">
<span
className="h-2 w-2 shrink-0 rounded-full"
style={{ background: COLORS[entry.device.toLowerCase()] }}
/>
))}
</Pie>
<Tooltip
content={
<DashboardChartTooltip
labelFormatter={(_, payload) => payload?.[0]?.payload?.device || ""}
itemRenderer={deviceTooltipRenderer}
/>
}
/>
</PieChart>
</ResponsiveContainer>
<span className="flex-1 text-xs font-medium text-[#2b2925]">
{entry.device}
</span>
<span className="text-xs tabular-nums text-[#7c7870]">
{entry.pageViews.toLocaleString()}
</span>
<span className="w-12 text-right text-xs font-bold tabular-nums text-[#2b2925]">
{pct}%
</span>
</div>
);
})}
</div>
</div>
</TabsContent>
</Tabs>
@@ -0,0 +1,861 @@
/**
* NightboardSmall the /small kiosk dashboard, Nightboard style.
*
* Designed for the office panel: 10.1" 1920×1200 at 1× CSS scale, read from
* across the room. Hierarchy comes from scale and spacing, not cards sizes
* below are tuned to that fixed viewport, so no transform-scale hacks.
* Data hooks mirror the retired Mini* components one-to-one; only the
* presentation changed (weather/calendar dropped, clock lives in the top line).
*/
import React, { useEffect, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
AreaChart,
Area,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
} from "recharts";
import { acotService } from "@/services/dashboard/acotService";
import { apiClient } from "@/utils/apiClient";
import { apiFetch } from "@/utils/api";
import config from "@/config";
import { PHASE_CONFIG, PHASE_KEYS_WITH_UNKNOWN as PHASE_KEYS } from "@/utils/lifecyclePhases";
// @ts-expect-error - JSX module without type declarations
import { processBasicData } from "@/components/dashboard/RealtimeAnalytics";
// @ts-expect-error - JSX module without type declarations
import { EventDialog } from "@/components/dashboard/EventFeed.jsx";
import LockButton from "@/components/dashboard/LockButton";
// ── palette (Nightboard) ─────────────────────────────────────────────────────
const NB = {
bg: "#0e1420",
txt: "#e6ebf4",
mut: "#8292a8",
faint: "#46536b",
line: "#1d2738",
amberText: "#ffb454", // large type only
amber: "#c9822a", // chart marks (CVD-validated on this ground)
up: "#58b98a",
dn: "#d4707c",
card: "#131b2c",
} as const;
// ── formatters ───────────────────────────────────────────────────────────────
const fmtMoney = (v: number | null | undefined) =>
v == null || isNaN(v)
? "—"
: new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
}).format(v);
const fmtCompact = (v: number | null | undefined) => {
if (v == null || isNaN(v)) return "—";
if (Math.abs(v) >= 1_000_000) return `$${(v / 1_000_000).toFixed(2)}M`;
if (Math.abs(v) >= 1_000) return `$${(v / 1_000).toFixed(0)}K`;
return `$${Math.round(v)}`;
};
const fmtCount = (v: number | null | undefined) => {
if (v == null || isNaN(v)) return "—";
if (v >= 10_000) return `${(v / 1_000).toFixed(1)}K`;
return v.toLocaleString();
};
const trendPct = (cur: number, prev: number) =>
prev > 0 ? ((cur - prev) / prev) * 100 : null;
// ── tiny presentational atoms ────────────────────────────────────────────────
const Lbl = ({ children }: { children: React.ReactNode }) => (
<div
className="text-[15px] font-medium uppercase"
style={{ color: NB.mut, letterSpacing: "0.22em" }}
>
{children}
</div>
);
const TrendArrow = ({ pct, invert = false }: { pct: number | null; invert?: boolean }) => {
if (pct == null || Math.abs(pct) < 0.1) return null;
const good = invert ? pct < 0 : pct > 0;
return (
<span style={{ color: good ? NB.up : NB.dn }}>
{pct > 0 ? "▲" : "▼"} {Math.abs(pct).toFixed(1)}%
</span>
);
};
// ── metric ids for the event feed (same Klaviyo metrics as MiniEventFeed) ────
const METRIC_IDS = {
PLACED_ORDER: "Y8cqcF",
SHIPPED_ORDER: "VExpdL",
ACCOUNT_CREATED: "TeeypV",
CANCELED_ORDER: "YjVMNg",
NEW_BLOG_POST: "YcxeDr",
PAYMENT_REFUNDED: "R7XUYh",
} as const;
const EVENT_META: Record<string, { label: string; dot: string }> = {
[METRIC_IDS.PLACED_ORDER]: { label: "Order", dot: NB.up },
[METRIC_IDS.SHIPPED_ORDER]: { label: "Shipped", dot: "#5580d0" },
[METRIC_IDS.ACCOUNT_CREATED]: { label: "Account", dot: "#8f7ad6" },
[METRIC_IDS.CANCELED_ORDER]: { label: "Canceled", dot: NB.dn },
[METRIC_IDS.PAYMENT_REFUNDED]: { label: "Refund", dot: "#e0a15a" },
[METRIC_IDS.NEW_BLOG_POST]: { label: "Blog", dot: NB.mut },
};
const fmtEventMoney = (amount: string | number | undefined) => {
const num = typeof amount === "string" ? parseFloat(amount) : amount;
if (num == null || isNaN(num)) return "";
return `${num < 0 ? "-" : ""}$${Math.abs(num).toFixed(2)}`;
};
const shipMethod = (method: string | undefined) => {
if (!method) return "Digital";
if (method.includes("usps")) return "USPS";
if (method.includes("fedex")) return "FedEx";
if (method.includes("ups")) return "UPS";
return "Standard";
};
interface EventProps {
ShippingName?: string;
OrderId?: string | number;
TotalAmount?: string | number;
IsOnHold?: boolean;
StillOwes?: boolean;
LocalPickup?: boolean;
HasPreorder?: boolean;
ShipMethod?: string;
ShippedBy?: string;
FirstName?: string;
LastName?: string;
EmailAddress?: string;
CancelReason?: string;
FromOrder?: string | number;
PaymentAmount?: string | number;
PaymentName?: string;
title?: string;
description?: string;
}
interface FeedEvent {
id: string;
metric_id: string;
datetime?: string;
attributes?: { datetime?: string; event_properties?: EventProps };
event_properties: EventProps;
}
interface PhaseSlice {
phase: string;
revenue: number;
percentage: number;
}
// ── response shapes (acotService is untyped JS; these mirror what the API returns) ──
interface TodayStats {
revenue: number;
orderCount: number;
itemCount: number;
averageOrderValue: number;
averageItemsPerOrder: number;
periodProgress: number;
prevPeriodRevenue: number;
prevPeriodOrders: number;
prevPeriodAOV: number;
projectedRevenue?: number;
}
interface Projection {
projectedRevenue?: number;
projectedOrders?: number;
}
interface DailyRow {
revenue?: number;
orders?: number;
prevRevenue?: number;
prevOrders?: number;
periodProgress?: number;
}
interface FinancialsResponse {
totals?: Record<string, number>;
previousTotals?: Record<string, number>;
comparison?: { margin?: { absolute?: number } };
}
// ── clock ────────────────────────────────────────────────────────────────────
const Clock = () => {
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const t = setInterval(() => setNow(new Date()), 10_000);
return () => clearInterval(t);
}, []);
return (
<span className="text-[17px]" style={{ color: NB.mut, letterSpacing: "0.06em" }}>
{now.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" })}
{" · "}
<span style={{ color: NB.txt, fontWeight: 500 }}>
{now.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })}
</span>
</span>
);
};
// ── main component ───────────────────────────────────────────────────────────
const NightboardSmall = () => {
// Today stats (same source as MiniStatCards)
const { data: stats } = useQuery({
queryKey: ["nightboard-stats-today"],
queryFn: async () => {
const res = (await acotService.getStats({ timeRange: "today" })) as { stats: TodayStats };
return res.stats;
},
refetchInterval: 60_000,
});
const { data: projection } = useQuery({
queryKey: ["nightboard-projection-today"],
queryFn: async () =>
(await acotService.getProjection({ timeRange: "today" })) as Projection,
enabled: stats != null && stats.periodProgress < 100,
refetchInterval: 60_000,
});
const { data: realtime } = useQuery({
queryKey: ["nightboard-realtime-users"],
queryFn: async () => {
const response = await apiFetch("/api/dashboard-analytics/realtime/basic", {
credentials: "include",
});
if (!response.ok) throw new Error("Failed to fetch realtime");
const result = await response.json();
return processBasicData(result.data) as { last30MinUsers: number; last5MinUsers: number };
},
refetchInterval: 30_000,
});
// 30-day summary + avg/day (same source as MiniSalesChart / MiniBusinessMetrics)
const { data: sum30 } = useQuery({
queryKey: ["nightboard-stats-30d"],
queryFn: async () => {
const response = (await acotService.getStatsDetails({
timeRange: "last30days",
metric: "revenue",
daily: true,
})) as { stats?: DailyRow[] };
const rows = Array.isArray(response.stats) ? response.stats : [];
const t = rows.reduce<Required<DailyRow>>(
(acc, day) => ({
revenue: acc.revenue + (Number(day.revenue) || 0),
orders: acc.orders + (Number(day.orders) || 0),
prevRevenue: acc.prevRevenue + (Number(day.prevRevenue) || 0),
prevOrders: acc.prevOrders + (Number(day.prevOrders) || 0),
periodProgress: day.periodProgress || 100,
}),
{ revenue: 0, orders: 0, prevRevenue: 0, prevOrders: 0, periodProgress: 100 }
);
const days = rows.length || 1;
return {
...t,
avgPerDay: t.revenue / days,
prevAvgPerDay: t.prevRevenue / days,
};
},
refetchInterval: 300_000,
});
const { data: projection30 } = useQuery({
queryKey: ["nightboard-projection-30d"],
queryFn: async () =>
(await acotService.getProjection({ timeRange: "last30days" })) as Projection,
enabled: sum30 != null && sum30.periodProgress < 100,
refetchInterval: 300_000,
});
// 30-day chart + phase mix (same endpoint as MiniSalesChart)
const { data: chartData } = useQuery({
queryKey: ["nightboard-sales-chart-30d"],
queryFn: async () => {
const now = new Date();
const thirtyDaysAgo = new Date(now);
thirtyDaysAgo.setDate(now.getDate() - 30);
const params = new URLSearchParams({
startDate: thirtyDaysAgo.toISOString(),
endDate: now.toISOString(),
});
const response = await apiFetch(`${config.apiUrl}/dashboard/sales/metrics?${params}`);
if (!response.ok) throw new Error("Failed to fetch sales metrics");
return response.json();
},
refetchInterval: 300_000,
});
// Business band (same sources as MiniBusinessMetrics)
const { data: forecastData } = useQuery({
queryKey: ["nightboard-forecast-30d"],
queryFn: async () => {
const response = await apiFetch(`${config.apiUrl}/dashboard/forecast/metrics`);
if (!response.ok) throw new Error("Failed to fetch forecast");
return response.json();
},
refetchInterval: 600_000,
});
const { data: yearData } = useQuery({
queryKey: ["nightboard-year-estimate"],
queryFn: async () => {
const response = await apiFetch(`${config.apiUrl}/dashboard/year-revenue-estimate`);
if (!response.ok) throw new Error("Failed to fetch year estimate");
return response.json();
},
refetchInterval: 600_000,
});
const { data: financialData } = useQuery({
queryKey: ["nightboard-financials-30d"],
queryFn: async () =>
(await acotService.getFinancials({ timeRange: "last30days" })) as FinancialsResponse,
refetchInterval: 300_000,
});
const { data: fteData } = useQuery({
queryKey: ["nightboard-fte-30d"],
queryFn: async () =>
// @ts-expect-error - acotService is a JS file, TypeScript can't infer the param type
(await acotService.getEmployeeMetrics({ timeRange: "last30days" })) as {
totals?: { fte?: number };
previousTotals?: { fte?: number };
},
refetchInterval: 300_000,
});
// Ops / inventory band (same sources as MiniInventorySnapshot)
const { data: opsData } = useQuery({
queryKey: ["nightboard-ops-today"],
queryFn: async () =>
// @ts-expect-error - acotService is a JS file, TypeScript can't infer the param type
(await acotService.getOperationsMetrics({ timeRange: "today" })) as {
totals?: { ordersShipped?: number; piecesPicked?: number };
},
refetchInterval: 120_000,
});
const { data: stockData } = useQuery({
queryKey: ["nightboard-stock-metrics"],
queryFn: async () => {
const response = await apiFetch(`${config.apiUrl}/dashboard/stock/metrics`);
if (!response.ok) throw new Error("Failed to fetch stock metrics");
return response.json();
},
refetchInterval: 300_000,
});
const { data: replenishData } = useQuery({
queryKey: ["nightboard-replenish-metrics"],
queryFn: async () => {
const response = await apiFetch(`${config.apiUrl}/dashboard/replenishment/metrics`);
if (!response.ok) throw new Error("Failed to fetch replenishment");
return response.json();
},
refetchInterval: 300_000,
});
const { data: overstockData } = useQuery({
queryKey: ["nightboard-overstock-metrics"],
queryFn: async () => {
const response = await apiFetch(`${config.apiUrl}/dashboard/overstock/metrics`);
if (!response.ok) throw new Error("Failed to fetch overstock");
return response.json();
},
refetchInterval: 300_000,
});
// Event feed (same source as MiniEventFeed)
const { data: events = [] } = useQuery<FeedEvent[]>({
queryKey: ["nightboard-event-feed"],
queryFn: async () => {
const response = await apiClient.get("/api/klaviyo/events/feed", {
params: {
timeRange: "today",
metricIds: JSON.stringify(Object.values(METRIC_IDS)),
},
});
return (response.data.data || []).map((event: FeedEvent) => ({
...event,
datetime: event.attributes?.datetime || event.datetime,
event_properties: event.attributes?.event_properties || {},
}));
},
refetchInterval: 30_000,
});
const feedRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (feedRef.current && events.length > 0) {
const el = feedRef.current;
const t = setTimeout(
() => el.scrollTo({ left: el.scrollWidth, behavior: "instant" as ScrollBehavior }),
150
);
return () => clearTimeout(t);
}
}, [events]);
// ── derived values ─────────────────────────────────────────────────────────
const inProgress = stats != null && stats.periodProgress < 100;
const projRevenue = projection?.projectedRevenue ?? stats?.projectedRevenue ?? null;
const revTrend =
stats && stats.prevPeriodRevenue > 0
? trendPct(
inProgress ? projRevenue ?? stats.revenue : stats.revenue,
stats.prevPeriodRevenue
)
: null;
const projOrders =
projection?.projectedOrders ??
(stats && stats.periodProgress > 0
? Math.round(stats.orderCount / (stats.periodProgress / 100))
: null);
const rev30Current =
sum30 && sum30.periodProgress < 100
? projection30?.projectedRevenue || sum30.revenue
: sum30?.revenue;
const rev30Trend = sum30 ? trendPct(rev30Current ?? 0, sum30.prevRevenue) : null;
const orders30Current =
sum30 && sum30.periodProgress < 100 && sum30.periodProgress > 0
? Math.round(sum30.orders * (100 / sum30.periodProgress))
: sum30?.orders;
const orders30Trend = sum30 ? trendPct(orders30Current ?? 0, sum30.prevOrders) : null;
const dailyTotals: { date: string; total: number }[] = (chartData?.dailySalesByPhase || []).map(
(day: { date: string } & Record<string, unknown>) => ({
date: day.date,
total: PHASE_KEYS.reduce((sum, key) => sum + (Number(day[key]) || 0), 0),
})
);
const activePhases: PhaseSlice[] = (chartData?.phaseBreakdown || [])
.filter((p: PhaseSlice) => p.revenue > 0)
.sort((a: PhaseSlice, b: PhaseSlice) => b.revenue - a.revenue);
// margin (same fallback math as MiniBusinessMetrics)
let margin: number | null = null;
let prevMargin: number | null = null;
if (financialData?.totals) {
const t = financialData.totals;
if (Number.isFinite(t.margin)) margin = t.margin;
else {
const income = (t.grossSales || 0) - (t.refunds || 0) - (t.discounts || 0) + (t.shippingFees || 0);
margin = income > 0 ? ((income - (t.cogs || 0)) / income) * 100 : 0;
}
const prev = financialData.previousTotals;
if (prev) {
if (Number.isFinite(prev.margin)) prevMargin = prev.margin;
else {
const pIncome =
(prev.grossSales || 0) - (prev.refunds || 0) - (prev.discounts || 0) + (prev.shippingFees || 0);
prevMargin = pIncome > 0 ? ((pIncome - (prev.cogs || 0)) / pIncome) * 100 : 0;
}
}
}
const fte = fteData?.totals?.fte ?? null;
const prevFte = fteData?.previousTotals?.fte ?? null;
const ops = opsData?.totals;
const band: { label: string; value: string; sub: React.ReactNode; group?: boolean }[] = [
{
label: "Forecast 30d",
value: fmtCompact(forecastData?.forecastRevenue),
sub: yearData?.yearTotal != null ? `${fmtCompact(yearData.yearTotal)} for year` : "—",
},
{
label: "Avg rev/day",
value: sum30 ? fmtMoney(sum30.avgPerDay) : "—",
sub: sum30 ? (
<>
prev {fmtMoney(sum30.prevAvgPerDay)}{" "}
<TrendArrow pct={trendPct(sum30.avgPerDay, sum30.prevAvgPerDay)} />
</>
) : (
"—"
),
},
{
label: "Margin",
value: margin != null ? `${margin.toFixed(1)}%` : "—",
sub:
prevMargin != null && margin != null ? (
<>
prev {prevMargin.toFixed(1)}%{" "}
<span style={{ color: margin >= prevMargin ? NB.up : NB.dn }}>
{margin >= prevMargin ? "▲" : "▼"} {Math.abs(margin - prevMargin).toFixed(1)}pp
</span>
</>
) : (
"—"
),
},
{
label: "Payroll FTE",
value: fte != null ? fte.toFixed(1) : "—",
sub:
prevFte != null && fte != null ? (
<>
prev {prevFte.toFixed(1)} <TrendArrow pct={trendPct(fte, prevFte)} invert />
</>
) : (
"—"
),
},
{
label: "Shipped today",
value: ops ? fmtCount(ops.ordersShipped) : "—",
sub: ops ? `${fmtCount(ops.piecesPicked)} pcs picked` : "—",
group: true,
},
{
label: "Stock value",
value: fmtCompact(stockData?.totalStockCost),
sub: stockData ? `${fmtCount(stockData.productsInStock)} products` : "—",
},
{
label: "Replenish",
value: replenishData ? `${fmtCount(replenishData.unitsToReplenish)} u` : "—",
sub: replenishData ? `${fmtCompact(replenishData.replenishmentCost)} cost` : "—",
},
{
label: "Overstocked",
value: overstockData ? fmtCount(overstockData.overstockedProducts) : "—",
sub: overstockData ? `${fmtCompact(overstockData.totalExcessCost)} excess` : "—",
},
];
return (
<div
className="h-screen w-screen overflow-hidden grid grid-rows-[auto_auto_minmax(0,1fr)_auto_auto] grid-cols-[minmax(0,1fr)] px-12 pt-8"
style={{
background: `radial-gradient(1200px 500px at 25% -10%, rgba(22,32,50,0.45) 0%, transparent 60%), ${NB.bg}`,
color: NB.txt,
fontVariantNumeric: "tabular-nums",
}}
>
{/* top line */}
<div className="flex items-baseline justify-between pb-6">
<span
className="text-[16px] font-medium uppercase"
style={{ color: NB.mut, letterSpacing: "0.3em" }}
>
A Cherry On Top
</span>
<div className="flex items-center gap-4">
<Clock />
<span className="opacity-30 hover:opacity-100 transition-opacity">
<LockButton />
</span>
</div>
</div>
{/* hero metrics */}
<div className="grid grid-cols-[1.5fr_1fr_1fr_1fr] gap-10 items-end pb-8">
<div>
<Lbl>Revenue today</Lbl>
<div
className="text-[104px] font-extralight leading-[0.95] mt-2"
style={{ color: NB.amberText, letterSpacing: "-0.03em" }}
>
{fmtMoney(stats?.revenue)}
</div>
<div className="text-[18px] mt-3" style={{ color: NB.mut }}>
{inProgress && projRevenue != null && (
<>
proj <span style={{ color: NB.txt, fontWeight: 500 }}>{fmtMoney(projRevenue)}</span>
{" · "}
</>
)}
<TrendArrow pct={revTrend} /> {revTrend != null && "vs last week"}
</div>
</div>
<div>
<Lbl>Orders</Lbl>
<div className="text-[52px] font-light leading-none mt-2">
{stats?.orderCount?.toLocaleString() ?? "—"}
</div>
<div className="text-[17px] mt-2" style={{ color: NB.mut }}>
{projOrders != null && inProgress && (
<>
proj <span style={{ color: NB.txt, fontWeight: 500 }}>{projOrders}</span>
{" · "}
</>
)}
{stats?.itemCount != null ? `${fmtCount(stats.itemCount)} items` : ""}
</div>
</div>
<div>
<Lbl>Avg order</Lbl>
<div className="text-[52px] font-light leading-none mt-2">
{stats?.averageOrderValue != null ? `$${stats.averageOrderValue.toFixed(2)}` : "—"}
</div>
<div className="text-[17px] mt-2" style={{ color: NB.mut }}>
{stats?.averageItemsPerOrder != null
? `${stats.averageItemsPerOrder.toFixed(1)} items / order`
: ""}
</div>
</div>
<div>
<Lbl>On site now</Lbl>
<div className="text-[52px] font-light leading-none mt-2 flex items-center gap-4">
<span className="relative inline-flex h-3 w-3">
<span
className="absolute inline-flex h-full w-full rounded-full opacity-75 motion-safe:animate-ping"
style={{ background: NB.up }}
/>
<span className="relative inline-flex h-3 w-3 rounded-full" style={{ background: NB.up }} />
</span>
{realtime?.last5MinUsers ?? "—"}
</div>
<div className="text-[17px] mt-2" style={{ color: NB.mut }}>
{realtime != null ? `${realtime.last30MinUsers} last 30 min` : ""}
</div>
</div>
</div>
{/* 30-day chart */}
<div className="flex flex-col min-h-0 pt-4" style={{ borderTop: `1px solid ${NB.line}` }}>
<div className="flex items-baseline justify-between pb-1">
<Lbl>Last 30 days</Lbl>
<span className="text-[17px]" style={{ color: NB.mut }}>
Revenue <span style={{ color: NB.txt, fontWeight: 500 }}>{fmtCompact(sum30?.revenue)}</span>{" "}
<TrendArrow pct={rev30Trend} />
{" · "}
Orders <span style={{ color: NB.txt, fontWeight: 500 }}>{fmtCount(sum30?.orders)}</span>{" "}
<TrendArrow pct={orders30Trend} />
</span>
</div>
<div className="flex-1 min-h-0">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={dailyTotals} margin={{ top: 10, right: 4, left: 4, bottom: 0 }}>
<defs>
<linearGradient id="nbRevFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={NB.amber} stopOpacity={0.28} />
<stop offset="100%" stopColor={NB.amber} stopOpacity={0} />
</linearGradient>
</defs>
<XAxis
dataKey="date"
tickFormatter={(v: string) =>
new Date(v).toLocaleDateString([], { month: "numeric", day: "numeric" })
}
tick={{ fill: NB.mut, fontSize: 14 }}
tickLine={false}
axisLine={false}
interval={5}
/>
<YAxis
tickFormatter={(v: number) => fmtCompact(v)}
tick={{ fill: NB.mut, fontSize: 14 }}
tickLine={false}
axisLine={false}
width={52}
/>
<Tooltip
cursor={{ stroke: NB.faint, strokeDasharray: "3 4" }}
content={({ active, payload }) => {
if (!active || !payload?.length) return null;
const row = payload[0].payload as { date: string; total: number };
return (
<div
className="rounded-lg px-4 py-2.5 text-[15px]"
style={{ background: NB.card, border: `1px solid ${NB.line}`, color: NB.txt }}
>
<div style={{ color: NB.mut }}>
{new Date(row.date).toLocaleDateString([], {
weekday: "short",
month: "short",
day: "numeric",
})}
</div>
<div className="font-medium">{fmtMoney(row.total)}</div>
</div>
);
}}
/>
<Area
type="monotone"
dataKey="total"
stroke={NB.amber}
strokeWidth={2.5}
fill="url(#nbRevFill)"
dot={false}
activeDot={{ r: 5, fill: NB.amberText, stroke: "none" }}
/>
</AreaChart>
</ResponsiveContainer>
</div>
{/* lifecycle-phase mix */}
{activePhases.length > 0 && (
<div className="flex items-center gap-5 pt-2 pb-3">
<div className="flex flex-1 gap-[3px]" style={{ height: 6 }}>
{activePhases.map((p) => (
<div
key={p.phase}
className="rounded-[3px]"
style={{
width: `${p.percentage}%`,
background: PHASE_CONFIG[p.phase]?.color || "#94A3B8",
}}
/>
))}
</div>
<div className="flex gap-5 text-[13px]" style={{ color: NB.mut }}>
{activePhases.slice(0, 5).map((p) => (
<span key={p.phase} className="flex items-center gap-2 whitespace-nowrap">
<span
className="inline-block h-2.5 w-2.5 rounded-[3px]"
style={{ background: PHASE_CONFIG[p.phase]?.color || "#94A3B8" }}
/>
{PHASE_CONFIG[p.phase]?.label || p.phase}
</span>
))}
</div>
</div>
)}
</div>
{/* business + ops band */}
<div className="grid grid-cols-8 py-5" style={{ borderTop: `1px solid ${NB.line}` }}>
{band.map((cell, i) => (
<div
key={cell.label}
className="px-5 first:pl-0"
style={{
borderLeft: i === 0 ? "none" : `1px solid ${cell.group ? "#2a3852" : "#1a2334"}`,
}}
>
<div
className="text-[13px] font-medium uppercase"
style={{ color: NB.mut, letterSpacing: "0.18em" }}
>
{cell.label}
</div>
<div className="text-[34px] font-light mt-1.5" style={{ letterSpacing: "-0.01em" }}>
{cell.value}
</div>
<div className="text-[15px] mt-1" style={{ color: NB.mut }}>
{cell.sub}
</div>
</div>
))}
</div>
{/* live event feed */}
<div className="relative min-w-0" style={{ borderTop: `1px solid ${NB.line}` }}>
<div
ref={feedRef}
className="overflow-x-auto overflow-y-hidden py-4 [&::-webkit-scrollbar]:hidden"
style={{ scrollbarWidth: "none" }}
>
<div className="flex gap-3 px-1" style={{ width: "max-content" }}>
{[...events].reverse().map((event) => {
const meta = EVENT_META[event.metric_id];
if (!meta) return null;
const d = event.event_properties;
let name = "";
let detail: React.ReactNode = "";
if (event.metric_id === METRIC_IDS.PLACED_ORDER) {
name = d.ShippingName ?? "";
const flags = [
d.IsOnHold && "On Hold",
d.StillOwes && "Owes",
d.LocalPickup && "Local",
d.HasPreorder && "Pre-order",
].filter(Boolean);
detail = (
<>
#{d.OrderId} · <span style={{ color: NB.amberText }}>{fmtEventMoney(d.TotalAmount)}</span>
{flags.length > 0 && ` · ${flags.join(" · ")}`}
</>
);
} else if (event.metric_id === METRIC_IDS.SHIPPED_ORDER) {
name = d.ShippingName ?? "";
detail = `#${d.OrderId} · ${shipMethod(d.ShipMethod)}${d.ShippedBy ? ` · by ${d.ShippedBy}` : ""}`;
} else if (event.metric_id === METRIC_IDS.ACCOUNT_CREATED) {
name = `${d.FirstName ?? ""} ${d.LastName ?? ""}`.trim();
detail = d.EmailAddress;
} else if (event.metric_id === METRIC_IDS.CANCELED_ORDER) {
name = d.ShippingName ?? "";
detail = (
<>
#{d.OrderId} · {fmtEventMoney(d.TotalAmount)}
{d.CancelReason ? ` · ${d.CancelReason}` : ""}
</>
);
} else if (event.metric_id === METRIC_IDS.PAYMENT_REFUNDED) {
name = d.ShippingName ?? "";
detail = (
<>
#{d.FromOrder} · {fmtEventMoney(d.PaymentAmount)}
{d.PaymentName ? ` · ${d.PaymentName}` : ""}
</>
);
} else if (event.metric_id === METRIC_IDS.NEW_BLOG_POST) {
name = d.title ?? "";
detail = d.description;
}
return (
<EventDialog key={event.id} event={event} scale={1.75}>
<div
className="w-[290px] shrink-0 rounded-2xl px-5 py-4 cursor-pointer transition-colors hover:brightness-125"
style={{ background: NB.card }}
>
<div
className="flex items-center justify-between text-[12px] font-medium uppercase"
style={{ color: NB.mut, letterSpacing: "0.16em" }}
>
<span className="flex items-center gap-2.5">
<span
className="inline-block h-2 w-2 rounded-full"
style={{ background: meta.dot }}
/>
{meta.label}
</span>
{event.datetime && (
<span style={{ letterSpacing: "0.04em" }}>
{new Date(event.datetime).toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
})}
</span>
)}
</div>
<div className="text-[21px] font-medium mt-2 truncate">{name || "—"}</div>
<div className="text-[16px] mt-1 truncate" style={{ color: NB.mut }}>
{detail}
</div>
</div>
</EventDialog>
);
})}
</div>
</div>
{/* edge fades */}
<div
className="pointer-events-none absolute inset-y-0 left-0 w-14"
style={{ background: `linear-gradient(to right, ${NB.bg}, transparent)` }}
/>
<div
className="pointer-events-none absolute inset-y-0 right-0 w-14"
style={{ background: `linear-gradient(to left, ${NB.bg}, transparent)` }}
/>
</div>
</div>
);
};
export default NightboardSmall;
@@ -31,7 +31,7 @@ export interface BusinessRangeSelectProps {
onValueChange: (value: string) => void;
/** Preset options. Defaults to the canonical TIME_RANGES list. */
options?: RangeOption[];
/** Merged onto the SelectTrigger (default sizing is w-[130px] h-9). */
/** Merged onto the SelectTrigger (default sizing is w-[130px] h-8). */
className?: string;
placeholder?: string;
id?: string;
@@ -47,10 +47,16 @@ export function BusinessRangeSelect({
}: BusinessRangeSelectProps) {
return (
<Select value={value} onValueChange={onValueChange}>
<SelectTrigger id={id} className={cn("w-[130px] h-9", className)}>
<SelectTrigger
id={id}
className={cn(
"h-8 w-auto gap-1.5 rounded-full border-[#e7e5e1] bg-white px-3.5 text-xs font-semibold text-[#2b2925] shadow-none hover:bg-[#faf9f7] focus:ring-1 focus:ring-[#e06a4e]/30 focus:ring-offset-0",
className
)}
>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
<SelectContent className="rounded-xl">
{options.map((range) => (
<SelectItem key={range.value} value={range.value}>
{range.label}
@@ -76,6 +76,8 @@ export interface DashboardSectionHeaderProps {
compact?: boolean;
/** Size variant for title */
size?: "default" | "large";
/** Show the accent bar next to the title (turn off for nested cards) */
accent?: boolean;
}
// =============================================================================
@@ -83,11 +85,12 @@ export interface DashboardSectionHeaderProps {
// =============================================================================
const defaultLastUpdatedFormat = (date: Date): string => {
return date.toLocaleTimeString("en-US", {
const time = date.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
});
return `Updated ${time}`;
};
// =============================================================================
@@ -105,55 +108,60 @@ export const DashboardSectionHeader: React.FC<DashboardSectionHeaderProps> = ({
className,
compact = false,
size = "default",
accent = true,
}) => {
const paddingClass = compact ? "p-4 pb-2" : "p-6 pb-2";
const paddingClass = compact ? "px-4 py-2.5" : "px-4 py-3";
const titleClass = size === "large"
? "text-xl font-semibold text-foreground"
: "text-lg font-semibold text-foreground";
? "text-[14px] font-semibold tracking-tight text-[#2b2925]"
: "text-[12.5px] font-semibold tracking-tight text-[#2b2925]";
void accent; // Studio design has no accent bar; prop kept for call-site compat
// Loading skeleton
if (loading) {
return (
<CardHeader className={cn(paddingClass, className)}>
<CardHeader className={cn("border-b border-[#f0eeea]", paddingClass, className)}>
<div className="flex justify-between items-start">
<div className="space-y-1">
<Skeleton className="h-6 w-40 bg-muted" />
{description && <Skeleton className="h-4 w-56 bg-muted" />}
<Skeleton className="h-5 w-40 bg-[#f0eeea]" />
{description && <Skeleton className="h-4 w-56 bg-[#f5f3f0]" />}
</div>
<div className="flex items-center gap-2">
{timeSelector && <Skeleton className="h-9 w-[130px] bg-muted rounded-md" />}
{actions && <Skeleton className="h-9 w-20 bg-muted rounded-md" />}
{timeSelector && <Skeleton className="h-8 w-[130px] bg-[#f0eeea] rounded-full" />}
{actions && <Skeleton className="h-8 w-20 bg-[#f0eeea] rounded-full" />}
</div>
</div>
</CardHeader>
);
}
const hasRightContent = timeSelector || actions || (lastUpdated && !loading);
const hasRightContent = timeSelector || actions;
return (
<CardHeader className={cn(paddingClass, className)}>
<div className="flex justify-between items-start gap-4">
{/* Left side: Title and description */}
<CardHeader className={cn("border-b border-[#f0eeea]", paddingClass, className)}>
<div className="flex flex-row items-center justify-between gap-3">
{/* Left side: title (+ optional description / updated time inline) */}
<div className="min-w-0 flex-1">
<CardTitle className={titleClass}>{title}</CardTitle>
<div className="flex flex-wrap items-baseline gap-x-2.5">
<CardTitle className={titleClass}>{title}</CardTitle>
{lastUpdated && !loading && (
<span className="text-[10.5px] text-[#a09b92]">
{lastUpdatedFormat(lastUpdated)}
</span>
)}
</div>
{description && (
<CardDescription className={cn(TYPOGRAPHY.cardDescription, "mt-1")}>
<CardDescription className={cn(TYPOGRAPHY.cardDescription, "mt-0.5 hidden sm:block")}>
{description}
</CardDescription>
)}
{lastUpdated && !loading && (
<p className="text-xs text-muted-foreground mt-1">
Last updated: {lastUpdatedFormat(lastUpdated)}
</p>
)}
</div>
{/* Right side: Time selector and actions */}
{/* Right side: actions (filters / Details) sit left of the date range
selector for a consistent order across sections */}
{hasRightContent && (
<div className="flex items-center gap-2 flex-shrink-0">
{timeSelector}
<div className="flex flex-wrap items-center justify-end gap-1.5 flex-shrink-0">
{actions}
{timeSelector}
</div>
)}
</div>
@@ -180,18 +188,18 @@ export const DashboardSectionHeaderSkeleton: React.FC<DashboardSectionHeaderSkel
compact = false,
className,
}) => {
const paddingClass = compact ? "p-4 pb-2" : "p-6 pb-4";
const paddingClass = compact ? "px-4 py-2.5" : "px-4 py-3";
return (
<CardHeader className={cn(paddingClass, className)}>
<CardHeader className={cn("border-b border-[#f0eeea]", paddingClass, className)}>
<div className="flex justify-between items-start">
<div className="space-y-2">
<Skeleton className="h-6 w-40 bg-muted" />
{hasDescription && <Skeleton className="h-4 w-56 bg-muted" />}
<Skeleton className="h-5 w-40 bg-[#f0eeea]" />
{hasDescription && <Skeleton className="h-4 w-56 bg-[#f5f3f0]" />}
</div>
<div className="flex items-center gap-2">
{hasTimeSelector && <Skeleton className="h-9 w-[130px] bg-muted rounded-md" />}
{hasActions && <Skeleton className="h-9 w-20 bg-muted rounded-md" />}
{hasTimeSelector && <Skeleton className="h-8 w-[130px] bg-[#f0eeea] rounded-full" />}
{hasActions && <Skeleton className="h-8 w-20 bg-[#f0eeea] rounded-full" />}
</div>
</div>
</CardHeader>
@@ -1,53 +1,27 @@
/**
* DashboardStatCard
* DashboardStatCard (Sorbet Studio)
*
* A reusable stat/metric card component for the dashboard.
* Supports icons, trend indicators, tooltips, and multiple size variants.
* The shared stat/metric card. Clean white panel, warm border, no icons
* the number is the point. Trend renders as a tinted pill; up/down semantics
* live in the pill text color only.
*
* `icon`, `iconColor`, and `tooltip` are still accepted so existing call sites
* compile, but they intentionally render nothing: decorative per-metric icons
* and info-circles explaining obvious metrics were removed in the redesign.
*
* @example
* // Basic usage
* <DashboardStatCard
* title="Total Revenue"
* value="$12,345"
* subtitle="Last 30 days"
* />
*
* @example
* // With icon and trend
* <DashboardStatCard
* title="Orders"
* value={1234}
* trend={{ value: 12.5, label: "vs last month" }}
* icon={ShoppingCart}
* iconColor="blue"
* />
*
* @example
* // With prefix/suffix and tooltip
* <DashboardStatCard
* title="Average Order Value"
* value={85.50}
* valuePrefix="$"
* valueSuffix="/order"
* tooltip="Calculated as total revenue divided by number of orders"
* subtitle="772 total items"
* trend={{ value: 12.5 }}
* />
*/
import React from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { ArrowUp, ArrowDown, Minus, Info, type LucideIcon } from "lucide-react";
import { type LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import {
CARD_STYLES,
TYPOGRAPHY,
STAT_ICON_STYLES,
getTrendColor,
} from "@/lib/dashboard/designTokens";
import { STUDIO_COLORS } from "@/lib/dashboard/designTokens";
// =============================================================================
// TYPES
@@ -55,7 +29,6 @@ import {
export type TrendDirection = "up" | "down" | "neutral";
export type CardSize = "default" | "compact" | "large";
export type IconColor = keyof typeof STAT_ICON_STYLES.colors;
export interface TrendProps {
/** The percentage or absolute change value */
@@ -81,10 +54,10 @@ export interface DashboardStatCardProps {
subtitle?: React.ReactNode;
/** Optional trend indicator */
trend?: TrendProps;
/** Optional icon component */
/** Accepted for compatibility; not rendered in the Studio design */
icon?: LucideIcon;
/** Icon color variant */
iconColor?: IconColor;
/** Accepted for compatibility; not rendered in the Studio design */
iconColor?: string;
/** Card size variant */
size?: CardSize;
/** Additional className for the card */
@@ -93,89 +66,48 @@ export interface DashboardStatCardProps {
onClick?: () => void;
/** Loading state */
loading?: boolean;
/** Tooltip text shown via info icon next to title */
/** Accepted for compatibility; not rendered in the Studio design */
tooltip?: string;
/** Additional content to render below the main value */
children?: React.ReactNode;
}
// =============================================================================
// HELPER COMPONENTS
// TREND PILL
// =============================================================================
interface TrendIndicatorProps {
value: number;
label?: string;
moreIsBetter?: boolean;
suffix?: string;
interface TrendPillProps extends TrendProps {
size?: CardSize;
}
const TrendIndicator: React.FC<TrendIndicatorProps> = ({
export const TrendPill: React.FC<TrendPillProps> = ({
value,
label,
moreIsBetter = true,
suffix = "%",
size = "default",
}) => {
const colors = getTrendColor(value, moreIsBetter);
const direction: TrendDirection =
value > 0 ? "up" : value < 0 ? "down" : "neutral";
const IconComponent =
direction === "up"
? ArrowUp
: direction === "down"
? ArrowDown
: Minus;
const iconSize = size === "compact" ? "h-3 w-3" : "h-3 w-3";
const textSize = size === "compact" ? "text-xs" : "text-xs";
// Format the value - use fixed decimal for percentages, integer for absolute values
const formattedValue = suffix === "%"
? value.toFixed(1)
: Math.abs(value).toString();
if (value === 0) return null;
const isGood = (value > 0) === moreIsBetter;
const formattedValue = suffix === "%" ? Math.abs(value).toFixed(1) : Math.abs(value).toString();
return (
<div className={cn("flex items-center gap-1", colors.text)}>
<IconComponent className={iconSize} />
<span className={cn("font-medium", textSize)}>
{value > 0 && suffix === "%" ? "+" : ""}
{formattedValue}{suffix}
<span className="inline-flex items-center gap-1">
<span
className="rounded-full px-1.5 py-px text-[10.5px] font-bold whitespace-nowrap"
style={{
background: isGood ? "rgba(35,122,77,.09)" : "rgba(179,80,63,.09)",
color: isGood ? STUDIO_COLORS.up : STUDIO_COLORS.down,
}}
>
{value > 0 ? "↑" : "↓"} {formattedValue}
{suffix}
</span>
{label && (
<span className={cn("text-muted-foreground", textSize)}>{label}</span>
<span className="text-[11px]" style={{ color: STUDIO_COLORS.muted }}>
{label}
</span>
)}
</div>
);
};
interface IconContainerProps {
icon: LucideIcon;
color?: IconColor;
size?: CardSize;
}
const IconContainer: React.FC<IconContainerProps> = ({
icon: Icon,
color = "blue",
size = "default",
}) => {
const colorStyles = STAT_ICON_STYLES.colors[color] || STAT_ICON_STYLES.colors.blue;
const containerSize = size === "compact" ? "p-1.5" : "p-2";
const iconSize = size === "compact" ? "h-3.5 w-3.5" : size === "large" ? "h-5 w-5" : "h-4 w-4";
return (
<div
className={cn(
STAT_ICON_STYLES.container,
containerSize,
colorStyles.container
)}
>
<Icon className={cn(iconSize, colorStyles.icon)} />
</div>
</span>
);
};
@@ -190,115 +122,84 @@ export const DashboardStatCard: React.FC<DashboardStatCardProps> = ({
valueSuffix,
subtitle,
trend,
icon,
iconColor = "blue",
size = "default",
className,
onClick,
loading = false,
tooltip,
children,
}) => {
// Size-based styling
const sizeStyles = {
default: {
header: CARD_STYLES.header,
value: TYPOGRAPHY.cardValue,
title: TYPOGRAPHY.cardTitle,
content: CARD_STYLES.content,
},
compact: {
header: CARD_STYLES.headerCompact,
value: TYPOGRAPHY.cardValueSmall,
title: "text-xs font-medium text-muted-foreground",
content: "px-4 pt-0 pb-3",
},
large: {
header: CARD_STYLES.header,
value: TYPOGRAPHY.cardValueLarge,
title: TYPOGRAPHY.cardTitle,
content: CARD_STYLES.contentPadded,
},
};
const valueClass =
size === "large"
? "text-[24px]"
: size === "compact"
? "text-[17px]"
: "text-[20px]";
const styles = sizeStyles[size];
const cardClass = onClick ? CARD_STYLES.interactive : CARD_STYLES.base;
const cardClass = cn(
"rounded-[14px] border bg-white px-3.5 py-3",
onClick &&
"cursor-pointer transition-colors duration-150 hover:bg-[#faf9f7] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
className
);
const cardStyle = { borderColor: STUDIO_COLORS.border };
// Loading state
if (loading) {
return (
<Card className={cn(cardClass, className)}>
<CardHeader className={cn(styles.header, "space-y-0")}>
<div className="h-4 w-24 bg-muted animate-pulse rounded" />
{icon && <div className="h-8 w-8 bg-muted animate-pulse rounded-lg" />}
</CardHeader>
<CardContent className={styles.content}>
<div className="h-8 w-32 bg-muted animate-pulse rounded mb-2" />
{subtitle && <div className="h-4 w-20 bg-muted animate-pulse rounded" />}
</CardContent>
</Card>
<div className={cn(cardClass, "min-h-[92px]")} style={cardStyle}>
<div className="h-3.5 w-20 animate-pulse rounded bg-[#f0eeea]" />
<div className="mt-2.5 h-6 w-28 animate-pulse rounded bg-[#f0eeea]" />
{subtitle && <div className="mt-2 h-3.5 w-24 animate-pulse rounded bg-[#f5f3f0]" />}
</div>
);
}
// Format the display value with prefix/suffix
const formattedValue = (
<>
{valuePrefix && <span className="">{valuePrefix}</span>}
{typeof value === "number" ? value.toLocaleString() : value}
{valueSuffix && <span className="">{valueSuffix}</span>}
</>
);
return (
<Card
className={cn(cardClass, onClick && "cursor-pointer", className)}
<div
className={cn(cardClass, "min-h-[92px]")}
style={cardStyle}
onClick={onClick}
onKeyDown={
onClick
? (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onClick();
}
}
: undefined
}
role={onClick ? "button" : undefined}
tabIndex={onClick ? 0 : undefined}
>
<CardHeader className={cn(styles.header, "space-y-0")}>
<div className="flex items-center gap-1.5">
<CardTitle className={styles.title}>{title}</CardTitle>
{tooltip && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="text-muted-foreground hover:text-foreground transition-colors"
onClick={(e) => e.stopPropagation()}
>
<Info className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<p className="text-sm">{tooltip}</p>
</TooltipContent>
</Tooltip>
)}
</div>
{icon && <IconContainer icon={icon} color={iconColor} size={size} />}
</CardHeader>
<CardContent className={styles.content}>
<div className={cn(styles.value, "text-foreground")}>
{formattedValue}
</div>
{(subtitle || trend) && (
<div className="flex flex-wrap items-center justify-between gap-2 mt-1">
{subtitle && (
<span className={TYPOGRAPHY.cardDescription}>{subtitle}</span>
)}
{trend && (
<TrendIndicator
value={trend.value}
label={trend.label}
moreIsBetter={trend.moreIsBetter}
suffix={trend.suffix}
size={size}
/>
)}
</div>
<h4 className="text-[11px] font-semibold" style={{ color: STUDIO_COLORS.muted }}>
{title}
</h4>
<div className="mt-0.5 flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span
className={cn(valueClass, "font-bold tracking-tight tabular-nums")}
style={{ color: STUDIO_COLORS.ink }}
>
{valuePrefix}
{typeof value === "number" ? value.toLocaleString() : value}
{valueSuffix}
</span>
{trend && (
<TrendPill
value={trend.value}
label={trend.label}
moreIsBetter={trend.moreIsBetter}
suffix={trend.suffix}
/>
)}
{children}
</CardContent>
</Card>
</div>
{subtitle && (
<div className="mt-0.5 text-[11.5px]" style={{ color: STUDIO_COLORS.muted }}>
{subtitle}
</div>
)}
{children}
</div>
);
};
@@ -314,31 +215,17 @@ export interface DashboardStatCardSkeletonProps {
}
export const DashboardStatCardSkeleton: React.FC<DashboardStatCardSkeletonProps> = ({
size = "default",
hasIcon = true,
hasSubtitle = true,
className,
}) => {
const sizeStyles = {
default: { header: CARD_STYLES.header, content: CARD_STYLES.content },
compact: { header: CARD_STYLES.headerCompact, content: "px-4 pt-0 pb-3" },
large: { header: CARD_STYLES.header, content: CARD_STYLES.contentPadded },
};
const styles = sizeStyles[size];
return (
<Card className={cn(CARD_STYLES.base, className)}>
<CardHeader className={cn(styles.header, "space-y-0")}>
<div className="h-4 w-24 bg-muted animate-pulse rounded" />
{hasIcon && <div className="h-8 w-8 bg-muted animate-pulse rounded-lg" />}
</CardHeader>
<CardContent className={styles.content}>
<div className="h-8 w-32 bg-muted animate-pulse rounded mb-2" />
{hasSubtitle && <div className="h-4 w-20 bg-muted animate-pulse rounded" />}
</CardContent>
</Card>
);
};
}) => (
<div
className={cn("min-h-[92px] rounded-[14px] border bg-white px-3.5 py-3", className)}
style={{ borderColor: STUDIO_COLORS.border }}
>
<div className="h-3.5 w-20 animate-pulse rounded bg-[#f0eeea]" />
<div className="mt-2.5 h-6 w-28 animate-pulse rounded bg-[#f0eeea]" />
{hasSubtitle && <div className="mt-2 h-3.5 w-24 animate-pulse rounded bg-[#f5f3f0]" />}
</div>
);
export default DashboardStatCard;
@@ -57,6 +57,7 @@ import {
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { ArrowDown, ArrowUp } from "lucide-react";
import { cn } from "@/lib/utils";
import {
TABLE_STYLES,
@@ -178,7 +179,7 @@ export function DashboardTable<T extends Record<string, unknown>>({
sortConfig,
onSort,
}: DashboardTableProps<T>): React.ReactElement {
const paddingClass = compact ? "px-3 py-2" : "px-4 py-3";
const paddingClass = compact ? "px-3 py-2" : "px-3.5 py-2.5";
const scrollClass = maxHeight !== "none" ? MAX_HEIGHT_CLASSES[maxHeight] : "";
// Handle sort click - toggles direction or sets new sort key
@@ -203,11 +204,12 @@ export function DashboardTable<T extends Record<string, unknown>>({
return (
<Button
variant={isActive ? "default" : "ghost"}
variant="ghost"
size="sm"
onClick={() => handleSortClick(col)}
className={cn(
"h-8 font-medium",
"h-7 gap-1 rounded px-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground",
isActive && "bg-muted text-foreground",
col.align === "center" && "w-full justify-center",
col.align === "right" && "w-full justify-end",
col.align === "left" && "justify-start",
@@ -215,6 +217,11 @@ export function DashboardTable<T extends Record<string, unknown>>({
)}
>
{col.header}
{isActive && (
sortConfig.direction === "asc"
? <ArrowUp className="h-3 w-3" />
: <ArrowDown className="h-3 w-3" />
)}
</Button>
);
};
@@ -222,7 +229,7 @@ export function DashboardTable<T extends Record<string, unknown>>({
// Loading skeleton
if (loading) {
return (
<div className={cn(scrollClass, className)}>
<div className={cn("overflow-hidden rounded-md border border-border/60", scrollClass, className)}>
<Table className={tableClassName}>
<TableHeader>
<TableRow className={cn(TABLE_STYLES.row, "hover:bg-transparent")}>
@@ -288,9 +295,9 @@ export function DashboardTable<T extends Record<string, unknown>>({
// Data table
return (
<div className={cn(scrollClass, className)}>
<div className={cn("overflow-hidden", bordered && "rounded-md border border-border/60", scrollClass, className)}>
<Table className={tableClassName}>
<TableHeader className={stickyHeader ? "sticky top-0 bg-background z-10" : ""}>
<TableHeader className={stickyHeader ? "sticky top-0 bg-muted/80 backdrop-blur-sm z-10" : ""}>
<TableRow className={cn(TABLE_STYLES.row, TABLE_STYLES.header, "hover:bg-transparent")}>
{columns.map((col) => (
<TableHead
@@ -321,7 +328,7 @@ export function DashboardTable<T extends Record<string, unknown>>({
TABLE_STYLES.rowHover,
onRowClick && "cursor-pointer",
striped && rowIndex % 2 === 1 && "bg-muted/30",
bordered && "border-b border-border/50"
bordered && "border-b border-border/40 last:border-b-0"
)}
onClick={onRowClick ? () => onRowClick(row, rowIndex) : undefined}
>
@@ -0,0 +1,119 @@
/**
* StudioControls shared Sorbet Studio control atoms.
*
* MetricPill: series toggle whose color dot doubles as the chart legend
* (charts using these should NOT also render a recharts <Legend/>).
* LegendChip: non-interactive series chip for charts without toggles.
* QuietStat: deemphasized stat cell for gap-px strips (secondary metrics).
* PILL_TRIGGER_CLASS / PILL_BUTTON_CLASS: class strings that make ad-hoc
* Select triggers and small buttons match BusinessRangeSelect's pill look.
*/
import React from "react";
import { cn } from "@/lib/utils";
export const PILL_TRIGGER_CLASS =
"h-8 w-auto gap-1.5 rounded-full border-[#e7e5e1] bg-white px-3.5 text-xs font-semibold text-[#2b2925] shadow-none hover:bg-[#faf9f7] focus:ring-1 focus:ring-[#e06a4e]/30 focus:ring-offset-0";
export const PILL_BUTTON_CLASS =
"h-8 rounded-full border-[#e7e5e1] bg-white px-3.5 text-xs font-semibold text-[#2b2925] shadow-none hover:bg-[#faf9f7]";
export interface MetricPillProps {
active: boolean;
color: string;
/** Dashed ring dot for comparison/average series */
dashed?: boolean;
onClick: () => void;
children: React.ReactNode;
}
export const MetricPill: React.FC<MetricPillProps> = ({
active,
color,
dashed = false,
onClick,
children,
}) => (
<button
type="button"
onClick={onClick}
className={cn(
"flex h-7 shrink-0 items-center gap-1.5 rounded-full px-2.5 text-xs font-medium transition-colors",
active
? "bg-[#f0eeea] text-[#2b2925] hover:bg-[#eae7e2]"
: "text-[#7c7870] hover:bg-[#f5f3f0] hover:text-[#2b2925]"
)}
>
<span
className="inline-block h-2 w-2 rounded-full"
style={
dashed
? { background: "transparent", border: `1.5px dashed ${active ? color : "#d8d4cd"}` }
: { background: active ? color : "#d8d4cd" }
}
/>
{children}
</button>
);
export interface LegendChipProps {
color: string;
dashed?: boolean;
children: React.ReactNode;
}
/** Static series chip — for charts whose series aren't toggleable */
export const LegendChip: React.FC<LegendChipProps> = ({ color, dashed = false, children }) => (
<span className="flex h-7 shrink-0 items-center gap-1.5 px-2 text-xs font-medium text-[#7c7870]">
<span
className="inline-block h-2 w-2 rounded-full"
style={
dashed
? { background: "transparent", border: `1.5px dashed ${color}` }
: { background: color }
}
/>
{children}
</span>
);
export interface QuietStatProps {
label: string;
value: React.ReactNode;
sub?: React.ReactNode;
onClick?: () => void;
loading?: boolean;
}
/**
* Deemphasized stat cell. Place inside:
* <div className="grid grid-cols-2 gap-px overflow-hidden rounded-[14px]
* border border-[#e7e5e1] bg-[#f0eeea] sm:grid-cols-3 lg:grid-cols-6">
*/
export const QuietStat: React.FC<QuietStatProps> = ({ label, value, sub, onClick, loading }) => {
const Tag = onClick ? "button" : "div";
return (
<Tag
{...(onClick ? { type: "button" as const, onClick } : {})}
className={cn(
"bg-white px-3.5 py-2.5 text-left",
onClick &&
"transition-colors hover:bg-[#faf9f7] focus-visible:z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
)}
>
<div className="text-[10.5px] font-semibold text-[#a09b92]">{label}</div>
{loading ? (
<div className="mt-1.5 h-4 w-16 animate-pulse rounded bg-[#f0eeea]" />
) : (
<>
<div className="text-[15px] font-bold tabular-nums tracking-tight text-[#2b2925]">
{value}
</div>
{sub && <div className="text-[10.5px] text-[#7c7870]">{sub}</div>}
</>
)}
</Tag>
);
};
export const QUIET_STRIP_CLASS =
"grid gap-px overflow-hidden rounded-[14px] border border-[#e7e5e1] bg-[#f0eeea]";
@@ -80,13 +80,24 @@ export {
// STAT CARDS
// =============================================================================
export {
MetricPill,
LegendChip,
QuietStat,
PILL_TRIGGER_CLASS,
PILL_BUTTON_CLASS,
QUIET_STRIP_CLASS,
type MetricPillProps,
type QuietStatProps,
} from "./StudioControls";
export {
DashboardStatCard,
DashboardStatCardSkeleton,
TrendPill,
type DashboardStatCardProps,
type TrendDirection,
type CardSize,
type IconColor,
type TrendProps,
type DashboardStatCardSkeletonProps,
} from "./DashboardStatCard";
@@ -0,0 +1,68 @@
/**
* Sparkline tiny inline trend line for stat tiles (Sorbet Studio).
*
* Pure SVG, no axes or labels: it shows shape, not values (the tile's number
* carries the value). Stretches to its container via preserveAspectRatio=none,
* so strokes use vector-effect to stay crisp.
*/
import React from "react";
interface SparklineProps {
data: number[];
color: string;
/** rgba fill for an area under the line (featured tiles) */
fill?: string;
/** Tooltip text, e.g. "Last 30 days" */
title?: string;
className?: string;
}
const W = 100;
const H = 32;
const PAD = 3;
const Sparkline: React.FC<SparklineProps> = ({ data, color, fill, title, className }) => {
if (!data || data.length < 2) return null;
const max = Math.max(...data);
const min = Math.min(...data);
const span = max - min || 1;
const pts = data.map((v, i) => [
PAD + (i * (W - 2 * PAD)) / (data.length - 1),
PAD + ((max - v) * (H - 2 * PAD)) / span,
]);
const d = pts
.map((p, i) => `${i ? "L" : "M"}${p[0].toFixed(1)} ${p[1].toFixed(1)}`)
.join(" ");
const [ex, ey] = pts[pts.length - 1];
return (
<svg
viewBox={`0 0 ${W} ${H}`}
preserveAspectRatio="none"
className={className}
style={{ display: "block", width: "100%", height: "100%" }}
aria-hidden="true"
>
{title && <title>{title}</title>}
{fill && (
<path
d={`${d} L${ex.toFixed(1)} ${H - PAD} L${PAD} ${H - PAD} Z`}
fill={fill}
/>
)}
<path
d={d}
fill="none"
stroke={color}
strokeWidth={2}
strokeLinejoin="round"
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
/>
<circle cx={ex.toFixed(1)} cy={ey.toFixed(1)} r={2.4} fill={color} />
</svg>
);
};
export default Sparkline;
@@ -0,0 +1,129 @@
/**
* StudioStatTile pastel hero stat tile (Sorbet Studio).
*
* The tile's pastel fill + matching sparkline tone are that metric's identity;
* up/down semantics live only in the delta pill's text color, never the fill.
* Drop-in beside DashboardStatCard: same title/value/subtitle/trend/onClick
* contract, plus a `tone` and optional `spark` series.
*/
import React from "react";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { STUDIO_COLORS, STUDIO_TILES } from "@/lib/dashboard/designTokens";
import Sparkline from "./Sparkline";
interface StudioStatTileProps {
title: string;
value: React.ReactNode;
valuePrefix?: string;
valueSuffix?: string;
subtitle?: React.ReactNode;
trend?: { value: number; moreIsBetter?: boolean };
tone: keyof typeof STUDIO_TILES;
/** Featured tile: larger numeral + area-filled sparkline */
featured?: boolean;
spark?: number[];
sparkTitle?: string;
onClick?: () => void;
loading?: boolean;
}
const StudioStatTile: React.FC<StudioStatTileProps> = ({
title,
value,
valuePrefix,
valueSuffix,
subtitle,
trend,
tone,
featured = false,
spark,
sparkTitle = "Last 30 days",
onClick,
loading = false,
}) => {
const t = STUDIO_TILES[tone];
const showTrend = trend != null && Math.abs(trend.value) >= 0.1;
const isGood = trend
? (trend.moreIsBetter ?? true) === trend.value > 0
: true;
return (
<div
className={cn(
"rounded-[14px] px-3.5 py-3 text-left transition-all",
onClick && "cursor-pointer hover:brightness-[1.03] hover:shadow-sm"
)}
style={{ background: t.fill, color: STUDIO_COLORS.ink }}
onClick={onClick}
role={onClick ? "button" : undefined}
tabIndex={onClick ? 0 : undefined}
onKeyDown={
onClick
? (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick();
}
}
: undefined
}
>
<div className="text-[11px] font-semibold" style={{ color: "rgba(43,41,37,.62)" }}>
{title}
</div>
{loading ? (
<>
<Skeleton className="mt-1.5 h-7 w-24 bg-white/60" />
<Skeleton className="mt-2 h-4 w-32 bg-white/50" />
</>
) : (
<>
<div className="mt-0.5 flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span
className={cn(
"font-bold tracking-tight tabular-nums",
featured ? "text-[26px]" : "text-[22px]"
)}
>
{valuePrefix}
{value}
{valueSuffix}
</span>
{showTrend && (
<span
className="rounded-full px-2 py-0.5 text-[10.5px] font-bold"
style={{
background: "rgba(255,255,255,.78)",
color: isGood ? STUDIO_COLORS.up : STUDIO_COLORS.down,
}}
>
{trend.value > 0 ? "↑" : "↓"} {Math.abs(trend.value).toFixed(1)}%
</span>
)}
</div>
{subtitle && (
<div className="mt-0.5 text-[11.5px]" style={{ color: "rgba(43,41,37,.6)" }}>
{subtitle}
</div>
)}
</>
)}
{spark && spark.length > 1 && !loading && (
<div className={cn("mt-2", featured ? "h-9" : "h-7")}>
<Sparkline
data={spark}
color={t.spark}
fill={featured ? t.sparkFill : undefined}
title={sparkTitle}
/>
</div>
)}
</div>
);
};
export default StudioStatTile;
@@ -1,26 +0,0 @@
import { Moon, Sun } from "lucide-react"
import { useTheme } from "@/components/dashboard/theme/ThemeProvider"
import { Button } from "@/components/ui/button"
export function ModeToggle() {
const { theme, setTheme } = useTheme()
return (
<Button
variant="outline"
size="icon"
onClick={() => setTheme(theme === "light" ? "dark" : "light")}
className="w-9 h-9 rounded-md border-none bg-transparent hover:bg-transparent"
>
<div className="relative w-5 h-5">
<Sun
className="absolute inset-0 h-full w-full transition-all duration-300 text-yellow-500 dark:rotate-0 dark:scale-0 dark:opacity-0 rotate-0 scale-100 opacity-100"
/>
<Moon
className="absolute inset-0 h-full w-full transition-all duration-300 text-slate-900 dark:text-slate-200 rotate-90 scale-0 opacity-0 dark:rotate-0 dark:scale-100 dark:opacity-100"
/>
</div>
<span className="sr-only">Toggle theme</span>
</Button>
)
}
+15 -3
View File
@@ -1,19 +1,31 @@
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { AppSidebar } from "./AppSidebar";
import { Outlet } from "react-router-dom";
import { Outlet, useLocation } from "react-router-dom";
import { motion } from "framer-motion";
import { cn } from "@/lib/utils";
export function MainLayout() {
const { pathname } = useLocation();
// The dashboard paints its own warm full-bleed background and scrolls in its
// own container, so it escapes the 1500px content cap (and the pr-2 gutter)
// to reach the window edge. Other pages keep the capped white layout.
const isDashboard = pathname.startsWith("/dashboard");
return (
<motion.div layout>
<SidebarProvider defaultOpen>
<div className="flex min-h-screen w-full pr-2">
<div className={cn("flex min-h-screen w-full", !isDashboard && "pr-2")}>
<AppSidebar />
<main className="flex-1 overflow-hidden">
<div className="flex h-14 w-full items-center border-b px-4 gap-4">
<SidebarTrigger />
</div>
<div className="overflow-auto h-[calc(100vh-3.5rem)] max-w-[1500px]">
<div
className={cn(
"overflow-auto h-[calc(100vh-3.5rem)]",
!isDashboard && "max-w-[1500px]"
)}
>
<Outlet />
</div>
</main>
@@ -229,8 +229,8 @@ export const BASE_IMPORT_FIELDS = [
{
label: "Weight",
key: "weight",
description: "Product weight (in lbs)",
alternateMatches: ["weight (lbs.)"],
description: "Product weight (in oz — convert lbs ×16, kg ×35.274)",
alternateMatches: ["weight (lbs.)", "weight (oz)", "weight (oz.)"],
fieldType: { type: "input" },
width: 100,
validations: [
@@ -3,7 +3,7 @@ import { SelectHeaderTable } from "./components/SelectHeaderTable"
import { useRsi } from "../../hooks/useRsi"
import type { RawData } from "../../types"
import { Button } from "@/components/ui/button"
import { useToast } from "@/hooks/use-toast"
import { toast } from "sonner"
type SelectHeaderProps = {
data: RawData[]
@@ -24,7 +24,6 @@ const isRowCompletelyEmpty = (row: RawData): boolean => {
export const SelectHeaderStep = ({ data, onContinue, onBack }: SelectHeaderProps) => {
const { translations } = useRsi()
const { toast } = useToast()
const [selectedRows, setSelectedRows] = useState<ReadonlySet<number>>(new Set([0]))
const [isLoading, setIsLoading] = useState(false)
@@ -133,19 +132,15 @@ export const SelectHeaderStep = ({ data, onContinue, onBack }: SelectHeaderProps
setLocalData(filteredRows);
setSelectedRows(new Set([newSelectedIndex]));
toast({
title: "Rows removed",
toast.success("Rows removed", {
description: `Removed ${localData.length - filteredRows.length} empty, single-value, or duplicate rows`,
variant: "default"
});
} else {
toast({
title: "No rows removed",
toast.info("No rows removed", {
description: "No empty, single-value, or duplicate rows were found",
variant: "default"
});
}
}, [localData, selectedRows, toast]);
}, [localData, selectedRows]);
return (
<div className="flex flex-col h-[calc(100vh-9.5rem)]">
@@ -13,7 +13,7 @@ import { exceedsMaxRecords } from "../utils/exceedsMaxRecords"
import { useRsi } from "../hooks/useRsi"
import type { RawData, Data } from "../types"
import { Progress } from "@/components/ui/progress"
import { useToast } from "@/hooks/use-toast"
import { toast } from "sonner"
import { addErrorsAndRunHooks } from "./ValidationStep/utils/dataMutations"
import { computeMappingSignature, type MappingSignature } from "./ValidationStep/utils/mappingSignature"
import { useValidationStore } from "./ValidationStep/store/validationStore"
@@ -88,7 +88,6 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
tableHook,
onSubmit } = useRsi()
const [uploadedFile, setUploadedFile] = useState<File | null>(null)
const { toast } = useToast()
const queryClient = useQueryClient()
const resetValidationStore = useValidationStore((state) => state.reset)
@@ -116,13 +115,9 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
}, [queryClient, resetValidationStore]);
const errorToast = useCallback(
(description: string) => {
toast({
variant: "destructive",
title: translations.alerts.toast.error,
description,
})
toast.error(translations.alerts.toast.error, { description })
},
[toast, translations],
[translations],
)
// Keep track of global selections across steps
@@ -6,14 +6,6 @@ import { StepType } from "../UploadFlow"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import { AuthContext } from "@/contexts/AuthContext"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Textarea } from "@/components/ui/textarea"
import { Label } from "@/components/ui/label"
import { Bug } from "lucide-react"
@@ -27,17 +19,23 @@ type UploadProps = {
onRestoreSession?: (session: ImportSession) => void
}
const OrSeparator = () => (
<div className="flex items-center justify-center">
<Separator className="w-24" />
<span className="px-3 text-muted-foreground text-sm font-medium">OR</span>
<Separator className="w-24" />
</div>
)
export const UploadStep = ({ onContinue, setInitialState, onRestoreSession }: UploadProps) => {
const [isLoading, setIsLoading] = useState(false)
const { translations } = useRsi()
const { user } = useContext(AuthContext)
const hasDebugPermission = Boolean(user?.is_admin || user?.permissions?.includes("admin:debug"))
// Debug import state
const [debugDialogOpen, setDebugDialogOpen] = useState(false)
const [debugJsonInput, setDebugJsonInput] = useState("")
const [debugError, setDebugError] = useState<string | null>(null)
const [jsonInput, setJsonInput] = useState("")
const [jsonError, setJsonError] = useState<string | null>(null)
const handleOnContinue = useCallback(
async (data: XLSX.WorkBook, file: File) => {
setIsLoading(true)
@@ -46,23 +44,23 @@ export const UploadStep = ({ onContinue, setInitialState, onRestoreSession }: Up
},
[onContinue],
)
const handleStartFromScratch = useCallback(() => {
if (setInitialState) {
setInitialState({ type: StepType.validateData, data: [{}], isFromScratch: true })
}
}, [setInitialState])
const handleDebugImport = useCallback(() => {
setDebugError(null)
const handleJsonImport = useCallback(() => {
setJsonError(null)
try {
const parsed = JSON.parse(debugJsonInput)
const parsed = JSON.parse(jsonInput)
// Handle both array and object with products property
let products: any[] = Array.isArray(parsed) ? parsed : parsed.products
const products: any[] = Array.isArray(parsed) ? parsed : parsed.products
if (!Array.isArray(products) || products.length === 0) {
setDebugError("JSON must be an array of products or an object with a 'products' array")
setJsonError("JSON must be an array of products or an object with a 'products' array")
return
}
@@ -79,47 +77,59 @@ export const UploadStep = ({ onContinue, setInitialState, onRestoreSession }: Up
isFromScratch: true
})
}
setDebugDialogOpen(false)
setDebugJsonInput("")
} catch (e) {
setDebugError(`Invalid JSON: ${e instanceof Error ? e.message : "Parse error"}`)
setJsonError(`Invalid JSON: ${e instanceof Error ? e.message : "Parse error"}`)
}
}, [debugJsonInput, setInitialState])
}, [jsonInput, setInitialState])
return (
<div className="p-8">
<div className="flex items-baseline justify-between">
<h2 className="text-3xl font-semibold mb-8 text-left">{translations.uploadStep.title}</h2>
{hasDebugPermission && (
<div className="max-w-xl mx-auto w-full space-y-8">
{hasDebugPermission && (
<>
<div className="flex justify-center">
<Button
onClick={() => setDebugDialogOpen(true)}
variant="outline"
className="min-w-[200px] text-amber-600 border-amber-600 hover:bg-amber-50"
disabled={!setInitialState}
>
<Bug className="mr-2 h-4 w-4" />
<div className="rounded-lg border border-amber-600/40 p-6 space-y-3">
<Label htmlFor="import-json" className="flex items-center gap-2 text-amber-600 font-semibold">
<Bug className="h-4 w-4" />
Import JSON
</Button>
</Label>
<p className="text-sm text-muted-foreground">
Paste product data in the same JSON format as the API submission. The data will be loaded into the validation step.
</p>
<Textarea
id="import-json"
placeholder='[{"supplier": "...", "company": "...", "name": "...", "product_images": "url1,url2", ...}]'
value={jsonInput}
onChange={(e) => {
setJsonInput(e.target.value)
setJsonError(null)
}}
className="min-h-[160px] font-mono text-sm"
/>
{jsonError && (
<p className="text-sm text-destructive">{jsonError}</p>
)}
<div className="flex justify-end">
<Button
onClick={handleJsonImport}
disabled={!setInitialState || !jsonInput.trim()}
className="bg-amber-600 hover:bg-amber-700"
>
Import & Go to Validation
</Button>
</div>
</div>
<OrSeparator />
</>
)}
</div>
<div className="max-w-xl mx-auto w-full space-y-8">
<div className="rounded-lg p-6 flex flex-col items-center">
<DropZone onContinue={handleOnContinue} isLoading={isLoading} />
</div>
<div className="flex items-center justify-center">
<Separator className="w-24" />
<span className="px-3 text-muted-foreground text-sm font-medium">OR</span>
<Separator className="w-24" />
</div>
<OrSeparator />
<div className="flex justify-center pb-8">
<Button
onClick={handleStartFromScratch}
@@ -136,50 +146,6 @@ export const UploadStep = ({ onContinue, setInitialState, onRestoreSession }: Up
<SavedSessionsList onRestore={onRestoreSession} />
)}
</div>
<Dialog open={debugDialogOpen} onOpenChange={setDebugDialogOpen}>
<DialogContent className="max-w-2xl max-h-[80vh]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-amber-600">
<Bug className="h-5 w-5" />
Debug: Import JSON Data
</DialogTitle>
<DialogDescription>
Paste product data in the same JSON format as the API submission. The data will be loaded into the validation step.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="debug-json">Product JSON</Label>
<Textarea
id="debug-json"
placeholder='[{"supplier": "...", "company": "...", "name": "...", "product_images": "url1,url2", ...}]'
value={debugJsonInput}
onChange={(e) => {
setDebugJsonInput(e.target.value)
setDebugError(null)
}}
className="min-h-[300px] font-mono text-sm"
/>
{debugError && (
<p className="text-sm text-destructive">{debugError}</p>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDebugDialogOpen(false)}>
Cancel
</Button>
<Button
onClick={handleDebugImport}
disabled={!debugJsonInput.trim()}
className="bg-amber-600 hover:bg-amber-700"
>
Import & Go to Validation
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
@@ -4,7 +4,7 @@ import { useState } from "react"
import { useRsi } from "../../../hooks/useRsi"
import { readFileAsync } from "../utils/readFilesAsync"
import { Button } from "@/components/ui/button"
import { useToast } from "@/hooks/use-toast"
import { toast } from "sonner"
import { cn } from "@/lib/utils"
type DropZoneProps = {
@@ -14,7 +14,6 @@ type DropZoneProps = {
export const DropZone = ({ onContinue, isLoading }: DropZoneProps) => {
const { translations, maxFileSize, dateFormat, parseRaw } = useRsi()
const { toast } = useToast()
const [loading, setLoading] = useState(false)
const { getRootProps, getInputProps, isDragActive, open } = useDropzone({
noClick: true,
@@ -29,9 +28,7 @@ export const DropZone = ({ onContinue, isLoading }: DropZoneProps) => {
onDropRejected: (fileRejections) => {
setLoading(false)
fileRejections.forEach((fileRejection) => {
toast({
variant: "destructive",
title: `${fileRejection.file.name} ${translations.uploadStep.dropzone.errorToastDescription}`,
toast.error(`${fileRejection.file.name} ${translations.uploadStep.dropzone.errorToastDescription}`, {
description: fileRejection.errors[0].message,
})
})
@@ -262,7 +262,7 @@ export function UserForm({ user, permissions, onSave, onCancel }: UserFormProps)
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input {...field} />
<Input {...field} autoComplete="off" />
</FormControl>
<FormMessage />
</FormItem>
@@ -290,10 +290,11 @@ export function UserForm({ user, permissions, onSave, onCancel }: UserFormProps)
<FormItem>
<FormLabel>{user ? "New Password" : "Password"}</FormLabel>
<FormControl>
<Input
type="password"
{...field}
placeholder={user ? "Leave blank to keep current password" : ""}
<Input
type="password"
autoComplete="new-password"
{...field}
placeholder={user ? "Leave blank to keep current password" : ""}
/>
</FormControl>
@@ -5,6 +5,7 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { UserList } from "./UserList";
import { UserForm } from "./UserForm";
import { toast } from "sonner";
import config from "@/config";
import { AuthContext } from "@/contexts/AuthContext";
import { ShieldAlert } from "lucide-react";
@@ -241,7 +242,16 @@ export function UserManagement() {
}
console.log("Server response after saving user:", responseData);
// Explicitly confirm whether a password was part of this save — the form
// silently drops an empty password on edit, so without this there is no
// way to tell if a password change actually went through.
toast.success(
userData.id
? (formattedUserData.password ? "User updated — password changed" : "User updated (password not changed)")
: "User created"
);
// Reset the form state
setSelectedUser(null);
setIsAddingUser(false);
@@ -15,7 +15,7 @@ import {
import { useDebounce } from '@/hooks/useDebounce';
import { Search } from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useToast } from "@/hooks/use-toast";
import { toast } from "sonner";
import config from "@/config";
interface VendorSetting {
@@ -34,7 +34,6 @@ export function VendorSettings() {
const [searchInputValue, setSearchInputValue] = useState('');
const searchQuery = useDebounce(searchInputValue, 300); // 300ms debounce
const [pendingChanges, setPendingChanges] = useState<Record<string, boolean>>({});
const { toast } = useToast();
// Use useCallback to avoid unnecessary re-renders
const loadSettings = useCallback(async () => {
@@ -50,15 +49,11 @@ export function VendorSettings() {
setSettings(data.items);
setTotalCount(data.total);
} catch (error) {
toast({
title: "Error",
description: `Failed to load settings: ${error instanceof Error ? error.message : 'Unknown error'}`,
variant: "destructive",
});
toast.error(`Failed to load settings: ${error instanceof Error ? error.message : 'Unknown error'}`);
} finally {
setLoading(false);
}
}, [page, searchQuery, pageSize, toast]);
}, [page, searchQuery, pageSize]);
useEffect(() => {
loadSettings();
@@ -93,19 +88,12 @@ export function VendorSettings() {
throw new Error(data.error || 'Failed to update vendor setting');
}
toast({
title: "Success",
description: `Settings updated for vendor ${vendor}`,
});
toast.success(`Settings updated for vendor ${vendor}`);
setPendingChanges(prev => ({ ...prev, [vendor]: false }));
} catch (error) {
toast({
title: "Error",
description: `Failed to update setting: ${error instanceof Error ? error.message : 'Unknown error'}`,
variant: "destructive",
});
toast.error(`Failed to update setting: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}, [settings, toast]);
}, [settings]);
const handleResetToDefault = useCallback(async (vendor: string) => {
try {
@@ -119,19 +107,12 @@ export function VendorSettings() {
throw new Error(data.error || 'Failed to reset vendor setting');
}
toast({
title: "Success",
description: `Settings reset for vendor ${vendor}`,
});
toast.success(`Settings reset for vendor ${vendor}`);
loadSettings(); // Reload settings to get defaults
} catch (error) {
toast({
title: "Error",
description: `Failed to reset setting: ${error instanceof Error ? error.message : 'Unknown error'}`,
variant: "destructive",
});
toast.error(`Failed to reset setting: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}, [loadSettings, toast]);
}, [loadSettings]);
const totalPages = useMemo(() => Math.ceil(totalCount / pageSize), [totalCount, pageSize]);
-127
View File
@@ -1,127 +0,0 @@
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}
-33
View File
@@ -1,33 +0,0 @@
import { useToast } from "@/hooks/use-toast"
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast"
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}
@@ -27,7 +27,7 @@ export const ScrollProvider: React.FC<ScrollProviderProps> = ({ children }) => {
useEffect(() => {
const handleScroll = (e: Event) => {
const scrollTop = e.target instanceof Element ? e.target.scrollTop : 0;
const headerHeight = 100; // Adjust as needed
const headerHeight = 64;
setIsStuck(scrollTop > headerHeight);
};
@@ -60,7 +60,7 @@ export const ScrollProvider: React.FC<ScrollProviderProps> = ({ children }) => {
// Fallback to window scroll
const windowScroll = () => {
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
const headerHeight = 100;
const headerHeight = 64;
setIsStuck(scrollTop > headerHeight);
};
window.addEventListener('scroll', windowScroll, { passive: true });
@@ -72,9 +72,9 @@ export const ScrollProvider: React.FC<ScrollProviderProps> = ({ children }) => {
const element = document.getElementById(sectionId);
if (element && scrollContainerRef.current) {
const container = scrollContainerRef.current;
const elementTop = element.offsetTop;
const containerTop = container.offsetTop;
const scrollTop = elementTop - containerTop - 80; // 80px offset for header
const elementTop = element.getBoundingClientRect().top;
const containerTop = container.getBoundingClientRect().top;
const scrollTop = container.scrollTop + elementTop - containerTop - 56;
container.scrollTo({
top: scrollTop,
-194
View File
@@ -1,194 +0,0 @@
"use client"
// Inspired by react-hot-toast library
import * as React from "react"
import type {
ToastActionElement,
ToastProps,
} from "@/components/ui/toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { useToast, toast }
+45 -23
View File
@@ -63,21 +63,21 @@
--card-glass: 0 0% 100%;
--card-glass-foreground: 222.2 84% 4.9%;
/* Semantic chart colors - Professional palette with deeper tones */
--chart-revenue: 161.4 93.5% 30.4%; /* #059669 - emerald-600 */
--chart-orders: 221.2 83.2% 53.3%; /* #2563eb - blue-600 */
--chart-aov: 262.1 83.3% 57.8%; /* #7c3aed - violet-600 */
--chart-comparison: 32.1 94.6% 43.7%; /* #d97706 - amber-600 */
--chart-expense: 20.5 90.2% 48.2%; /* #ea580c - orange-600 */
--chart-profit: 142.1 76.2% 36.3%; /* #16a34a - green-600 */
--chart-secondary: 188.7 94.5% 42.7%; /* #0891b2 - cyan-600 */
--chart-tertiary: 335.1 77.6% 50%; /* #db2777 - pink-600 */
/* Semantic chart colors - Sorbet Studio palette (must match METRIC_COLORS) */
--chart-revenue: 11.5 70.2% 59.2%; /* #e06a4e - Studio coral */
--chart-orders: 246.9 48.1% 57.6%; /* #6b5fc7 - Studio violet */
--chart-aov: 39.1 64.7% 40.0%; /* #a87a24 - Studio amber */
--chart-comparison: 168.8 81.0% 31.0%; /* #0f8f77 - Studio teal */
--chart-expense: 41.2 84.8% 31.0%; /* #92680c - dark gold */
--chart-profit: 147.8 51.3% 37.1%; /* #2e8f5b - green */
--chart-secondary: 205.4 46.8% 46.5%; /* #3f7fae - slate blue */
--chart-tertiary: 340.8 53.0% 54.9%; /* #c94f76 - berry */
/* Trend colors - matches revenue for consistency */
--trend-positive: 161.4 93.5% 30.4%; /* emerald-600 */
--trend-positive-muted: 158.1 64.4% 91.6%; /* emerald-100 */
--trend-negative: 346.8 77.2% 49.8%; /* rose-500 */
--trend-negative-muted: 355.7 100% 94.7%; /* rose-100 */
/* Trend colors - Studio semantic up/down */
--trend-positive: 148.6 55.7% 31.2%; /* #237a4d */
--trend-positive-muted: 148 35% 92%;
--trend-negative: 12.3 42.2% 47.5%; /* #b3503f */
--trend-negative-muted: 12 55% 94%;
}
.dark {
@@ -131,15 +131,16 @@
--card-glass: 222.2 47.4% 11.2%;
--card-glass-foreground: 210 40% 98%;
/* Semantic chart colors - brighter in dark mode for visibility (one step lighter) */
--chart-revenue: 160.1 84.1% 39.4%; /* #10b981 - emerald-500 */
--chart-orders: 217.2 91.2% 59.8%; /* #3b82f6 - blue-500 */
--chart-aov: 258.3 89.5% 66.3%; /* #8b5cf6 - violet-500 */
--chart-comparison: 37.7 92.1% 50.2%; /* #f59e0b - amber-500 */
--chart-expense: 24.6 95% 53.1%; /* #f97316 - orange-500 */
--chart-profit: 142.1 76.2% 45.7%; /* #22c55e - green-500 */
--chart-secondary: 187.9 85.7% 53.3%; /* #06b6d4 - cyan-500 */
--chart-tertiary: 330.4 81.2% 60.4%; /* #ec4899 - pink-500 */
/* Semantic chart colors - Sorbet Studio palette (dashboard is light-first;
kept identical so charts stay on-palette if dark ever returns) */
--chart-revenue: 11.5 70.2% 59.2%; /* #e06a4e - Studio coral */
--chart-orders: 246.9 48.1% 57.6%; /* #6b5fc7 - Studio violet */
--chart-aov: 39.1 64.7% 40.0%; /* #a87a24 - Studio amber */
--chart-comparison: 168.8 81.0% 31.0%; /* #0f8f77 - Studio teal */
--chart-expense: 41.2 84.8% 31.0%; /* #92680c - dark gold */
--chart-profit: 147.8 51.3% 37.1%; /* #2e8f5b - green */
--chart-secondary: 205.4 46.8% 46.5%; /* #3f7fae - slate blue */
--chart-tertiary: 340.8 53.0% 54.9%; /* #c94f76 - berry */
/* Trend colors - brighter in dark mode */
--trend-positive: 160.1 84.1% 39.4%; /* emerald-500 */
@@ -209,6 +210,27 @@
.dashboard-slide-up {
animation: dashboardSlideUp 0.3s ease-out;
}
/* Full dashboard controls intentionally stay denser than the rest of the app. */
.dashboard-page [role="tablist"] {
@apply h-8 rounded-md bg-muted/60 p-0.5;
}
.dashboard-page [role="tab"] {
@apply h-7 rounded px-2.5 text-xs;
}
.dashboard-page [role="combobox"] {
@apply h-8 text-xs;
}
.dashboard-page .recharts-cartesian-grid line {
stroke-opacity: 0.45;
}
.dashboard-page .recharts-legend-item-text {
@apply text-xs text-muted-foreground;
}
}
/* Dashboard animation keyframes */
+7 -7
View File
@@ -132,11 +132,11 @@ export const BAR_CONFIG = {
*/
export const TOOLTIP_STYLES = {
// Outer container
container: "rounded-lg border border-border/50 bg-popover px-3 py-2 shadow-lg",
container: "rounded-md border border-border/70 bg-popover/95 px-2.5 py-2 shadow-md backdrop-blur-sm",
// Header/label at top of tooltip
header: "font-medium text-sm text-foreground pb-1.5 mb-1.5 border-b border-border/50",
label: "font-medium text-sm text-foreground pb-1.5 mb-1.5 border-b border-border/50",
header: "font-medium text-xs text-foreground pb-1.5 mb-1.5 border-b border-border/50",
label: "font-medium text-xs text-foreground pb-1.5 mb-1.5 border-b border-border/50",
// Content wrapper
content: "space-y-1",
@@ -148,14 +148,14 @@ export const TOOLTIP_STYLES = {
rowLabel: "flex items-center gap-2",
// Color indicator dot
dot: "h-2.5 w-2.5 rounded-full shrink-0",
dot: "h-2 w-2 rounded-full shrink-0",
// Metric name / item label
name: "text-sm text-muted-foreground",
item: "text-sm text-muted-foreground",
name: "text-xs text-muted-foreground",
item: "text-xs text-muted-foreground",
// Value on right side
value: "text-sm font-medium text-foreground",
value: "text-xs font-semibold tabular-nums text-foreground",
// Divider between sections (if needed)
divider: "border-t border-border/50 my-1.5",
+69 -39
View File
@@ -14,29 +14,29 @@
/**
* Standard card styling classes
* Uses the glass effect by default for dashboard cards
* Uses quiet solid surfaces so dense dashboard data stays visually organized.
*/
export const CARD_STYLES = {
/** Base card appearance with glass effect (default for dashboard) */
base: "card-glass",
/** Base card appearance (Sorbet Studio: white panel, warm border, 14px radius) */
base: "bg-white border border-[#e7e5e1] shadow-[0_1px_2px_rgba(43,41,37,.04)] rounded-[14px]",
/** Elevated card for primary/featured sections (Sales, Financial) */
elevated: "card-glass shadow-lg",
elevated: "bg-white border border-[#e7e5e1] shadow-[0_1px_2px_rgba(43,41,37,.04)] rounded-[14px]",
/** Subtle card for secondary/supporting content */
subtle: "card-glass bg-card-glass/40",
subtle: "bg-[#faf9f7] border border-[#efede9] rounded-[12px]",
/** Accent card with subtle ring highlight */
accent: "card-glass ring-1 ring-primary/10",
accent: "bg-white border border-[#e06a4e]/25 shadow-[0_1px_2px_rgba(43,41,37,.04)] rounded-[14px]",
/** Card with subtle hover effect */
interactive: "card-glass transition-all duration-200 ease-out hover:shadow-md hover:scale-[1.01]",
interactive: "bg-white border border-[#e7e5e1] shadow-[0_1px_2px_rgba(43,41,37,.04)] rounded-[14px] transition-colors duration-150 hover:border-[#d8d4cd] hover:bg-[#faf9f7]",
/** Solid card without glass effect (use sparingly) */
solid: "bg-card border border-border/50 shadow-sm rounded-xl",
solid: "bg-white border border-[#e7e5e1] shadow-[0_1px_2px_rgba(43,41,37,.04)] rounded-[14px]",
/** Card header layout */
header: "flex flex-row items-center justify-between p-4 pb-0",
header: "flex flex-row items-center justify-between p-3.5 pb-0",
/** Compact header for stat cards */
headerCompact: "flex flex-row items-center justify-between px-4 pt-4 pb-2",
headerCompact: "flex flex-row items-center justify-between px-3.5 pt-3.5 pb-1.5",
/** Card content area */
content: "p-3 pt-0",
content: "px-3.5 pb-3.5 pt-0",
/** Card content with extra bottom padding */
contentPadded: "p-3 pt-0 pb-4",
contentPadded: "px-3.5 pb-3.5 pt-0",
} as const;
/**
@@ -44,11 +44,11 @@ export const CARD_STYLES = {
*/
export const TYPOGRAPHY = {
/** Card/section titles */
cardTitle: "text-xs font-medium text-muted-foreground uppercase tracking-wide",
cardTitle: "text-[11px] font-medium text-muted-foreground uppercase tracking-wide",
/** Primary metric values */
cardValue: "text-lg font-semibold tracking-tight",
cardValue: "text-xl font-semibold tracking-tight tabular-nums",
/** Large hero metrics (real-time, attention-grabbing) */
cardValueLarge: "text-lg font-bold tracking-tight",
cardValueLarge: "text-2xl font-semibold tracking-tight tabular-nums",
/** Hero stat value (extra large prominent numbers) */
cardValueHero: "text-xl font-bold tracking-tighter",
/** Small metric values */
@@ -56,7 +56,7 @@ export const TYPOGRAPHY = {
/** Supporting descriptions */
cardDescription: "text-xs text-muted-foreground",
/** Section headings within cards */
sectionTitle: "text-base font-semibold",
sectionTitle: "text-sm font-semibold tracking-tight",
/** Table headers */
tableHeader: "text-xs font-medium text-muted-foreground uppercase tracking-wider",
/** Table cells */
@@ -72,11 +72,11 @@ export const TYPOGRAPHY = {
*/
export const SPACING = {
/** Gap between cards in a grid */
cardGap: "gap-4",
cardGap: "gap-3",
/** Standard card padding */
cardPadding: "p-4",
cardPadding: "p-3.5",
/** Inner content spacing */
contentGap: "space-y-4",
contentGap: "space-y-3",
/** Tight content spacing */
contentGapTight: "space-y-2",
} as const;
@@ -85,7 +85,7 @@ export const SPACING = {
* Border radius tokens
*/
export const RADIUS = {
card: "rounded-xl",
card: "rounded-lg",
button: "rounded-lg",
badge: "rounded-full",
input: "rounded-md",
@@ -192,10 +192,10 @@ export const EVENT_COLORS = {
* Follow accounting visualization conventions
*/
export const FINANCIAL_COLORS = {
income: "#3b82f6", // Blue - Revenue streams
expense: "#f97316", // Orange - Costs/Expenses (COGS)
profit: "#10b981", // Green - Positive financial outcome
margin: "#8b5cf6", // Purple - Percentage metrics
income: "#e06a4e", // Coral - Revenue streams (Studio primary)
expense: "#92680c", // Dark gold - Costs/Expenses (COGS); deeper than the AOV amber, validated vs coral
profit: "#0f8f77", // Teal - Positive financial outcome
margin: "#6b5fc7", // Violet - Percentage metrics
} as const;
/**
@@ -208,14 +208,14 @@ export const FINANCIAL_COLORS = {
* IMPORTANT: These MUST match the CSS variables in index.css
*/
export const METRIC_COLORS = {
revenue: "#059669", // Emerald-600 - Primary positive metric, deeper professional tone
orders: "#2563eb", // Blue-600 - Count/volume metrics, richer blue
aov: "#7c3aed", // Violet-600 - Calculated/derived metrics, deeper purple
comparison: "#d97706", // Amber-600 - Previous period comparison, warmer amber
expense: "#ea580c", // Orange-600 - Costs/expenses, less neon
profit: "#16a34a", // Green-600 - Profit metrics, professional green
secondary: "#0891b2", // Cyan-600 - Secondary metrics, deeper cyan
tertiary: "#db2777", // Pink-600 - Tertiary metrics, richer pink
revenue: "#e06a4e", // Studio coral - primary metric / working accent
orders: "#6b5fc7", // Studio violet - count/volume metrics
aov: "#a87a24", // Studio amber - calculated/derived metrics
comparison: "#0f8f77", // Studio teal - previous period (always dashed)
expense: "#92680c", // Dark gold - costs/expenses (matches FINANCIAL_COLORS.expense)
profit: "#2e8f5b", // Green - profit metrics
secondary: "#3f7fae", // Slate blue - secondary metrics
tertiary: "#c94f76", // Berry - tertiary metrics
} as const;
/**
@@ -233,6 +233,36 @@ export const METRIC_COLORS_HSL = {
tertiary: "hsl(var(--chart-tertiary))",
} as const;
/**
* Sorbet Studio tokens (2026-07 redesign, applied section by section)
*
* Working accent is coral, second data hue is teal; pastel tile fills carry
* per-metric identity with a deeper "spark" tone for the sparkline drawn on
* each fill. Chart hues validated for lightness/chroma/CVD/contrast on light
* surfaces; prev-period series must stay dashed (that's their secondary
* encoding do not encode "previous" with color alone).
*/
export const STUDIO_COLORS = {
ground: "#f8f7f5",
border: "#e7e5e1",
ink: "#2b2925",
muted: "#7c7870",
coral: "#e06a4e", // working accent + revenue series
teal: "#0f8f77", // second data hue + prev-revenue (dashed)
violet: "#6b5fc7", // orders series
amber: "#a87a24", // AOV series
up: "#237a4d",
down: "#b3503f",
} as const;
/** Pastel tile fills with their matching sparkline tones (≥3.3:1 on fill) */
export const STUDIO_TILES = {
peach: { fill: "#fbe7de", spark: "#c94f35", sparkFill: "rgba(201,79,53,.14)" },
mint: { fill: "#ddf0ea", spark: "#0f8f77", sparkFill: "rgba(15,143,119,.12)" },
lemon: { fill: "#faf0d7", spark: "#a87a24", sparkFill: "rgba(168,122,36,.12)" },
lilac: { fill: "#eae6f6", spark: "#6b5fc7", sparkFill: "rgba(107,95,199,.12)" },
} as const;
/**
* Status indicator colors
*/
@@ -271,7 +301,7 @@ export const STATUS_COLORS = {
* Stat card icon styling
*/
export const STAT_ICON_STYLES = {
container: "p-2 rounded-full",
container: "p-2 rounded-md",
icon: "h-4 w-4",
// Color variants for icons
colors: {
@@ -342,12 +372,12 @@ export const STAT_ICON_STYLES = {
* Table styling tokens
*/
export const TABLE_STYLES = {
container: "rounded-lg border border-border/50 overflow-hidden",
header: "bg-muted/30",
headerCell: "text-xs font-medium text-muted-foreground uppercase tracking-wider",
row: "border-b border-border/50 last:border-0",
rowHover: "hover:bg-muted/50 transition-colors",
cell: "text-sm",
container: "rounded-xl border border-[#efede9] overflow-hidden",
header: "bg-[#faf9f7]",
headerCell: "text-[10.5px] font-semibold text-[#a09b92] uppercase tracking-wide",
row: "border-b border-[#f0eeea] last:border-0",
rowHover: "hover:bg-[#faf9f7] transition-colors",
cell: "text-xs",
cellNumeric: "text-sm tabular-nums text-right",
} as const;
+106 -92
View File
@@ -1,5 +1,4 @@
import { ScrollProvider } from "@/contexts/DashboardScrollContext";
import { ThemeProvider } from "@/components/dashboard/theme/ThemeProvider";
import { Protected } from "@/components/auth/Protected";
import EventFeed from "@/components/dashboard/EventFeed";
import StatCards from "@/components/dashboard/StatCards";
@@ -9,9 +8,11 @@ import SalesChart from "@/components/dashboard/SalesChart";
import KlaviyoCampaigns from "@/components/dashboard/KlaviyoCampaigns";
import MetaCampaigns from "@/components/dashboard/MetaCampaigns";
import AnalyticsDashboard from "@/components/dashboard/AnalyticsDashboard";
import RealtimeAnalytics from "@/components/dashboard/RealtimeAnalytics";
// @ts-expect-error - JSX module without type declarations
import { RealtimeChip } from "@/components/dashboard/RealtimeAnalytics";
import UserBehaviorDashboard from "@/components/dashboard/UserBehaviorDashboard";
import TypeformDashboard from "@/components/dashboard/TypeformDashboard";
import CustomerServiceDashboard from "@/components/dashboard/CustomerServiceDashboard";
import PayrollMetrics from "@/components/dashboard/PayrollMetrics";
import OperationsMetrics from "@/components/dashboard/OperationsMetrics";
import Header from "@/components/dashboard/Header";
@@ -19,104 +20,117 @@ import Navigation from "@/components/dashboard/Navigation";
export function Dashboard() {
return (
<ThemeProvider>
<ScrollProvider>
<div className="flex-1 h-full relative">
<div className="h-full overflow-auto" id="dashboard-scroll-container">
<div className="min-h-screen max-w-[1600px] mx-auto relative">
<div className="p-4">
<Header />
</div>
<Navigation />
<div className="p-4 space-y-4">
<div className="grid grid-cols-1 xl:grid-cols-6 gap-4">
<Protected permission="dashboard:stats">
<div className="xl:col-span-4 col-span-6">
<div className="space-y-4 h-full w-full">
<div id="stats">
<StatCards />
</div>
</div>
</div>
</Protected>
<Protected permission="dashboard:realtime">
<div id="realtime" className="xl:col-span-2 col-span-6 overflow-auto">
<div className="h-full">
<RealtimeAnalytics />
</div>
</div>
</Protected>
</div>
<Protected permission="dashboard:financial">
<div className="grid grid-cols-12 gap-4">
<div id="financial" className="col-span-12">
<FinancialOverview />
</div>
</div>
<ScrollProvider>
<div className="dashboard-page flex-1 h-full relative bg-[#f8f7f5]">
<div className="h-full overflow-auto" id="dashboard-scroll-container">
<div className="min-h-screen max-w-[1460px]">
<Header
right={
<Protected permission="dashboard:realtime">
<RealtimeChip />
</Protected>
<Protected permission="dashboard:payroll">
<div id="payroll-metrics">
<PayrollMetrics />
</div>
}
/>
<Navigation />
<main className="px-3 pb-6 pt-3 sm:px-4 lg:px-5 space-y-3">
<Protected permission="dashboard:stats">
<section id="stats">
<StatCards />
</section>
</Protected>
<Protected permission="dashboard:financial">
<section id="financial">
<FinancialOverview />
</section>
</Protected>
{/* Feed stretches to match the sales panel's height on xl so the
row bottoms align; below xl it gets its own bounded height */}
<div className="grid grid-cols-12 gap-3 items-stretch">
<Protected permission="dashboard:sales">
<section id="sales" className="col-span-12 xl:col-span-8">
<SalesChart className="w-full" />
</section>
</Protected>
<Protected permission="dashboard:operations">
<div id="operations-metrics">
<OperationsMetrics />
</div>
</Protected>
<div className="grid grid-cols-12 gap-4">
<Protected permission="dashboard:feed">
<div id="feed" className="col-span-12 lg:col-span-6 xl:col-span-4 h-[600px] lg:h-[740px]">
<Protected permission="dashboard:feed">
<section id="feed" className="relative col-span-12 xl:col-span-4 h-[520px] xl:h-auto">
{/* absolute fill on xl: the feed matches the sales panel's
height instead of driving the row taller */}
<div className="h-full xl:absolute xl:inset-0">
<EventFeed />
</div>
</Protected>
<Protected permission="dashboard:sales">
<div id="sales" className="col-span-12 xl:col-span-8 h-full w-full flex">
<SalesChart className="w-full h-full"/>
</div>
</Protected>
</div>
<div className="grid grid-cols-12 gap-4">
<Protected permission="dashboard:products">
<div id="products" className="col-span-12 lg:col-span-4 h-[500px]">
<ProductGrid />
</div>
</Protected>
<Protected permission="dashboard:campaigns">
<div id="campaigns" className="col-span-12 lg:col-span-8">
<KlaviyoCampaigns />
</div>
</Protected>
</div>
<div className="grid grid-cols-12 gap-4">
<Protected permission="dashboard:analytics">
<div id="analytics" className="col-span-12 xl:col-span-8">
<AnalyticsDashboard />
</div>
</Protected>
<Protected permission="dashboard:user_behavior">
<div id="user-behavior" className="col-span-12 xl:col-span-4">
<UserBehaviorDashboard />
</div>
</Protected>
</div>
<Protected permission="dashboard:meta_campaigns">
<div id="meta-campaigns">
<MetaCampaigns />
</div>
</Protected>
<Protected permission="dashboard:typeform">
<div id="typeform">
<TypeformDashboard />
</div>
</section>
</Protected>
</div>
</div>
<Protected permission="dashboard:operations">
<section id="operations-metrics">
<OperationsMetrics />
</section>
</Protected>
<Protected permission="dashboard:payroll">
<section id="payroll-metrics">
<PayrollMetrics />
</section>
</Protected>
{/* Top sellers scrolls internally and stretches to match the
campaigns table height so the row bottoms align */}
<div className="grid grid-cols-12 gap-3 items-stretch">
<Protected permission="dashboard:products">
<section id="products" className="relative col-span-12 lg:col-span-5 h-[480px] lg:h-auto">
<div className="h-full lg:absolute lg:inset-0">
<ProductGrid />
</div>
</section>
</Protected>
<Protected permission="dashboard:campaigns">
<section id="campaigns" className="col-span-12 lg:col-span-7">
<KlaviyoCampaigns />
</section>
</Protected>
</div>
<div className="grid grid-cols-12 gap-3 items-stretch">
<Protected permission="dashboard:analytics">
<section id="analytics" className="col-span-12 xl:col-span-8">
<AnalyticsDashboard />
</section>
</Protected>
<Protected permission="dashboard:user_behavior">
<section id="user-behavior" className="relative col-span-12 xl:col-span-4">
{/* absolute-fill on xl so the card matches the Analytics
panel's height; tabs scroll internally rather than
driving the row height per selected tab */}
<div className="h-full xl:absolute xl:inset-0">
<UserBehaviorDashboard />
</div>
</section>
</Protected>
</div>
<Protected permission="dashboard:meta_campaigns">
<section id="meta-campaigns">
<MetaCampaigns />
</section>
</Protected>
<Protected permission="dashboard:typeform">
<section id="typeform">
<TypeformDashboard />
</section>
</Protected>
<Protected permission="dashboard:customer_service">
<section id="customer-service">
<CustomerServiceDashboard />
</section>
</Protected>
</main>
</div>
</div>
</ScrollProvider>
</ThemeProvider>
</div>
</ScrollProvider>
);
}
+2 -5
View File
@@ -19,7 +19,7 @@ import {
SurchargeConfig,
CogsCalculationMode,
} from "@/types/discount-simulator";
import { useToast } from "@/hooks/use-toast";
import { toast } from "sonner";
import { toDateOnly } from "@/utils/businessTime";
const DEFAULT_POINT_VALUE = 0.005;
@@ -53,7 +53,6 @@ const defaultShippingPromo = {
};
export function DiscountSimulator() {
const { toast } = useToast();
const [dateRange, setDateRange] = useState<DateRange>(() => getDefaultDateRange());
const [selectedPromoId, setSelectedPromoId] = useState<number | undefined>(undefined);
const [productPromo, setProductPromo] = useState(defaultProductPromo);
@@ -214,10 +213,8 @@ export function DiscountSimulator() {
},
onError: (error) => {
console.error('Simulation error', error);
toast({
title: 'Simulation failed',
toast.error('Simulation failed', {
description: error instanceof Error ? error.message : 'Unable to run discount simulation right now.',
variant: 'destructive',
});
},
onSettled: () => {
+7 -18
View File
@@ -9,7 +9,7 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { useToast } from "@/hooks/use-toast";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
type HtsProduct = {
@@ -47,7 +47,6 @@ type HtsLookupResponse = {
};
export default function HtsLookup() {
const { toast } = useToast();
const [searchTerm, setSearchTerm] = useState("");
const [submittedTerm, setSubmittedTerm] = useState("");
const [copiedCode, setCopiedCode] = useState<string | null>(null);
@@ -93,13 +92,9 @@ export default function HtsLookup() {
useEffect(() => {
if (error instanceof Error) {
toast({
title: "Search failed",
description: error.message,
variant: "destructive",
});
toast.error("Search failed", { description: error.message });
}
}, [error, toast]);
}, [error]);
const totalMatches = data?.total ?? 0;
const groupedResults = useMemo(() => data?.results ?? [], [data]);
@@ -110,10 +105,8 @@ export default function HtsLookup() {
const valueToCopy = code === "Unspecified" ? "" : code;
if (!navigator?.clipboard) {
toast({
title: "Clipboard unavailable",
toast.error("Clipboard unavailable", {
description: "Your browser did not allow copying the code.",
variant: "destructive",
});
return;
}
@@ -125,15 +118,12 @@ export default function HtsLookup() {
}
setCopiedCode(code);
copyTimerRef.current = window.setTimeout(() => setCopiedCode(null), 1200);
toast({
title: "Copied HTS code",
toast.success("Copied HTS code", {
description: valueToCopy || "Empty code copied",
});
} catch (err) {
toast({
title: "Copy failed",
toast.error("Copy failed", {
description: err instanceof Error ? err.message : "Unable to copy code",
variant: "destructive",
});
}
};
@@ -143,8 +133,7 @@ export default function HtsLookup() {
const trimmed = searchTerm.trim();
if (!trimmed) {
toast({
title: "Enter a search term",
toast.info("Enter a search term", {
description: "Search by title, SKU, vendor, barcode, or HTS code.",
});
return;
+3 -8
View File
@@ -32,7 +32,7 @@ import { ProductDetail } from "@/components/products/ProductDetail";
import { ProductViews } from "@/components/products/ProductViews";
import { ProductTableSkeleton } from "@/components/products/ProductTableSkeleton";
import { ProductSummaryCards } from "@/components/products/ProductSummaryCards";
import { useToast } from "@/hooks/use-toast";
import { toast } from "sonner";
import { useDebounce } from "@/hooks/useDebounce";
import { AVAILABLE_COLUMNS, VIEW_COLUMNS, COLUMNS_BY_GROUP } from "@/components/products/columnDefinitions";
import { transformMetricsRow, OPERATOR_MAP } from "@/utils/transformUtils";
@@ -160,7 +160,6 @@ export function Products() {
const [showNonReplenishable, setShowNonReplenishable] = useState(false);
const [showInvisible, setShowInvisible] = useState(false);
const [selectedProductId, setSelectedProductId] = useState<number | null>(null);
const { toast } = useToast();
const searchInputRef = React.useRef<HTMLInputElement>(null);
// Store visible columns and order for each view
@@ -310,14 +309,10 @@ export function Products() {
};
} catch (error) {
console.error('Error fetching products:', error);
toast({
title: "Error",
description: "Failed to fetch products. Please try again.",
variant: "destructive",
});
toast.error("Failed to fetch products. Please try again.");
return null;
}
}, [currentPage, pageSize, sortColumn, sortDirection, activeView, effectiveFilters, showNonReplenishable, showInvisible, toast]);
}, [currentPage, pageSize, sortColumn, sortDirection, activeView, effectiveFilters, showNonReplenishable, showInvisible]);
// Query for filter options
const { data: filterOptionsData, isLoading: isLoadingFilterOptions } = useQuery({
+4 -106
View File
@@ -1,16 +1,8 @@
import React, { useEffect, useState } from "react";
import { Navigate } from "react-router-dom";
import { ThemeProvider } from "@/components/dashboard/theme/ThemeProvider";
import LockButton from "@/components/dashboard/LockButton";
import PinProtection from "@/components/dashboard/PinProtection";
import DateTimeWeatherDisplay from "@/components/dashboard/DateTime";
import MiniStatCards from "@/components/dashboard/MiniStatCards";
import MiniSalesChart from "@/components/dashboard/MiniSalesChart";
import MiniEventFeed from "@/components/dashboard/MiniEventFeed";
// @ts-expect-error - JSX component without type declarations
import MiniBusinessMetrics from "@/components/dashboard/MiniBusinessMetrics";
// @ts-expect-error - JSX component without type declarations
import MiniInventorySnapshot from "@/components/dashboard/MiniInventorySnapshot";
import NightboardSmall from "@/components/dashboard/nightboard/NightboardSmall";
import PageLoading from "@/components/ui/page-loading";
import { apiFetch } from "@/utils/api";
@@ -67,103 +59,9 @@ const AccessGate = ({ children }: { children: React.ReactNode }) => {
return <>{children}</>;
};
// Small Layout
const SmallLayout = () => {
const DATETIME_SCALE = 2;
const STATS_SCALE = 1.65;
const PANELS_SCALE = 1.65;
const SALES_SCALE = 1.65;
const FEED_SCALE = 1.65;
return (
<div className="min-h-screen w-screen relative bg-gradient-to-br from-slate-950 via-slate-900 to-indigo-950 overflow-hidden">
{/* Subtle ambient glow spots */}
<div className="absolute top-0 left-1/4 w-[600px] h-[600px] bg-indigo-500/[0.07] rounded-full blur-[120px] pointer-events-none" />
<div className="absolute bottom-1/4 right-0 w-[500px] h-[500px] bg-sky-500/[0.05] rounded-full blur-[100px] pointer-events-none" />
<span className="absolute top-4 left-4 z-50">
<LockButton />
</span>
<div className="p-4 grid grid-cols-12 gap-4 relative z-10">
{/* DateTime */}
<div className="col-span-3">
<div style={{
transform: `scale(${DATETIME_SCALE})`,
transformOrigin: 'top left',
width: `${100/DATETIME_SCALE}%`
}}>
<DateTimeWeatherDisplay scaleFactor={DATETIME_SCALE} />
</div>
</div>
{/* Stats and Analytics */}
<div className="col-span-9">
<div className="">
{/* Mini Stat Cards */}
<div>
<div style={{
transform: `scale(${STATS_SCALE})`,
transformOrigin: 'top left',
width: `${100/STATS_SCALE}%`
}}>
<MiniStatCards
title="Live Stats"
timeRange="today"
/>
</div>
</div>
{/* Mini Charts Grid */}
<div className="grid grid-cols-2 gap-4 mt-28">
{/* Mini Sales Chart */}
<div>
<div style={{
transform: `scale(${SALES_SCALE})`,
transformOrigin: 'top left',
width: `${100/SALES_SCALE}%`
}}>
<MiniSalesChart />
</div>
</div>
{/* Operations + Inventory Panels */}
<div className="h-full">
<div className="h-full" style={{
transform: `scale(${PANELS_SCALE})`,
transformOrigin: 'top left',
width: `${100/PANELS_SCALE}%`,
height: '100%',
}}>
<div className="flex gap-2 h-full">
<div className="flex-1 min-w-0 h-full">
<MiniBusinessMetrics />
</div>
<div className="flex-1 min-w-0 h-full">
<MiniInventorySnapshot />
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Event Feed at bottom */}
<div className="absolute bottom-0 left-0 right-0 z-10">
<div style={{
transform: `scale(${FEED_SCALE})`,
transformOrigin: 'bottom center',
width: `${100/FEED_SCALE}%`,
margin: '0 auto'
}}>
<MiniEventFeed />
</div>
</div>
</div>
);
};
// Small Layout — Nightboard kiosk board, built at the panel's real resolution
// (10.1" 1920×1200); no transform-scale hacks.
const SmallLayout = () => <NightboardSmall />;
export function SmallDashboard() {
return (
+6 -9
View File
@@ -12,7 +12,7 @@ import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { useToast } from "@/hooks/use-toast";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
type NumericAggregate = {
@@ -112,7 +112,6 @@ function formatCurrency(n: number): string {
}
export default function SpecLookup() {
const { toast } = useToast();
const [company, setCompany] = useState("");
const [term, setTerm] = useState("");
const [companyOpen, setCompanyOpen] = useState(false);
@@ -168,9 +167,9 @@ export default function SpecLookup() {
useEffect(() => {
if (error instanceof Error) {
toast({ title: "Search failed", description: error.message, variant: "destructive" });
toast.error("Search failed", { description: error.message });
}
}, [error, toast]);
}, [error]);
const handleSubmit = (event?: FormEvent) => {
event?.preventDefault();
@@ -178,7 +177,7 @@ export default function SpecLookup() {
const trimmedTerm = term.trim();
if (!trimmedCompany && !trimmedTerm) {
toast({ title: "Enter a company or product type" });
toast.info("Enter a company or product type");
return;
}
@@ -193,7 +192,7 @@ export default function SpecLookup() {
event.preventDefault();
event.stopPropagation();
if (!navigator?.clipboard) {
toast({ title: "Clipboard unavailable", variant: "destructive" });
toast.error("Clipboard unavailable");
return;
}
try {
@@ -202,10 +201,8 @@ export default function SpecLookup() {
setCopied(key);
copyTimerRef.current = window.setTimeout(() => setCopied(null), 1200);
} catch (err) {
toast({
title: "Copy failed",
toast.error("Copy failed", {
description: err instanceof Error ? err.message : "Unable to copy",
variant: "destructive",
});
}
};
File diff suppressed because one or more lines are too long
+6
View File
@@ -115,6 +115,12 @@ export default defineConfig(({ mode }) => {
secure: false,
rewrite: (path) => path,
},
"/api/freescout": {
target: "https://tools.acherryontop.com",
changeOrigin: true,
secure: false,
rewrite: (path) => path,
},
"/api/acot": {
target: "https://tools.acherryontop.com",
changeOrigin: true,