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;
|
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:', {
|
||||||
|
orderId: rawProps.OrderId,
|
||||||
|
shippedBy: rawProps.ShippedBy,
|
||||||
datetime: event.attributes?.datetime
|
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
|
// Add partial current hour
|
||||||
expectedPercentageSeen += hourlyPatterns[currentHour].percentage * hourProgress;
|
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) {
|
||||||
|
|||||||
@@ -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,64 +240,64 @@ 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>
|
||||||
@@ -302,61 +306,71 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
|
|||||||
{forecast && (
|
{forecast && (
|
||||||
<div>
|
<div>
|
||||||
<div className="grid grid-cols-5 gap-2">
|
<div className="grid grid-cols-5 gap-2">
|
||||||
{forecast.map((day, index) => (
|
{forecast.map((day, index) => {
|
||||||
<Card key={index} className="p-2">
|
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">
|
<div className="flex flex-col items-center gap-1">
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium text-white">
|
||||||
{new Date(day.dt * 1000).toLocaleDateString('en-US', { weekday: 'short' })}
|
{forecastTime.toLocaleDateString('en-US', { weekday: 'short' })}
|
||||||
</span>
|
</span>
|
||||||
{getWeatherIcon(day.weather[0].id, new Date(day.dt * 1000), true)}
|
{getWeatherIcon(day.weather[0].id, forecastTime, true)}
|
||||||
<div className="flex justify-center gap-1 items-baseline w-full">
|
<div className="flex justify-center gap-1 items-baseline w-full">
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium text-white">
|
||||||
{Math.round(day.main.temp_max)}°
|
{Math.round(day.main.temp_max)}°
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-slate-300">
|
||||||
{Math.round(day.main.temp_min)}°
|
{Math.round(day.main.temp_min)}°
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center gap-1 w-full pt-1">
|
<div className="flex flex-col items-center gap-1 w-full pt-1">
|
||||||
{day.rain?.['3h'] > 0 && (
|
{day.rain?.['3h'] > 0 && (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<CloudRain className="w-3 h-3 text-blue-400" />
|
<CloudRain className="w-3 h-3 text-blue-300" />
|
||||||
<span className="text-xs">{day.rain['3h'].toFixed(2)}"</span>
|
<span className="text-xs text-white">{day.rain['3h'].toFixed(2)}"</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{day.snow?.['3h'] > 0 && (
|
{day.snow?.['3h'] > 0 && (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<CloudSnow className="w-3 h-3 text-blue-400" />
|
<CloudSnow className="w-3 h-3 text-blue-300" />
|
||||||
<span className="text-xs">{day.snow['3h'].toFixed(2)}"</span>
|
<span className="text-xs text-white">{day.snow['3h'].toFixed(2)}"</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!day.rain?.['3h'] && !day.snow?.['3h'] && (
|
{!day.rain?.['3h'] && !day.snow?.['3h'] && (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Umbrella className="w-3 h-3 text-gray-400" />
|
<Umbrella className="w-3 h-3 text-slate-300" />
|
||||||
<span className="text-xs">0"</span>
|
<span className="text-xs text-white">0"</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center w-full transition-opacity duration-300 ${mounted ? 'opacity-100' : 'opacity-0'}">
|
<div className="flex flex-col items-center w-full transition-opacity duration-300 ${mounted ? 'opacity-100' : 'opacity-0'}">
|
||||||
{/* Time Display */}
|
{/* 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">
|
<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">
|
<CardContent className="p-3 h-[106px] flex items-center">
|
||||||
<div className="flex justify-center items-baseline w-full">
|
<div className="flex justify-center items-baseline w-full">
|
||||||
<div className={`transition-opacity duration-200 ${isTimeChanging ? 'opacity-60' : 'opacity-100'}`}>
|
<div className={`transition-opacity duration-200 ${isTimeChanging ? 'opacity-60' : 'opacity-100'}`}>
|
||||||
<span className="text-7xl font-bold text-white">{hours}</span>
|
<span className="text-6xl font-bold text-white">{hours}</span>
|
||||||
<span className="text-7xl font-bold text-white">:</span>
|
<span className="text-6xl font-bold text-white">:</span>
|
||||||
<span className="text-7xl font-bold text-white">{minutes}</span>
|
<span className="text-6xl font-bold text-white">{minutes}</span>
|
||||||
<span className="text-xl font-medium text-white/90 ml-1">{ampm}</span>
|
<span className="text-lg font-medium text-white/90 ml-1">{ampm}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -364,10 +378,10 @@ return (
|
|||||||
|
|
||||||
{/* Date and Weather Display */}
|
{/* Date and Weather Display */}
|
||||||
<div className="h-[125px] mb-[6px] grid grid-cols-2 gap-2 w-full">
|
<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">
|
<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">
|
<CardContent className="h-full p-0">
|
||||||
<div className="flex flex-col items-center justify-center h-full">
|
<div className="flex flex-col items-center justify-center h-full">
|
||||||
<span className="text-7xl font-bold text-white">
|
<span className="text-6xl font-bold text-white">
|
||||||
{dateInfo.day}
|
{dateInfo.day}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-bold text-white mt-2">
|
<span className="text-sm font-bold text-white mt-2">
|
||||||
@@ -385,18 +399,12 @@ return (
|
|||||||
weather.weather[0]?.id,
|
weather.weather[0]?.id,
|
||||||
datetime.getHours() >= 18 || datetime.getHours() < 6
|
datetime.getHours() >= 18 || datetime.getHours() < 6
|
||||||
),
|
),
|
||||||
"flex items-center justify-center cursor-pointer hover:brightness-110 transition-all relative"
|
"flex items-center justify-center cursor-pointer hover:brightness-110 transition-all relative backdrop-blur-sm"
|
||||||
)}>
|
)}>
|
||||||
<CardContent className="h-full p-3">
|
<CardContent className="h-full p-3">
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
{getWeatherIcon(weather.weather[0]?.id, datetime)}
|
{getWeatherIcon(weather.weather[0]?.id, datetime)}
|
||||||
<span className={cn(
|
<span className="text-3xl font-bold ml-1 mt-2 text-white">
|
||||||
"text-3xl font-bold ml-1 mt-2",
|
|
||||||
getTemperatureColor(
|
|
||||||
weather.weather[0]?.id,
|
|
||||||
datetime.getHours() >= 18 || datetime.getHours() < 6
|
|
||||||
)
|
|
||||||
)}>
|
|
||||||
{Math.round(weather.main.temp)}°
|
{Math.round(weather.main.temp)}°
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -409,7 +417,7 @@ return (
|
|||||||
</Card>
|
</Card>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
className="w-[450px]"
|
className="w-[450px] bg-gradient-to-br from-slate-800 to-slate-700 border-slate-600"
|
||||||
align="start"
|
align="start"
|
||||||
side="right"
|
side="right"
|
||||||
sideOffset={10}
|
sideOffset={10}
|
||||||
@@ -419,9 +427,9 @@ return (
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{weather.alerts && (
|
{weather.alerts && (
|
||||||
<Alert variant="warning" className="mb-3">
|
<Alert variant="warning" className="mb-3 bg-amber-900/50 border-amber-700">
|
||||||
<AlertTriangle className="h-3 w-3" />
|
<AlertTriangle className="h-3 w-3 text-amber-500" />
|
||||||
<AlertDescription className="text-xs">
|
<AlertDescription className="text-xs text-amber-200">
|
||||||
{weather.alerts[0].event}
|
{weather.alerts[0].event}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
@@ -433,7 +441,7 @@ return (
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Calendar Display */}
|
{/* Calendar Display */}
|
||||||
<Card className="w-full">
|
<Card className="w-full bg-white dark:bg-slate-800">
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<CalendarComponent
|
<CalendarComponent
|
||||||
selected={datetime}
|
selected={datetime}
|
||||||
@@ -442,7 +450,7 @@ return (
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default DateTimeWeatherDisplay;
|
export default DateTimeWeatherDisplay;
|
||||||
@@ -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>
|
||||||
)}
|
|
||||||
{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>
|
</div>
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
<Card className="mt-6">
|
{formatShipMethodSimple(details.ShipMethod)}
|
||||||
<CardHeader className="pb-2">
|
{event.event_properties?.ShippedBy && (
|
||||||
<CardTitle className="text-sm font-medium">Shipped Items</CardTitle>
|
<>
|
||||||
</CardHeader>
|
<span className="text-sm text-gray-500"> • </span>
|
||||||
<CardContent>
|
<span className="text-sm font-medium text-blue-600 dark:text-blue-400">Shipped by {event.event_properties.ShippedBy}</span>
|
||||||
<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>
|
</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);
|
||||||
|
|||||||
@@ -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,15 +400,41 @@ 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 && (
|
||||||
|
<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' }}>
|
<div className="flex flex-row gap-3 pr-4" style={{ width: 'max-content' }}>
|
||||||
{loading && !events.length ? (
|
{loading && !events.length ? (
|
||||||
<LoadingState />
|
<LoadingState />
|
||||||
@@ -391,7 +451,7 @@ const MiniEventFeed = ({
|
|||||||
<EmptyState />
|
<EmptyState />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
events.map((event) => (
|
[...events].reverse().map((event) => (
|
||||||
<EventCard
|
<EventCard
|
||||||
key={event.id}
|
key={event.id}
|
||||||
event={event}
|
event={event}
|
||||||
|
|||||||
@@ -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,67 +105,46 @@ const MiniRealtimeAnalytics = () => {
|
|||||||
};
|
};
|
||||||
}, [isPaused]);
|
}, [isPaused]);
|
||||||
|
|
||||||
if (loading && !basicData) {
|
const renderContent = () => {
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Alert variant="destructive" className="mb-4">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="grid grid-cols-2 gap-2 mt-1 mb-2">
|
<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">
|
<SkeletonCard colorScheme="sky" />
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
|
<SkeletonCard colorScheme="sky" />
|
||||||
<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" />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="bg-gradient-to-br from-sky-900 to-sky-800 backdrop-blur-sm">
|
<Card className="bg-gradient-to-br from-sky-900 to-sky-800 backdrop-blur-sm">
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="h-[230px] relative">
|
<div className="h-[216px]">
|
||||||
|
<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-sky-700"
|
className="absolute w-full h-px bg-sky-300/20"
|
||||||
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-sky-700 rounded-sm" />
|
<Skeleton key={i} className="h-3 w-6 bg-sky-300/20 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-sky-700 rounded-sm" />
|
<Skeleton key={i} className="h-3 w-8 bg-sky-300/20 rounded-sm" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{/* Bars */}
|
{/* Bars */}
|
||||||
@@ -144,12 +152,13 @@ const MiniRealtimeAnalytics = () => {
|
|||||||
{[...Array(24)].map((_, i) => (
|
{[...Array(24)].map((_, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="w-2 bg-sky-700 rounded-sm"
|
className="w-2 bg-sky-300/20 rounded-sm"
|
||||||
style={{ height: `${Math.random() * 80 + 10}%` }}
|
style={{ height: `${Math.random() * 80 + 10}%` }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -158,13 +167,6 @@ const MiniRealtimeAnalytics = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<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",
|
||||||
@@ -246,6 +248,9 @@ const MiniRealtimeAnalytics = () => {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return renderContent();
|
||||||
};
|
};
|
||||||
|
|
||||||
export default MiniRealtimeAnalytics;
|
export default MiniRealtimeAnalytics;
|
||||||
@@ -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-32 bg-${colorScheme}-300/20`} />
|
<Skeleton className={`h-8 w-20 bg-${colorScheme}-300`} />
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div className="space-y-1">
|
<Skeleton className={`h-4 w-24 bg-${colorScheme}-300`} />
|
||||||
<Skeleton className={`h-4 w-24 bg-${colorScheme}-300/20`} />
|
<Skeleton className={`h-4 w-12 bg-${colorScheme}-300 rounded-full`} />
|
||||||
</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"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user