Fix Demo enterprise

This commit is contained in:
Urtzi Alfaro
2025-12-17 13:03:52 +01:00
parent 0bbfa010bf
commit 8bfe4f2dd7
111 changed files with 26200 additions and 2245 deletions

View 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]);
}

View File

@@ -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]);
}

View File

@@ -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 {

View File

@@ -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>
)}

View File

@@ -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 */}

View File

@@ -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})

View File

@@ -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 })

View File

@@ -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"
},

View File

@@ -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",

View File

@@ -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"

View File

@@ -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",

View File

@@ -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"
},

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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"
},

View File

@@ -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"

View File

@@ -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",

View File

@@ -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}
/>

View File

@@ -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>
);
};