Compare commits
10 Commits
d636c29f69
...
225e63a985
| Author | SHA1 | Date | |
|---|---|---|---|
| 225e63a985 | |||
| 254a9a6511 | |||
| abe43c03b7 | |||
| 8ad566c7f4 | |||
| 304d09e3c4 | |||
| 5063120731 | |||
| 1b797eecaf | |||
| 194ac96732 | |||
| a2eb5bfcd7 | |||
| e1f12539d3 |
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -1339,58 +1339,62 @@ export class EventsService {
|
||||
event.attributes?.metric_id;
|
||||
|
||||
// Extract properties from all possible locations
|
||||
const rawProps = {
|
||||
...(event.attributes?.event_properties || {}),
|
||||
...(event.attributes?.properties || {}),
|
||||
...(event.attributes?.profile || {}),
|
||||
value: event.attributes?.value,
|
||||
datetime: event.attributes?.datetime
|
||||
};
|
||||
const rawProps = event.attributes?.event_properties || {};
|
||||
|
||||
// Only log for shipped orders and only show relevant fields
|
||||
if (event.relationships?.metric?.data?.id === METRIC_IDS.SHIPPED_ORDER) {
|
||||
console.log('[EventsService] Shipped Order:', {
|
||||
orderId: rawProps.OrderId,
|
||||
shippedBy: rawProps.ShippedBy,
|
||||
datetime: event.attributes?.datetime
|
||||
});
|
||||
}
|
||||
|
||||
// Normalize shipping data
|
||||
const shippingData = {
|
||||
name: rawProps.ShippingName || rawProps.shipping_name || rawProps.shipping?.name,
|
||||
street1: rawProps.ShippingStreet1 || rawProps.shipping_street1 || rawProps.shipping?.street1,
|
||||
street2: rawProps.ShippingStreet2 || rawProps.shipping_street2 || rawProps.shipping?.street2,
|
||||
city: rawProps.ShippingCity || rawProps.shipping_city || rawProps.shipping?.city,
|
||||
state: rawProps.ShippingState || rawProps.shipping_state || rawProps.shipping?.state,
|
||||
zip: rawProps.ShippingZip || rawProps.shipping_zip || rawProps.shipping?.zip,
|
||||
country: rawProps.ShippingCountry || rawProps.shipping_country || rawProps.shipping?.country,
|
||||
method: rawProps.ShipMethod || rawProps.shipping_method || rawProps.shipping?.method,
|
||||
tracking: rawProps.TrackingNumber || rawProps.tracking_number
|
||||
ShippingName: rawProps.ShippingName,
|
||||
ShippingStreet1: rawProps.ShippingStreet1,
|
||||
ShippingStreet2: rawProps.ShippingStreet2,
|
||||
ShippingCity: rawProps.ShippingCity,
|
||||
ShippingState: rawProps.ShippingState,
|
||||
ShippingZip: rawProps.ShippingZip,
|
||||
ShippingCountry: rawProps.ShippingCountry,
|
||||
ShipMethod: rawProps.ShipMethod,
|
||||
TrackingNumber: rawProps.TrackingNumber,
|
||||
ShippedBy: rawProps.ShippedBy
|
||||
};
|
||||
|
||||
// Normalize payment data
|
||||
const paymentData = {
|
||||
method: rawProps.PaymentMethod || rawProps.payment_method || rawProps.payment?.method,
|
||||
name: rawProps.PaymentName || rawProps.payment_name || rawProps.payment?.name,
|
||||
amount: Number(rawProps.PaymentAmount || rawProps.payment_amount || rawProps.payment?.amount || 0)
|
||||
method: rawProps.PaymentMethod,
|
||||
name: rawProps.PaymentName,
|
||||
amount: Number(rawProps.PaymentAmount || 0)
|
||||
};
|
||||
|
||||
// Normalize order flags
|
||||
const orderFlags = {
|
||||
type: rawProps.OrderType || rawProps.order_type || 'standard',
|
||||
hasPreorder: Boolean(rawProps.HasPreorder || rawProps.has_preorder || rawProps.preorder),
|
||||
localPickup: Boolean(rawProps.LocalPickup || rawProps.local_pickup || rawProps.pickup),
|
||||
isOnHold: Boolean(rawProps.IsOnHold || rawProps.is_on_hold || rawProps.on_hold),
|
||||
hasDigiItem: Boolean(rawProps.HasDigiItem || rawProps.has_digital_item || rawProps.digital_item),
|
||||
hasNotions: Boolean(rawProps.HasNotions || rawProps.has_notions || rawProps.notions),
|
||||
hasDigitalGC: Boolean(rawProps.HasDigitalGC || rawProps.has_digital_gc || rawProps.gift_card),
|
||||
stillOwes: Boolean(rawProps.StillOwes || rawProps.still_owes || rawProps.balance_due)
|
||||
type: rawProps.OrderType || 'standard',
|
||||
hasPreorder: Boolean(rawProps.HasPreorder),
|
||||
localPickup: Boolean(rawProps.LocalPickup),
|
||||
isOnHold: Boolean(rawProps.IsOnHold),
|
||||
hasDigiItem: Boolean(rawProps.HasDigiItem),
|
||||
hasNotions: Boolean(rawProps.HasNotions),
|
||||
hasDigitalGC: Boolean(rawProps.HasDigitalGC),
|
||||
stillOwes: Boolean(rawProps.StillOwes)
|
||||
};
|
||||
|
||||
// Normalize refund/cancel data
|
||||
const refundData = {
|
||||
reason: rawProps.CancelReason || rawProps.cancel_reason || rawProps.reason,
|
||||
message: rawProps.CancelMessage || rawProps.cancel_message || rawProps.message,
|
||||
orderMessage: rawProps.OrderMessage || rawProps.order_message || rawProps.note
|
||||
reason: rawProps.CancelReason,
|
||||
message: rawProps.CancelMessage,
|
||||
orderMessage: rawProps.OrderMessage
|
||||
};
|
||||
|
||||
// Transform items
|
||||
const items = this._transformItems(rawProps.Items || rawProps.items || rawProps.line_items || []);
|
||||
const items = this._transformItems(rawProps.Items || []);
|
||||
|
||||
// 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 transformed = {
|
||||
@@ -1408,29 +1412,10 @@ export class EventsService {
|
||||
},
|
||||
relationships: event.relationships,
|
||||
event_properties: {
|
||||
// Basic properties
|
||||
EmailAddress: rawProps.EmailAddress || rawProps.email,
|
||||
FirstName: rawProps.FirstName || rawProps.first_name,
|
||||
LastName: rawProps.LastName || rawProps.last_name,
|
||||
OrderId: rawProps.OrderId || rawProps.FromOrder || rawProps.order_id,
|
||||
...rawProps, // Include all original properties
|
||||
Items: items, // Override with transformed items
|
||||
TotalAmount: totalAmount,
|
||||
ItemCount: itemCount,
|
||||
Items: items,
|
||||
|
||||
// Shipping information
|
||||
...shippingData,
|
||||
|
||||
// Payment information
|
||||
...paymentData,
|
||||
|
||||
// Order flags
|
||||
...orderFlags,
|
||||
|
||||
// Refund/cancel information
|
||||
...refundData,
|
||||
|
||||
// Original properties (for backward compatibility)
|
||||
...rawProps
|
||||
ItemCount: itemCount
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2112,21 +2097,43 @@ export class EventsService {
|
||||
const currentHour = now.hour;
|
||||
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)
|
||||
const hourProgress = currentMinute / 60;
|
||||
|
||||
// Calculate how much of the expected daily revenue we've seen so far
|
||||
let expectedPercentageSeen = 0;
|
||||
for (let i = 0; i < currentHour; i++) {
|
||||
expectedPercentageSeen += hourlyPatterns[i].percentage;
|
||||
let totalDayPercentage = 0;
|
||||
|
||||
// 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
|
||||
let projectedRevenue = 0;
|
||||
if (expectedPercentageSeen > 0) {
|
||||
projectedRevenue = (currentRevenue / (expectedPercentageSeen / 100));
|
||||
projectedRevenue = (currentRevenue / (expectedPercentageSeen / totalDayPercentage));
|
||||
}
|
||||
|
||||
// Calculate confidence score (0-1) based on:
|
||||
@@ -2134,8 +2141,19 @@ export class EventsService {
|
||||
// 2. How consistent the patterns are
|
||||
// 3. How far through the period we are
|
||||
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 = (
|
||||
(patternConsistency * 0.4) +
|
||||
@@ -2154,8 +2172,11 @@ export class EventsService {
|
||||
historicalOrders: totalHistoricalOrders,
|
||||
hourlyPatterns,
|
||||
expectedPercentageSeen,
|
||||
totalDayPercentage,
|
||||
currentHour,
|
||||
currentMinute
|
||||
currentMinute,
|
||||
isInEdgeCase,
|
||||
adjustedCurrentHour
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@@ -122,35 +122,37 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
|
||||
if (!weatherCode) return <CircleAlert className="w-12 h-12 text-red-500" />;
|
||||
const code = parseInt(weatherCode, 10);
|
||||
const iconProps = small ? "w-8 h-8" : "w-12 h-12";
|
||||
const isNight = currentTime.getHours() >= 18 || currentTime.getHours() < 6;
|
||||
|
||||
switch (true) {
|
||||
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:
|
||||
return <CloudDrizzle className={cn(iconProps, "text-blue-600")} />;
|
||||
return <CloudDrizzle className={cn(iconProps, "text-blue-300")} />;
|
||||
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:
|
||||
return <CloudSnow className={cn(iconProps, "text-blue-400")} />;
|
||||
return <CloudSnow className={cn(iconProps, "text-blue-200")} />;
|
||||
case code >= 700 && code < 721:
|
||||
return <CloudFog className={cn(iconProps, "text-gray-600")} />;
|
||||
return <CloudFog className={cn(iconProps, "text-gray-300")} />;
|
||||
case code === 721:
|
||||
return <Haze className={cn(iconProps, "text-gray-700")} />;
|
||||
return <Haze className={cn(iconProps, "text-gray-300")} />;
|
||||
case code >= 722 && code < 781:
|
||||
return <CloudFog className={cn(iconProps, "text-gray-600")} />;
|
||||
return <CloudFog className={cn(iconProps, "text-gray-300")} />;
|
||||
case code === 781:
|
||||
return <Tornado className={cn(iconProps, "text-gray-700")} />;
|
||||
return <Tornado className={cn(iconProps, "text-gray-300")} />;
|
||||
case code === 800:
|
||||
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:
|
||||
return <CloudSun className={cn(iconProps, "text-gray-600")} />;
|
||||
return <CloudSun className={cn(iconProps, isNight ? "text-gray-300" : "text-gray-200")} />;
|
||||
case code >= 803:
|
||||
return <Cloud className={cn(iconProps, "text-gray-200")} />;
|
||||
return <Cloud className={cn(iconProps, "text-gray-300")} />;
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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.)
|
||||
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)
|
||||
if (code === 800) {
|
||||
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)
|
||||
if (code > 800) {
|
||||
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
|
||||
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 code = parseInt(weatherCode, 10);
|
||||
|
||||
// Use dark text for light backgrounds
|
||||
if (code >= 600 && code < 700) { // Snow
|
||||
return "text-slate-900";
|
||||
// Snow - dark background, light text
|
||||
if (code >= 600 && code < 700) {
|
||||
return "text-white";
|
||||
}
|
||||
|
||||
if (code === 800 && !isNight) { // Clear day
|
||||
return "text-slate-900";
|
||||
// Clear day - light background, dark text
|
||||
if (code === 800 && !isNight) {
|
||||
return "text-white";
|
||||
}
|
||||
|
||||
if (code > 800 && !isNight) { // Cloudy day
|
||||
return "text-slate-900";
|
||||
// Cloudy day - medium background, ensure contrast
|
||||
if (code > 800 && !isNight) {
|
||||
return "text-white";
|
||||
}
|
||||
|
||||
// Default to white text for all other (darker) backgrounds
|
||||
// All other cases (darker backgrounds)
|
||||
return "text-white";
|
||||
};
|
||||
|
||||
@@ -236,213 +240,217 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
|
||||
};
|
||||
|
||||
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 className="space-y-4 p-3 bg-gradient-to-br from-slate-800 to-slate-700">
|
||||
<div className="grid grid-cols-3 gap-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">
|
||||
<ThermometerSun className="w-5 h-5 text-yellow-300" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-slate-300">High</span>
|
||||
<span className="text-sm font-bold text-white">{Math.round(weather.main.temp_max)}°F</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</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>
|
||||
<Card className="bg-gradient-to-br from-slate-700 to-slate-600 backdrop-blur-sm p-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<ThermometerSnowflake className="w-5 h-5 text-blue-300" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-slate-300">Low</span>
|
||||
<span className="text-sm font-bold text-white">{Math.round(weather.main.temp_min)}°F</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</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>
|
||||
<Card className="bg-gradient-to-br from-slate-700 to-slate-600 backdrop-blur-sm p-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Droplets className="w-5 h-5 text-blue-300" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-slate-300">Humidity</span>
|
||||
<span className="text-sm font-bold text-white">{weather.main.humidity}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</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>
|
||||
<Card className="bg-gradient-to-br from-slate-700 to-slate-600 backdrop-blur-sm p-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Wind className="w-5 h-5 text-slate-300" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-slate-300">Wind</span>
|
||||
<span className="text-sm font-bold text-white">{Math.round(weather.wind.speed)} mph</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</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>
|
||||
<Card className="bg-gradient-to-br from-slate-700 to-slate-600 backdrop-blur-sm p-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Sunrise className="w-5 h-5 text-yellow-300" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-slate-300">Sunrise</span>
|
||||
<span className="text-sm font-bold text-white">{formatTime(weather.sys?.sunrise)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</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>
|
||||
<Card className="bg-gradient-to-br from-slate-700 to-slate-600 backdrop-blur-sm p-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Sunset className="w-5 h-5 text-orange-300" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-slate-300">Sunset</span>
|
||||
<span className="text-sm font-bold text-white">{formatTime(weather.sys?.sunset)}</span>
|
||||
</div>
|
||||
</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-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>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center w-full transition-opacity duration-300 ${mounted ? 'opacity-100' : 'opacity-0'}">
|
||||
{/* Time Display */}
|
||||
<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">
|
||||
<CardContent className="p-3 h-[106px] flex items-center">
|
||||
<div className="flex justify-center items-baseline w-full">
|
||||
<div className={`transition-opacity duration-200 ${isTimeChanging ? 'opacity-60' : 'opacity-100'}`}>
|
||||
<span className="text-7xl font-bold text-white">{hours}</span>
|
||||
<span className="text-7xl font-bold text-white">:</span>
|
||||
<span className="text-7xl font-bold text-white">{minutes}</span>
|
||||
<span className="text-xl font-medium text-white/90 ml-1">{ampm}</span>
|
||||
{forecast && (
|
||||
<div>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{forecast.map((day, index) => {
|
||||
const forecastTime = new Date(day.dt * 1000);
|
||||
const isNight = forecastTime.getHours() >= 18 || forecastTime.getHours() < 6;
|
||||
return (
|
||||
<Card
|
||||
key={index}
|
||||
className={cn(
|
||||
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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
{/* Date and Weather Display */}
|
||||
<div className="h-[125px] mb-[6px] grid grid-cols-2 gap-2 w-full">
|
||||
<Card className="h-full bg-gradient-to-br from-slate-900 via-violet-800 to-purple-800 flex items-center justify-center">
|
||||
<CardContent className="h-full p-0">
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<span className="text-7xl font-bold text-white">
|
||||
{dateInfo.day}
|
||||
</span>
|
||||
<span className="text-sm font-bold text-white mt-2">
|
||||
{dateInfo.weekday}
|
||||
</span>
|
||||
return (
|
||||
<div className="flex flex-col items-center w-full transition-opacity duration-300 ${mounted ? 'opacity-100' : 'opacity-0'}">
|
||||
{/* Time Display */}
|
||||
<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">
|
||||
<CardContent className="p-3 h-[106px] flex items-center">
|
||||
<div className="flex justify-center items-baseline w-full">
|
||||
<div className={`transition-opacity duration-200 ${isTimeChanging ? 'opacity-60' : 'opacity-100'}`}>
|
||||
<span className="text-6xl font-bold text-white">{hours}</span>
|
||||
<span className="text-6xl font-bold text-white">:</span>
|
||||
<span className="text-6xl font-bold text-white">{minutes}</span>
|
||||
<span className="text-lg font-medium text-white/90 ml-1">{ampm}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{weather?.main && (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Card className={cn(
|
||||
getWeatherBackground(
|
||||
weather.weather[0]?.id,
|
||||
datetime.getHours() >= 18 || datetime.getHours() < 6
|
||||
),
|
||||
"flex items-center justify-center cursor-pointer hover:brightness-110 transition-all relative"
|
||||
)}>
|
||||
<CardContent className="h-full p-3">
|
||||
<div className="flex flex-col items-center">
|
||||
{getWeatherIcon(weather.weather[0]?.id, datetime)}
|
||||
<span className={cn(
|
||||
"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>
|
||||
{/* Date and Weather Display */}
|
||||
<div className="h-[125px] mb-[6px] grid grid-cols-2 gap-2 w-full">
|
||||
<Card className="h-full bg-gradient-to-br from-violet-900 to-purple-800 backdrop-blur-sm flex items-center justify-center">
|
||||
<CardContent className="h-full p-0">
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<span className="text-6xl font-bold text-white">
|
||||
{dateInfo.day}
|
||||
</span>
|
||||
<span className="text-sm font-bold text-white mt-2">
|
||||
{dateInfo.weekday}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Calendar Display */}
|
||||
<Card className="w-full">
|
||||
<CardContent className="p-0">
|
||||
<CalendarComponent
|
||||
selected={datetime}
|
||||
className="w-full"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
{weather?.main && (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Card className={cn(
|
||||
getWeatherBackground(
|
||||
weather.weather[0]?.id,
|
||||
datetime.getHours() >= 18 || datetime.getHours() < 6
|
||||
),
|
||||
"flex items-center justify-center cursor-pointer hover:brightness-110 transition-all relative backdrop-blur-sm"
|
||||
)}>
|
||||
<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;
|
||||
@@ -679,72 +679,26 @@ const EventDialog = ({ event, children }) => {
|
||||
|
||||
{event.metric_id === METRIC_IDS.SHIPPED_ORDER && (
|
||||
<>
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Shipping Address</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1">
|
||||
<p className="text-sm font-medium">{details.ShippingName}</p>
|
||||
{details.ShippingStreet1 && (
|
||||
<p className="text-sm text-muted-foreground">{details.ShippingStreet1}</p>
|
||||
)}
|
||||
{details.ShippingStreet2 && (
|
||||
<p className="text-sm text-muted-foreground">{details.ShippingStreet2}</p>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{details.ShippingCity}, {details.ShippingState} {details.ShippingZip}
|
||||
</p>
|
||||
{details.ShippingCountry !== "US" && (
|
||||
<p className="text-sm text-muted-foreground">{details.ShippingCountry}</p>
|
||||
)}
|
||||
</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 className="mt-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{toTitleCase(details.ShippingName)}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">•</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
#{details.OrderId}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{formatShipMethodSimple(details.ShipMethod)}
|
||||
{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>
|
||||
|
||||
<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">
|
||||
{toTitleCase(details.ShippingName)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<span className="text-sm text-gray-500">•</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
#{details.OrderId}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">•</span>
|
||||
<span className="font-medium text-blue-600 dark:text-blue-400">
|
||||
{formatShipMethodSimple(details.ShipMethod)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{formatShipMethodSimple(details.ShipMethod)}
|
||||
{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>
|
||||
</>
|
||||
@@ -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) => ({
|
||||
...event,
|
||||
datetime: event.attributes?.datetime || event.datetime,
|
||||
event_properties: {
|
||||
...event.event_properties,
|
||||
datetime: event.attributes?.datetime || event.datetime,
|
||||
},
|
||||
// Don't spread event_properties to preserve the nested structure
|
||||
event_properties: event.attributes?.event_properties || {}
|
||||
}));
|
||||
|
||||
setEvents(processedEvents);
|
||||
|
||||
@@ -18,11 +18,14 @@ import {
|
||||
Activity,
|
||||
AlertCircle,
|
||||
FileText,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { EventDialog } from "./EventFeed.jsx";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const METRIC_IDS = {
|
||||
PLACED_ORDER: "Y8cqcF",
|
||||
@@ -247,6 +250,11 @@ const EventCard = ({ event }) => {
|
||||
#{details.OrderId} • {formatShipMethodSimple(details.ShipMethod)}
|
||||
</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 [lastUpdate, setLastUpdate] = useState(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 () => {
|
||||
try {
|
||||
@@ -338,10 +374,7 @@ const MiniEventFeed = ({
|
||||
const processedEvents = (response.data.data || []).map((event) => ({
|
||||
...event,
|
||||
datetime: event.attributes?.datetime || event.datetime,
|
||||
event_properties: {
|
||||
...event.event_properties,
|
||||
datetime: event.attributes?.datetime || event.datetime,
|
||||
},
|
||||
event_properties: event.attributes?.event_properties || {}
|
||||
}));
|
||||
|
||||
setEvents(processedEvents);
|
||||
@@ -354,6 +387,7 @@ const MiniEventFeed = ({
|
||||
left: scrollRef.current.scrollWidth,
|
||||
behavior: 'instant'
|
||||
});
|
||||
handleScroll();
|
||||
}, 0);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -366,41 +400,67 @@ const MiniEventFeed = ({
|
||||
|
||||
useEffect(() => {
|
||||
fetchEvents();
|
||||
const interval = setInterval(fetchEvents, 60000);
|
||||
const interval = setInterval(fetchEvents, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchEvents]);
|
||||
|
||||
useEffect(() => {
|
||||
handleScroll();
|
||||
}, [events]);
|
||||
|
||||
return (
|
||||
<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">
|
||||
<div className="px-1 pt-2 pb-3">
|
||||
<div 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.map((event) => (
|
||||
<EventCard
|
||||
key={event.id}
|
||||
event={event}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
<Card className="bg-gradient-to-br rounded-none from-gray-900 to-gray-600 backdrop-blur">
|
||||
<div className="px-1 pt-2 pb-3 relative">
|
||||
{showLeftArrow && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
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"
|
||||
onClick={scrollToStart}
|
||||
>
|
||||
<ChevronLeft className="text-white" />
|
||||
</Button>
|
||||
)}
|
||||
{showRightArrow && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
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"
|
||||
onClick={scrollToEnd}
|
||||
>
|
||||
<ChevronRight className="text-white" />
|
||||
</Button>
|
||||
)}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
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>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,35 @@ import {
|
||||
SkeletonBarChart,
|
||||
processBasicData,
|
||||
} 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 [basicData, setBasicData] = useState({
|
||||
@@ -76,95 +105,68 @@ const MiniRealtimeAnalytics = () => {
|
||||
};
|
||||
}, [isPaused]);
|
||||
|
||||
if (loading && !basicData) {
|
||||
return (
|
||||
<div>
|
||||
<div className="grid grid-cols-2 gap-2 mt-1 mb-2">
|
||||
<Card className="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 className="text-sky-100 font-bold text-md">
|
||||
<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" />
|
||||
<Skeleton className="h-5 w-5 bg-sky-700 relative rounded-full" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-20 bg-sky-700" />
|
||||
<Skeleton className="h-4 w-32 bg-sky-700" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="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 className="text-sky-100 font-bold text-md">
|
||||
<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" />
|
||||
<Skeleton className="h-5 w-5 bg-sky-700 relative rounded-full" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-20 bg-sky-700" />
|
||||
<Skeleton className="h-4 w-32 bg-sky-700" />
|
||||
const renderContent = () => {
|
||||
if (error) {
|
||||
return (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div>
|
||||
<div className="grid grid-cols-2 gap-2 mt-1 mb-2">
|
||||
<SkeletonCard colorScheme="sky" />
|
||||
<SkeletonCard colorScheme="sky" />
|
||||
</div>
|
||||
|
||||
<Card className="bg-gradient-to-br from-sky-900 to-sky-800 backdrop-blur-sm">
|
||||
<CardContent className="p-4">
|
||||
<div className="h-[216px]">
|
||||
<div className="h-full w-full relative">
|
||||
{/* Grid lines */}
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute w-full h-px bg-sky-300/20"
|
||||
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-300/20 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-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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
<Card className="bg-gradient-to-br from-sky-900 to-sky-800 backdrop-blur-sm">
|
||||
<CardContent className="p-4">
|
||||
<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>
|
||||
)}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="grid grid-cols-2 gap-2 mt-1 mb-2">
|
||||
{summaryCard(
|
||||
"Last 30 Minutes",
|
||||
@@ -245,7 +247,10 @@ const MiniRealtimeAnalytics = () => {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
return renderContent();
|
||||
};
|
||||
|
||||
export default MiniRealtimeAnalytics;
|
||||
@@ -23,45 +23,38 @@ import { AlertCircle, TrendingUp, DollarSign, ShoppingCart, Truck, PiggyBank, Ar
|
||||
import { formatCurrency, CustomTooltip, processData, StatCard } from "./SalesChart.jsx";
|
||||
|
||||
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-full relative">
|
||||
<div className="h-[216px]">
|
||||
<div className="h-full w-full relative">
|
||||
{/* Grid lines */}
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div
|
||||
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}%` }}
|
||||
/>
|
||||
))}
|
||||
{/* 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-slate-500 rounded-sm" />
|
||||
<Skeleton key={i} className="h-3 w-6 bg-slate-600 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-slate-500 rounded-sm" />
|
||||
<Skeleton key={i} className="h-3 w-8 bg-slate-600 rounded-sm" />
|
||||
))}
|
||||
</div>
|
||||
{/* Chart lines */}
|
||||
<div className="absolute inset-x-8 bottom-6 top-4">
|
||||
<div className="h-full w-full relative">
|
||||
<div
|
||||
className="absolute inset-0 bg-slate-500 rounded-sm"
|
||||
className="absolute inset-0 bg-slate-600 rounded-sm"
|
||||
style={{
|
||||
opacity: 0.5,
|
||||
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>
|
||||
@@ -135,28 +128,24 @@ const MiniStatCard = memo(({
|
||||
MiniStatCard.displayName = "MiniStatCard";
|
||||
|
||||
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">
|
||||
<CardTitle>
|
||||
<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>
|
||||
</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 className={`absolute inset-0 rounded-full bg-${colorScheme}-300`} />
|
||||
<Skeleton className={`h-5 w-5 bg-${colorScheme}-300 relative rounded-full`} />
|
||||
</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>
|
||||
<Skeleton className={`h-6 w-16 bg-${colorScheme}-300/20 rounded-full`} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className={`h-8 w-20 bg-${colorScheme}-300`} />
|
||||
<div className="flex justify-between items-center">
|
||||
<Skeleton className={`h-4 w-24 bg-${colorScheme}-300`} />
|
||||
<Skeleton className={`h-4 w-12 bg-${colorScheme}-300 rounded-full`} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -176,11 +165,26 @@ const MiniSalesChart = ({ className = "" }) => {
|
||||
totalOrders: 0,
|
||||
prevRevenue: 0,
|
||||
prevOrders: 0,
|
||||
growth: {
|
||||
revenue: 0,
|
||||
orders: 0
|
||||
}
|
||||
periodProgress: 100
|
||||
});
|
||||
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 () => {
|
||||
try {
|
||||
@@ -211,33 +215,30 @@ const MiniSalesChart = ({ className = "" }) => {
|
||||
totalOrders: acc.totalOrders + (Number(day.orders) || 0),
|
||||
prevRevenue: acc.prevRevenue + (Number(day.prevRevenue) || 0),
|
||||
prevOrders: acc.prevOrders + (Number(day.prevOrders) || 0),
|
||||
periodProgress: day.periodProgress || 100,
|
||||
}), {
|
||||
totalRevenue: 0,
|
||||
totalOrders: 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);
|
||||
setSummaryStats({ ...totals, growth });
|
||||
setSummaryStats(totals);
|
||||
setError(null);
|
||||
|
||||
// Fetch projection if needed
|
||||
if (totals.periodProgress < 100) {
|
||||
fetchProjection();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching data:", error);
|
||||
setError(error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [fetchProjection]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
@@ -305,11 +306,19 @@ const MiniSalesChart = ({ className = "" }) => {
|
||||
title="30 Days Revenue"
|
||||
value={formatCurrency(summaryStats.totalRevenue, false)}
|
||||
previousValue={formatCurrency(summaryStats.prevRevenue, false)}
|
||||
trend={summaryStats.growth.revenue >= 0 ? "up" : "down"}
|
||||
trendValue={`${Math.abs(Math.round(summaryStats.growth.revenue))}%`}
|
||||
trend={
|
||||
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"
|
||||
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}
|
||||
iconColor="text-emerald-900"
|
||||
iconBackground="bg-emerald-300"
|
||||
@@ -320,11 +329,19 @@ const MiniSalesChart = ({ className = "" }) => {
|
||||
title="30 Days Orders"
|
||||
value={summaryStats.totalOrders.toLocaleString()}
|
||||
previousValue={summaryStats.prevOrders.toLocaleString()}
|
||||
trend={summaryStats.growth.orders >= 0 ? "up" : "down"}
|
||||
trendValue={`${Math.abs(Math.round(summaryStats.growth.orders))}%`}
|
||||
trend={
|
||||
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"
|
||||
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}
|
||||
iconColor="text-blue-900"
|
||||
iconBackground="bg-blue-300"
|
||||
|
||||
@@ -246,25 +246,45 @@ const MiniStatCards = ({
|
||||
}, []);
|
||||
|
||||
const calculateRevenueTrend = useCallback(() => {
|
||||
if (!stats?.prevPeriodRevenue && stats?.prevPeriodRevenue !== 0)
|
||||
return null;
|
||||
const currentRevenue =
|
||||
stats.periodProgress < 100 ? stats.projectedRevenue : stats.revenue;
|
||||
const prevRevenue = stats.prevPeriodRevenue;
|
||||
if (!stats?.prevPeriodRevenue && stats?.prevPeriodRevenue !== 0) return null;
|
||||
|
||||
// If period is complete, use actual revenue
|
||||
// If period is incomplete, use smart projection when available, fallback to simple projection
|
||||
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;
|
||||
|
||||
// Calculate absolute difference percentage
|
||||
const trend = currentRevenue >= prevRevenue ? "up" : "down";
|
||||
const diff = Math.abs(currentRevenue - prevRevenue);
|
||||
const percentage = (diff / prevRevenue) * 100;
|
||||
|
||||
console.log('[MiniStatCards RevenueTrend Result]', {
|
||||
trend,
|
||||
percentage,
|
||||
calculation: `(|${currentRevenue} - ${prevRevenue}| / ${prevRevenue}) * 100 = ${percentage}%`
|
||||
});
|
||||
|
||||
return {
|
||||
trend,
|
||||
value: percentage,
|
||||
current: currentRevenue,
|
||||
previous: prevRevenue,
|
||||
};
|
||||
}, [stats]);
|
||||
}, [stats, projection]);
|
||||
|
||||
const calculateOrderTrend = useCallback(() => {
|
||||
if (!stats?.prevPeriodOrders) return null;
|
||||
|
||||
@@ -342,16 +342,8 @@ const calculateSummaryStats = (data = []) => {
|
||||
return best;
|
||||
}, null);
|
||||
|
||||
// Calculate growth percentages
|
||||
const growth = {
|
||||
revenue: prevRevenue
|
||||
? ((totalRevenue - prevRevenue) / prevRevenue) * 100
|
||||
: 0,
|
||||
orders: prevOrders ? ((totalOrders - prevOrders) / prevOrders) * 100 : 0,
|
||||
avgOrderValue: prevAvgOrderValue
|
||||
? ((avgOrderValue - prevAvgOrderValue) / prevAvgOrderValue) * 100
|
||||
: 0,
|
||||
};
|
||||
// Get period progress from the last day
|
||||
const periodProgress = data[data.length - 1]?.periodProgress || 100;
|
||||
|
||||
return {
|
||||
totalRevenue,
|
||||
@@ -361,7 +353,7 @@ const calculateSummaryStats = (data = []) => {
|
||||
prevRevenue,
|
||||
prevOrders,
|
||||
prevAvgOrderValue,
|
||||
growth,
|
||||
periodProgress,
|
||||
movingAverages: {
|
||||
revenue: data[data.length - 1]?.movingAverage || 0,
|
||||
orders: data[data.length - 1]?.orderMovingAverage || 0,
|
||||
@@ -371,7 +363,7 @@ const calculateSummaryStats = (data = []) => {
|
||||
};
|
||||
|
||||
// Add memoized SummaryStats component
|
||||
const SummaryStats = memo(({ stats = {} }) => {
|
||||
const SummaryStats = memo(({ stats = {}, projection = null, projectionLoading = false }) => {
|
||||
const {
|
||||
totalRevenue = 0,
|
||||
totalOrders = 0,
|
||||
@@ -380,17 +372,39 @@ const SummaryStats = memo(({ stats = {} }) => {
|
||||
prevRevenue = 0,
|
||||
prevOrders = 0,
|
||||
prevAvgOrderValue = 0,
|
||||
growth = { revenue: 0, orders: 0, avgOrderValue: 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 ? (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 (
|
||||
<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={`Previous: ${formatCurrency(prevRevenue, false)}`}
|
||||
trend={growth.revenue >= 0 ? "up" : "down"}
|
||||
trendValue={formatPercentage(growth.revenue)}
|
||||
description={
|
||||
periodProgress < 100
|
||||
? `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"
|
||||
colorClass="text-green-600 dark:text-green-400"
|
||||
/>
|
||||
@@ -398,9 +412,13 @@ const SummaryStats = memo(({ stats = {} }) => {
|
||||
<StatCard
|
||||
title="Total Orders"
|
||||
value={totalOrders.toLocaleString()}
|
||||
description={`Previous: ${prevOrders.toLocaleString()} orders`}
|
||||
trend={growth.orders >= 0 ? "up" : "down"}
|
||||
trendValue={formatPercentage(growth.orders)}
|
||||
description={
|
||||
periodProgress < 100
|
||||
? `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"
|
||||
colorClass="text-blue-600 dark:text-blue-400"
|
||||
/>
|
||||
@@ -408,9 +426,13 @@ const SummaryStats = memo(({ stats = {} }) => {
|
||||
<StatCard
|
||||
title="AOV"
|
||||
value={formatCurrency(avgOrderValue)}
|
||||
description={`Previous: ${formatCurrency(prevAvgOrderValue)}`}
|
||||
trend={growth.avgOrderValue >= 0 ? "up" : "down"}
|
||||
trendValue={formatPercentage(growth.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"
|
||||
/>
|
||||
@@ -519,6 +541,25 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
|
||||
showPrevious: false,
|
||||
});
|
||||
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
|
||||
const fetchData = useCallback(async (params) => {
|
||||
@@ -551,13 +592,18 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
|
||||
setData(processedData);
|
||||
setSummaryStats(stats);
|
||||
setError(null);
|
||||
|
||||
// Fetch projection if needed
|
||||
if (stats.periodProgress < 100) {
|
||||
fetchProjection(params);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching data:", error);
|
||||
setError(error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [fetchProjection]);
|
||||
|
||||
// Handle time range change
|
||||
const handleTimeRangeChange = useCallback(
|
||||
@@ -832,7 +878,11 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
|
||||
(loading ? (
|
||||
<SkeletonStats />
|
||||
) : (
|
||||
<SummaryStats stats={summaryStats} />
|
||||
<SummaryStats
|
||||
stats={summaryStats}
|
||||
projection={projection}
|
||||
projectionLoading={projectionLoading}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Show metric toggles only if not in error state */}
|
||||
|
||||
@@ -1256,6 +1256,98 @@ const SkeletonTable = ({ rows = 8 }) => (
|
||||
</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 = ({
|
||||
timeRange: initialTimeRange = "today",
|
||||
startDate,
|
||||
@@ -1476,28 +1568,45 @@ const StatCards = ({
|
||||
}, []);
|
||||
|
||||
const calculateRevenueTrend = useCallback(() => {
|
||||
if (!stats?.prevPeriodRevenue && stats?.prevPeriodRevenue !== 0)
|
||||
return null;
|
||||
if (!stats?.prevPeriodRevenue && stats?.prevPeriodRevenue !== 0) return null;
|
||||
|
||||
// For incomplete periods, compare projected revenue to previous period
|
||||
// For complete periods, compare actual revenue to previous period
|
||||
const currentRevenue =
|
||||
stats.periodProgress < 100 ? stats.projectedRevenue : stats.revenue;
|
||||
const prevRevenue = stats.prevPeriodRevenue;
|
||||
// If period is complete, use actual revenue
|
||||
// If period is incomplete, use smart projection when available, fallback to simple projection
|
||||
const currentRevenue = stats.periodProgress < 100
|
||||
? (projection?.projectedRevenue || stats.projectedRevenue)
|
||||
: 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;
|
||||
|
||||
// Calculate absolute difference percentage
|
||||
const trend = currentRevenue >= prevRevenue ? "up" : "down";
|
||||
const diff = Math.abs(currentRevenue - prevRevenue);
|
||||
const percentage = (diff / prevRevenue) * 100;
|
||||
|
||||
console.log('[RevenueTrend Result]', {
|
||||
trend,
|
||||
percentage,
|
||||
calculation: `(|${currentRevenue} - ${prevRevenue}| / ${prevRevenue}) * 100 = ${percentage}%`
|
||||
});
|
||||
|
||||
return {
|
||||
trend,
|
||||
value: percentage,
|
||||
current: currentRevenue,
|
||||
previous: prevRevenue,
|
||||
};
|
||||
}, [stats]);
|
||||
}, [stats, projection]);
|
||||
|
||||
const calculateOrderTrend = useCallback(() => {
|
||||
if (!stats?.prevPeriodOrders) return null;
|
||||
|
||||
Reference in New Issue
Block a user