Compare commits

...

10 Commits

Author SHA1 Message Date
225e63a985 Add shippedby to event feed 2025-01-09 11:10:49 -05:00
254a9a6511 Move pm2 config up a level 2025-01-09 09:54:30 -05:00
abe43c03b7 Update mini feed every 30s 2025-01-06 10:02:15 -05:00
8ad566c7f4 Attempt to add saleschart projections 2025-01-06 09:15:30 -05:00
304d09e3c4 Fix projected revenue trend regression 2025-01-04 11:15:35 -05:00
5063120731 Fix minisaleschart skeletons 2025-01-04 01:06:35 -05:00
1b797eecaf Reverse feed direction and add navigation arrows 2025-01-04 00:51:18 -05:00
194ac96732 Style date time weather to fit in better 2025-01-04 00:37:29 -05:00
a2eb5bfcd7 Add missing skeletons 2025-01-04 00:23:42 -05:00
e1f12539d3 Fix revenue projection calc for 12-1am 2025-01-04 00:15:29 -05:00
10 changed files with 807 additions and 750 deletions

View File

@@ -1,189 +0,0 @@
// ecosystem.config.js
const path = require('path');
const dotenv = require('dotenv');
// Load environment variables safely with error handling
const loadEnvFile = (envPath) => {
try {
console.log('Loading env from:', envPath);
const result = dotenv.config({ path: envPath });
if (result.error) {
console.warn(`Warning: .env file not found or invalid at ${envPath}:`, result.error.message);
return {};
}
console.log('Env variables loaded from', envPath, ':', Object.keys(result.parsed || {}));
return result.parsed || {};
} catch (error) {
console.warn(`Warning: Error loading .env file at ${envPath}:`, error.message);
return {};
}
};
// Load environment variables for each server
const authEnv = loadEnvFile(path.resolve(__dirname, 'auth-server/.env'));
const aircallEnv = loadEnvFile(path.resolve(__dirname, 'aircall-server/.env'));
const klaviyoEnv = loadEnvFile(path.resolve(__dirname, 'klaviyo-server/.env'));
const metaEnv = loadEnvFile(path.resolve(__dirname, 'meta-server/.env'));
const googleAnalyticsEnv = require('dotenv').config({
path: path.resolve(__dirname, 'google-server/.env')
}).parsed || {};
const typeformEnv = loadEnvFile(path.resolve(__dirname, 'typeform-server/.env'));
// Common log settings for all apps
const logSettings = {
log_rotate: true,
max_size: '10M',
retain: '10',
log_date_format: 'YYYY-MM-DD HH:mm:ss'
};
// Common app settings
const commonSettings = {
instances: 1,
exec_mode: 'fork',
autorestart: true,
watch: false,
max_memory_restart: '1G',
time: true,
...logSettings,
ignore_watch: [
'node_modules',
'logs',
'.git',
'*.log'
],
min_uptime: 5000,
max_restarts: 5,
restart_delay: 4000,
listen_timeout: 50000,
kill_timeout: 5000,
node_args: '--max-old-space-size=1536'
};
module.exports = {
apps: [
{
...commonSettings,
name: 'auth-server',
script: './auth-server/index.js',
env: {
NODE_ENV: 'production',
PORT: 3003,
...authEnv
},
error_file: 'auth-server/logs/pm2/err.log',
out_file: 'auth-server/logs/pm2/out.log',
log_file: 'auth-server/logs/pm2/combined.log',
env_production: {
NODE_ENV: 'production',
PORT: 3003
},
env_development: {
NODE_ENV: 'development',
PORT: 3003
}
},
{
...commonSettings,
name: 'aircall-server',
script: './aircall-server/server.js',
env: {
NODE_ENV: 'production',
AIRCALL_PORT: 3002,
...aircallEnv
},
error_file: 'aircall-server/logs/pm2/err.log',
out_file: 'aircall-server/logs/pm2/out.log',
log_file: 'aircall-server/logs/pm2/combined.log',
env_production: {
NODE_ENV: 'production',
AIRCALL_PORT: 3002
}
},
{
...commonSettings,
name: 'klaviyo-server',
script: './klaviyo-server/server.js',
env: {
NODE_ENV: 'production',
KLAVIYO_PORT: 3004,
...klaviyoEnv
},
error_file: 'klaviyo-server/logs/pm2/err.log',
out_file: 'klaviyo-server/logs/pm2/out.log',
log_file: 'klaviyo-server/logs/pm2/combined.log',
env_production: {
NODE_ENV: 'production',
KLAVIYO_PORT: 3004
}
},
{
...commonSettings,
name: 'meta-server',
script: './meta-server/server.js',
env: {
NODE_ENV: 'production',
PORT: 3005,
...metaEnv
},
error_file: 'meta-server/logs/pm2/err.log',
out_file: 'meta-server/logs/pm2/out.log',
log_file: 'meta-server/logs/pm2/combined.log',
env_production: {
NODE_ENV: 'production',
PORT: 3005
}
},
{
name: "gorgias-server",
script: "./gorgias-server/server.js",
env: {
NODE_ENV: "development",
PORT: 3006
},
env_production: {
NODE_ENV: "production",
PORT: 3006
},
error_file: "./logs/gorgias-server-error.log",
out_file: "./logs/gorgias-server-out.log",
log_file: "./logs/gorgias-server-combined.log",
time: true
},
{
...commonSettings,
name: 'google-server',
script: path.resolve(__dirname, './google-server/server.js'),
watch: false,
env: {
NODE_ENV: 'production',
GOOGLE_ANALYTICS_PORT: 3007,
...googleAnalyticsEnv
},
error_file: path.resolve(__dirname, './google-server/logs/pm2/err.log'),
out_file: path.resolve(__dirname, './google-server/logs/pm2/out.log'),
log_file: path.resolve(__dirname, './google-server/logs/pm2/combined.log'),
env_production: {
NODE_ENV: 'production',
GOOGLE_ANALYTICS_PORT: 3007
}
},
{
...commonSettings,
name: 'typeform-server',
script: './typeform-server/server.js',
env: {
NODE_ENV: 'production',
TYPEFORM_PORT: 3008,
...typeformEnv
},
error_file: 'typeform-server/logs/pm2/err.log',
out_file: 'typeform-server/logs/pm2/out.log',
log_file: 'typeform-server/logs/pm2/combined.log',
env_production: {
NODE_ENV: 'production',
TYPEFORM_PORT: 3008
}
}
]
};

View File

