Fix Demo enterprise
This commit is contained in:
443
frontend/src/api/hooks/useControlPanelData.ts
Normal file
443
frontend/src/api/hooks/useControlPanelData.ts
Normal file
@@ -0,0 +1,443 @@
|
||||
/**
|
||||
* Enhanced Control Panel Data Hook
|
||||
*
|
||||
* Handles initial API fetch, SSE integration, and data merging with priority rules
|
||||
* for the control panel page.
|
||||
*/
|
||||
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { alertService } from '../services/alertService';
|
||||
import { getPendingApprovalPurchaseOrders } from '../services/purchase_orders';
|
||||
import { productionService } from '../services/production';
|
||||
import { ProcurementService } from '../services/procurement-service';
|
||||
import * as orchestratorService from '../services/orchestrator';
|
||||
import { suppliersService } from '../services/suppliers';
|
||||
import { aiInsightsService } from '../services/aiInsights';
|
||||
import { useSSEEvents } from '../../hooks/useSSE';
|
||||
import { parseISO } from 'date-fns';
|
||||
|
||||
// ============================================================
|
||||
// Types
|
||||
// ============================================================
|
||||
|
||||
export interface ControlPanelData {
|
||||
// Raw data from APIs
|
||||
alerts: any[];
|
||||
pendingPOs: any[];
|
||||
productionBatches: any[];
|
||||
deliveries: any[];
|
||||
orchestrationSummary: OrchestrationSummary | null;
|
||||
aiInsights: any[];
|
||||
|
||||
// Computed/derived data
|
||||
preventedIssues: any[];
|
||||
issuesRequiringAction: number;
|
||||
issuesPreventedByAI: number;
|
||||
|
||||
// Filtered data for blocks
|
||||
overdueDeliveries: any[];
|
||||
pendingDeliveries: any[];
|
||||
lateToStartBatches: any[];
|
||||
runningBatches: any[];
|
||||
pendingBatches: any[];
|
||||
|
||||
// Categorized alerts
|
||||
equipmentAlerts: any[];
|
||||
productionAlerts: any[];
|
||||
otherAlerts: any[];
|
||||
}
|
||||
|
||||
export interface OrchestrationSummary {
|
||||
runTimestamp: string | null;
|
||||
runNumber?: number;
|
||||
status: string;
|
||||
purchaseOrdersCreated: number;
|
||||
productionBatchesCreated: number;
|
||||
userActionsRequired: number;
|
||||
aiHandlingRate?: number;
|
||||
estimatedSavingsEur?: number;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Data Priority and Merging Logic
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Merge data with priority rules:
|
||||
* 1. Services API data takes precedence
|
||||
* 2. Alerts data enriches services data
|
||||
* 3. Alerts data is used as fallback when no services data exists
|
||||
* 4. Deduplicate alerts for entities already shown in UI
|
||||
*/
|
||||
function mergeDataWithPriority(
|
||||
servicesData: any,
|
||||
alertsData: any,
|
||||
entityType: 'po' | 'batch' | 'delivery'
|
||||
): any[] {
|
||||
const mergedEntities = [...servicesData];
|
||||
const servicesEntityIds = new Set(servicesData.map((entity: any) => entity.id));
|
||||
|
||||
// Enrich services data with alerts data
|
||||
const enrichedEntities = mergedEntities.map(entity => {
|
||||
const matchingAlert = alertsData.find((alert: any) =>
|
||||
alert.entity_links?.[entityType === 'po' ? 'purchase_order' : entityType === 'batch' ? 'production_batch' : 'delivery'] === entity.id
|
||||
);
|
||||
|
||||
if (matchingAlert) {
|
||||
return {
|
||||
...entity,
|
||||
alert_reasoning: matchingAlert.reasoning_data,
|
||||
alert_priority: matchingAlert.priority_level,
|
||||
alert_timestamp: matchingAlert.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
return entity;
|
||||
});
|
||||
|
||||
// Add alerts data as fallback for entities not in services data
|
||||
alertsData.forEach((alert: any) => {
|
||||
const entityId = alert.entity_links?.[entityType === 'po' ? 'purchase_order' : entityType === 'batch' ? 'production_batch' : 'delivery'];
|
||||
|
||||
if (entityId && !servicesEntityIds.has(entityId)) {
|
||||
// Create a synthetic entity from alert data
|
||||
const syntheticEntity = {
|
||||
id: entityId,
|
||||
status: alert.event_metadata?.status || 'UNKNOWN',
|
||||
alert_reasoning: alert.reasoning_data,
|
||||
alert_priority: alert.priority_level,
|
||||
alert_timestamp: alert.timestamp,
|
||||
source: 'alert_fallback',
|
||||
};
|
||||
|
||||
// Add entity-specific fields from alert metadata
|
||||
if (entityType === 'po') {
|
||||
(syntheticEntity as any).supplier_id = alert.event_metadata?.supplier_id;
|
||||
(syntheticEntity as any).po_number = alert.event_metadata?.po_number;
|
||||
} else if (entityType === 'batch') {
|
||||
(syntheticEntity as any).batch_number = alert.event_metadata?.batch_number;
|
||||
(syntheticEntity as any).product_id = alert.event_metadata?.product_id;
|
||||
} else if (entityType === 'delivery') {
|
||||
(syntheticEntity as any).expected_delivery_date = alert.event_metadata?.expected_delivery_date;
|
||||
}
|
||||
|
||||
enrichedEntities.push(syntheticEntity);
|
||||
}
|
||||
});
|
||||
|
||||
return enrichedEntities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Categorize alerts by type
|
||||
*/
|
||||
function categorizeAlerts(alerts: any[], batchIds: Set<string>, deliveryIds: Set<string>): {
|
||||
equipmentAlerts: any[],
|
||||
productionAlerts: any[],
|
||||
otherAlerts: any[]
|
||||
} {
|
||||
const equipmentAlerts: any[] = [];
|
||||
const productionAlerts: any[] = [];
|
||||
const otherAlerts: any[] = [];
|
||||
|
||||
alerts.forEach(alert => {
|
||||
const eventType = alert.event_type || '';
|
||||
const batchId = alert.event_metadata?.batch_id || alert.entity_links?.production_batch;
|
||||
const deliveryId = alert.event_metadata?.delivery_id || alert.entity_links?.delivery;
|
||||
|
||||
// Equipment alerts
|
||||
if (eventType.includes('equipment_') ||
|
||||
eventType.includes('maintenance') ||
|
||||
eventType.includes('machine_failure')) {
|
||||
equipmentAlerts.push(alert);
|
||||
}
|
||||
// Production alerts (not equipment-related)
|
||||
else if (eventType.includes('production.') ||
|
||||
eventType.includes('batch_') ||
|
||||
eventType.includes('production_') ||
|
||||
eventType.includes('delay') ||
|
||||
(batchId && !batchIds.has(batchId))) {
|
||||
productionAlerts.push(alert);
|
||||
}
|
||||
// Other alerts
|
||||
else {
|
||||
otherAlerts.push(alert);
|
||||
}
|
||||
});
|
||||
|
||||
return { equipmentAlerts, productionAlerts, otherAlerts };
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Main Hook
|
||||
// ============================================================
|
||||
|
||||
export function useControlPanelData(tenantId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
const [sseEvents, setSseEvents] = useState<any[]>([]);
|
||||
|
||||
// Subscribe to SSE events for control panel
|
||||
const { events: sseAlerts } = useSSEEvents({
|
||||
channels: ['*.alerts', '*.notifications', 'recommendations']
|
||||
});
|
||||
|
||||
// Update SSE events state when new events arrive
|
||||
useEffect(() => {
|
||||
if (sseAlerts.length > 0) {
|
||||
setSseEvents(prev => {
|
||||
// Deduplicate by event ID
|
||||
const eventIds = new Set(prev.map(e => e.id));
|
||||
const newEvents = sseAlerts.filter(event => !eventIds.has(event.id));
|
||||
return [...prev, ...newEvents];
|
||||
});
|
||||
}
|
||||
}, [sseAlerts]);
|
||||
|
||||
const query = useQuery<ControlPanelData>({
|
||||
queryKey: ['control-panel-data', tenantId],
|
||||
queryFn: async () => {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const now = new Date();
|
||||
const nowUTC = new Date();
|
||||
|
||||
// Parallel fetch from all services
|
||||
const [alertsResponse, pendingPOs, productionResponse, deliveriesResponse, orchestration, suppliers, aiInsightsResponse] = await Promise.all([
|
||||
alertService.getEvents(tenantId, { status: 'active', limit: 100 }).catch(() => []),
|
||||
getPendingApprovalPurchaseOrders(tenantId, 100).catch(() => []),
|
||||
productionService.getBatches(tenantId, { start_date: today, page_size: 100 }).catch(() => ({ batches: [] })),
|
||||
ProcurementService.getExpectedDeliveries(tenantId, { days_ahead: 1, include_overdue: true }).catch(() => ({ deliveries: [] })),
|
||||
orchestratorService.getLastOrchestrationRun(tenantId).catch(() => null),
|
||||
suppliersService.getSuppliers(tenantId).catch(() => []),
|
||||
aiInsightsService.getInsights(tenantId, {
|
||||
status: 'new',
|
||||
priority: 'high',
|
||||
limit: 5
|
||||
}).catch(() => ({ items: [], total: 0, limit: 5, offset: 0, has_more: false })),
|
||||
]);
|
||||
|
||||
// Normalize responses
|
||||
const alerts = Array.isArray(alertsResponse) ? alertsResponse : (alertsResponse?.items || []);
|
||||
const productionBatches = productionResponse?.batches || [];
|
||||
const deliveries = deliveriesResponse?.deliveries || [];
|
||||
const aiInsights = aiInsightsResponse?.items || [];
|
||||
|
||||
// Create supplier map
|
||||
const supplierMap = new Map<string, string>();
|
||||
(suppliers || []).forEach((supplier: any) => {
|
||||
supplierMap.set(supplier.id, supplier.name || supplier.supplier_name);
|
||||
});
|
||||
|
||||
// Merge SSE events with API data
|
||||
const allAlerts = [...alerts];
|
||||
if (sseEvents.length > 0) {
|
||||
// Merge SSE events, prioritizing newer events
|
||||
const sseEventIds = new Set(sseEvents.map(e => e.id));
|
||||
const mergedAlerts = alerts.filter(alert => !sseEventIds.has(alert.id));
|
||||
allAlerts.push(...sseEvents);
|
||||
}
|
||||
|
||||
// Apply data priority rules for POs
|
||||
const enrichedPendingPOs = mergeDataWithPriority(pendingPOs, allAlerts, 'po');
|
||||
|
||||
// Apply data priority rules for batches
|
||||
const enrichedProductionBatches = mergeDataWithPriority(productionBatches, allAlerts, 'batch');
|
||||
|
||||
// Apply data priority rules for deliveries
|
||||
const enrichedDeliveries = mergeDataWithPriority(deliveries, allAlerts, 'delivery');
|
||||
|
||||
// Filter and categorize data
|
||||
const isPending = (status: string) =>
|
||||
status === 'PENDING' || status === 'sent_to_supplier' || status === 'confirmed';
|
||||
|
||||
const overdueDeliveries = enrichedDeliveries.filter((d: any) => {
|
||||
if (!isPending(d.status)) return false;
|
||||
const expectedDate = parseISO(d.expected_delivery_date);
|
||||
return expectedDate < nowUTC;
|
||||
}).map((d: any) => ({
|
||||
...d,
|
||||
hoursOverdue: Math.ceil((nowUTC.getTime() - parseISO(d.expected_delivery_date).getTime()) / (1000 * 60 * 60)),
|
||||
}));
|
||||
|
||||
const pendingDeliveriesFiltered = enrichedDeliveries.filter((d: any) => {
|
||||
if (!isPending(d.status)) return false;
|
||||
const expectedDate = parseISO(d.expected_delivery_date);
|
||||
return expectedDate >= nowUTC;
|
||||
}).map((d: any) => ({
|
||||
...d,
|
||||
hoursUntil: Math.ceil((parseISO(d.expected_delivery_date).getTime() - nowUTC.getTime()) / (1000 * 60 * 60)),
|
||||
}));
|
||||
|
||||
// Filter production batches
|
||||
const lateToStartBatches = enrichedProductionBatches.filter((b: any) => {
|
||||
const status = b.status?.toUpperCase();
|
||||
if (status !== 'PENDING' && status !== 'SCHEDULED') return false;
|
||||
const plannedStart = b.planned_start_time;
|
||||
if (!plannedStart) return false;
|
||||
return parseISO(plannedStart) < nowUTC;
|
||||
}).map((b: any) => ({
|
||||
...b,
|
||||
hoursLate: Math.ceil((nowUTC.getTime() - parseISO(b.planned_start_time).getTime()) / (1000 * 60 * 60)),
|
||||
}));
|
||||
|
||||
const runningBatches = enrichedProductionBatches.filter((b: any) =>
|
||||
b.status?.toUpperCase() === 'IN_PROGRESS'
|
||||
);
|
||||
|
||||
const pendingBatchesFiltered = enrichedProductionBatches.filter((b: any) => {
|
||||
const status = b.status?.toUpperCase();
|
||||
if (status !== 'PENDING' && status !== 'SCHEDULED') return false;
|
||||
const plannedStart = b.planned_start_time;
|
||||
if (!plannedStart) return true;
|
||||
return parseISO(plannedStart) >= nowUTC;
|
||||
});
|
||||
|
||||
// Create sets for deduplication
|
||||
const lateBatchIds = new Set(lateToStartBatches.map((b: any) => b.id));
|
||||
const runningBatchIds = new Set(runningBatches.map((b: any) => b.id));
|
||||
const deliveryIds = new Set([...overdueDeliveries, ...pendingDeliveriesFiltered].map((d: any) => d.id));
|
||||
|
||||
// Create array of all batch IDs for categorization
|
||||
const allBatchIds = new Set([
|
||||
...Array.from(lateBatchIds),
|
||||
...Array.from(runningBatchIds),
|
||||
...pendingBatchesFiltered.map((b: any) => b.id)
|
||||
]);
|
||||
|
||||
// Categorize alerts and filter out duplicates for batches already shown
|
||||
const { equipmentAlerts, productionAlerts, otherAlerts } = categorizeAlerts(
|
||||
allAlerts,
|
||||
allBatchIds,
|
||||
deliveryIds
|
||||
);
|
||||
|
||||
// Additional deduplication: filter out equipment alerts for batches already shown in UI
|
||||
const deduplicatedEquipmentAlerts = equipmentAlerts.filter(alert => {
|
||||
const batchId = alert.event_metadata?.batch_id || alert.entity_links?.production_batch;
|
||||
if (batchId && allBatchIds.has(batchId)) {
|
||||
return false; // Filter out if batch is already shown
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Compute derived data
|
||||
const preventedIssues = allAlerts.filter((a: any) => a.type_class === 'prevented_issue');
|
||||
const actionNeededAlerts = allAlerts.filter((a: any) =>
|
||||
a.type_class === 'action_needed' &&
|
||||
!a.hidden_from_ui &&
|
||||
a.status === 'active'
|
||||
);
|
||||
|
||||
// Calculate total issues requiring action:
|
||||
// 1. Action needed alerts
|
||||
// 2. Pending PO approvals (each PO requires approval action)
|
||||
// 3. Late to start batches (each requires start action)
|
||||
const issuesRequiringAction = actionNeededAlerts.length +
|
||||
enrichedPendingPOs.length +
|
||||
lateToStartBatches.length;
|
||||
|
||||
// Build orchestration summary
|
||||
let orchestrationSummary: OrchestrationSummary | null = null;
|
||||
if (orchestration && orchestration.timestamp) {
|
||||
orchestrationSummary = {
|
||||
runTimestamp: orchestration.timestamp,
|
||||
runNumber: orchestration.runNumber ?? undefined,
|
||||
status: 'completed',
|
||||
purchaseOrdersCreated: enrichedPendingPOs.length,
|
||||
productionBatchesCreated: enrichedProductionBatches.length,
|
||||
userActionsRequired: actionNeededAlerts.length,
|
||||
aiHandlingRate: preventedIssues.length > 0
|
||||
? Math.round((preventedIssues.length / (preventedIssues.length + actionNeededAlerts.length)) * 100)
|
||||
: undefined,
|
||||
estimatedSavingsEur: preventedIssues.length * 50,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
// Raw data
|
||||
alerts: allAlerts,
|
||||
pendingPOs: enrichedPendingPOs,
|
||||
productionBatches: enrichedProductionBatches,
|
||||
deliveries: enrichedDeliveries,
|
||||
orchestrationSummary,
|
||||
aiInsights,
|
||||
|
||||
// Computed
|
||||
preventedIssues,
|
||||
issuesRequiringAction,
|
||||
issuesPreventedByAI: preventedIssues.length,
|
||||
|
||||
// Filtered for blocks
|
||||
overdueDeliveries,
|
||||
pendingDeliveries: pendingDeliveriesFiltered,
|
||||
lateToStartBatches,
|
||||
runningBatches,
|
||||
pendingBatches: pendingBatchesFiltered,
|
||||
|
||||
// Categorized alerts (deduplicated to prevent showing alerts for batches already in UI)
|
||||
equipmentAlerts: deduplicatedEquipmentAlerts,
|
||||
productionAlerts,
|
||||
otherAlerts,
|
||||
};
|
||||
},
|
||||
enabled: !!tenantId,
|
||||
staleTime: 20000, // 20 seconds
|
||||
refetchOnMount: 'always',
|
||||
retry: 2,
|
||||
});
|
||||
|
||||
// SSE integration - invalidate query on relevant events
|
||||
useEffect(() => {
|
||||
if (sseAlerts.length > 0 && tenantId) {
|
||||
const relevantEvents = sseAlerts.filter(event =>
|
||||
event.event_type.includes('production.') ||
|
||||
event.event_type.includes('batch_') ||
|
||||
event.event_type.includes('delivery') ||
|
||||
event.event_type.includes('purchase_order') ||
|
||||
event.event_type.includes('equipment_')
|
||||
);
|
||||
|
||||
if (relevantEvents.length > 0) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['control-panel-data', tenantId],
|
||||
refetchType: 'active',
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [sseAlerts, tenantId, queryClient]);
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Real-time SSE Hook for Control Panel
|
||||
// ============================================================
|
||||
|
||||
export function useControlPanelRealtimeSync(tenantId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Subscribe to SSE events
|
||||
const { events: sseEvents } = useSSEEvents({
|
||||
channels: ['*.alerts', '*.notifications', 'recommendations']
|
||||
});
|
||||
|
||||
// Invalidate control panel data on relevant events
|
||||
useEffect(() => {
|
||||
if (sseEvents.length === 0 || !tenantId) return;
|
||||
|
||||
const latest = sseEvents[0];
|
||||
const relevantEventTypes = [
|
||||
'batch_completed', 'batch_started', 'batch_state_changed',
|
||||
'delivery_received', 'delivery_overdue', 'delivery_arriving_soon',
|
||||
'stock_receipt_incomplete', 'orchestration_run_completed',
|
||||
'production_delay', 'batch_start_delayed', 'equipment_maintenance'
|
||||
];
|
||||
|
||||
if (relevantEventTypes.includes(latest.event_type)) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['control-panel-data', tenantId],
|
||||
refetchType: 'active',
|
||||
});
|
||||
}
|
||||
}, [sseEvents, tenantId, queryClient]);
|
||||
}
|
||||
@@ -1,410 +0,0 @@
|
||||
/**
|
||||
* Unified Dashboard Data Hook
|
||||
*
|
||||
* Single data fetch for all 4 dashboard blocks.
|
||||
* Fetches data once and computes derived values for efficiency.
|
||||
*/
|
||||
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect } from 'react';
|
||||
import { alertService } from '../services/alertService';
|
||||
import { getPendingApprovalPurchaseOrders } from '../services/purchase_orders';
|
||||
import { productionService } from '../services/production';
|
||||
import { ProcurementService } from '../services/procurement-service';
|
||||
import * as orchestratorService from '../services/orchestrator';
|
||||
import { suppliersService } from '../services/suppliers';
|
||||
import { aiInsightsService } from '../services/aiInsights';
|
||||
import { useBatchNotifications, useDeliveryNotifications, useOrchestrationNotifications } from '../../hooks/useEventNotifications';
|
||||
import { useSSEEvents } from '../../hooks/useSSE';
|
||||
import { parseISO } from 'date-fns';
|
||||
|
||||
// ============================================================
|
||||
// Helper Functions
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Map AI insight category to dashboard block type
|
||||
*/
|
||||
function mapInsightTypeToBlockType(category: string): string {
|
||||
const mapping: Record<string, string> = {
|
||||
'inventory': 'safety_stock',
|
||||
'forecasting': 'demand_forecast',
|
||||
'demand': 'demand_forecast',
|
||||
'procurement': 'cost_optimization',
|
||||
'cost': 'cost_optimization',
|
||||
'production': 'waste_reduction',
|
||||
'quality': 'risk_alert',
|
||||
'efficiency': 'waste_reduction',
|
||||
};
|
||||
return mapping[category] || 'demand_forecast';
|
||||
}
|
||||
|
||||
/**
|
||||
* Map AI insight priority to dashboard impact level
|
||||
*/
|
||||
function mapPriorityToImpact(priority: string): 'high' | 'medium' | 'low' {
|
||||
if (priority === 'critical' || priority === 'high') return 'high';
|
||||
if (priority === 'medium') return 'medium';
|
||||
return 'low';
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Types
|
||||
// ============================================================
|
||||
|
||||
export interface DashboardData {
|
||||
// Raw data from APIs
|
||||
alerts: any[];
|
||||
pendingPOs: any[];
|
||||
productionBatches: any[];
|
||||
deliveries: any[];
|
||||
orchestrationSummary: OrchestrationSummary | null;
|
||||
aiInsights: any[]; // AI-generated insights for professional/enterprise tiers
|
||||
|
||||
// Computed/derived data
|
||||
preventedIssues: any[];
|
||||
issuesRequiringAction: number;
|
||||
issuesPreventedByAI: number;
|
||||
|
||||
// Filtered data for blocks
|
||||
overdueDeliveries: any[];
|
||||
pendingDeliveries: any[];
|
||||
lateToStartBatches: any[];
|
||||
runningBatches: any[];
|
||||
pendingBatches: any[];
|
||||
}
|
||||
|
||||
export interface OrchestrationSummary {
|
||||
runTimestamp: string | null;
|
||||
runNumber?: number;
|
||||
status: string;
|
||||
purchaseOrdersCreated: number;
|
||||
productionBatchesCreated: number;
|
||||
userActionsRequired: number;
|
||||
aiHandlingRate?: number;
|
||||
estimatedSavingsEur?: number;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Main Hook
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Unified dashboard data hook.
|
||||
* Fetches ALL data needed by the 4 dashboard blocks in a single parallel request.
|
||||
*
|
||||
* @param tenantId - Tenant identifier
|
||||
* @returns Dashboard data for all blocks
|
||||
*/
|
||||
export function useDashboardData(tenantId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const query = useQuery<DashboardData>({
|
||||
queryKey: ['dashboard-data', tenantId],
|
||||
queryFn: async () => {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const now = new Date(); // Keep for local time display
|
||||
const nowUTC = new Date(); // UTC time for accurate comparison with API dates
|
||||
|
||||
// Parallel fetch ALL data needed by all 4 blocks (including suppliers for PO enrichment and AI insights)
|
||||
const [alertsResponse, pendingPOs, productionResponse, deliveriesResponse, orchestration, suppliers, aiInsightsResponse] = await Promise.all([
|
||||
alertService.getEvents(tenantId, { status: 'active', limit: 100 }).catch(() => []),
|
||||
getPendingApprovalPurchaseOrders(tenantId, 100).catch(() => []),
|
||||
productionService.getBatches(tenantId, { start_date: today, page_size: 100 }).catch(() => ({ batches: [] })),
|
||||
ProcurementService.getExpectedDeliveries(tenantId, { days_ahead: 1, include_overdue: true }).catch(() => ({ deliveries: [] })),
|
||||
orchestratorService.getLastOrchestrationRun(tenantId).catch(() => null),
|
||||
suppliersService.getSuppliers(tenantId).catch(() => []),
|
||||
aiInsightsService.getInsights(tenantId, {
|
||||
status: 'new',
|
||||
priority: 'high',
|
||||
limit: 5
|
||||
}).catch(() => ({ items: [], total: 0, limit: 5, offset: 0, has_more: false })),
|
||||
]);
|
||||
|
||||
// Normalize alerts (API returns array directly or {items: []})
|
||||
const alerts = Array.isArray(alertsResponse) ? alertsResponse : (alertsResponse?.items || []);
|
||||
const productionBatches = productionResponse?.batches || [];
|
||||
const deliveries = deliveriesResponse?.deliveries || [];
|
||||
const aiInsights = aiInsightsResponse?.items || [];
|
||||
|
||||
// Create supplier ID -> supplier name map for quick lookup
|
||||
const supplierMap = new Map<string, string>();
|
||||
(suppliers || []).forEach((supplier: any) => {
|
||||
supplierMap.set(supplier.id, supplier.name || supplier.supplier_name);
|
||||
});
|
||||
|
||||
// Compute derived data - prevented issues and action-needed counts
|
||||
const preventedIssues = alerts.filter((a: any) => a.type_class === 'prevented_issue');
|
||||
const actionNeededAlerts = alerts.filter((a: any) =>
|
||||
a.type_class === 'action_needed' &&
|
||||
!a.hidden_from_ui &&
|
||||
a.status === 'active'
|
||||
);
|
||||
|
||||
// Find PO approval alerts to get reasoning data
|
||||
const poApprovalAlerts = alerts.filter((a: any) =>
|
||||
a.event_type === 'po_approval_needed' ||
|
||||
a.event_type === 'purchase_order_created'
|
||||
);
|
||||
|
||||
// Create a map of PO ID -> reasoning data from alerts
|
||||
const poReasoningMap = new Map<string, any>();
|
||||
poApprovalAlerts.forEach((alert: any) => {
|
||||
// Get PO ID from multiple possible locations
|
||||
const poId = alert.event_metadata?.po_id ||
|
||||
alert.entity_links?.purchase_order ||
|
||||
alert.entity_id ||
|
||||
alert.metadata?.purchase_order_id ||
|
||||
alert.reference_id;
|
||||
|
||||
// Get reasoning data from multiple possible locations
|
||||
const reasoningData = alert.event_metadata?.reasoning_data ||
|
||||
alert.metadata?.reasoning_data ||
|
||||
alert.ai_reasoning_details ||
|
||||
alert.reasoning_data ||
|
||||
alert.ai_reasoning ||
|
||||
alert.metadata?.reasoning;
|
||||
|
||||
// Get supplier name from reasoning data (which has the actual name, not the placeholder)
|
||||
const supplierNameFromReasoning = reasoningData?.parameters?.supplier_name;
|
||||
|
||||
if (poId && reasoningData) {
|
||||
poReasoningMap.set(poId, {
|
||||
reasoning_data: reasoningData,
|
||||
ai_reasoning_summary: alert.ai_reasoning_summary || alert.description || alert.i18n?.message_key,
|
||||
supplier_name_from_alert: supplierNameFromReasoning, // Real supplier name from alert reasoning
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Enrich POs with reasoning data from alerts AND supplier names
|
||||
const enrichedPendingPOs = (pendingPOs || []).map((po: any) => {
|
||||
const reasoningInfo = poReasoningMap.get(po.id);
|
||||
// Prioritize supplier name from alert reasoning (has actual name in demo data)
|
||||
const supplierName = reasoningInfo?.supplier_name_from_alert ||
|
||||
supplierMap.get(po.supplier_id) ||
|
||||
po.supplier_name;
|
||||
|
||||
return {
|
||||
...po,
|
||||
supplier_name: supplierName, // Enrich with actual supplier name
|
||||
// Prioritize reasoning_data from PO itself, then fall back to alert
|
||||
reasoning_data: po.reasoning_data || reasoningInfo?.reasoning_data,
|
||||
ai_reasoning_summary: po.ai_reasoning_summary || reasoningInfo?.ai_reasoning_summary,
|
||||
};
|
||||
});
|
||||
|
||||
// Filter deliveries by status
|
||||
const isPending = (status: string) =>
|
||||
status === 'PENDING' || status === 'sent_to_supplier' || status === 'confirmed';
|
||||
|
||||
const overdueDeliveries = deliveries.filter((d: any) => {
|
||||
if (!isPending(d.status)) return false;
|
||||
const expectedDate = parseISO(d.expected_delivery_date); // Proper UTC parsing
|
||||
return expectedDate < nowUTC;
|
||||
}).map((d: any) => ({
|
||||
...d,
|
||||
hoursOverdue: Math.ceil((nowUTC.getTime() - parseISO(d.expected_delivery_date).getTime()) / (1000 * 60 * 60)),
|
||||
}));
|
||||
|
||||
const pendingDeliveriesFiltered = deliveries.filter((d: any) => {
|
||||
if (!isPending(d.status)) return false;
|
||||
const expectedDate = parseISO(d.expected_delivery_date); // Proper UTC parsing
|
||||
return expectedDate >= nowUTC;
|
||||
}).map((d: any) => ({
|
||||
...d,
|
||||
hoursUntil: Math.ceil((parseISO(d.expected_delivery_date).getTime() - nowUTC.getTime()) / (1000 * 60 * 60)),
|
||||
}));
|
||||
|
||||
// Filter production batches by status
|
||||
const lateToStartBatches = productionBatches.filter((b: any) => {
|
||||
const status = b.status?.toUpperCase();
|
||||
if (status !== 'PENDING' && status !== 'SCHEDULED') return false;
|
||||
const plannedStart = b.planned_start_time;
|
||||
if (!plannedStart) return false;
|
||||
return parseISO(plannedStart) < nowUTC;
|
||||
}).map((b: any) => ({
|
||||
...b,
|
||||
hoursLate: Math.ceil((nowUTC.getTime() - parseISO(b.planned_start_time).getTime()) / (1000 * 60 * 60)),
|
||||
}));
|
||||
|
||||
const runningBatches = productionBatches.filter((b: any) =>
|
||||
b.status?.toUpperCase() === 'IN_PROGRESS'
|
||||
);
|
||||
|
||||
const pendingBatchesFiltered = productionBatches.filter((b: any) => {
|
||||
const status = b.status?.toUpperCase();
|
||||
if (status !== 'PENDING' && status !== 'SCHEDULED') return false;
|
||||
const plannedStart = b.planned_start_time;
|
||||
if (!plannedStart) return true; // No planned start, count as pending
|
||||
return parseISO(plannedStart) >= nowUTC;
|
||||
});
|
||||
|
||||
// Create set of batch IDs that we already show in the UI (late or running)
|
||||
const lateBatchIds = new Set(lateToStartBatches.map((b: any) => b.id));
|
||||
const runningBatchIds = new Set(runningBatches.map((b: any) => b.id));
|
||||
|
||||
// Filter alerts to exclude those for batches already shown in the UI
|
||||
// This prevents duplicate display: batch card + separate alert for the same batch
|
||||
const deduplicatedAlerts = alerts.filter((a: any) => {
|
||||
const eventType = a.event_type || '';
|
||||
const batchId = a.event_metadata?.batch_id || a.entity_links?.production_batch;
|
||||
|
||||
if (!batchId) return true; // Keep alerts not related to batches
|
||||
|
||||
// Filter out batch_start_delayed alerts for batches shown in "late to start" section
|
||||
if (eventType.includes('batch_start_delayed') && lateBatchIds.has(batchId)) {
|
||||
return false; // Already shown as late batch
|
||||
}
|
||||
|
||||
// Filter out production_delay alerts for batches shown in "running" section
|
||||
if (eventType.includes('production_delay') && runningBatchIds.has(batchId)) {
|
||||
return false; // Already shown as running batch (with progress bar showing delay)
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Build orchestration summary
|
||||
// Note: The API only returns timestamp and runNumber, other stats are computed/estimated
|
||||
let orchestrationSummary: OrchestrationSummary | null = null;
|
||||
if (orchestration && orchestration.timestamp) {
|
||||
orchestrationSummary = {
|
||||
runTimestamp: orchestration.timestamp,
|
||||
runNumber: orchestration.runNumber ?? undefined,
|
||||
status: 'completed',
|
||||
purchaseOrdersCreated: enrichedPendingPOs.length, // Estimate from pending POs
|
||||
productionBatchesCreated: productionBatches.length,
|
||||
userActionsRequired: actionNeededAlerts.length,
|
||||
aiHandlingRate: preventedIssues.length > 0
|
||||
? Math.round((preventedIssues.length / (preventedIssues.length + actionNeededAlerts.length)) * 100)
|
||||
: undefined,
|
||||
estimatedSavingsEur: preventedIssues.length * 50, // Rough estimate: €50 per prevented issue
|
||||
};
|
||||
}
|
||||
|
||||
// Map AI insights to dashboard format
|
||||
const mappedAiInsights = aiInsights.map((insight: any) => ({
|
||||
id: insight.id,
|
||||
title: insight.title,
|
||||
description: insight.description,
|
||||
type: mapInsightTypeToBlockType(insight.category),
|
||||
impact: mapPriorityToImpact(insight.priority),
|
||||
impact_value: insight.impact_value?.toString(),
|
||||
impact_currency: insight.impact_unit === 'euros' ? '€' : '',
|
||||
created_at: insight.created_at,
|
||||
recommendation_actions: insight.recommendation_actions || [],
|
||||
}));
|
||||
|
||||
return {
|
||||
// Raw data
|
||||
alerts: deduplicatedAlerts,
|
||||
pendingPOs: enrichedPendingPOs,
|
||||
productionBatches,
|
||||
deliveries,
|
||||
orchestrationSummary,
|
||||
aiInsights: mappedAiInsights,
|
||||
|
||||
// Computed
|
||||
preventedIssues,
|
||||
issuesRequiringAction: actionNeededAlerts.length,
|
||||
issuesPreventedByAI: preventedIssues.length,
|
||||
|
||||
// Filtered for blocks
|
||||
overdueDeliveries,
|
||||
pendingDeliveries: pendingDeliveriesFiltered,
|
||||
lateToStartBatches,
|
||||
runningBatches,
|
||||
pendingBatches: pendingBatchesFiltered,
|
||||
};
|
||||
},
|
||||
enabled: !!tenantId,
|
||||
staleTime: 20000, // 20 seconds
|
||||
refetchOnMount: 'always',
|
||||
retry: 2,
|
||||
});
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Real-time SSE Hook
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Real-time dashboard synchronization via SSE.
|
||||
* Invalidates the dashboard-data query when relevant events occur.
|
||||
*
|
||||
* @param tenantId - Tenant identifier
|
||||
*/
|
||||
export function useDashboardRealtimeSync(tenantId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Subscribe to SSE notifications
|
||||
const { notifications: batchNotifications } = useBatchNotifications();
|
||||
const { notifications: deliveryNotifications } = useDeliveryNotifications();
|
||||
const { recentNotifications: orchestrationNotifications } = useOrchestrationNotifications();
|
||||
const { events: alertEvents } = useSSEEvents({ channels: ['*.alerts'] });
|
||||
const { events: aiInsightEvents } = useSSEEvents({ channels: ['*.ai_insights'] });
|
||||
|
||||
// Invalidate dashboard data on batch events
|
||||
useEffect(() => {
|
||||
if (batchNotifications.length === 0 || !tenantId) return;
|
||||
|
||||
const latest = batchNotifications[0];
|
||||
if (['batch_completed', 'batch_started', 'batch_state_changed'].includes(latest.event_type)) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['dashboard-data', tenantId],
|
||||
refetchType: 'active',
|
||||
});
|
||||
}
|
||||
}, [batchNotifications, tenantId, queryClient]);
|
||||
|
||||
// Invalidate dashboard data on delivery events
|
||||
useEffect(() => {
|
||||
if (deliveryNotifications.length === 0 || !tenantId) return;
|
||||
|
||||
const latest = deliveryNotifications[0];
|
||||
if (['delivery_received', 'delivery_overdue', 'delivery_arriving_soon', 'stock_receipt_incomplete'].includes(latest.event_type)) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['dashboard-data', tenantId],
|
||||
refetchType: 'active',
|
||||
});
|
||||
}
|
||||
}, [deliveryNotifications, tenantId, queryClient]);
|
||||
|
||||
// Invalidate dashboard data on orchestration events
|
||||
useEffect(() => {
|
||||
if (orchestrationNotifications.length === 0 || !tenantId) return;
|
||||
|
||||
const latest = orchestrationNotifications[0];
|
||||
if (latest.event_type === 'orchestration_run_completed') {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['dashboard-data', tenantId],
|
||||
refetchType: 'active',
|
||||
});
|
||||
}
|
||||
}, [orchestrationNotifications, tenantId, queryClient]);
|
||||
|
||||
// Invalidate dashboard data on alert events
|
||||
useEffect(() => {
|
||||
if (!alertEvents || alertEvents.length === 0 || !tenantId) return;
|
||||
|
||||
// Any new alert should trigger a refresh
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['dashboard-data', tenantId],
|
||||
refetchType: 'active',
|
||||
});
|
||||
}, [alertEvents, tenantId, queryClient]);
|
||||
|
||||
// Invalidate dashboard data on AI insight events
|
||||
useEffect(() => {
|
||||
if (!aiInsightEvents || aiInsightEvents.length === 0 || !tenantId) return;
|
||||
|
||||
// Any new AI insight should trigger a refresh
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['dashboard-data', tenantId],
|
||||
refetchType: 'active',
|
||||
});
|
||||
}, [aiInsightEvents, tenantId, queryClient]);
|
||||
}
|
||||
@@ -13,8 +13,8 @@
|
||||
// Mirror: app/models/inventory.py
|
||||
|
||||
export enum ProductType {
|
||||
INGREDIENT = 'ingredient',
|
||||
FINISHED_PRODUCT = 'finished_product'
|
||||
INGREDIENT = 'INGREDIENT',
|
||||
FINISHED_PRODUCT = 'FINISHED_PRODUCT'
|
||||
}
|
||||
|
||||
export enum ProductionStage {
|
||||
@@ -26,15 +26,15 @@ export enum ProductionStage {
|
||||
}
|
||||
|
||||
export enum UnitOfMeasure {
|
||||
KILOGRAMS = 'kg',
|
||||
GRAMS = 'g',
|
||||
LITERS = 'l',
|
||||
MILLILITERS = 'ml',
|
||||
UNITS = 'units',
|
||||
PIECES = 'pcs',
|
||||
PACKAGES = 'pkg',
|
||||
BAGS = 'bags',
|
||||
BOXES = 'boxes'
|
||||
KILOGRAMS = 'KILOGRAMS',
|
||||
GRAMS = 'GRAMS',
|
||||
LITERS = 'LITERS',
|
||||
MILLILITERS = 'MILLILITERS',
|
||||
UNITS = 'UNITS',
|
||||
PIECES = 'PIECES',
|
||||
PACKAGES = 'PACKAGES',
|
||||
BAGS = 'BAGS',
|
||||
BOXES = 'BOXES'
|
||||
}
|
||||
|
||||
export enum IngredientCategory {
|
||||
|
||||
@@ -27,6 +27,8 @@ interface ProductionStatusBlockProps {
|
||||
runningBatches?: any[];
|
||||
pendingBatches?: any[];
|
||||
alerts?: any[]; // Add alerts prop for production-related alerts
|
||||
equipmentAlerts?: any[]; // Equipment-specific alerts
|
||||
productionAlerts?: any[]; // Production alerts (non-equipment)
|
||||
onStartBatch?: (batchId: string) => Promise<void>;
|
||||
onViewBatch?: (batchId: string) => void;
|
||||
loading?: boolean;
|
||||
@@ -37,6 +39,8 @@ export function ProductionStatusBlock({
|
||||
runningBatches = [],
|
||||
pendingBatches = [],
|
||||
alerts = [],
|
||||
equipmentAlerts = [],
|
||||
productionAlerts = [],
|
||||
onStartBatch,
|
||||
onViewBatch,
|
||||
loading,
|
||||
@@ -46,13 +50,35 @@ export function ProductionStatusBlock({
|
||||
const [processingId, setProcessingId] = useState<string | null>(null);
|
||||
|
||||
// Filter production-related alerts and deduplicate by ID
|
||||
const productionAlerts = React.useMemo(() => {
|
||||
// Also filter out alerts for batches already shown in late/running/pending sections
|
||||
const filteredProductionAlerts = React.useMemo(() => {
|
||||
const filtered = alerts.filter((alert: any) => {
|
||||
const eventType = alert.event_type || '';
|
||||
return eventType.includes('production.') ||
|
||||
eventType.includes('equipment_maintenance') ||
|
||||
eventType.includes('production_delay') ||
|
||||
eventType.includes('batch_start_delayed');
|
||||
|
||||
// First filter by event type
|
||||
const isProductionAlert = eventType.includes('production.') ||
|
||||
eventType.includes('equipment_maintenance') ||
|
||||
eventType.includes('production_delay') ||
|
||||
eventType.includes('batch_start_delayed');
|
||||
|
||||
if (!isProductionAlert) return false;
|
||||
|
||||
// Get batch ID from alert
|
||||
const batchId = alert.event_metadata?.batch_id || alert.entity_links?.production_batch;
|
||||
|
||||
// Filter out alerts for batches already shown in UI sections
|
||||
if (batchId) {
|
||||
const isLateBatch = lateToStartBatches.some(batch => batch.id === batchId);
|
||||
const isRunningBatch = runningBatches.some(batch => batch.id === batchId);
|
||||
const isPendingBatch = pendingBatches.some(batch => batch.id === batchId);
|
||||
|
||||
// If this alert is about a batch already shown, filter it out to prevent duplication
|
||||
if (isLateBatch || isRunningBatch || isPendingBatch) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Deduplicate by alert ID to prevent duplicates from API + SSE
|
||||
@@ -65,7 +91,7 @@ export function ProductionStatusBlock({
|
||||
});
|
||||
|
||||
return Array.from(uniqueAlerts.values());
|
||||
}, [alerts]);
|
||||
}, [alerts, lateToStartBatches, runningBatches, pendingBatches]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -88,12 +114,14 @@ export function ProductionStatusBlock({
|
||||
const hasLate = lateToStartBatches.length > 0;
|
||||
const hasRunning = runningBatches.length > 0;
|
||||
const hasPending = pendingBatches.length > 0;
|
||||
const hasAlerts = productionAlerts.length > 0;
|
||||
const hasEquipmentAlerts = equipmentAlerts.length > 0;
|
||||
const hasProductionAlerts = filteredProductionAlerts.length > 0;
|
||||
const hasAlerts = hasEquipmentAlerts || hasProductionAlerts;
|
||||
const hasAnyProduction = hasLate || hasRunning || hasPending || hasAlerts;
|
||||
const totalCount = lateToStartBatches.length + runningBatches.length + pendingBatches.length;
|
||||
|
||||
// Determine header status - prioritize alerts and late batches
|
||||
const status = hasAlerts || hasLate ? 'error' : hasRunning ? 'info' : hasPending ? 'warning' : 'success';
|
||||
// Determine header status - prioritize equipment alerts, then production alerts, then late batches
|
||||
const status = hasEquipmentAlerts ? 'error' : hasProductionAlerts || hasLate ? 'warning' : hasRunning ? 'info' : hasPending ? 'warning' : 'success';
|
||||
|
||||
const statusStyles = {
|
||||
success: {
|
||||
@@ -718,17 +746,32 @@ export function ProductionStatusBlock({
|
||||
{/* Content */}
|
||||
{hasAnyProduction ? (
|
||||
<div className="border-t border-[var(--border-primary)]">
|
||||
{/* Production Alerts Section */}
|
||||
{hasAlerts && (
|
||||
{/* Equipment Alerts Section */}
|
||||
{equipmentAlerts.length > 0 && (
|
||||
<div className="bg-[var(--color-error-50)]">
|
||||
<div className="px-6 py-3 border-b border-[var(--color-error-100)]">
|
||||
<h3 className="text-sm font-semibold text-[var(--color-error-700)] flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
{t('dashboard:new_dashboard.production_status.alerts_section')}
|
||||
{t('dashboard:new_dashboard.production_status.equipment_alerts')}
|
||||
</h3>
|
||||
</div>
|
||||
{productionAlerts.map((alert, index) =>
|
||||
renderAlertItem(alert, index, productionAlerts.length)
|
||||
{equipmentAlerts.map((alert, index) =>
|
||||
renderAlertItem(alert, index, equipmentAlerts.length)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Production Alerts Section */}
|
||||
{filteredProductionAlerts.length > 0 && (
|
||||
<div className="bg-[var(--color-warning-50)]">
|
||||
<div className="px-6 py-3 border-b border-[var(--color-warning-100)]">
|
||||
<h3 className="text-sm font-semibold text-[var(--color-warning-700)] flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
{t('dashboard:new_dashboard.production_status.production_alerts')}
|
||||
</h3>
|
||||
</div>
|
||||
{filteredProductionAlerts.map((alert, index) =>
|
||||
renderAlertItem(alert, index, filteredProductionAlerts.length)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -21,10 +21,10 @@ import {
|
||||
Sparkles,
|
||||
TrendingUp,
|
||||
} from 'lucide-react';
|
||||
import type { DashboardData, OrchestrationSummary } from '../../../api/hooks/useDashboardData';
|
||||
import type { ControlPanelData, OrchestrationSummary } from '../../../api/hooks/useControlPanelData';
|
||||
|
||||
interface SystemStatusBlockProps {
|
||||
data: DashboardData | undefined;
|
||||
data: ControlPanelData | undefined;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
@@ -137,8 +137,8 @@ export function SystemStatusBlock({ data, loading }: SystemStatusBlockProps) {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Issues Prevented by AI */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-[var(--bg-primary)] border border-[var(--border-primary)]">
|
||||
{/* Issues Prevented by AI - Show specific issue types */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-[var(--bg-primary)] border border-[var(--border-primary)] group relative">
|
||||
<Bot className="w-5 h-5 text-[var(--color-primary)]" />
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{issuesPreventedByAI}
|
||||
@@ -146,6 +146,32 @@ export function SystemStatusBlock({ data, loading }: SystemStatusBlockProps) {
|
||||
<span className="text-sm text-[var(--text-secondary)]">
|
||||
{t('dashboard:new_dashboard.system_status.ai_prevented_label')}
|
||||
</span>
|
||||
|
||||
{/* Show specific issue types on hover */}
|
||||
{preventedIssues.length > 0 && (
|
||||
<div className="absolute left-0 bottom-full mb-2 hidden group-hover:block z-10">
|
||||
<div className="bg-[var(--bg-primary)] border border-[var(--border-primary)] rounded-lg shadow-lg p-3 min-w-[200px]">
|
||||
<p className="text-sm font-semibold text-[var(--text-primary)] mb-2">
|
||||
{t('dashboard:new_dashboard.system_status.prevented_issues_types')}
|
||||
</p>
|
||||
<div className="space-y-1 max-h-[150px] overflow-y-auto">
|
||||
{preventedIssues.slice(0, 3).map((issue: any, index: number) => (
|
||||
<div key={index} className="flex items-start gap-2 text-xs">
|
||||
<CheckCircle2 className="w-3 h-3 text-[var(--color-success-500)] mt-0.5 flex-shrink-0" />
|
||||
<span className="text-[var(--text-secondary)] truncate">
|
||||
{issue.title || issue.event_type || issue.message || t('dashboard:new_dashboard.system_status.issue_prevented')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{preventedIssues.length > 3 && (
|
||||
<div className="text-xs text-[var(--text-tertiary)] mt-1">
|
||||
+{preventedIssues.length - 3} {t('common:more')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Last Run */}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useIngredients } from '../../../../api/hooks/inventory';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
import { useAuthUser } from '../../../../stores/auth.store';
|
||||
import { MeasurementUnit } from '../../../../api/types/recipes';
|
||||
import { ProductType } from '../../../../api/types/inventory';
|
||||
import type { RecipeCreate, RecipeIngredientCreate } from '../../../../api/types/recipes';
|
||||
import { getAllRecipeTemplates, matchIngredientToTemplate, type RecipeTemplate } from '../data/recipeTemplates';
|
||||
import { QuickAddIngredientModal } from '../../inventory/QuickAddIngredientModal';
|
||||
@@ -566,7 +567,7 @@ export const RecipesSetupStep: React.FC<SetupStepProps> = ({ onUpdate, onComplet
|
||||
>
|
||||
<option value="">{t('setup_wizard:recipes.placeholders.finished_product', 'Select finished product...')}</option>
|
||||
{ingredients
|
||||
.filter((ing) => ing.product_type === 'finished_product')
|
||||
.filter((ing) => ing.product_type === ProductType.FINISHED_PRODUCT)
|
||||
.map((ing) => (
|
||||
<option key={ing.id} value={ing.id}>
|
||||
{ing.name} ({ing.unit_of_measure})
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { getRegisterUrl } from '../../utils/navigation';
|
||||
|
||||
type BillingCycle = 'monthly' | 'yearly';
|
||||
type DisplayMode = 'landing' | 'settings';
|
||||
type DisplayMode = 'landing' | 'settings' | 'selection';
|
||||
|
||||
interface SubscriptionPricingCardsProps {
|
||||
mode?: DisplayMode;
|
||||
@@ -81,7 +81,7 @@ export const SubscriptionPricingCards: React.FC<SubscriptionPricingCardsProps> =
|
||||
};
|
||||
|
||||
const handlePlanAction = (tier: string, plan: PlanMetadata) => {
|
||||
if (mode === 'settings' && onPlanSelect) {
|
||||
if ((mode === 'settings' || mode === 'selection') && onPlanSelect) {
|
||||
onPlanSelect(tier);
|
||||
}
|
||||
};
|
||||
@@ -146,7 +146,7 @@ export const SubscriptionPricingCards: React.FC<SubscriptionPricingCardsProps> =
|
||||
)}
|
||||
|
||||
{/* Billing Cycle Toggle */}
|
||||
<div className="flex justify-center mb-12">
|
||||
<div className="flex justify-center mb-8">
|
||||
<div className="inline-flex rounded-lg border-2 border-[var(--border-primary)] p-1 bg-[var(--bg-secondary)]">
|
||||
<button
|
||||
onClick={() => setBillingCycle('monthly')}
|
||||
@@ -174,6 +174,16 @@ export const SubscriptionPricingCards: React.FC<SubscriptionPricingCardsProps> =
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selection Mode Helper Text */}
|
||||
{mode === 'selection' && (
|
||||
<div className="text-center mb-6">
|
||||
<p className="text-sm text-[var(--text-secondary)] flex items-center justify-center gap-2">
|
||||
<span className="inline-block w-2 h-2 bg-green-500 rounded-full animate-pulse"></span>
|
||||
{t('ui.click_to_select', 'Haz clic en cualquier plan para seleccionarlo')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Simplified Plans Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 items-start lg:items-stretch">
|
||||
{Object.entries(plans).map(([tier, plan]) => {
|
||||
@@ -186,7 +196,11 @@ export const SubscriptionPricingCards: React.FC<SubscriptionPricingCardsProps> =
|
||||
const CardWrapper = mode === 'landing' ? Link : 'div';
|
||||
const cardProps = mode === 'landing'
|
||||
? { to: getRegisterUrl(tier) }
|
||||
: { onClick: () => handlePlanAction(tier, plan) };
|
||||
: mode === 'selection' || mode === 'settings'
|
||||
? { onClick: () => handlePlanAction(tier, plan) }
|
||||
: {};
|
||||
|
||||
const isSelected = mode === 'selection' && selectedPlan === tier;
|
||||
|
||||
return (
|
||||
<CardWrapper
|
||||
@@ -194,15 +208,27 @@ export const SubscriptionPricingCards: React.FC<SubscriptionPricingCardsProps> =
|
||||
{...cardProps}
|
||||
className={`
|
||||
relative rounded-2xl p-8 transition-all duration-300 block no-underline
|
||||
${mode === 'settings' ? 'cursor-pointer' : mode === 'landing' ? 'cursor-pointer' : ''}
|
||||
${isPopular
|
||||
? 'bg-gradient-to-br from-blue-600 to-blue-800 shadow-xl ring-2 ring-blue-400 lg:scale-105 lg:z-10'
|
||||
: 'bg-[var(--bg-secondary)] border-2 border-[var(--border-primary)] hover:border-[var(--color-primary)] hover:shadow-lg'
|
||||
${mode === 'settings' || mode === 'selection' || mode === 'landing' ? 'cursor-pointer' : ''}
|
||||
${isSelected
|
||||
? 'bg-gradient-to-br from-green-600 to-emerald-700 shadow-2xl ring-4 ring-green-400/60 scale-105 z-20 transform animate-[pulse_2s_ease-in-out_infinite]'
|
||||
: isPopular
|
||||
? 'bg-gradient-to-br from-blue-600 to-blue-800 shadow-xl ring-2 ring-blue-400 lg:scale-105 lg:z-10 hover:ring-4 hover:ring-blue-300 hover:shadow-2xl'
|
||||
: 'bg-[var(--bg-secondary)] border-2 border-[var(--border-primary)] hover:border-[var(--color-primary)] hover:shadow-xl hover:scale-[1.03] hover:ring-2 hover:ring-[var(--color-primary)]/30'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{/* Selected Badge */}
|
||||
{isSelected && (
|
||||
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2 z-10">
|
||||
<div className="bg-white text-green-700 px-6 py-2 rounded-full text-sm font-bold shadow-xl flex items-center gap-1.5 border-2 border-green-400">
|
||||
<Check className="w-4 h-4 stroke-[3]" />
|
||||
{t('ui.selected', 'Seleccionado')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Popular Badge */}
|
||||
{isPopular && (
|
||||
{isPopular && !isSelected && (
|
||||
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2">
|
||||
<div className="bg-gradient-to-r from-green-500 to-emerald-600 text-white px-6 py-2 rounded-full text-sm font-bold shadow-lg flex items-center gap-1">
|
||||
<Star className="w-4 h-4 fill-current" />
|
||||
@@ -213,10 +239,10 @@ export const SubscriptionPricingCards: React.FC<SubscriptionPricingCardsProps> =
|
||||
|
||||
{/* Plan Header */}
|
||||
<div className="mb-6">
|
||||
<h3 className={`text-2xl font-bold mb-2 ${isPopular ? 'text-white' : 'text-[var(--text-primary)]'}`}>
|
||||
<h3 className={`text-2xl font-bold mb-2 ${(isPopular || isSelected) ? 'text-white' : 'text-[var(--text-primary)]'}`}>
|
||||
{plan.name}
|
||||
</h3>
|
||||
<p className={`text-sm ${isPopular ? 'text-white/90' : 'text-[var(--text-secondary)]'}`}>
|
||||
<p className={`text-sm ${(isPopular || isSelected) ? 'text-white/90' : 'text-[var(--text-secondary)]'}`}>
|
||||
{plan.tagline_key ? t(plan.tagline_key) : plan.tagline || ''}
|
||||
</p>
|
||||
</div>
|
||||
@@ -224,17 +250,17 @@ export const SubscriptionPricingCards: React.FC<SubscriptionPricingCardsProps> =
|
||||
{/* Price */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-baseline">
|
||||
<span className={`text-4xl font-bold ${isPopular ? 'text-white' : 'text-[var(--text-primary)]'}`}>
|
||||
<span className={`text-4xl font-bold ${(isPopular || isSelected) ? 'text-white' : 'text-[var(--text-primary)]'}`}>
|
||||
{subscriptionService.formatPrice(price)}
|
||||
</span>
|
||||
<span className={`ml-2 text-lg ${isPopular ? 'text-white/80' : 'text-[var(--text-secondary)]'}`}>
|
||||
<span className={`ml-2 text-lg ${(isPopular || isSelected) ? 'text-white/80' : 'text-[var(--text-secondary)]'}`}>
|
||||
/{billingCycle === 'monthly' ? t('ui.per_month') : t('ui.per_year')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Trial Badge - Always Visible */}
|
||||
<div className={`mt-3 px-3 py-1.5 text-sm font-medium rounded-full inline-block ${
|
||||
isPopular ? 'bg-white/20 text-white' : 'bg-green-500/10 text-green-600 dark:text-green-400'
|
||||
(isPopular || isSelected) ? 'bg-white/20 text-white' : 'bg-green-500/10 text-green-600 dark:text-green-400'
|
||||
}`}>
|
||||
{savings
|
||||
? t('ui.save_amount', { amount: subscriptionService.formatPrice(savings.savingsAmount) })
|
||||
@@ -248,9 +274,9 @@ export const SubscriptionPricingCards: React.FC<SubscriptionPricingCardsProps> =
|
||||
{/* Perfect For */}
|
||||
{plan.recommended_for_key && (
|
||||
<div className={`mb-6 text-center px-4 py-2 rounded-lg ${
|
||||
isPopular ? 'bg-white/10' : 'bg-[var(--bg-primary)] border border-[var(--border-primary)]'
|
||||
(isPopular || isSelected) ? 'bg-white/10' : 'bg-[var(--bg-primary)] border border-[var(--border-primary)]'
|
||||
}`}>
|
||||
<p className={`text-sm font-medium ${isPopular ? 'text-white/90' : 'text-[var(--text-secondary)]'}`}>
|
||||
<p className={`text-sm font-medium ${(isPopular || isSelected) ? 'text-white/90' : 'text-[var(--text-secondary)]'}`}>
|
||||
{t(plan.recommended_for_key)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -259,12 +285,12 @@ export const SubscriptionPricingCards: React.FC<SubscriptionPricingCardsProps> =
|
||||
{/* Feature Inheritance Indicator */}
|
||||
{tier === SUBSCRIPTION_TIERS.PROFESSIONAL && (
|
||||
<div className={`mb-5 px-4 py-3 rounded-xl transition-all ${
|
||||
isPopular
|
||||
(isPopular || isSelected)
|
||||
? 'bg-gradient-to-r from-white/20 to-white/10 border-2 border-white/40 shadow-lg'
|
||||
: 'bg-gradient-to-r from-blue-500/10 to-blue-600/5 border-2 border-blue-400/30 dark:from-blue-400/10 dark:to-blue-500/5 dark:border-blue-400/30'
|
||||
}`}>
|
||||
<p className={`text-xs font-bold uppercase tracking-wide text-center ${
|
||||
isPopular ? 'text-white drop-shadow-sm' : 'text-blue-600 dark:text-blue-300'
|
||||
(isPopular || isSelected) ? 'text-white drop-shadow-sm' : 'text-blue-600 dark:text-blue-300'
|
||||
}`}>
|
||||
✓ {t('ui.feature_inheritance_professional')}
|
||||
</p>
|
||||
@@ -272,12 +298,12 @@ export const SubscriptionPricingCards: React.FC<SubscriptionPricingCardsProps> =
|
||||
)}
|
||||
{tier === SUBSCRIPTION_TIERS.ENTERPRISE && (
|
||||
<div className={`mb-5 px-4 py-3 rounded-xl transition-all ${
|
||||
isPopular
|
||||
(isPopular || isSelected)
|
||||
? 'bg-gradient-to-r from-white/20 to-white/10 border-2 border-white/40 shadow-lg'
|
||||
: 'bg-gradient-to-r from-gray-700/20 to-gray-800/10 border-2 border-gray-600/40 dark:from-gray-600/20 dark:to-gray-700/10 dark:border-gray-500/40'
|
||||
}`}>
|
||||
<p className={`text-xs font-bold uppercase tracking-wide text-center ${
|
||||
isPopular ? 'text-white drop-shadow-sm' : 'text-gray-700 dark:text-gray-300'
|
||||
(isPopular || isSelected) ? 'text-white drop-shadow-sm' : 'text-gray-700 dark:text-gray-300'
|
||||
}`}>
|
||||
✓ {t('ui.feature_inheritance_enterprise')}
|
||||
</p>
|
||||
@@ -291,34 +317,34 @@ export const SubscriptionPricingCards: React.FC<SubscriptionPricingCardsProps> =
|
||||
<div key={feature} className="flex items-start">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<div className={`w-5 h-5 rounded-full flex items-center justify-center ${
|
||||
isPopular ? 'bg-white' : 'bg-green-500'
|
||||
(isPopular || isSelected) ? 'bg-white' : 'bg-green-500'
|
||||
}`}>
|
||||
<Check className={`w-3 h-3 ${isPopular ? 'text-blue-600' : 'text-white'}`} />
|
||||
<Check className={`w-3 h-3 ${(isPopular || isSelected) ? 'text-blue-600' : 'text-white'}`} />
|
||||
</div>
|
||||
</div>
|
||||
<span className={`ml-3 text-sm font-medium ${isPopular ? 'text-white' : 'text-[var(--text-primary)]'}`}>
|
||||
<span className={`ml-3 text-sm font-medium ${(isPopular || isSelected) ? 'text-white' : 'text-[var(--text-primary)]'}`}>
|
||||
{formatFeatureName(feature)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Key Limits (Users, Locations, Products) */}
|
||||
<div className={`pt-4 mt-4 border-t space-y-2 ${isPopular ? 'border-white/20' : 'border-[var(--border-primary)]'}`}>
|
||||
<div className={`pt-4 mt-4 border-t space-y-2 ${(isPopular || isSelected) ? 'border-white/20' : 'border-[var(--border-primary)]'}`}>
|
||||
<div className="flex items-center text-sm">
|
||||
<Users className={`w-4 h-4 mr-2 ${isPopular ? 'text-white/80' : 'text-[var(--text-secondary)]'}`} />
|
||||
<span className={`font-medium ${isPopular ? 'text-white' : 'text-[var(--text-primary)]'}`}>
|
||||
<Users className={`w-4 h-4 mr-2 ${(isPopular || isSelected) ? 'text-white/80' : 'text-[var(--text-secondary)]'}`} />
|
||||
<span className={`font-medium ${(isPopular || isSelected) ? 'text-white' : 'text-[var(--text-primary)]'}`}>
|
||||
{formatLimit(plan.limits.users, 'limits.users_unlimited')} {t('limits.users_label', 'usuarios')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center text-sm">
|
||||
<MapPin className={`w-4 h-4 mr-2 ${isPopular ? 'text-white/80' : 'text-[var(--text-secondary)]'}`} />
|
||||
<span className={`font-medium ${isPopular ? 'text-white' : 'text-[var(--text-primary)]'}`}>
|
||||
<MapPin className={`w-4 h-4 mr-2 ${(isPopular || isSelected) ? 'text-white/80' : 'text-[var(--text-secondary)]'}`} />
|
||||
<span className={`font-medium ${(isPopular || isSelected) ? 'text-white' : 'text-[var(--text-primary)]'}`}>
|
||||
{formatLimit(plan.limits.locations, 'limits.locations_unlimited')} {t('limits.locations_label', 'ubicaciones')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center text-sm">
|
||||
<Package className={`w-4 h-4 mr-2 ${isPopular ? 'text-white/80' : 'text-[var(--text-secondary)]'}`} />
|
||||
<span className={`font-medium ${isPopular ? 'text-white' : 'text-[var(--text-primary)]'}`}>
|
||||
<Package className={`w-4 h-4 mr-2 ${(isPopular || isSelected) ? 'text-white/80' : 'text-[var(--text-secondary)]'}`} />
|
||||
<span className={`font-medium ${(isPopular || isSelected) ? 'text-white' : 'text-[var(--text-primary)]'}`}>
|
||||
{formatLimit(plan.limits.products, 'limits.products_unlimited')} {t('limits.products_label', 'productos')}
|
||||
</span>
|
||||
</div>
|
||||
@@ -328,23 +354,32 @@ export const SubscriptionPricingCards: React.FC<SubscriptionPricingCardsProps> =
|
||||
{/* CTA Button */}
|
||||
<Button
|
||||
className={`w-full py-4 text-base font-semibold transition-all ${
|
||||
isPopular
|
||||
isSelected
|
||||
? 'bg-white text-green-700 hover:bg-gray-50 shadow-lg border-2 border-white/50'
|
||||
: isPopular
|
||||
? 'bg-white text-blue-600 hover:bg-gray-100'
|
||||
: 'bg-[var(--color-primary)] text-white hover:bg-[var(--color-primary-dark)]'
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
if (mode === 'settings') {
|
||||
if (mode === 'settings' || mode === 'selection') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handlePlanAction(tier, plan);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('ui.start_free_trial')}
|
||||
{mode === 'selection' && isSelected
|
||||
? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Check className="w-5 h-5" />
|
||||
{t('ui.plan_selected', 'Plan Seleccionado')}
|
||||
</span>
|
||||
)
|
||||
: t('ui.start_free_trial')}
|
||||
</Button>
|
||||
|
||||
{/* Footer */}
|
||||
<p className={`text-xs text-center mt-3 ${isPopular ? 'text-white/80' : 'text-[var(--text-secondary)]'}`}>
|
||||
<p className={`text-xs text-center mt-3 ${(isPopular || isSelected) ? 'text-white/80' : 'text-[var(--text-secondary)]'}`}>
|
||||
{showPilotBanner
|
||||
? t('ui.free_trial_footer', { months: pilotTrialMonths })
|
||||
: t('ui.free_trial_footer', { months: 0 })
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
"login_link": "Sign in here",
|
||||
"terms_link": "Terms of Service",
|
||||
"privacy_link": "Privacy Policy",
|
||||
"step_of": "Step {{current}} of {{total}}",
|
||||
"step_of": "Step {current} of {total}",
|
||||
"continue": "Continue",
|
||||
"back": "Back"
|
||||
},
|
||||
|
||||
@@ -437,6 +437,7 @@
|
||||
"ai_prevented_label": "prevented by AI",
|
||||
"last_run_label": "Last run",
|
||||
"ai_prevented_details": "Issues Prevented by AI",
|
||||
"prevented_issues_types": "Prevented Issues Types",
|
||||
"ai_handling_rate": "AI Handling Rate",
|
||||
"estimated_savings": "Estimated Savings",
|
||||
"issues_prevented": "Issues Prevented",
|
||||
@@ -525,6 +526,8 @@
|
||||
"confidence": "Confidence: {confidence}%",
|
||||
"variance": "Variance: +{variance}%",
|
||||
"historical_avg": "Hist. avg: {avg} units",
|
||||
"equipment_alerts": "Equipment Alerts",
|
||||
"production_alerts": "Production Alerts",
|
||||
"alerts_section": "Production Alerts",
|
||||
"alerts": {
|
||||
"equipment_maintenance": "Equipment Maintenance Required",
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"finish": "Finish"
|
||||
},
|
||||
"progress": {
|
||||
"step_of": "Step {{current}} of {{total}}",
|
||||
"step_of": "Step {current} of {total}",
|
||||
"completed": "Completed",
|
||||
"in_progress": "In progress",
|
||||
"pending": "Pending"
|
||||
|
||||
@@ -127,6 +127,8 @@
|
||||
"start_free_trial": "Start Free Trial",
|
||||
"choose_plan": "Choose Plan",
|
||||
"selected": "Selected",
|
||||
"plan_selected": "Plan Selected",
|
||||
"click_to_select": "Click on any plan to select it",
|
||||
"best_value": "Best Value",
|
||||
"free_trial_footer": "{months} months free • Card required",
|
||||
"professional_value_badge": "10x capacity • Advanced AI • Multi-location",
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
"login_link": "Inicia sesión aquí",
|
||||
"terms_link": "Términos de Servicio",
|
||||
"privacy_link": "Política de Privacidad",
|
||||
"step_of": "Paso {{current}} de {{total}}",
|
||||
"step_of": "Paso {current} de {total}",
|
||||
"continue": "Continuar",
|
||||
"back": "Atrás"
|
||||
},
|
||||
|
||||
@@ -486,6 +486,7 @@
|
||||
"ai_prevented_label": "evitados por IA",
|
||||
"last_run_label": "Última ejecución",
|
||||
"ai_prevented_details": "Problemas Evitados por IA",
|
||||
"prevented_issues_types": "Tipos de Problemas Evitados",
|
||||
"ai_handling_rate": "Tasa de Gestión IA",
|
||||
"estimated_savings": "Ahorros Estimados",
|
||||
"issues_prevented": "Problemas Evitados",
|
||||
@@ -574,6 +575,8 @@
|
||||
"confidence": "Confianza: {confidence}%",
|
||||
"variance": "Variación: +{variance}%",
|
||||
"historical_avg": "Media hist.: {avg} unidades",
|
||||
"equipment_alerts": "Alertas de Equipo",
|
||||
"production_alerts": "Alertas de Producción",
|
||||
"alerts_section": "Alertas de Producción",
|
||||
"alerts": {
|
||||
"equipment_maintenance": "Mantenimiento de Equipo Requerido",
|
||||
|
||||
@@ -193,11 +193,11 @@
|
||||
"out_of_stock_alert": "Alerta de Sin Stock",
|
||||
"expiration_alert": "Alerta de Caducidad",
|
||||
"reorder_reminder": "Recordatorio de Reorden",
|
||||
"low_stock_message": "{{item}} tiene stock bajo ({{current}} / {{min}})",
|
||||
"out_of_stock_message": "{{item}} está sin stock",
|
||||
"expiring_message": "{{item}} caduca el {{date}}",
|
||||
"expired_message": "{{item}} ha caducado",
|
||||
"reorder_message": "Es hora de reordenar {{item}}"
|
||||
"low_stock_message": "{item} tiene stock bajo ({current} / {min})",
|
||||
"out_of_stock_message": "{item} está sin stock",
|
||||
"expiring_message": "{item} caduca el {date}",
|
||||
"expired_message": "{item} ha caducado",
|
||||
"reorder_message": "Es hora de reordenar {item}"
|
||||
},
|
||||
"filters": {
|
||||
"all_categories": "Todas las categorías",
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
"finish": "Finalizar"
|
||||
},
|
||||
"progress": {
|
||||
"step_of": "Paso {{current}} de {{total}}",
|
||||
"step_of": "Paso {current} de {total}",
|
||||
"completed": "Completado",
|
||||
"in_progress": "En progreso",
|
||||
"pending": "Pendiente"
|
||||
@@ -395,7 +395,7 @@
|
||||
"skip_for_now": "Omitir por ahora (se establecerá a 0)",
|
||||
"ingredients": "Ingredientes",
|
||||
"finished_products": "Productos Terminados",
|
||||
"incomplete_warning": "Faltan {{count}} productos por completar",
|
||||
"incomplete_warning": "Faltan {count} productos por completar",
|
||||
"incomplete_help": "Puedes continuar, pero recomendamos ingresar todas las cantidades para un mejor control de inventario.",
|
||||
"complete": "Completar Configuración",
|
||||
"continue_anyway": "Continuar de todos modos",
|
||||
|
||||
@@ -127,6 +127,8 @@
|
||||
"start_free_trial": "Comenzar Prueba Gratuita",
|
||||
"choose_plan": "Elegir Plan",
|
||||
"selected": "Seleccionado",
|
||||
"plan_selected": "Plan Seleccionado",
|
||||
"click_to_select": "Haz clic en cualquier plan para seleccionarlo",
|
||||
"best_value": "Mejor Valor",
|
||||
"free_trial_footer": "{months} meses gratis • Tarjeta requerida",
|
||||
"professional_value_badge": "10x capacidad • IA Avanzada • Multi-ubicación",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"next": "Siguiente",
|
||||
"back": "Atrás",
|
||||
"complete": "Completar",
|
||||
"stepOf": "Paso {{current}} de {{total}}"
|
||||
"stepOf": "Paso {current} de {total}"
|
||||
},
|
||||
"keyValueEditor": {
|
||||
"showBuilder": "Mostrar Constructor",
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
"login_link": "Hasi saioa hemen",
|
||||
"terms_link": "Zerbitzu baldintzak",
|
||||
"privacy_link": "Pribatutasun politika",
|
||||
"step_of": "{{current}}. urratsa {{total}}-tik",
|
||||
"step_of": "{current}. urratsa {total}-tik",
|
||||
"continue": "Jarraitu",
|
||||
"back": "Atzera"
|
||||
},
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
"finish": "Amaitu"
|
||||
},
|
||||
"progress": {
|
||||
"step_of": "{{current}}. pausoa {{total}}tik",
|
||||
"step_of": "{current}. pausoa {total}tik",
|
||||
"completed": "Osatuta",
|
||||
"in_progress": "Abian",
|
||||
"pending": "Zain"
|
||||
|
||||
@@ -127,6 +127,8 @@
|
||||
"start_free_trial": "Hasi proba doakoa",
|
||||
"choose_plan": "Plana aukeratu",
|
||||
"selected": "Hautatuta",
|
||||
"plan_selected": "Plana Hautatuta",
|
||||
"click_to_select": "Egin klik edozein planetan hautatzeko",
|
||||
"best_value": "Balio Onena",
|
||||
"free_trial_footer": "{months} hilabete doan • Txartela beharrezkoa",
|
||||
"professional_value_badge": "10x ahalmena • AI Aurreratua • Hainbat kokapen",
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
useApprovePurchaseOrder,
|
||||
useStartProductionBatch,
|
||||
} from '../../api/hooks/useProfessionalDashboard';
|
||||
import { useDashboardData, useDashboardRealtimeSync } from '../../api/hooks/useDashboardData';
|
||||
import { useControlPanelData, useControlPanelRealtimeSync } from '../../api/hooks/useControlPanelData';
|
||||
import { useRejectPurchaseOrder } from '../../api/hooks/purchase-orders';
|
||||
import { useIngredients } from '../../api/hooks/inventory';
|
||||
import { useSuppliers } from '../../api/hooks/suppliers';
|
||||
@@ -99,15 +99,15 @@ export function BakeryDashboard({ plan }: { plan?: string }) {
|
||||
);
|
||||
const qualityTemplates = Array.isArray(qualityData?.templates) ? qualityData.templates : [];
|
||||
|
||||
// NEW: Single unified data fetch for all 4 dashboard blocks
|
||||
// NEW: Enhanced control panel data fetch with SSE integration
|
||||
const {
|
||||
data: dashboardData,
|
||||
isLoading: dashboardLoading,
|
||||
refetch: refetchDashboard,
|
||||
} = useDashboardData(tenantId);
|
||||
} = useControlPanelData(tenantId);
|
||||
|
||||
// Enable SSE real-time state synchronization
|
||||
useDashboardRealtimeSync(tenantId);
|
||||
// Enable enhanced SSE real-time state synchronization
|
||||
useControlPanelRealtimeSync(tenantId);
|
||||
|
||||
// Mutations
|
||||
const approvePO = useApprovePurchaseOrder();
|
||||
@@ -417,6 +417,7 @@ export function BakeryDashboard({ plan }: { plan?: string }) {
|
||||
runningBatches={dashboardData?.runningBatches || []}
|
||||
pendingBatches={dashboardData?.pendingBatches || []}
|
||||
alerts={dashboardData?.alerts || []}
|
||||
equipmentAlerts={dashboardData?.equipmentAlerts || []}
|
||||
loading={dashboardLoading}
|
||||
onStartBatch={handleStartBatch}
|
||||
/>
|
||||
|
||||
@@ -4,15 +4,17 @@ import { useTranslation } from 'react-i18next';
|
||||
import { PublicLayout } from '../../components/layout';
|
||||
import {
|
||||
Button,
|
||||
TableOfContents,
|
||||
ProgressBar,
|
||||
FloatingCTA,
|
||||
ScrollReveal,
|
||||
SavingsCalculator,
|
||||
StepTimeline,
|
||||
AnimatedCounter,
|
||||
TOCSection,
|
||||
TimelineStep
|
||||
TimelineStep,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
Badge
|
||||
} from '../../components/ui';
|
||||
import { getDemoUrl } from '../../utils/navigation';
|
||||
import {
|
||||
@@ -41,7 +43,8 @@ import {
|
||||
Droplets,
|
||||
Award,
|
||||
Database,
|
||||
FileText
|
||||
FileText,
|
||||
Sparkles
|
||||
} from 'lucide-react';
|
||||
|
||||
const FeaturesPage: React.FC = () => {
|
||||
@@ -117,39 +120,6 @@ const FeaturesPage: React.FC = () => {
|
||||
},
|
||||
];
|
||||
|
||||
// Table of Contents sections
|
||||
const tocSections: TOCSection[] = [
|
||||
{
|
||||
id: 'automatic-system',
|
||||
label: t('toc.automatic', 'Sistema Automático'),
|
||||
icon: <Clock className="w-4 h-4" />
|
||||
},
|
||||
{
|
||||
id: 'local-intelligence',
|
||||
label: t('toc.local', 'Inteligencia Local'),
|
||||
icon: <MapPin className="w-4 h-4" />
|
||||
},
|
||||
{
|
||||
id: 'demand-forecasting',
|
||||
label: t('toc.forecasting', 'Predicción de Demanda'),
|
||||
icon: <Target className="w-4 h-4" />
|
||||
},
|
||||
{
|
||||
id: 'waste-reduction',
|
||||
label: t('toc.waste', 'Reducción de Desperdicios'),
|
||||
icon: <Recycle className="w-4 h-4" />
|
||||
},
|
||||
{
|
||||
id: 'sustainability',
|
||||
label: t('toc.sustainability', 'Sostenibilidad'),
|
||||
icon: <Leaf className="w-4 h-4" />
|
||||
},
|
||||
{
|
||||
id: 'business-models',
|
||||
label: t('toc.business', 'Modelos de Negocio'),
|
||||
icon: <Store className="w-4 h-4" />
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PublicLayout
|
||||
@@ -175,44 +145,69 @@ const FeaturesPage: React.FC = () => {
|
||||
dismissible
|
||||
/>
|
||||
|
||||
{/* Main Content with Sidebar */}
|
||||
<div className="flex gap-8">
|
||||
{/* Sidebar - Table of Contents */}
|
||||
<aside className="hidden lg:block w-64 flex-shrink-0">
|
||||
<TableOfContents sections={tocSections} />
|
||||
</aside>
|
||||
{/* Hero Section - Enhanced with Demo page style */}
|
||||
<section className="relative overflow-hidden bg-gradient-to-br from-[var(--bg-primary)] via-[var(--bg-secondary)] to-[var(--color-primary)]/5 dark:from-[var(--bg-primary)] dark:via-[var(--bg-secondary)] dark:to-[var(--color-primary)]/10 py-24">
|
||||
{/* Background Pattern */}
|
||||
<div className="absolute inset-0 bg-pattern opacity-50"></div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Hero Section */}
|
||||
<section className="bg-gradient-to-br from-[var(--bg-primary)] via-[var(--bg-secondary)] to-[var(--color-primary)]/5 py-20">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{/* Animated Background Elements */}
|
||||
<div className="absolute top-20 left-10 w-72 h-72 bg-[var(--color-primary)]/10 rounded-full blur-3xl animate-pulse"></div>
|
||||
<div className="absolute bottom-10 right-10 w-96 h-96 bg-[var(--color-secondary)]/10 rounded-full blur-3xl animate-pulse" style={{ animationDelay: '1s' }}></div>
|
||||
|
||||
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<ScrollReveal variant="fadeUp" delay={0.1}>
|
||||
<div className="text-center max-w-4xl mx-auto">
|
||||
<h1 className="text-4xl lg:text-6xl font-extrabold text-[var(--text-primary)] mb-6">
|
||||
{t('hero.title', 'Cómo Bakery-IA Trabaja Para Ti Cada Día')}
|
||||
<div className="text-center max-w-4xl mx-auto space-y-6">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-[var(--color-primary)]/10 dark:bg-[var(--color-primary)]/20 border border-[var(--color-primary)]/20 dark:border-[var(--color-primary)]/30 mb-4">
|
||||
<Sparkles className="w-4 h-4 text-[var(--color-primary)]" />
|
||||
<span className="text-sm font-medium text-[var(--color-primary)]">{t('hero.badge', 'Funcionalidades Completas')}</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-5xl md:text-6xl font-bold text-[var(--text-primary)] mb-6 leading-tight">
|
||||
{t('hero.title', 'Cómo Bakery-IA')}
|
||||
<span className="block bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-secondary)] bg-clip-text text-transparent">
|
||||
{t('hero.title_highlight', 'Trabaja Para Ti Cada Día')}
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-xl text-[var(--text-secondary)] leading-relaxed">
|
||||
|
||||
<p className="text-xl md:text-2xl text-[var(--text-secondary)] max-w-3xl mx-auto leading-relaxed">
|
||||
{t('hero.subtitle', 'Todas las funcionalidades explicadas en lenguaje sencillo para dueños de panaderías')}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-center gap-8 pt-4 text-sm text-[var(--text-tertiary)]">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-5 h-5 text-[var(--color-success)]" />
|
||||
<span>{t('hero.feature1', 'IA Automática 24/7')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-5 h-5 text-[var(--color-success)]" />
|
||||
<span>{t('hero.feature2', '92% Precisión')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-5 h-5 text-[var(--color-success)]" />
|
||||
<span>{t('hero.feature3', 'ROI Inmediato')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Feature 1: Automatic Daily System - THE KILLER FEATURE */}
|
||||
<section id="automatic-system" className="py-20 bg-[var(--bg-primary)] scroll-mt-24">
|
||||
<section id="automatic-system" className="py-20 bg-[var(--bg-primary)]">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<ScrollReveal variant="fadeUp" delay={0.1}>
|
||||
<div className="text-center mb-16">
|
||||
<div className="inline-flex items-center gap-2 bg-[var(--color-primary)]/10 text-[var(--color-primary)] px-4 py-2 rounded-full text-sm font-medium mb-6">
|
||||
<Clock className="w-4 h-4" />
|
||||
<div className="inline-flex items-center gap-2 bg-[var(--color-primary)]/10 dark:bg-[var(--color-primary)]/20 border border-[var(--color-primary)]/20 dark:border-[var(--color-primary)]/30 text-[var(--color-primary)] px-4 py-2 rounded-full text-sm font-medium mb-6">
|
||||
<Clock className="w-5 h-5" />
|
||||
<span>{t('automatic.badge', 'La Funcionalidad Estrella')}</span>
|
||||
</div>
|
||||
<h2 className="text-3xl lg:text-5xl font-extrabold text-[var(--text-primary)] mb-6">
|
||||
{t('automatic.title', 'Tu Asistente Personal Que Nunca Duerme')}
|
||||
<h2 className="text-4xl lg:text-5xl font-bold text-[var(--text-primary)] mb-6">
|
||||
{t('automatic.title', 'Tu Asistente Personal')}
|
||||
<span className="block bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-secondary)] bg-clip-text text-transparent">
|
||||
{t('automatic.title_highlight', 'Que Nunca Duerme')}
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-xl text-[var(--text-secondary)] max-w-3xl mx-auto">
|
||||
<p className="text-xl text-[var(--text-secondary)] max-w-3xl mx-auto leading-relaxed">
|
||||
{t('automatic.intro', 'Imagina contratar un ayudante súper organizado que llega a las 5:30 AM (antes que tú) y hace todo esto AUTOMÁTICAMENTE:')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -229,61 +224,65 @@ const FeaturesPage: React.FC = () => {
|
||||
/>
|
||||
|
||||
{/* Morning Result */}
|
||||
<div className="bg-gradient-to-r from-[var(--color-primary)] to-orange-600 rounded-2xl p-8 text-white">
|
||||
<div className="text-center max-w-3xl mx-auto">
|
||||
<Clock className="w-16 h-16 mx-auto mb-4" />
|
||||
<h3 className="text-2xl lg:text-3xl font-bold mb-4">
|
||||
<div className="relative overflow-hidden bg-gradient-to-r from-[var(--color-primary)] to-orange-600 rounded-2xl p-8 text-white shadow-xl mt-8">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent animate-shimmer"></div>
|
||||
<div className="relative text-center max-w-3xl mx-auto">
|
||||
<div className="relative inline-block mb-6">
|
||||
<Clock className="w-16 h-16 mx-auto" />
|
||||
<div className="absolute inset-0 rounded-full bg-white/20 blur-xl animate-pulse"></div>
|
||||
</div>
|
||||
<h3 className="text-2xl lg:text-3xl font-bold mb-6">
|
||||
{t('automatic.result.title', 'A las 6:00 AM recibes un email:')}
|
||||
</h3>
|
||||
<div className="space-y-2 text-lg">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<CheckCircle2 className="w-6 h-6" />
|
||||
<span>{t('automatic.result.item1', 'Predicción del día hecha')}</span>
|
||||
<div className="grid md:grid-cols-2 gap-4 text-left">
|
||||
<div className="flex items-center gap-3 bg-white/10 backdrop-blur-sm rounded-lg p-4">
|
||||
<CheckCircle2 className="w-6 h-6 flex-shrink-0" />
|
||||
<span className="text-lg">{t('automatic.result.item1', 'Predicción del día hecha')}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<CheckCircle2 className="w-6 h-6" />
|
||||
<span>{t('automatic.result.item2', 'Plan de producción listo')}</span>
|
||||
<div className="flex items-center gap-3 bg-white/10 backdrop-blur-sm rounded-lg p-4">
|
||||
<CheckCircle2 className="w-6 h-6 flex-shrink-0" />
|
||||
<span className="text-lg">{t('automatic.result.item2', 'Plan de producción listo')}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<CheckCircle2 className="w-6 h-6" />
|
||||
<span>{t('automatic.result.item3', '3 pedidos creados (aprobar con 1 clic)')}</span>
|
||||
<div className="flex items-center gap-3 bg-white/10 backdrop-blur-sm rounded-lg p-4">
|
||||
<CheckCircle2 className="w-6 h-6 flex-shrink-0" />
|
||||
<span className="text-lg">{t('automatic.result.item3', '3 pedidos creados (aprobar con 1 clic)')}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<CheckCircle2 className="w-6 h-6" />
|
||||
<span>{t('automatic.result.item4', 'Alerta: "Leche caduca en 2 días, úsala primero"')}</span>
|
||||
<div className="flex items-center gap-3 bg-white/10 backdrop-blur-sm rounded-lg p-4">
|
||||
<CheckCircle2 className="w-6 h-6 flex-shrink-0" />
|
||||
<span className="text-lg">{t('automatic.result.item4', 'Alerta: "Leche caduca en 2 días, úsala primero"')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* What it eliminates */}
|
||||
<div className="bg-[var(--bg-secondary)] rounded-2xl p-8 border border-[var(--border-primary)]">
|
||||
<div className="bg-gradient-to-br from-[var(--bg-secondary)] to-[var(--bg-tertiary)] rounded-2xl p-8 border border-[var(--border-primary)] shadow-lg mt-8">
|
||||
<h3 className="text-2xl font-bold text-[var(--text-primary)] mb-6 text-center">
|
||||
{t('automatic.eliminates.title', 'Lo que ELIMINA de tu rutina:')}
|
||||
</h3>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-red-500 text-xl">❌</span>
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg hover:bg-[var(--bg-primary)] transition-colors">
|
||||
<span className="text-red-500 text-xl flex-shrink-0">❌</span>
|
||||
<span className="text-[var(--text-secondary)]">{t('automatic.eliminates.item1', 'Adivinar cuánto hacer')}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-red-500 text-xl">❌</span>
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg hover:bg-[var(--bg-primary)] transition-colors">
|
||||
<span className="text-red-500 text-xl flex-shrink-0">❌</span>
|
||||
<span className="text-[var(--text-secondary)]">{t('automatic.eliminates.item2', 'Contar inventario manualmente')}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-red-500 text-xl">❌</span>
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg hover:bg-[var(--bg-primary)] transition-colors">
|
||||
<span className="text-red-500 text-xl flex-shrink-0">❌</span>
|
||||
<span className="text-[var(--text-secondary)]">{t('automatic.eliminates.item3', 'Calcular cuándo pedir a proveedores')}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-red-500 text-xl">❌</span>
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg hover:bg-[var(--bg-primary)] transition-colors">
|
||||
<span className="text-red-500 text-xl flex-shrink-0">❌</span>
|
||||
<span className="text-[var(--text-secondary)]">{t('automatic.eliminates.item4', 'Recordar fechas de caducidad')}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-red-500 text-xl">❌</span>
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg hover:bg-[var(--bg-primary)] transition-colors">
|
||||
<span className="text-red-500 text-xl flex-shrink-0">❌</span>
|
||||
<span className="text-[var(--text-secondary)]">{t('automatic.eliminates.item5', 'Preocuparte por quedarte sin stock')}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-red-500 text-xl">❌</span>
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg hover:bg-[var(--bg-primary)] transition-colors">
|
||||
<span className="text-red-500 text-xl flex-shrink-0">❌</span>
|
||||
<span className="text-[var(--text-secondary)]">{t('automatic.eliminates.item6', 'Desperdiciar ingredientes caducados')}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -294,18 +293,21 @@ const FeaturesPage: React.FC = () => {
|
||||
</section>
|
||||
|
||||
{/* Feature 2: Local Intelligence */}
|
||||
<section id="local-intelligence" className="py-20 bg-[var(--bg-secondary)] scroll-mt-24">
|
||||
<section id="local-intelligence" className="py-20 bg-[var(--bg-secondary)]">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<ScrollReveal variant="fadeUp" delay={0.1}>
|
||||
<div className="text-center mb-16">
|
||||
<div className="inline-flex items-center gap-2 bg-[var(--color-primary)]/10 text-[var(--color-primary)] px-4 py-2 rounded-full text-sm font-medium mb-6">
|
||||
<MapPin className="w-4 h-4" />
|
||||
<div className="inline-flex items-center gap-2 bg-[var(--color-primary)]/10 dark:bg-[var(--color-primary)]/20 border border-[var(--color-primary)]/20 dark:border-[var(--color-primary)]/30 text-[var(--color-primary)] px-4 py-2 rounded-full text-sm font-medium mb-6">
|
||||
<MapPin className="w-5 h-5" />
|
||||
<span>{t('local.badge', 'Tu Ventaja Competitiva')}</span>
|
||||
</div>
|
||||
<h2 className="text-3xl lg:text-5xl font-extrabold text-[var(--text-primary)] mb-6">
|
||||
{t('local.title', 'Tu Panadería Es Única. La IA También.')}
|
||||
<h2 className="text-4xl lg:text-5xl font-bold text-[var(--text-primary)] mb-6">
|
||||
{t('local.title', 'Tu Panadería Es Única.')}
|
||||
<span className="block bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-secondary)] bg-clip-text text-transparent">
|
||||
{t('local.title_highlight', 'La IA También.')}
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-xl text-[var(--text-secondary)] max-w-3xl mx-auto">
|
||||
<p className="text-xl text-[var(--text-secondary)] max-w-3xl mx-auto leading-relaxed">
|
||||
{t('local.intro', 'Las IA genéricas saben que es lunes. La TUYA sabe que:')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -314,8 +316,8 @@ const FeaturesPage: React.FC = () => {
|
||||
<div className="grid md:grid-cols-2 gap-8 max-w-6xl mx-auto">
|
||||
{/* Schools */}
|
||||
<ScrollReveal variant="fadeUp" delay={0.1}>
|
||||
<div className="bg-[var(--bg-primary)] rounded-2xl p-6 border border-[var(--border-primary)] hover:shadow-xl transition-all">
|
||||
<div className="w-12 h-12 bg-blue-500/10 rounded-xl flex items-center justify-center mb-4">
|
||||
<div className="bg-[var(--bg-primary)] rounded-2xl p-6 border border-[var(--border-primary)] shadow-lg hover:shadow-2xl transition-all duration-300 hover:-translate-y-1">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-blue-500/10 to-blue-600/10 rounded-xl flex items-center justify-center mb-4">
|
||||
<School className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-[var(--text-primary)] mb-3">
|
||||
@@ -340,8 +342,8 @@ const FeaturesPage: React.FC = () => {
|
||||
|
||||
{/* Offices */}
|
||||
<ScrollReveal variant="fadeUp" delay={0.15}>
|
||||
<div className="bg-[var(--bg-primary)] rounded-2xl p-6 border border-[var(--border-primary)] hover:shadow-xl transition-all">
|
||||
<div className="w-12 h-12 bg-purple-500/10 rounded-xl flex items-center justify-center mb-4">
|
||||
<div className="bg-[var(--bg-primary)] rounded-2xl p-6 border border-[var(--border-primary)] shadow-lg hover:shadow-2xl transition-all duration-300 hover:-translate-y-1">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-purple-500/10 to-purple-600/10 rounded-xl flex items-center justify-center mb-4">
|
||||
<Building2 className="w-6 h-6 text-purple-600" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-[var(--text-primary)] mb-3">
|
||||
@@ -366,8 +368,8 @@ const FeaturesPage: React.FC = () => {
|
||||
|
||||
{/* Gyms */}
|
||||
<ScrollReveal variant="fadeUp" delay={0.2}>
|
||||
<div className="bg-[var(--bg-primary)] rounded-2xl p-6 border border-[var(--border-primary)] hover:shadow-xl transition-all">
|
||||
<div className="w-12 h-12 bg-green-500/10 rounded-xl flex items-center justify-center mb-4">
|
||||
<div className="bg-[var(--bg-primary)] rounded-2xl p-6 border border-[var(--border-primary)] shadow-lg hover:shadow-2xl transition-all duration-300 hover:-translate-y-1">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-green-500/10 to-green-600/10 rounded-xl flex items-center justify-center mb-4">
|
||||
<Dumbbell className="w-6 h-6 text-green-600" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-[var(--text-primary)] mb-3">
|
||||
@@ -392,8 +394,8 @@ const FeaturesPage: React.FC = () => {
|
||||
|
||||
{/* Competition */}
|
||||
<ScrollReveal variant="fadeUp" delay={0.25}>
|
||||
<div className="bg-[var(--bg-primary)] rounded-2xl p-6 border border-[var(--border-primary)] hover:shadow-xl transition-all">
|
||||
<div className="w-12 h-12 bg-amber-500/10 rounded-xl flex items-center justify-center mb-4">
|
||||
<div className="bg-[var(--bg-primary)] rounded-2xl p-6 border border-[var(--border-primary)] shadow-lg hover:shadow-2xl transition-all duration-300 hover:-translate-y-1">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-amber-500/10 to-amber-600/10 rounded-xl flex items-center justify-center mb-4">
|
||||
<ShoppingBag className="w-6 h-6 text-amber-600" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-[var(--text-primary)] mb-3">
|
||||
@@ -418,8 +420,8 @@ const FeaturesPage: React.FC = () => {
|
||||
|
||||
{/* Weather */}
|
||||
<ScrollReveal variant="fadeUp" delay={0.3}>
|
||||
<div className="bg-[var(--bg-primary)] rounded-2xl p-6 border border-[var(--border-primary)] hover:shadow-xl transition-all">
|
||||
<div className="w-12 h-12 bg-sky-500/10 rounded-xl flex items-center justify-center mb-4">
|
||||
<div className="bg-[var(--bg-primary)] rounded-2xl p-6 border border-[var(--border-primary)] shadow-lg hover:shadow-2xl transition-all duration-300 hover:-translate-y-1">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-sky-500/10 to-sky-600/10 rounded-xl flex items-center justify-center mb-4">
|
||||
<Cloud className="w-6 h-6 text-sky-600" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-[var(--text-primary)] mb-3">
|
||||
@@ -444,8 +446,8 @@ const FeaturesPage: React.FC = () => {
|
||||
|
||||
{/* Events */}
|
||||
<ScrollReveal variant="fadeUp" delay={0.35}>
|
||||
<div className="bg-[var(--bg-primary)] rounded-2xl p-6 border border-[var(--border-primary)] hover:shadow-xl transition-all">
|
||||
<div className="w-12 h-12 bg-pink-500/10 rounded-xl flex items-center justify-center mb-4">
|
||||
<div className="bg-[var(--bg-primary)] rounded-2xl p-6 border border-[var(--border-primary)] shadow-lg hover:shadow-2xl transition-all duration-300 hover:-translate-y-1">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-pink-500/10 to-pink-600/10 rounded-xl flex items-center justify-center mb-4">
|
||||
<PartyPopper className="w-6 h-6 text-pink-600" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-[var(--text-primary)] mb-3">
|
||||
@@ -471,37 +473,49 @@ const FeaturesPage: React.FC = () => {
|
||||
|
||||
{/* Why it matters */}
|
||||
<ScrollReveal variant="fadeUp" delay={0.4}>
|
||||
<div className="mt-12 max-w-4xl mx-auto bg-gradient-to-r from-[var(--color-primary)] to-orange-600 rounded-2xl p-8 text-white">
|
||||
<h3 className="text-2xl font-bold mb-4 text-center">
|
||||
{t('local.why_matters.title', 'Por qué importa:')}
|
||||
</h3>
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="bg-white/10 rounded-lg p-4">
|
||||
<p className="font-medium mb-2">{t('local.why_matters.generic', 'IA genérica:')}</p>
|
||||
<p className="text-white/90">{t('local.why_matters.generic_example', '"Es lunes → vende X"')}</p>
|
||||
<div className="mt-12 max-w-4xl mx-auto relative overflow-hidden bg-gradient-to-r from-[var(--color-primary)] to-orange-600 rounded-2xl p-8 text-white shadow-xl">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent animate-shimmer"></div>
|
||||
<div className="relative">
|
||||
<h3 className="text-2xl font-bold mb-6 text-center">
|
||||
{t('local.why_matters.title', 'Por qué importa:')}
|
||||
</h3>
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="bg-white/10 backdrop-blur-sm rounded-lg p-5 border border-white/20">
|
||||
<p className="font-semibold mb-3 text-lg">{t('local.why_matters.generic', 'IA genérica:')}</p>
|
||||
<p className="text-white/90 text-base leading-relaxed">{t('local.why_matters.generic_example', '"Es lunes → vende X"')}</p>
|
||||
</div>
|
||||
<div className="bg-white/20 backdrop-blur-sm rounded-lg p-5 border-2 border-white shadow-lg">
|
||||
<p className="font-semibold mb-3 text-lg">{t('local.why_matters.yours', 'TU IA:')}</p>
|
||||
<p className="text-white/90 text-base leading-relaxed">{t('local.why_matters.yours_example', '"Es lunes, llueve, colegio cerrado (festivo local), mercadillo cancelado → vende Y"')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white/20 rounded-lg p-4 border-2 border-white">
|
||||
<p className="font-medium mb-2">{t('local.why_matters.yours', 'TU IA:')}</p>
|
||||
<p className="text-white/90">{t('local.why_matters.yours_example', '"Es lunes, llueve, colegio cerrado (festivo local), mercadillo cancelado → vende Y"')}</p>
|
||||
<div className="text-center mt-8 p-4 bg-white/10 backdrop-blur-sm rounded-lg">
|
||||
<p className="text-xl font-bold">
|
||||
Precisión: <span className="text-3xl"><AnimatedCounter value={92} suffix="%" className="inline" /></span> <span className="text-white/80 text-lg">(vs 60-70% de sistemas genéricos)</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-center mt-6 text-xl font-bold">
|
||||
Precisión: <AnimatedCounter value={92} suffix="%" className="inline text-[var(--color-primary)]" /> (vs 60-70% de sistemas genéricos)
|
||||
</p>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Feature 3: Demand Forecasting */}
|
||||
<section id="demand-forecasting" className="py-20 bg-[var(--bg-primary)] scroll-mt-24">
|
||||
<section id="demand-forecasting" className="py-20 bg-[var(--bg-primary)]">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<ScrollReveal variant="fadeUp" delay={0.1}>
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl lg:text-4xl font-extrabold text-[var(--text-primary)] mb-4">
|
||||
Sabe Cuánto Venderás Mañana (<AnimatedCounter value={92} suffix="%" className="inline text-[var(--color-primary)]" /> de Precisión)
|
||||
<div className="inline-flex items-center gap-2 bg-[var(--color-primary)]/10 dark:bg-[var(--color-primary)]/20 border border-[var(--color-primary)]/20 dark:border-[var(--color-primary)]/30 text-[var(--color-primary)] px-4 py-2 rounded-full text-sm font-medium mb-6">
|
||||
<Target className="w-5 h-5" />
|
||||
<span>{t('forecasting.badge', 'Predicción Inteligente')}</span>
|
||||
</div>
|
||||
<h2 className="text-4xl lg:text-5xl font-bold text-[var(--text-primary)] mb-4">
|
||||
{t('forecasting.title', 'Sabe Cuánto Venderás Mañana')}
|
||||
<span className="block">
|
||||
(<AnimatedCounter value={92} suffix="%" className="inline bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-secondary)] bg-clip-text text-transparent" /> de Precisión)
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-xl text-[var(--text-secondary)] max-w-3xl mx-auto">
|
||||
<p className="text-xl text-[var(--text-secondary)] max-w-3xl mx-auto leading-relaxed">
|
||||
{t('forecasting.subtitle', 'No es magia. Es matemáticas con tus datos.')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -570,12 +584,19 @@ const FeaturesPage: React.FC = () => {
|
||||
</section>
|
||||
|
||||
{/* Feature 4: Reduce Waste = Save Money */}
|
||||
<section id="waste-reduction" className="py-20 bg-[var(--bg-secondary)] scroll-mt-24">
|
||||
<section id="waste-reduction" className="py-20 bg-[var(--bg-secondary)]">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<ScrollReveal variant="fadeUp" delay={0.1}>
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl lg:text-4xl font-extrabold text-[var(--text-primary)] mb-4">
|
||||
{t('waste.title', 'Menos Pan en la Basura, Más Dinero en Tu Bolsillo')}
|
||||
<div className="inline-flex items-center gap-2 bg-green-500/10 dark:bg-green-500/20 border border-green-500/20 dark:border-green-500/30 text-green-600 dark:text-green-400 px-4 py-2 rounded-full text-sm font-medium mb-6">
|
||||
<Recycle className="w-5 h-5" />
|
||||
<span>{t('waste.badge', 'Ahorro Directo')}</span>
|
||||
</div>
|
||||
<h2 className="text-4xl lg:text-5xl font-bold text-[var(--text-primary)] mb-4">
|
||||
{t('waste.title_part1', 'Menos Pan en la Basura,')}
|
||||
<span className="block bg-gradient-to-r from-green-600 to-emerald-600 bg-clip-text text-transparent">
|
||||
{t('waste.title_part2', 'Más Dinero en Tu Bolsillo')}
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
@@ -640,16 +661,19 @@ const FeaturesPage: React.FC = () => {
|
||||
</section>
|
||||
|
||||
{/* Feature 5: Sustainability + Grants */}
|
||||
<section id="sustainability" className="py-20 bg-[var(--bg-primary)] scroll-mt-24">
|
||||
<section id="sustainability" className="py-20 bg-[var(--bg-primary)]">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<ScrollReveal variant="fadeUp" delay={0.1}>
|
||||
<div className="text-center mb-12">
|
||||
<div className="inline-flex items-center gap-2 bg-green-500/10 text-green-600 dark:text-green-400 px-4 py-2 rounded-full text-sm font-medium mb-6">
|
||||
<Leaf className="w-4 h-4" />
|
||||
<span>{t('sustainability.badge', 'Funcionalidad del Sistema')}</span>
|
||||
<div className="inline-flex items-center gap-2 bg-green-500/10 dark:bg-green-500/20 border border-green-500/20 dark:border-green-500/30 text-green-600 dark:text-green-400 px-4 py-2 rounded-full text-sm font-medium mb-6">
|
||||
<Leaf className="w-5 h-5" />
|
||||
<span>{t('sustainability.badge', 'Impacto Positivo')}</span>
|
||||
</div>
|
||||
<h2 className="text-3xl lg:text-4xl font-extrabold text-[var(--text-primary)] mb-4">
|
||||
{t('sustainability.title', 'Impacto Ambiental y Sostenibilidad')}
|
||||
<h2 className="text-4xl lg:text-5xl font-bold text-[var(--text-primary)] mb-4">
|
||||
{t('sustainability.title', 'Impacto Ambiental')}
|
||||
<span className="block bg-gradient-to-r from-green-600 to-emerald-600 bg-clip-text text-transparent">
|
||||
{t('sustainability.title_highlight', 'y Sostenibilidad')}
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
@@ -813,22 +837,32 @@ const FeaturesPage: React.FC = () => {
|
||||
</section>
|
||||
|
||||
{/* Feature 6: Business Models */}
|
||||
<section id="business-models" className="py-20 bg-[var(--bg-secondary)] scroll-mt-24">
|
||||
<section id="business-models" className="py-20 bg-[var(--bg-secondary)]">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl lg:text-4xl font-extrabold text-[var(--text-primary)] mb-4">
|
||||
{t('business_models.title', 'Para Cualquier Modelo de Negocio')}
|
||||
</h2>
|
||||
<p className="text-xl text-[var(--text-secondary)]">
|
||||
{t('business_models.subtitle', 'No importa cómo trabajes, funciona para ti')}
|
||||
</p>
|
||||
</div>
|
||||
<ScrollReveal variant="fadeUp" delay={0.1}>
|
||||
<div className="text-center mb-12">
|
||||
<div className="inline-flex items-center gap-2 bg-[var(--color-primary)]/10 dark:bg-[var(--color-primary)]/20 border border-[var(--color-primary)]/20 dark:border-[var(--color-primary)]/30 text-[var(--color-primary)] px-4 py-2 rounded-full text-sm font-medium mb-6">
|
||||
<Store className="w-5 h-5" />
|
||||
<span>{t('business_models.badge', 'Flexible y Adaptable')}</span>
|
||||
</div>
|
||||
<h2 className="text-4xl lg:text-5xl font-bold text-[var(--text-primary)] mb-4">
|
||||
{t('business_models.title', 'Para Cualquier')}
|
||||
<span className="block bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-secondary)] bg-clip-text text-transparent">
|
||||
{t('business_models.title_highlight', 'Modelo de Negocio')}
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-xl text-[var(--text-secondary)] max-w-3xl mx-auto leading-relaxed">
|
||||
{t('business_models.subtitle', 'No importa cómo trabajes, funciona para ti')}
|
||||
</p>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-8 max-w-5xl mx-auto">
|
||||
<div className="bg-[var(--bg-primary)] rounded-2xl p-8 border-2 border-[var(--color-primary)]">
|
||||
<div className="w-12 h-12 bg-[var(--color-primary)]/10 rounded-xl flex items-center justify-center mb-4">
|
||||
<Store className="w-6 h-6 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
<ScrollReveal variant="fadeUp" delay={0.15}>
|
||||
<div className="bg-[var(--bg-primary)] rounded-2xl p-8 border-2 border-[var(--color-primary)] shadow-lg hover:shadow-2xl transition-all duration-300 hover:-translate-y-1">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-[var(--color-primary)]/10 to-[var(--color-primary)]/20 rounded-xl flex items-center justify-center mb-4">
|
||||
<Store className="w-6 h-6 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-[var(--text-primary)] mb-4">
|
||||
{t('business_models.local.title', 'Panadería Producción Local')}
|
||||
</h3>
|
||||
@@ -845,9 +879,11 @@ const FeaturesPage: React.FC = () => {
|
||||
<span className="text-[var(--text-secondary)]">{t('business_models.local.benefit2', 'Gestiona inventario de un punto')}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="bg-[var(--bg-primary)] rounded-2xl p-8 border-2 border-blue-600">
|
||||
<ScrollReveal variant="fadeUp" delay={0.2}>
|
||||
<div className="bg-[var(--bg-primary)] rounded-2xl p-8 border-2 border-blue-600 shadow-lg hover:shadow-2xl transition-all duration-300 hover:-translate-y-1">
|
||||
<div className="w-12 h-12 bg-blue-500/10 rounded-xl flex items-center justify-center mb-4">
|
||||
<Globe className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
@@ -871,33 +907,68 @@ const FeaturesPage: React.FC = () => {
|
||||
<span className="text-[var(--text-secondary)]">{t('business_models.central.benefit3', 'Gestiona inventario central + puntos')}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Final CTA */}
|
||||
<section className="py-20 bg-gradient-to-r from-[var(--color-primary)] to-orange-600">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<h2 className="text-3xl lg:text-4xl font-bold text-white mb-6">
|
||||
{t('cta.title', 'Ver Bakery-IA en Acción')}
|
||||
</h2>
|
||||
<p className="text-xl text-white/90 mb-8">
|
||||
{t('cta.subtitle', 'Solicita una demo personalizada para tu panadería')}
|
||||
</p>
|
||||
<Link to={getDemoUrl()}>
|
||||
<Button
|
||||
size="lg"
|
||||
className="bg-white text-[var(--color-primary)] hover:bg-gray-100 font-bold text-lg px-8 py-4"
|
||||
>
|
||||
{t('cta.button', 'Solicitar Demo')}
|
||||
<ArrowRight className="ml-2 w-5 h-5" />
|
||||
</Button>
|
||||
</Link>
|
||||
<section className="relative overflow-hidden py-24 bg-gradient-to-r from-[var(--color-primary)] to-orange-600">
|
||||
{/* Animated shimmer effect */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent animate-shimmer"></div>
|
||||
|
||||
{/* Animated background blobs */}
|
||||
<div className="absolute top-0 left-0 w-96 h-96 bg-white/10 rounded-full blur-3xl animate-pulse"></div>
|
||||
<div className="absolute bottom-0 right-0 w-96 h-96 bg-white/10 rounded-full blur-3xl animate-pulse" style={{ animationDelay: '1s' }}></div>
|
||||
|
||||
<div className="relative max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<ScrollReveal variant="fadeUp" delay={0.1}>
|
||||
<div className="text-center space-y-6">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-white/20 backdrop-blur-sm border border-white/30 mb-4">
|
||||
<Sparkles className="w-5 h-5 text-white" />
|
||||
<span className="text-sm font-medium text-white">{t('cta.badge', 'Prueba Gratuita Disponible')}</span>
|
||||
</div>
|
||||
|
||||
<h2 className="text-4xl lg:text-5xl font-bold text-white mb-6 leading-tight">
|
||||
{t('cta.title', 'Ver Bakery-IA en Acción')}
|
||||
</h2>
|
||||
|
||||
<p className="text-xl lg:text-2xl text-white/90 mb-8 max-w-2xl mx-auto leading-relaxed">
|
||||
{t('cta.subtitle', 'Solicita una demo personalizada para tu panadería')}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 pt-4">
|
||||
<Link to={getDemoUrl()}>
|
||||
<Button
|
||||
size="lg"
|
||||
className="bg-white text-[var(--color-primary)] hover:bg-gray-50 hover:shadow-2xl font-bold text-lg px-10 py-5 transition-all duration-300 hover:scale-105 group shadow-xl"
|
||||
>
|
||||
<Sparkles className="mr-2 w-5 h-5" />
|
||||
{t('cta.button', 'Solicitar Demo')}
|
||||
<ArrowRight className="ml-2 w-5 h-5 group-hover:translate-x-1 transition-transform" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-8 pt-6 text-sm text-white/80">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-5 h-5" />
|
||||
<span>{t('cta.feature1', 'Sin compromiso')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-5 h-5" />
|
||||
<span>{t('cta.feature2', 'Configuración en minutos')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-5 h-5" />
|
||||
<span>{t('cta.feature3', 'Soporte dedicado')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</PublicLayout>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user