@@ -1339,58 +1339,62 @@ export class EventsService {
event.attributes?.metric_id; event.attributes?.metric_id;
// Extract properties from all possible locations // Extract properties from all possible locations
const rawProps = { const rawProps = event.attributes?.event_properties || {};
...(event.attributes?.event_properties || {}),
...(event.attributes?.properties || {}), // Only log for shipped orders and only show relevant fields
...(event.attributes?.profile || {}), if (event.relationships?.metric?.data?.id === METRIC_IDS.SHIPPED_ORDER) {
value: event.attributes?.value, console.log('[EventsService] Shipped Order:', {
datetime: event.attributes?.datetime orderId: rawProps.OrderId,
}; shippedBy: rawProps.ShippedBy,
datetime: event.attributes?.datetime
});
}
// Normalize shipping data // Normalize shipping data
const shippingData = { const shippingData = {
name: rawProps.ShippingName || rawProps.shipping_name || rawProps.shipping?.name, ShippingName: rawProps.ShippingName,
street1: rawProps.ShippingStreet1 || rawProps.shipping_street1 || rawProps.shipping?.street1, ShippingStreet1: rawProps.ShippingStreet1,
street2: rawProps.ShippingStreet2 || rawProps.shipping_street2 || rawProps.shipping?.street2, ShippingStreet2: rawProps.ShippingStreet2,
city: rawProps.ShippingCity || rawProps.shipping_city || rawProps.shipping?.city, ShippingCity: rawProps.ShippingCity,
state: rawProps.ShippingState || rawProps.shipping_state || rawProps.shipping?.state, ShippingState: rawProps.ShippingState,
zip: rawProps.ShippingZip || rawProps.shipping_zip || rawProps.shipping?.zip, ShippingZip: rawProps.ShippingZip,
country: rawProps.ShippingCountry || rawProps.shipping_country || rawProps.shipping?.country, ShippingCountry: rawProps.ShippingCountry,
method: rawProps.ShipMethod || rawProps.shipping_method || rawProps.shipping?.method, ShipMethod: rawProps.ShipMethod,
tracking: rawProps.TrackingNumber || rawProps.tracking_number TrackingNumber: rawProps.TrackingNumber,
ShippedBy: rawProps.ShippedBy
}; };
// Normalize payment data // Normalize payment data
const paymentData = { const paymentData = {
method: rawProps.PaymentMethod || rawProps.payment_method || rawProps.payment?.method, method: rawProps.PaymentMethod,
name: rawProps.PaymentName || rawProps.payment_name || rawProps.payment?.name, name: rawProps.PaymentName,
amount: Number(rawProps.PaymentAmount || rawProps.payment_amount || rawProps.payment?.amount || 0) amount: Number(rawProps.PaymentAmount || 0)
}; };
// Normalize order flags // Normalize order flags
const orderFlags = { const orderFlags = {
type: rawProps.OrderType || rawProps.order_type || 'standard', type: rawProps.OrderType || 'standard',
hasPreorder: Boolean(rawProps.HasPreorder || rawProps.has_preorder || rawProps.preorder), hasPreorder: Boolean(rawProps.HasPreorder),
localPickup: Boolean(rawProps.LocalPickup || rawProps.local_pickup || rawProps.pickup), localPickup: Boolean(rawProps.LocalPickup),
isOnHold: Boolean(rawProps.IsOnHold || rawProps.is_on_hold || rawProps.on_hold), isOnHold: Boolean(rawProps.IsOnHold),
hasDigiItem: Boolean(rawProps.HasDigiItem || rawProps.has_digital_item || rawProps.digital_item), hasDigiItem: Boolean(rawProps.HasDigiItem),
hasNotions: Boolean(rawProps.HasNotions || rawProps.has_notions || rawProps.notions), hasNotions: Boolean(rawProps.HasNotions),
hasDigitalGC: Boolean(rawProps.HasDigitalGC || rawProps.has_digital_gc || rawProps.gift_card), hasDigitalGC: Boolean(rawProps.HasDigitalGC),
stillOwes: Boolean(rawProps.StillOwes || rawProps.still_owes || rawProps.balance_due) stillOwes: Boolean(rawProps.StillOwes)
}; };
// Normalize refund/cancel data // Normalize refund/cancel data
const refundData = { const refundData = {
reason: rawProps.CancelReason || rawProps.cancel_reason || rawProps.reason, reason: rawProps.CancelReason,
message: rawProps.CancelMessage || rawProps.cancel_message || rawProps.message, message: rawProps.CancelMessage,
orderMessage: rawProps.OrderMessage || rawProps.order_message || rawProps.note orderMessage: rawProps.OrderMessage
}; };
// Transform items // Transform items
const items = this._transformItems(rawProps.Items || rawProps.items || rawProps.line_items || []); const items = this._transformItems(rawProps.Items || []);
// Calculate totals // Calculate totals
const totalAmount = Number(rawProps.TotalAmount || rawProps.PaymentAmount || rawProps.total_amount || rawProps.value || 0); const totalAmount = Number(rawProps.TotalAmount || rawProps.PaymentAmount || rawProps.value || 0);
const itemCount = items.reduce((sum, item) => sum + Number(item.Quantity || item.QuantityOrdered || 1), 0); const itemCount = items.reduce((sum, item) => sum + Number(item.Quantity || item.QuantityOrdered || 1), 0);
const transformed = { const transformed = {
@@ -1408,29 +1412,10 @@ export class EventsService {
}, },
relationships: event.relationships, relationships: event.relationships,
event_properties: { event_properties: {
// Basic properties ...rawProps, // Include all original properties
EmailAddress: rawProps.EmailAddress || rawProps.email, Items: items, // Override with transformed items
FirstName: rawProps.FirstName || rawProps.first_name,
LastName: rawProps.LastName || rawProps.last_name,
OrderId: rawProps.OrderId || rawProps.FromOrder || rawProps.order_id,
TotalAmount: totalAmount, TotalAmount: totalAmount,
ItemCount: itemCount, ItemCount: itemCount
Items: items,
// Shipping information
...shippingData,
// Payment information
...paymentData,
// Order flags
...orderFlags,
// Refund/cancel information
...refundData,
// Original properties (for backward compatibility)
...rawProps
} }
}; };
@@ -2112,21 +2097,43 @@ export class EventsService {
const currentHour = now.hour; const currentHour = now.hour;
const currentMinute = now.minute; const currentMinute = now.minute;
// Handle the 12-1 AM edge case
const isInEdgeCase = currentHour < this.timeManager.dayStartHour;
const adjustedCurrentHour = isInEdgeCase ? currentHour + 24 : currentHour;
const adjustedDayStartHour = this.timeManager.dayStartHour;
// Calculate how much of the current hour has passed (0-1) // Calculate how much of the current hour has passed (0-1)
const hourProgress = currentMinute / 60; const hourProgress = currentMinute / 60;
// Calculate how much of the expected daily revenue we've seen so far // Calculate how much of the expected daily revenue we've seen so far
let expectedPercentageSeen = 0; let expectedPercentageSeen = 0;
for (let i = 0; i < currentHour; i++) { let totalDayPercentage = 0;
expectedPercentageSeen += hourlyPatterns[i].percentage;
// First, calculate total percentage for a full day
for (let i = 0; i < 24; i++) {
totalDayPercentage += hourlyPatterns[i].percentage;
}
if (isInEdgeCase) {
// If we're between 12-1 AM, we want to use almost the full day's percentage
// since we're at the end of the previous day
expectedPercentageSeen = totalDayPercentage;
// Subtract the remaining portion of the current hour
expectedPercentageSeen -= hourlyPatterns[currentHour].percentage * (1 - hourProgress);
} else {
// Normal case - add up percentages from day start to current hour
for (let i = adjustedDayStartHour; i < adjustedCurrentHour; i++) {
const hourIndex = i % 24;
expectedPercentageSeen += hourlyPatterns[hourIndex].percentage;
}
// Add partial current hour
expectedPercentageSeen += hourlyPatterns[currentHour].percentage * hourProgress;
} }
// Add partial current hour
expectedPercentageSeen += hourlyPatterns[currentHour].percentage * hourProgress;
// Calculate projection based on patterns // Calculate projection based on patterns
let projectedRevenue = 0; let projectedRevenue = 0;
if (expectedPercentageSeen > 0) { if (expectedPercentageSeen > 0) {
projectedRevenue = (currentRevenue / (expectedPercentageSeen / 100)); projectedRevenue = (currentRevenue / (expectedPercentageSeen / totalDayPercentage));
} }
// Calculate confidence score (0-1) based on: // Calculate confidence score (0-1) based on:
@@ -2134,8 +2141,19 @@ export class EventsService {
// 2. How consistent the patterns are // 2. How consistent the patterns are
// 3. How far through the period we are // 3. How far through the period we are
const patternConsistency = this._calculatePatternConsistency(hourlyPatterns); const patternConsistency = this._calculatePatternConsistency(hourlyPatterns);
const periodProgress = Math.min(100, Math.max(0, (now.diff(periodStart).milliseconds / periodEnd.diff(periodStart).milliseconds) * 100));
const historicalDataAmount = Math.min(totalHistoricalOrders / 1000, 1); // Normalize to 0-1, considering 1000+ orders as maximum confidence // Calculate period progress considering the 1 AM day start
const totalDuration = periodEnd.diff(periodStart);
const elapsedDuration = now.diff(periodStart);
let periodProgress = Math.min(100, Math.max(0, (elapsedDuration.milliseconds / totalDuration.milliseconds) * 100));
// Adjust period progress for the 12-1 AM edge case
if (isInEdgeCase) {
// If we're between 12-1 AM, we're actually at the end of the previous day
periodProgress = Math.min(100, Math.max(0, ((24 - adjustedDayStartHour + currentHour) / 24) * 100));
}
const historicalDataAmount = Math.min(totalHistoricalOrders / 1000, 1);
const confidence = ( const confidence = (
(patternConsistency * 0.4) + (patternConsistency * 0.4) +
@@ -2154,8 +2172,11 @@ export class EventsService {
historicalOrders: totalHistoricalOrders, historicalOrders: totalHistoricalOrders,
hourlyPatterns, hourlyPatterns,
expectedPercentageSeen, expectedPercentageSeen,
totalDayPercentage,
currentHour, currentHour,
currentMinute currentMinute,
isInEdgeCase,
adjustedCurrentHour
} }
}; };
} catch (error) { } catch (error) {

View File

@@ -122,35 +122,37 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
if (!weatherCode) return <CircleAlert className="w-12 h-12 text-red-500" />; if (!weatherCode) return <CircleAlert className="w-12 h-12 text-red-500" />;
const code = parseInt(weatherCode, 10); const code = parseInt(weatherCode, 10);
const iconProps = small ? "w-8 h-8" : "w-12 h-12"; const iconProps = small ? "w-8 h-8" : "w-12 h-12";
const isNight = currentTime.getHours() >= 18 || currentTime.getHours() < 6;
switch (true) { switch (true) {
case code >= 200 && code < 300: case code >= 200 && code < 300:
return <CloudLightning className={cn(iconProps, "text-gray-700")} />; return <CloudLightning className={cn(iconProps, "text-yellow-300")} />;
case code >= 300 && code < 500: case code >= 300 && code < 500:
return <CloudDrizzle className={cn(iconProps, "text-blue-600")} />; return <CloudDrizzle className={cn(iconProps, "text-blue-300")} />;
case code >= 500 && code < 600: case code >= 500 && code < 600:
return <CloudRain className={cn(iconProps, "text-blue-600")} />; return <CloudRain className={cn(iconProps, "text-blue-300")} />;
case code >= 600 && code < 700: case code >= 600 && code < 700:
return <CloudSnow className={cn(iconProps, "text-blue-400")} />; return <CloudSnow className={cn(iconProps, "text-blue-200")} />;
case code >= 700 && code < 721: case code >= 700 && code < 721:
return <CloudFog className={cn(iconProps, "text-gray-600")} />; return <CloudFog className={cn(iconProps, "text-gray-300")} />;
case code === 721: case code === 721:
return <Haze className={cn(iconProps, "text-gray-700")} />; return <Haze className={cn(iconProps, "text-gray-300")} />;
case code >= 722 && code < 781: case code >= 722 && code < 781:
return <CloudFog className={cn(iconProps, "text-gray-600")} />; return <CloudFog className={cn(iconProps, "text-gray-300")} />;
case code === 781: case code === 781:
return <Tornado className={cn(iconProps, "text-gray-700")} />; return <Tornado className={cn(iconProps, "text-gray-300")} />;
case code === 800: case code === 800:
return currentTime.getHours() >= 6 && currentTime.getHours() < 18 ? ( return currentTime.getHours() >= 6 && currentTime.getHours() < 18 ? (
<Sun className={cn(iconProps, "text-yellow-700")} /> <Sun className={cn(iconProps, "text-yellow-300")} />
) : ( ) : (
<Moon className={cn(iconProps, "text-gray-500")} /> <Moon className={cn(iconProps, "text-gray-300")} />
); );
case code >= 800 && code < 803: case code >= 800 && code < 803:
return <CloudSun className={cn(iconProps, "text-gray-600")} />; return <CloudSun className={cn(iconProps, isNight ? "text-gray-300" : "text-gray-200")} />;
case code >= 803: case code >= 803:
return <Cloud className={cn(iconProps, "text-gray-200")} />; return <Cloud className={cn(iconProps, "text-gray-300")} />;
default: default:
return <CircleAlert className={cn(iconProps, "text-red-700")} />; return <CircleAlert className={cn(iconProps, "text-red-500")} />;
} }
}; };
@@ -159,66 +161,68 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
// Thunderstorm (200-299) // Thunderstorm (200-299)
if (code >= 200 && code < 300) { if (code >= 200 && code < 300) {
return "bg-gradient-to-br from-slate-900 via-purple-900 to-slate-800"; return "bg-gradient-to-br from-slate-900 to-purple-800";
} }
// Drizzle (300-399) // Drizzle (300-399)
if (code >= 300 && code < 400) { if (code >= 300 && code < 400) {
return "bg-gradient-to-br from-slate-700 via-blue-800 to-slate-700"; return "bg-gradient-to-br from-slate-800 to-blue-800";
} }
// Rain (500-599) // Rain (500-599)
if (code >= 500 && code < 600) { if (code >= 500 && code < 600) {
return "bg-gradient-to-br from-slate-800 via-blue-900 to-slate-700"; return "bg-gradient-to-br from-slate-800 to-blue-800";
} }
// Snow (600-699) // Snow (600-699)
if (code >= 600 && code < 700) { if (code >= 600 && code < 700) {
return "bg-gradient-to-br from-slate-200 via-blue-100 to-slate-100"; return "bg-gradient-to-br from-slate-700 to-blue-800";
} }
// Atmosphere (700-799: mist, smoke, haze, fog, etc.) // Atmosphere (700-799: mist, smoke, haze, fog, etc.)
if (code >= 700 && code < 800) { if (code >= 700 && code < 800) {
return "bg-gradient-to-br from-slate-600 via-slate-500 to-slate-400"; return "bg-gradient-to-br from-slate-700 to-slate-500";
} }
// Clear (800) // Clear (800)
if (code === 800) { if (code === 800) {
if (isNight) { if (isNight) {
return "bg-gradient-to-br from-slate-900 via-blue-950 to-slate-800"; return "bg-gradient-to-br from-slate-900 to-blue-900";
} }
return "bg-gradient-to-br from-sky-400 via-blue-400 to-sky-500"; return "bg-gradient-to-br from-blue-600 to-sky-400";
} }
// Clouds (801-804) // Clouds (801-804)
if (code > 800) { if (code > 800) {
if (isNight) { if (isNight) {
return "bg-gradient-to-br from-slate-800 via-slate-700 to-slate-600"; return "bg-gradient-to-br from-slate-800 to-slate-600";
} }
return "bg-gradient-to-br from-slate-400 via-slate-500 to-slate-400"; return "bg-gradient-to-br from-slate-600 to-slate-400";
} }
// Default fallback // Default fallback
return "bg-gradient-to-br from-slate-700 via-slate-600 to-slate-500"; return "bg-gradient-to-br from-slate-700 to-slate-500";
}; };
const getTemperatureColor = (weatherCode, isNight) => { const getTemperatureColor = (weatherCode, isNight) => {
const code = parseInt(weatherCode, 10); const code = parseInt(weatherCode, 10);
// Use dark text for light backgrounds // Snow - dark background, light text
if (code >= 600 && code < 700) { // Snow if (code >= 600 && code < 700) {
return "text-slate-900"; return "text-white";
} }
if (code === 800 && !isNight) { // Clear day // Clear day - light background, dark text
return "text-slate-900"; if (code === 800 && !isNight) {
return "text-white";
} }
if (code > 800 && !isNight) { // Cloudy day // Cloudy day - medium background, ensure contrast
return "text-slate-900"; if (code > 800 && !isNight) {
return "text-white";
} }
// Default to white text for all other (darker) backgrounds // All other cases (darker backgrounds)
return "text-white"; return "text-white";
}; };
@@ -236,213 +240,217 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
}; };
const WeatherDetails = () => ( const WeatherDetails = () => (
<div className="space-y-4 p-3"> <div className="space-y-4 p-3 bg-gradient-to-br from-slate-800 to-slate-700">
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
<Card className="p-2"> <Card className="bg-gradient-to-br from-slate-700 to-slate-600 backdrop-blur-sm p-2">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<ThermometerSun className="w-5 h-5 text-orange-500" /> <ThermometerSun className="w-5 h-5 text-yellow-300" />
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-xs text-muted-foreground">High</span> <span className="text-xs text-slate-300">High</span>
<span className="text-sm font-bold">{Math.round(weather.main.temp_max)}°F</span> <span className="text-sm font-bold text-white">{Math.round(weather.main.temp_max)}°F</span>
</div>
</div> </div>
</div> </Card>
</Card>
<Card className="p-2"> <Card className="bg-gradient-to-br from-slate-700 to-slate-600 backdrop-blur-sm p-2">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<ThermometerSnowflake className="w-5 h-5 text-blue-500" /> <ThermometerSnowflake className="w-5 h-5 text-blue-300" />
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-xs text-muted-foreground">Low</span> <span className="text-xs text-slate-300">Low</span>
<span className="text-sm font-bold">{Math.round(weather.main.temp_min)}°F</span> <span className="text-sm font-bold text-white">{Math.round(weather.main.temp_min)}°F</span>
</div>
</div> </div>
</div> </Card>
</Card>
<Card className="p-2"> <Card className="bg-gradient-to-br from-slate-700 to-slate-600 backdrop-blur-sm p-2">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Droplets className="w-5 h-5 text-blue-400" /> <Droplets className="w-5 h-5 text-blue-300" />
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-xs text-muted-foreground">Humidity</span> <span className="text-xs text-slate-300">Humidity</span>
<span className="text-sm font-bold">{weather.main.humidity}%</span> <span className="text-sm font-bold text-white">{weather.main.humidity}%</span>
</div>
</div> </div>
</div> </Card>
</Card>
<Card className="p-2"> <Card className="bg-gradient-to-br from-slate-700 to-slate-600 backdrop-blur-sm p-2">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Wind className="w-5 h-5 text-gray-500" /> <Wind className="w-5 h-5 text-slate-300" />
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-xs text-muted-foreground">Wind</span> <span className="text-xs text-slate-300">Wind</span>
<span className="text-sm font-bold">{Math.round(weather.wind.speed)} mph</span> <span className="text-sm font-bold text-white">{Math.round(weather.wind.speed)} mph</span>
</div>
</div> </div>
</div> </Card>
</Card>
<Card className="p-2"> <Card className="bg-gradient-to-br from-slate-700 to-slate-600 backdrop-blur-sm p-2">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Sunrise className="w-5 h-5 text-yellow-500" /> <Sunrise className="w-5 h-5 text-yellow-300" />
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-xs text-muted-foreground">Sunrise</span> <span className="text-xs text-slate-300">Sunrise</span>
<span className="text-sm font-bold">{formatTime(weather.sys?.sunrise)}</span> <span className="text-sm font-bold text-white">{formatTime(weather.sys?.sunrise)}</span>
</div>
</div> </div>
</div> </Card>
</Card>
<Card className="p-2"> <Card className="bg-gradient-to-br from-slate-700 to-slate-600 backdrop-blur-sm p-2">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Sunset className="w-5 h-5 text-orange-400" /> <Sunset className="w-5 h-5 text-orange-300" />
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-xs text-muted-foreground">Sunset</span> <span className="text-xs text-slate-300">Sunset</span>
<span className="text-sm font-bold">{formatTime(weather.sys?.sunset)}</span> <span className="text-sm font-bold text-white">{formatTime(weather.sys?.sunset)}</span>
</div>
</div> </div>
</div> </Card>
</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-medium">
{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>
)}
</div>
);
return ( {forecast && (
<div className="flex flex-col items-center w-full transition-opacity duration-300 ${mounted ? 'opacity-100' : 'opacity-0'}"> <div>
{/* Time Display */} <div className="grid grid-cols-5 gap-2">
<Card className="bg-gradient-to-br mb-[7px] from-slate-900 via-sky-800 to-cyan-800 dark:bg-slate-800 px-1 py-2 w-full hover:scale-[1.02] transition-transform duration-300"> {forecast.map((day, index) => {
<CardContent className="p-3 h-[106px] flex items-center"> const forecastTime = new Date(day.dt * 1000);
<div className="flex justify-center items-baseline w-full"> const isNight = forecastTime.getHours() >= 18 || forecastTime.getHours() < 6;
<div className={`transition-opacity duration-200 ${isTimeChanging ? 'opacity-60' : 'opacity-100'}`}> return (
<span className="text-7xl font-bold text-white">{hours}</span> <Card
<span className="text-7xl font-bold text-white">:</span> key={index}
<span className="text-7xl font-bold text-white">{minutes}</span> className={cn(
<span className="text-xl font-medium text-white/90 ml-1">{ampm}</span> getWeatherBackground(day.weather[0].id, isNight),
"p-2"
)}
>
<div className="flex flex-col items-center gap-1">
<span className="text-sm font-medium text-white">
{forecastTime.toLocaleDateString('en-US', { weekday: 'short' })}
</span>
{getWeatherIcon(day.weather[0].id, forecastTime, true)}
<div className="flex justify-center gap-1 items-baseline w-full">
<span className="text-sm font-medium text-white">
{Math.round(day.main.temp_max)}°
</span>
<span className="text-xs text-slate-300">
{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-300" />
<span className="text-xs text-white">{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-300" />
<span className="text-xs text-white">{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-slate-300" />
<span className="text-xs text-white">0"</span>
</div>
)}
</div>
</div>
</Card>
);
})}
</div> </div>
</div> </div>
</CardContent> )}
</Card> </div>
);
{/* Date and Weather Display */} return (
<div className="h-[125px] mb-[6px] grid grid-cols-2 gap-2 w-full"> <div className="flex flex-col items-center w-full transition-opacity duration-300 ${mounted ? 'opacity-100' : 'opacity-0'}">
<Card className="h-full bg-gradient-to-br from-slate-900 via-violet-800 to-purple-800 flex items-center justify-center"> {/* Time Display */}
<CardContent className="h-full p-0"> <Card className="bg-gradient-to-br mb-[7px] from-indigo-900 to-blue-800 backdrop-blur-sm dark:bg-slate-800 px-1 py-2 w-full hover:scale-[1.02] transition-transform duration-300">
<div className="flex flex-col items-center justify-center h-full"> <CardContent className="p-3 h-[106px] flex items-center">
<span className="text-7xl font-bold text-white"> <div className="flex justify-center items-baseline w-full">
{dateInfo.day} <div className={`transition-opacity duration-200 ${isTimeChanging ? 'opacity-60' : 'opacity-100'}`}>
</span> <span className="text-6xl font-bold text-white">{hours}</span>
<span className="text-sm font-bold text-white mt-2"> <span className="text-6xl font-bold text-white">:</span>
{dateInfo.weekday} <span className="text-6xl font-bold text-white">{minutes}</span>
</span> <span className="text-lg font-medium text-white/90 ml-1">{ampm}</span>
</div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
{weather?.main && ( {/* Date and Weather Display */}
<Popover> <div className="h-[125px] mb-[6px] grid grid-cols-2 gap-2 w-full">
<PopoverTrigger asChild> <Card className="h-full bg-gradient-to-br from-violet-900 to-purple-800 backdrop-blur-sm flex items-center justify-center">
<Card className={cn( <CardContent className="h-full p-0">
getWeatherBackground( <div className="flex flex-col items-center justify-center h-full">
weather.weather[0]?.id, <span className="text-6xl font-bold text-white">
datetime.getHours() >= 18 || datetime.getHours() < 6 {dateInfo.day}
), </span>
"flex items-center justify-center cursor-pointer hover:brightness-110 transition-all relative" <span className="text-sm font-bold text-white mt-2">
)}> {dateInfo.weekday}
<CardContent className="h-full p-3"> </span>
<div className="flex flex-col items-center"> </div>
{getWeatherIcon(weather.weather[0]?.id, datetime)} </CardContent>
<span className={cn( </Card>
"text-3xl font-bold ml-1 mt-2",
getTemperatureColor(
weather.weather[0]?.id,
datetime.getHours() >= 18 || datetime.getHours() < 6
)
)}>
{Math.round(weather.main.temp)}°
</span>
</div>
</CardContent>
{weather.alerts && (
<div className="absolute top-1 right-1">
<AlertTriangle className="w-5 h-5 text-red-500" />
</div>
)}
</Card>
</PopoverTrigger>
<PopoverContent
className="w-[450px]"
align="start"
side="right"
sideOffset={10}
style={{
transform: `scale(${scaleFactor})`,
transformOrigin: 'left top'
}}
>
{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>
{/* Calendar Display */} {weather?.main && (
<Card className="w-full"> <Popover>
<CardContent className="p-0"> <PopoverTrigger asChild>
<CalendarComponent <Card className={cn(
selected={datetime} getWeatherBackground(
className="w-full" weather.weather[0]?.id,
/> datetime.getHours() >= 18 || datetime.getHours() < 6
</CardContent> ),
</Card> "flex items-center justify-center cursor-pointer hover:brightness-110 transition-all relative backdrop-blur-sm"
</div> )}>
); <CardContent className="h-full p-3">
<div className="flex flex-col items-center">
{getWeatherIcon(weather.weather[0]?.id, datetime)}
<span className="text-3xl font-bold ml-1 mt-2 text-white">
{Math.round(weather.main.temp)}°
</span>
</div>
</CardContent>
{weather.alerts && (
<div className="absolute top-1 right-1">
<AlertTriangle className="w-5 h-5 text-red-500" />
</div>
)}
</Card>
</PopoverTrigger>
<PopoverContent
className="w-[450px] bg-gradient-to-br from-slate-800 to-slate-700 border-slate-600"
align="start"
side="right"
sideOffset={10}
style={{
transform: `scale(${scaleFactor})`,
transformOrigin: 'left top'
}}
>
{weather.alerts && (
<Alert variant="warning" className="mb-3 bg-amber-900/50 border-amber-700">
<AlertTriangle className="h-3 w-3 text-amber-500" />
<AlertDescription className="text-xs text-amber-200">
{weather.alerts[0].event}
</AlertDescription>
</Alert>
)}
<WeatherDetails />
</PopoverContent>
</Popover>
)}
</div>
{/* Calendar Display */}
<Card className="w-full bg-white dark:bg-slate-800">
<CardContent className="p-0">
<CalendarComponent
selected={datetime}
className="w-full"
/>
</CardContent>
</Card>
</div>
);
}; };
export default DateTimeWeatherDisplay; export default DateTimeWeatherDisplay;

View File

@@ -679,72 +679,26 @@ const EventDialog = ({ event, children }) => {
{event.metric_id === METRIC_IDS.SHIPPED_ORDER && ( {event.metric_id === METRIC_IDS.SHIPPED_ORDER && (
<> <>
<div className="grid gap-6 sm:grid-cols-2"> <div className="mt-1">
<Card> <div className="flex items-center gap-2">
<CardHeader className="pb-2"> <span className="text-sm font-medium text-gray-900 dark:text-gray-100">
<CardTitle className="text-sm font-medium">Shipping Address</CardTitle> {toTitleCase(details.ShippingName)}
</CardHeader> </span>
<CardContent className="space-y-1"> <span className="text-sm text-gray-500"></span>
<p className="text-sm font-medium">{details.ShippingName}</p> <span className="text-sm text-gray-500">
{details.ShippingStreet1 && ( #{details.OrderId}
<p className="text-sm text-muted-foreground">{details.ShippingStreet1}</p> </span>
)} </div>
{details.ShippingStreet2 && ( <div className="text-sm text-gray-500">
<p className="text-sm text-muted-foreground">{details.ShippingStreet2}</p> {formatShipMethodSimple(details.ShipMethod)}
)} {event.event_properties?.ShippedBy && (
<p className="text-sm text-muted-foreground"> <>
{details.ShippingCity}, {details.ShippingState} {details.ShippingZip} <span className="text-sm text-gray-500"> </span>
</p> <span className="text-sm font-medium text-blue-600 dark:text-blue-400">Shipped by {event.event_properties.ShippedBy}</span>
{details.ShippingCountry !== "US" && ( </>
<p className="text-sm text-muted-foreground">{details.ShippingCountry}</p> )}
)} </div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Tracking Information</CardTitle>
</CardHeader>
<CardContent className="space-y-1">
<p className="text-sm font-medium">{details.TrackingNumber}</p>
<p className="text-sm text-muted-foreground">
{formatShipMethod(details.ShipMethod)}
</p>
</CardContent>
</Card>
</div> </div>
<Card className="mt-6">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Shipped Items</CardTitle>
</CardHeader>
<CardContent>
<div className="divide-y">
{details.Items?.map((item, i) => (
<div key={i} className="flex gap-4 py-4 first:pt-0 last:pb-0">
{item.ImgThumb && (
<img
src={item.ImgThumb}
alt={item.ProductName}
className="w-16 h-16 object-cover rounded bg-muted"
/>
)}
<div className="flex-1 min-w-0">
<p className="font-medium text-sm">{item.ProductName}</p>
<p className="text-sm text-muted-foreground">
Shipped: {item.QuantitySent} of {item.QuantityOrdered}
</p>
{item.QuantityBackordered > 0 && (
<Badge variant="secondary" className="mt-2">
{item.QuantityBackordered} Backordered
</Badge>
)}
</div>
</div>
))}
</div>
</CardContent>
</Card>
</> </>
)} )}
@@ -1038,15 +992,19 @@ const EventCard = ({ event }) => {
<span className="text-sm font-medium text-gray-900 dark:text-gray-100"> <span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{toTitleCase(details.ShippingName)} {toTitleCase(details.ShippingName)}
</span> </span>
</div> <span className="text-sm text-gray-500"></span>
<div className="flex items-center gap-2 text-sm text-gray-500">
<span className="text-sm text-gray-500"> <span className="text-sm text-gray-500">
#{details.OrderId} #{details.OrderId}
</span> </span>
<span className="text-sm text-gray-500"></span> </div>
<span className="font-medium text-blue-600 dark:text-blue-400"> <div className="text-sm text-gray-500">
{formatShipMethodSimple(details.ShipMethod)} {formatShipMethodSimple(details.ShipMethod)}
</span> {event.event_properties?.ShippedBy && (
<>
<span className="text-sm text-gray-500"> </span>
<span className="text-sm font-medium text-blue-600 dark:text-blue-400">Shipped by {event.event_properties.ShippedBy}</span>
</>
)}
</div> </div>
</div> </div>
</> </>
@@ -1179,14 +1137,12 @@ const EventFeed = ({
}, },
}); });
// Ensure we have the datetime field in the response // Keep the original event structure intact
const processedEvents = (response.data.data || []).map((event) => ({ const processedEvents = (response.data.data || []).map((event) => ({
...event, ...event,
datetime: event.attributes?.datetime || event.datetime, datetime: event.attributes?.datetime || event.datetime,
event_properties: { // Don't spread event_properties to preserve the nested structure
...event.event_properties, event_properties: event.attributes?.event_properties || {}
datetime: event.attributes?.datetime || event.datetime,
},
})); }));
setEvents(processedEvents); setEvents(processedEvents);

View File

@@ -18,11 +18,14 @@ import {
Activity, Activity,
AlertCircle, AlertCircle,
FileText, FileText,
ChevronLeft,
ChevronRight,
} from "lucide-react"; } from "lucide-react";
import { format } from "date-fns"; import { format } from "date-fns";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { EventDialog } from "./EventFeed.jsx"; import { EventDialog } from "./EventFeed.jsx";
import { Button } from "@/components/ui/button";
const METRIC_IDS = { const METRIC_IDS = {
PLACED_ORDER: "Y8cqcF", PLACED_ORDER: "Y8cqcF",
@@ -247,6 +250,11 @@ const EventCard = ({ event }) => {
#{details.OrderId} {formatShipMethodSimple(details.ShipMethod)} #{details.OrderId} {formatShipMethodSimple(details.ShipMethod)}
</div> </div>
</div> </div>
{event.event_properties?.ShippedBy && (
<div className={`text-sm font-medium ${eventType.textColor} opacity-90 truncate mt-1`}>
Shipped by {event.event_properties.ShippedBy}
</div>
)}
</> </>
)} )}
@@ -319,6 +327,34 @@ const MiniEventFeed = ({
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [lastUpdate, setLastUpdate] = useState(null); const [lastUpdate, setLastUpdate] = useState(null);
const scrollRef = useRef(null); const scrollRef = useRef(null);
const [showLeftArrow, setShowLeftArrow] = useState(false);
const [showRightArrow, setShowRightArrow] = useState(false);
const handleScroll = () => {
if (scrollRef.current) {
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
setShowLeftArrow(scrollLeft > 0);
setShowRightArrow(scrollLeft < scrollWidth - clientWidth - 1);
}
};
const scrollToEnd = () => {
if (scrollRef.current) {
scrollRef.current.scrollTo({
left: scrollRef.current.scrollWidth,
behavior: 'smooth'
});
}
};
const scrollToStart = () => {
if (scrollRef.current) {
scrollRef.current.scrollTo({
left: 0,
behavior: 'smooth'
});
}
};
const fetchEvents = useCallback(async () => { const fetchEvents = useCallback(async () => {
try { try {
@@ -338,10 +374,7 @@ const MiniEventFeed = ({
const processedEvents = (response.data.data || []).map((event) => ({ const processedEvents = (response.data.data || []).map((event) => ({
...event, ...event,
datetime: event.attributes?.datetime || event.datetime, datetime: event.attributes?.datetime || event.datetime,
event_properties: { event_properties: event.attributes?.event_properties || {}
...event.event_properties,
datetime: event.attributes?.datetime || event.datetime,
},
})); }));
setEvents(processedEvents); setEvents(processedEvents);
@@ -354,6 +387,7 @@ const MiniEventFeed = ({
left: scrollRef.current.scrollWidth, left: scrollRef.current.scrollWidth,
behavior: 'instant' behavior: 'instant'
}); });
handleScroll();
}, 0); }, 0);
} }
} catch (error) { } catch (error) {
@@ -366,41 +400,67 @@ const MiniEventFeed = ({
useEffect(() => { useEffect(() => {
fetchEvents(); fetchEvents();
const interval = setInterval(fetchEvents, 60000); const interval = setInterval(fetchEvents, 30000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [fetchEvents]); }, [fetchEvents]);
useEffect(() => {
handleScroll();
}, [events]);
return ( return (
<div className="fixed bottom-0 left-0 right-0"> <div className="fixed bottom-0 left-0 right-0">
<Card className="bg-gradient-to-br rounded-none from-gray-900 to-gray-600 backdrop-blur"> <Card className="bg-gradient-to-br rounded-none from-gray-900 to-gray-600 backdrop-blur">
<div className="px-1 pt-2 pb-3"> <div className="px-1 pt-2 pb-3 relative">
<div className="overflow-x-auto overflow-y-hidden [&::-webkit-scrollbar]:hidden [-ms-overflow-style:'none'] [scrollbar-width:'none']"> {showLeftArrow && (
<div className="flex flex-row gap-3 pr-4" style={{ width: 'max-content' }}> <Button
{loading && !events.length ? ( variant="ghost"
<LoadingState /> className="absolute left-0 top-1/2 -translate-y-1/2 z-10 bg-gray-900/50 hover:bg-gray-900/75 h-12 w-8 p-0 [&_svg]:!h-8 [&_svg]:!w-8"
) : error ? ( onClick={scrollToStart}
<Alert variant="destructive" className="mx-4"> >
<AlertCircle className="h-4 w-4" /> <ChevronLeft className="text-white" />
<AlertTitle>Error</AlertTitle> </Button>
<AlertDescription> )}
Failed to load event feed: {error} {showRightArrow && (
</AlertDescription> <Button
</Alert> variant="ghost"
) : !events || events.length === 0 ? ( className="absolute right-0 top-1/2 -translate-y-1/2 z-10 bg-gray-900/50 hover:bg-gray-900/75 h-12 w-8 p-0 [&_svg]:!h-8 [&_svg]:!w-8"
<div className="px-4"> onClick={scrollToEnd}
<EmptyState /> >
</div> <ChevronRight className="text-white" />
) : ( </Button>
events.map((event) => ( )}
<EventCard <div
key={event.id} ref={scrollRef}
event={event} onScroll={handleScroll}
/> className="overflow-x-auto overflow-y-hidden [&::-webkit-scrollbar]:hidden [-ms-overflow-style:'none'] [scrollbar-width:'none']"
)) >
)} <div className="flex flex-row gap-3 pr-4" style={{ width: 'max-content' }}>
{loading && !events.length ? (
<LoadingState />
) : error ? (
<Alert variant="destructive" className="mx-4">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>
Failed to load event feed: {error}
</AlertDescription>
</Alert>
) : !events || events.length === 0 ? (
<div className="px-4">
<EmptyState />
</div>
) : (
[...events].reverse().map((event) => (
<EventCard
key={event.id}
event={event}
/>
))
)}
</div>
</div> </div>
</div> </div>
</div>
</Card> </Card>
</div> </div>
); );

View File

@@ -17,6 +17,35 @@ import {
SkeletonBarChart, SkeletonBarChart,
processBasicData, processBasicData,
} from "./RealtimeAnalytics"; } from "./RealtimeAnalytics";
import { Skeleton } from "@/components/ui/skeleton";
const SkeletonCard = ({ colorScheme = "sky" }) => (
<Card className={`w-full h-[150px] bg-gradient-to-br from-sky-900 to-sky-800 backdrop-blur-sm`}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
<CardTitle>
<div className="space-y-2">
<Skeleton className={`h-4 w-24 bg-${colorScheme}-300/20`} />
</div>
</CardTitle>
<div className="relative p-2">
<div className={`absolute inset-0 rounded-full bg-${colorScheme}-300/20`} />
<div className="h-5 w-5 relative rounded-full bg-${colorScheme}-300/20" />
</div>
</CardHeader>
<CardContent className="p-4 pt-0">
<div className="space-y-4">
<div className="space-y-2">
<Skeleton className={`h-8 w-32 bg-${colorScheme}-300/20`} />
<div className="flex justify-between items-center">
<div className="space-y-1">
<Skeleton className={`h-4 w-24 bg-${colorScheme}-300/20`} />
</div>
</div>
</div>
</div>
</CardContent>
</Card>
);
const MiniRealtimeAnalytics = () => { const MiniRealtimeAnalytics = () => {
const [basicData, setBasicData] = useState({ const [basicData, setBasicData] = useState({
@@ -76,95 +105,68 @@ const MiniRealtimeAnalytics = () => {
}; };
}, [isPaused]); }, [isPaused]);
if (loading && !basicData) { const renderContent = () => {
return ( if (error) {
<div> return (
<div className="grid grid-cols-2 gap-2 mt-1 mb-2"> <Alert variant="destructive" className="mb-4">
<Card className="h-[150px] bg-gradient-to-br from-sky-900 to-sky-800 backdrop-blur-sm"> <AlertTriangle className="h-4 w-4" />
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2"> <AlertDescription>{error}</AlertDescription>
<CardTitle className="text-sky-100 font-bold text-md"> </Alert>
<Skeleton className="h-4 w-24 bg-sky-700" /> );
</CardTitle> }
<div className="relative p-2">
<div className="absolute inset-0 rounded-full bg-sky-300" /> if (loading) {
<Skeleton className="h-5 w-5 bg-sky-700 relative rounded-full" /> return (
</div> <div>
</CardHeader> <div className="grid grid-cols-2 gap-2 mt-1 mb-2">
<CardContent className="p-4 pt-0"> <SkeletonCard colorScheme="sky" />
<div className="space-y-2"> <SkeletonCard colorScheme="sky" />
<Skeleton className="h-8 w-20 bg-sky-700" /> </div>
<Skeleton className="h-4 w-32 bg-sky-700" />
</div> <Card className="bg-gradient-to-br from-sky-900 to-sky-800 backdrop-blur-sm">
</CardContent> <CardContent className="p-4">
</Card> <div className="h-[216px]">
<Card className="h-[150px] bg-gradient-to-br from-sky-900 to-sky-800 backdrop-blur-sm"> <div className="h-full w-full relative">
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2"> {/* Grid lines */}
<CardTitle className="text-sky-100 font-bold text-md"> {[...Array(5)].map((_, i) => (
<Skeleton className="h-4 w-24 bg-sky-700" /> <div
</CardTitle> key={i}
<div className="relative p-2"> className="absolute w-full h-px bg-sky-300/20"
<div className="absolute inset-0 rounded-full bg-sky-300" /> style={{ top: `${(i + 1) * 20}%` }}
<Skeleton className="h-5 w-5 bg-sky-700 relative rounded-full" /> />
</div> ))}
</CardHeader> {/* Y-axis labels */}
<CardContent className="p-4 pt-0"> <div className="absolute left-0 top-0 bottom-0 w-8 flex flex-col justify-between py-4">
<div className="space-y-2"> {[...Array(5)].map((_, i) => (
<Skeleton className="h-8 w-20 bg-sky-700" /> <Skeleton key={i} className="h-3 w-6 bg-sky-300/20 rounded-sm" />
<Skeleton className="h-4 w-32 bg-sky-700" /> ))}
</div>
{/* X-axis labels */}
<div className="absolute left-8 right-4 bottom-0 flex justify-between">
{[...Array(6)].map((_, i) => (
<Skeleton key={i} className="h-3 w-8 bg-sky-300/20 rounded-sm" />
))}
</div>
{/* Bars */}
<div className="absolute inset-x-8 bottom-6 top-4 flex items-end justify-between gap-1">
{[...Array(24)].map((_, i) => (
<div
key={i}
className="w-2 bg-sky-300/20 rounded-sm"
style={{ height: `${Math.random() * 80 + 10}%` }}
/>
))}
</div>
</div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
);
}
<Card className="bg-gradient-to-br from-sky-900 to-sky-800 backdrop-blur-sm"> return (
<CardContent className="p-4"> <div>
<div className="h-[230px] relative">
{/* Grid lines */}
{[...Array(5)].map((_, i) => (
<div
key={i}
className="absolute w-full h-px bg-sky-700"
style={{ top: `${(i + 1) * 20}%` }}
/>
))}
{/* Y-axis labels */}
<div className="absolute left-0 top-0 bottom-0 w-8 flex flex-col justify-between py-4">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-3 w-6 bg-sky-700 rounded-sm" />
))}
</div>
{/* X-axis labels */}
<div className="absolute left-8 right-4 bottom-0 flex justify-between">
{[...Array(6)].map((_, i) => (
<Skeleton key={i} className="h-3 w-8 bg-sky-700 rounded-sm" />
))}
</div>
{/* Bars */}
<div className="absolute inset-x-8 bottom-6 top-4 flex items-end justify-between gap-1">
{[...Array(24)].map((_, i) => (
<div
key={i}
className="w-2 bg-sky-700 rounded-sm"
style={{ height: `${Math.random() * 80 + 10}%` }}
/>
))}
</div>
</div>
</CardContent>
</Card>
</div>
);
}
return (
<div>
{error && (
<Alert variant="destructive" className="mb-4">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="grid grid-cols-2 gap-2 mt-1 mb-2"> <div className="grid grid-cols-2 gap-2 mt-1 mb-2">
{summaryCard( {summaryCard(
"Last 30 Minutes", "Last 30 Minutes",
@@ -245,7 +247,10 @@ const MiniRealtimeAnalytics = () => {
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
); );
};
return renderContent();
}; };
export default MiniRealtimeAnalytics; export default MiniRealtimeAnalytics;

View File

@@ -23,45 +23,38 @@ import { AlertCircle, TrendingUp, DollarSign, ShoppingCart, Truck, PiggyBank, Ar
import { formatCurrency, CustomTooltip, processData, StatCard } from "./SalesChart.jsx"; import { formatCurrency, CustomTooltip, processData, StatCard } from "./SalesChart.jsx";
const SkeletonChart = () => ( const SkeletonChart = () => (
<div className="h-[230px] w-full bg-gradient-to-br from-slate-700 to-slate-600 backdrop-blur-sm rounded-lg p-4"> <div className="h-[216px]">
<div className="h-full relative"> <div className="h-full w-full relative">
{/* Grid lines */} {/* Grid lines */}
{[...Array(5)].map((_, i) => ( {[...Array(5)].map((_, i) => (
<div <div
key={i} key={i}
className="absolute w-full h-px bg-slate-500" className="absolute w-full h-px bg-slate-600"
style={{ top: `${(i + 1) * 20}%` }} style={{ top: `${(i + 1) * 20}%` }}
/> />
))} ))}
{/* Y-axis labels */} {/* Y-axis labels */}
<div className="absolute left-0 top-0 bottom-0 w-8 flex flex-col justify-between py-4"> <div className="absolute left-0 top-0 bottom-0 w-8 flex flex-col justify-between py-4">
{[...Array(5)].map((_, i) => ( {[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-3 w-6 bg-slate-500 rounded-sm" /> <Skeleton key={i} className="h-3 w-6 bg-slate-600 rounded-sm" />
))} ))}
</div> </div>
{/* X-axis labels */} {/* X-axis labels */}
<div className="absolute left-8 right-4 bottom-0 flex justify-between"> <div className="absolute left-8 right-4 bottom-0 flex justify-between">
{[...Array(6)].map((_, i) => ( {[...Array(6)].map((_, i) => (
<Skeleton key={i} className="h-3 w-8 bg-slate-500 rounded-sm" /> <Skeleton key={i} className="h-3 w-8 bg-slate-600 rounded-sm" />
))} ))}
</div> </div>
{/* Chart lines */} {/* Chart lines */}
<div className="absolute inset-x-8 bottom-6 top-4"> <div className="absolute inset-x-8 bottom-6 top-4">
<div className="h-full w-full relative"> <div className="h-full w-full relative">
<div <div
className="absolute inset-0 bg-slate-500 rounded-sm" className="absolute inset-0 bg-slate-600 rounded-sm"
style={{ style={{
opacity: 0.5, opacity: 0.5,
clipPath: "polygon(0 50%, 100% 20%, 100% 100%, 0 100%)", clipPath: "polygon(0 50%, 100% 20%, 100% 100%, 0 100%)",
}} }}
/> />
<div
className="absolute inset-0 bg-slate-500 rounded-sm"
style={{
opacity: 0.3,
clipPath: "polygon(0 70%, 100% 40%, 100% 100%, 0 100%)",
}}
/>
</div> </div>
</div> </div>
</div> </div>
@@ -135,28 +128,24 @@ const MiniStatCard = memo(({
MiniStatCard.displayName = "MiniStatCard"; MiniStatCard.displayName = "MiniStatCard";
const SkeletonCard = ({ colorScheme = "emerald" }) => ( const SkeletonCard = ({ colorScheme = "emerald" }) => (
<Card className={`w-full h-[150px] bg-gradient-to-br from-slate-700 to-slate-600 backdrop-blur-sm`}> <Card className="w-full h-[150px] bg-gradient-to-br from-slate-700 to-slate-600 backdrop-blur-sm">
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
<CardTitle> <CardTitle>
<div className="space-y-2"> <div className="space-y-2">
<Skeleton className={`h-4 w-24 bg-${colorScheme}-300/20`} /> <Skeleton className={`h-4 w-24 bg-${colorScheme}-300`} />
</div> </div>
</CardTitle> </CardTitle>
<div className="relative p-2"> <div className="relative p-2">
<div className={`absolute inset-0 rounded-full bg-${colorScheme}-300/20`} /> <div className={`absolute inset-0 rounded-full bg-${colorScheme}-300`} />
<div className="h-5 w-5 relative rounded-full bg-${colorScheme}-300/20" /> <Skeleton className={`h-5 w-5 bg-${colorScheme}-300 relative rounded-full`} />
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="p-4 pt-0"> <CardContent className="p-4 pt-0">
<div className="space-y-4"> <div className="space-y-2">
<div className="space-y-2"> <Skeleton className={`h-8 w-20 bg-${colorScheme}-300`} />
<Skeleton className={`h-8 w-32 bg-${colorScheme}-300/20`} /> <div className="flex justify-between items-center">
<div className="flex justify-between items-center"> <Skeleton className={`h-4 w-24 bg-${colorScheme}-300`} />
<div className="space-y-1"> <Skeleton className={`h-4 w-12 bg-${colorScheme}-300 rounded-full`} />
<Skeleton className={`h-4 w-24 bg-${colorScheme}-300/20`} />
</div>
<Skeleton className={`h-6 w-16 bg-${colorScheme}-300/20 rounded-full`} />
</div>
</div> </div>
</div> </div>
</CardContent> </CardContent>
@@ -176,11 +165,26 @@ const MiniSalesChart = ({ className = "" }) => {
totalOrders: 0, totalOrders: 0,
prevRevenue: 0, prevRevenue: 0,
prevOrders: 0, prevOrders: 0,
growth: { periodProgress: 100
revenue: 0,
orders: 0
}
}); });
const [projection, setProjection] = useState(null);
const [projectionLoading, setProjectionLoading] = useState(false);
const fetchProjection = useCallback(async () => {
if (summaryStats.periodProgress >= 100) return;
try {
setProjectionLoading(true);
const response = await axios.get("/api/klaviyo/events/projection", {
params: { timeRange: "last30days" }
});
setProjection(response.data);
} catch (error) {
console.error("Error loading projection:", error);
} finally {
setProjectionLoading(false);
}
}, [summaryStats.periodProgress]);
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
try { try {
@@ -211,33 +215,30 @@ const MiniSalesChart = ({ className = "" }) => {
totalOrders: acc.totalOrders + (Number(day.orders) || 0), totalOrders: acc.totalOrders + (Number(day.orders) || 0),
prevRevenue: acc.prevRevenue + (Number(day.prevRevenue) || 0), prevRevenue: acc.prevRevenue + (Number(day.prevRevenue) || 0),
prevOrders: acc.prevOrders + (Number(day.prevOrders) || 0), prevOrders: acc.prevOrders + (Number(day.prevOrders) || 0),
periodProgress: day.periodProgress || 100,
}), { }), {
totalRevenue: 0, totalRevenue: 0,
totalOrders: 0, totalOrders: 0,
prevRevenue: 0, prevRevenue: 0,
prevOrders: 0 prevOrders: 0,
periodProgress: 100
}); });
// Calculate growth percentages
const growth = {
revenue: totals.prevRevenue > 0
? ((totals.totalRevenue - totals.prevRevenue) / totals.prevRevenue) * 100
: 0,
orders: totals.prevOrders > 0
? ((totals.totalOrders - totals.prevOrders) / totals.prevOrders) * 100
: 0
};
setData(processedData); setData(processedData);
setSummaryStats({ ...totals, growth }); setSummaryStats(totals);
setError(null); setError(null);
// Fetch projection if needed
if (totals.periodProgress < 100) {
fetchProjection();
}
} catch (error) { } catch (error) {
console.error("Error fetching data:", error); console.error("Error fetching data:", error);
setError(error.message); setError(error.message);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [fetchProjection]);
useEffect(() => { useEffect(() => {
fetchData(); fetchData();
@@ -305,11 +306,19 @@ const MiniSalesChart = ({ className = "" }) => {
title="30 Days Revenue" title="30 Days Revenue"
value={formatCurrency(summaryStats.totalRevenue, false)} value={formatCurrency(summaryStats.totalRevenue, false)}
previousValue={formatCurrency(summaryStats.prevRevenue, false)} previousValue={formatCurrency(summaryStats.prevRevenue, false)}
trend={summaryStats.growth.revenue >= 0 ? "up" : "down"} trend={
trendValue={`${Math.abs(Math.round(summaryStats.growth.revenue))}%`} summaryStats.periodProgress < 100
? ((projection?.projectedRevenue || summaryStats.totalRevenue) >= summaryStats.prevRevenue ? "up" : "down")
: (summaryStats.totalRevenue >= summaryStats.prevRevenue ? "up" : "down")
}
trendValue={
summaryStats.periodProgress < 100
? `${Math.abs(Math.round(((projection?.projectedRevenue || summaryStats.totalRevenue) - summaryStats.prevRevenue) / summaryStats.prevRevenue * 100))}%`
: `${Math.abs(Math.round(((summaryStats.totalRevenue - summaryStats.prevRevenue) / summaryStats.prevRevenue) * 100))}%`
}
colorClass="text-emerald-300" colorClass="text-emerald-300"
titleClass="text-emerald-300 font-bold text-md" titleClass="text-emerald-300 font-bold text-md"
descriptionClass="text-emerald-300 text-md font-semibold" descriptionClass="text-emerald-300 text-md font-semibold pb-1"
icon={PiggyBank} icon={PiggyBank}
iconColor="text-emerald-900" iconColor="text-emerald-900"
iconBackground="bg-emerald-300" iconBackground="bg-emerald-300"
@@ -320,11 +329,19 @@ const MiniSalesChart = ({ className = "" }) => {
title="30 Days Orders" title="30 Days Orders"
value={summaryStats.totalOrders.toLocaleString()} value={summaryStats.totalOrders.toLocaleString()}
previousValue={summaryStats.prevOrders.toLocaleString()} previousValue={summaryStats.prevOrders.toLocaleString()}
trend={summaryStats.growth.orders >= 0 ? "up" : "down"} trend={
trendValue={`${Math.abs(Math.round(summaryStats.growth.orders))}%`} summaryStats.periodProgress < 100
? ((Math.round(summaryStats.totalOrders * (100 / summaryStats.periodProgress))) >= summaryStats.prevOrders ? "up" : "down")
: (summaryStats.totalOrders >= summaryStats.prevOrders ? "up" : "down")
}
trendValue={
summaryStats.periodProgress < 100
? `${Math.abs(Math.round(((Math.round(summaryStats.totalOrders * (100 / summaryStats.periodProgress))) - summaryStats.prevOrders) / summaryStats.prevOrders * 100))}%`
: `${Math.abs(Math.round(((summaryStats.totalOrders - summaryStats.prevOrders) / summaryStats.prevOrders) * 100))}%`
}
colorClass="text-blue-300" colorClass="text-blue-300"
titleClass="text-blue-300 font-bold text-md" titleClass="text-blue-300 font-bold text-md"
descriptionClass="text-blue-300 text-md font-semibold" descriptionClass="text-blue-300 text-md font-semibold pb-1"
icon={Truck} icon={Truck}
iconColor="text-blue-900" iconColor="text-blue-900"
iconBackground="bg-blue-300" iconBackground="bg-blue-300"

View File

@@ -246,25 +246,45 @@ const MiniStatCards = ({
}, []); }, []);
const calculateRevenueTrend = useCallback(() => { const calculateRevenueTrend = useCallback(() => {
if (!stats?.prevPeriodRevenue && stats?.prevPeriodRevenue !== 0) if (!stats?.prevPeriodRevenue && stats?.prevPeriodRevenue !== 0) return null;
return null;
const currentRevenue = // If period is complete, use actual revenue
stats.periodProgress < 100 ? stats.projectedRevenue : stats.revenue; // If period is incomplete, use smart projection when available, fallback to simple projection
const prevRevenue = stats.prevPeriodRevenue; const currentRevenue = stats.periodProgress < 100
? (projection?.projectedRevenue || stats.projectedRevenue)
: stats.revenue;
const prevRevenue = stats.prevPeriodRevenue; // Previous period's total revenue
console.log('[MiniStatCards RevenueTrend Debug]', {
periodProgress: stats.periodProgress,
currentRevenue,
smartProjection: projection?.projectedRevenue,
simpleProjection: stats.projectedRevenue,
actualRevenue: stats.revenue,
prevRevenue,
isProjected: stats.periodProgress < 100
});
if (!currentRevenue || !prevRevenue) return null; if (!currentRevenue || !prevRevenue) return null;
// Calculate absolute difference percentage
const trend = currentRevenue >= prevRevenue ? "up" : "down"; const trend = currentRevenue >= prevRevenue ? "up" : "down";
const diff = Math.abs(currentRevenue - prevRevenue); const diff = Math.abs(currentRevenue - prevRevenue);
const percentage = (diff / prevRevenue) * 100; const percentage = (diff / prevRevenue) * 100;
console.log('[MiniStatCards RevenueTrend Result]', {
trend,
percentage,
calculation: `(|${currentRevenue} - ${prevRevenue}| / ${prevRevenue}) * 100 = ${percentage}%`
});
return { return {
trend, trend,
value: percentage, value: percentage,
current: currentRevenue, current: currentRevenue,
previous: prevRevenue, previous: prevRevenue,
}; };
}, [stats]); }, [stats, projection]);
const calculateOrderTrend = useCallback(() => { const calculateOrderTrend = useCallback(() => {
if (!stats?.prevPeriodOrders) return null; if (!stats?.prevPeriodOrders) return null;

View File

@@ -342,16 +342,8 @@ const calculateSummaryStats = (data = []) => {
return best; return best;
}, null); }, null);
// Calculate growth percentages // Get period progress from the last day
const growth = { const periodProgress = data[data.length - 1]?.periodProgress || 100;
revenue: prevRevenue
? ((totalRevenue - prevRevenue) / prevRevenue) * 100
: 0,
orders: prevOrders ? ((totalOrders - prevOrders) / prevOrders) * 100 : 0,
avgOrderValue: prevAvgOrderValue
? ((avgOrderValue - prevAvgOrderValue) / prevAvgOrderValue) * 100
: 0,
};
return { return {
totalRevenue, totalRevenue,
@@ -361,7 +353,7 @@ const calculateSummaryStats = (data = []) => {
prevRevenue, prevRevenue,
prevOrders, prevOrders,
prevAvgOrderValue, prevAvgOrderValue,
growth, periodProgress,
movingAverages: { movingAverages: {
revenue: data[data.length - 1]?.movingAverage || 0, revenue: data[data.length - 1]?.movingAverage || 0,
orders: data[data.length - 1]?.orderMovingAverage || 0, orders: data[data.length - 1]?.orderMovingAverage || 0,
@@ -371,7 +363,7 @@ const calculateSummaryStats = (data = []) => {
}; };
// Add memoized SummaryStats component // Add memoized SummaryStats component
const SummaryStats = memo(({ stats = {} }) => { const SummaryStats = memo(({ stats = {}, projection = null, projectionLoading = false }) => {
const { const {
totalRevenue = 0, totalRevenue = 0,
totalOrders = 0, totalOrders = 0,
@@ -380,17 +372,39 @@ const SummaryStats = memo(({ stats = {} }) => {
prevRevenue = 0, prevRevenue = 0,
prevOrders = 0, prevOrders = 0,
prevAvgOrderValue = 0, prevAvgOrderValue = 0,
growth = { revenue: 0, orders: 0, avgOrderValue: 0 }, periodProgress = 100
} = stats; } = 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;
// 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;
return ( return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4 max-w-3xl"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4 max-w-3xl">
<StatCard <StatCard
title="Total Revenue" title="Total Revenue"
value={formatCurrency(totalRevenue, false)} value={formatCurrency(totalRevenue, false)}
description={`Previous: ${formatCurrency(prevRevenue, false)}`} description={
trend={growth.revenue >= 0 ? "up" : "down"} periodProgress < 100
trendValue={formatPercentage(growth.revenue)} ? `Projected: ${formatCurrency(projection?.projectedRevenue || totalRevenue, false)}`
: `Previous: ${formatCurrency(prevRevenue, false)}`
}
trend={projectionLoading && periodProgress < 100 ? undefined : revenueTrend}
trendValue={projectionLoading && periodProgress < 100 ? null : formatPercentage(revenuePercentage)}
info="Total revenue for the selected period" info="Total revenue for the selected period"
colorClass="text-green-600 dark:text-green-400" colorClass="text-green-600 dark:text-green-400"
/> />
@@ -398,9 +412,13 @@ const SummaryStats = memo(({ stats = {} }) => {
<StatCard <StatCard
title="Total Orders" title="Total Orders"
value={totalOrders.toLocaleString()} value={totalOrders.toLocaleString()}
description={`Previous: ${prevOrders.toLocaleString()} orders`} description={
trend={growth.orders >= 0 ? "up" : "down"} periodProgress < 100
trendValue={formatPercentage(growth.orders)} ? `Projected: ${(projection?.projectedOrders || totalOrders).toLocaleString()}`
: `Previous: ${prevOrders.toLocaleString()}`
}
trend={projectionLoading && periodProgress < 100 ? undefined : ordersTrend}
trendValue={projectionLoading && periodProgress < 100 ? null : formatPercentage(ordersPercentage)}
info="Total number of orders for the selected period" info="Total number of orders for the selected period"
colorClass="text-blue-600 dark:text-blue-400" colorClass="text-blue-600 dark:text-blue-400"
/> />
@@ -408,9 +426,13 @@ const SummaryStats = memo(({ stats = {} }) => {
<StatCard <StatCard
title="AOV" title="AOV"
value={formatCurrency(avgOrderValue)} value={formatCurrency(avgOrderValue)}
description={`Previous: ${formatCurrency(prevAvgOrderValue)}`} description={
trend={growth.avgOrderValue >= 0 ? "up" : "down"} periodProgress < 100
trendValue={formatPercentage(growth.avgOrderValue)} ? `Projected: ${formatCurrency(currentAOV)}`
: `Previous: ${formatCurrency(prevAvgOrderValue)}`
}
trend={projectionLoading && periodProgress < 100 ? undefined : aovTrend}
trendValue={projectionLoading && periodProgress < 100 ? null : formatPercentage(aovPercentage)}
info="Average value per order for the selected period" info="Average value per order for the selected period"
colorClass="text-purple-600 dark:text-purple-400" colorClass="text-purple-600 dark:text-purple-400"
/> />
@@ -519,6 +541,25 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
showPrevious: false, showPrevious: false,
}); });
const [summaryStats, setSummaryStats] = useState({}); const [summaryStats, setSummaryStats] = useState({});
const [projection, setProjection] = useState(null);
const [projectionLoading, setProjectionLoading] = useState(false);
// Add function to fetch projection
const fetchProjection = useCallback(async (params) => {
if (!params) return;
try {
setProjectionLoading(true);
const response = await axios.get("/api/klaviyo/events/projection", {
params,
});
setProjection(response.data);
} catch (error) {
console.error("Error loading projection:", error);
} finally {
setProjectionLoading(false);
}
}, []);
// Fetch data function // Fetch data function
const fetchData = useCallback(async (params) => { const fetchData = useCallback(async (params) => {
@@ -551,13 +592,18 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
setData(processedData); setData(processedData);
setSummaryStats(stats); setSummaryStats(stats);
setError(null); setError(null);
// Fetch projection if needed
if (stats.periodProgress < 100) {
fetchProjection(params);
}
} catch (error) { } catch (error) {
console.error("Error fetching data:", error); console.error("Error fetching data:", error);
setError(error.message); setError(error.message);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [fetchProjection]);
// Handle time range change // Handle time range change
const handleTimeRangeChange = useCallback( const handleTimeRangeChange = useCallback(
@@ -832,7 +878,11 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
(loading ? ( (loading ? (
<SkeletonStats /> <SkeletonStats />
) : ( ) : (
<SummaryStats stats={summaryStats} /> <SummaryStats
stats={summaryStats}
projection={projection}
projectionLoading={projectionLoading}
/>
))} ))}
{/* Show metric toggles only if not in error state */} {/* Show metric toggles only if not in error state */}

View File

@@ -1256,6 +1256,98 @@ const SkeletonTable = ({ rows = 8 }) => (
</div> </div>
); );
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
} = 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;
// Calculate order trends
const currentOrders = periodProgress < 100 ? Math.round(totalOrders * (100 / periodProgress)) : 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;
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4 max-w-3xl">
<StatCard
title="Total Revenue"
value={formatCurrency(totalRevenue, false)}
description={
periodProgress < 100
? `Projected: ${formatCurrency(currentRevenue, false)}`
: `Previous: ${formatCurrency(prevRevenue, false)}`
}
trend={projectionLoading && periodProgress < 100 ? undefined : revenueTrend}
trendValue={projectionLoading && periodProgress < 100 ? null : formatPercentage(revenuePercentage)}
info="Total revenue for the selected period"
colorClass="text-green-600 dark:text-green-400"
/>
<StatCard
title="Total Orders"
value={totalOrders.toLocaleString()}
description={
periodProgress < 100
? `Projected: ${currentOrders.toLocaleString()}`
: `Previous: ${prevOrders.toLocaleString()}`
}
trend={projectionLoading && periodProgress < 100 ? undefined : ordersTrend}
trendValue={projectionLoading && periodProgress < 100 ? null : formatPercentage(ordersPercentage)}
info="Total number of orders for the selected period"
colorClass="text-blue-600 dark:text-blue-400"
/>
<StatCard
title="AOV"
value={formatCurrency(avgOrderValue)}
description={
periodProgress < 100
? `Projected: ${formatCurrency(currentAOV)}`
: `Previous: ${formatCurrency(prevAvgOrderValue)}`
}
trend={projectionLoading && periodProgress < 100 ? undefined : aovTrend}
trendValue={projectionLoading && periodProgress < 100 ? null : formatPercentage(aovPercentage)}
info="Average value per order for the selected period"
colorClass="text-purple-600 dark:text-purple-400"
/>
<StatCard
title="Best Day"
value={formatCurrency(bestDay?.revenue || 0, false)}
description={
bestDay?.timestamp
? `${new Date(bestDay.timestamp).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})} - ${bestDay.orders} orders`
: "No data"
}
info="Day with highest revenue in the selected period"
colorClass="text-orange-600 dark:text-orange-400"
/>
</div>
);
});
const StatCards = ({ const StatCards = ({
timeRange: initialTimeRange = "today", timeRange: initialTimeRange = "today",
startDate, startDate,
@@ -1476,28 +1568,45 @@ const StatCards = ({
}, []); }, []);
const calculateRevenueTrend = useCallback(() => { const calculateRevenueTrend = useCallback(() => {
if (!stats?.prevPeriodRevenue && stats?.prevPeriodRevenue !== 0) if (!stats?.prevPeriodRevenue && stats?.prevPeriodRevenue !== 0) return null;
return null;
// For incomplete periods, compare projected revenue to previous period // If period is complete, use actual revenue
// For complete periods, compare actual revenue to previous period // If period is incomplete, use smart projection when available, fallback to simple projection
const currentRevenue = const currentRevenue = stats.periodProgress < 100
stats.periodProgress < 100 ? stats.projectedRevenue : stats.revenue; ? (projection?.projectedRevenue || stats.projectedRevenue)
const prevRevenue = stats.prevPeriodRevenue; : stats.revenue;
const prevRevenue = stats.prevPeriodRevenue; // Previous period's total revenue
console.log('[RevenueTrend Debug]', {
periodProgress: stats.periodProgress,
currentRevenue,
smartProjection: projection?.projectedRevenue,
simpleProjection: stats.projectedRevenue,
actualRevenue: stats.revenue,
prevRevenue,
isProjected: stats.periodProgress < 100
});
if (!currentRevenue || !prevRevenue) return null; if (!currentRevenue || !prevRevenue) return null;
// Calculate absolute difference percentage
const trend = currentRevenue >= prevRevenue ? "up" : "down"; const trend = currentRevenue >= prevRevenue ? "up" : "down";
const diff = Math.abs(currentRevenue - prevRevenue); const diff = Math.abs(currentRevenue - prevRevenue);
const percentage = (diff / prevRevenue) * 100; const percentage = (diff / prevRevenue) * 100;
console.log('[RevenueTrend Result]', {
trend,
percentage,
calculation: `(|${currentRevenue} - ${prevRevenue}| / ${prevRevenue}) * 100 = ${percentage}%`
});
return { return {
trend, trend,
value: percentage, value: percentage,
current: currentRevenue, current: currentRevenue,
previous: prevRevenue, previous: prevRevenue,
}; };
}, [stats]); }, [stats, projection]);
const calculateOrderTrend = useCallback(() => { const calculateOrderTrend = useCallback(() => {
if (!stats?.prevPeriodOrders) return null; if (!stats?.prevPeriodOrders) return null;