New alert service
This commit is contained in:
@@ -4,105 +4,12 @@ import { useTranslation } from 'react-i18next';
|
||||
import { PublicLayout } from '../../components/layout';
|
||||
import { Calendar, Clock, ArrowRight, Brain } from 'lucide-react';
|
||||
|
||||
interface BlogPost {
|
||||
id: string;
|
||||
slug: string;
|
||||
titleKey: string;
|
||||
excerptKey: string;
|
||||
authorKey: string;
|
||||
date: string;
|
||||
readTime: string;
|
||||
categoryKey: string;
|
||||
tagsKeys: string[];
|
||||
}
|
||||
import { blogPosts } from '../../constants/blog';
|
||||
|
||||
const BlogPage: React.FC = () => {
|
||||
const { t, i18n } = useTranslation(['blog', 'common']);
|
||||
|
||||
// Blog posts metadata - translations come from i18n
|
||||
const blogPosts: BlogPost[] = [
|
||||
{
|
||||
id: '1',
|
||||
slug: 'reducir-desperdicio-alimentario-panaderia',
|
||||
titleKey: 'posts.waste_reduction.title',
|
||||
excerptKey: 'posts.waste_reduction.excerpt',
|
||||
authorKey: 'posts.waste_reduction.author',
|
||||
date: '2025-01-15',
|
||||
readTime: '8',
|
||||
categoryKey: 'categories.management',
|
||||
tagsKeys: [
|
||||
'posts.waste_reduction.tags.food_waste',
|
||||
'posts.waste_reduction.tags.sustainability',
|
||||
'posts.waste_reduction.tags.ai',
|
||||
'posts.waste_reduction.tags.management',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
slug: 'ia-predecir-demanda-panaderia',
|
||||
titleKey: 'posts.ai_prediction.title',
|
||||
excerptKey: 'posts.ai_prediction.excerpt',
|
||||
authorKey: 'posts.ai_prediction.author',
|
||||
date: '2025-01-10',
|
||||
readTime: '10',
|
||||
categoryKey: 'categories.technology',
|
||||
tagsKeys: [
|
||||
'posts.ai_prediction.tags.ai',
|
||||
'posts.ai_prediction.tags.machine_learning',
|
||||
'posts.ai_prediction.tags.prediction',
|
||||
'posts.ai_prediction.tags.technology',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
slug: 'optimizar-produccion-panaderia-artesanal',
|
||||
titleKey: 'posts.production_optimization.title',
|
||||
excerptKey: 'posts.production_optimization.excerpt',
|
||||
authorKey: 'posts.production_optimization.author',
|
||||
date: '2025-01-05',
|
||||
readTime: '12',
|
||||
categoryKey: 'categories.production',
|
||||
tagsKeys: [
|
||||
'posts.production_optimization.tags.optimization',
|
||||
'posts.production_optimization.tags.production',
|
||||
'posts.production_optimization.tags.artisan',
|
||||
'posts.production_optimization.tags.management',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
slug: 'obrador-central-vs-produccion-local',
|
||||
titleKey: 'posts.central_vs_local.title',
|
||||
excerptKey: 'posts.central_vs_local.excerpt',
|
||||
authorKey: 'posts.central_vs_local.author',
|
||||
date: '2025-01-20',
|
||||
readTime: '15',
|
||||
categoryKey: 'categories.strategy',
|
||||
tagsKeys: [
|
||||
'posts.central_vs_local.tags.business_models',
|
||||
'posts.central_vs_local.tags.central_bakery',
|
||||
'posts.central_vs_local.tags.local_production',
|
||||
'posts.central_vs_local.tags.scalability',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
slug: 'gdpr-proteccion-datos-panaderia',
|
||||
titleKey: 'posts.gdpr.title',
|
||||
excerptKey: 'posts.gdpr.excerpt',
|
||||
authorKey: 'posts.gdpr.author',
|
||||
date: '2025-01-01',
|
||||
readTime: '9',
|
||||
categoryKey: 'categories.legal',
|
||||
tagsKeys: [
|
||||
'posts.gdpr.tags.gdpr',
|
||||
'posts.gdpr.tags.rgpd',
|
||||
'posts.gdpr.tags.privacy',
|
||||
'posts.gdpr.tags.legal',
|
||||
'posts.gdpr.tags.security',
|
||||
],
|
||||
},
|
||||
];
|
||||
// Blog posts are now imported from constants/blog.ts
|
||||
|
||||
return (
|
||||
<PublicLayout
|
||||
|
||||
185
frontend/src/pages/public/BlogPostPage.tsx
Normal file
185
frontend/src/pages/public/BlogPostPage.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import React from 'react';
|
||||
import { useParams, Navigate, Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PublicLayout } from '../../components/layout';
|
||||
import { Calendar, Clock, ArrowLeft, User, Tag } from 'lucide-react';
|
||||
import { blogPosts } from '../../constants/blog';
|
||||
|
||||
const BlogPostPage: React.FC = () => {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const { t, i18n } = useTranslation(['blog', 'common']);
|
||||
|
||||
const post = blogPosts.find((p) => p.slug === slug);
|
||||
|
||||
if (!post) {
|
||||
return <Navigate to="/blog" replace />;
|
||||
}
|
||||
|
||||
// Helper to render content sections dynamically
|
||||
const renderContent = () => {
|
||||
// We need to access the structure of the content from the translation file
|
||||
// Since i18next t() function returns a string, we need to know the structure beforehand
|
||||
// or use returnObjects: true, but that returns an unknown type.
|
||||
// For this implementation, we'll assume a standard structure based on the existing blog.json
|
||||
|
||||
// However, since the structure varies per post (e.g. problem_title, solution_1_title),
|
||||
// we might need a more flexible approach or standardized content structure.
|
||||
// Given the current JSON structure, it's quite specific per post.
|
||||
// A robust way is to use `t` with `returnObjects: true` and iterate, but for now,
|
||||
// let's try to render specific known sections if they exist, or just use a generic "content" key if we refactor.
|
||||
|
||||
// Actually, looking at blog.json, the content is nested under `content`.
|
||||
// We can try to render the `intro` and then specific sections if we can infer them.
|
||||
// But since the keys are like `problem_title`, `solution_1_title`, it's hard to iterate without knowing keys.
|
||||
|
||||
// A better approach for this specific codebase without refactoring all JSONs might be
|
||||
// to just render the `intro` and `conclusion` and maybe a "read full guide" if it was a real app,
|
||||
// but here we want to show the content.
|
||||
|
||||
// Let's use `t` to get the whole content object and iterate over keys?
|
||||
// i18next `t` with `returnObjects: true` returns the object.
|
||||
const content = t(`blog:${post.titleKey.replace('.title', '.content')}`, { returnObjects: true });
|
||||
|
||||
if (typeof content !== 'object' || content === null) {
|
||||
return <p>{t('blog:post.content_not_available')}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="prose prose-lg max-w-none text-[var(--text-secondary)]">
|
||||
{Object.entries(content).map(([key, value]) => {
|
||||
if (key === 'intro' || key === 'conclusion') {
|
||||
return <p key={key} className="mb-6">{value as string}</p>;
|
||||
}
|
||||
if (key.endsWith('_title')) {
|
||||
return <h3 key={key} className="text-2xl font-bold text-[var(--text-primary)] mt-8 mb-4">{value as string}</h3>;
|
||||
}
|
||||
if (key.endsWith('_desc')) {
|
||||
// Check if it contains markdown-like bold
|
||||
const text = value as string;
|
||||
const parts = text.split(/(\*\*.*?\*\*)/g);
|
||||
return (
|
||||
<p key={key} className="mb-4">
|
||||
{parts.map((part, i) => {
|
||||
if (part.startsWith('**') && part.endsWith('**')) {
|
||||
return <strong key={i} className="text-[var(--text-primary)]">{part.slice(2, -2)}</strong>;
|
||||
}
|
||||
return part;
|
||||
})}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return (
|
||||
<ul key={key} className="list-disc pl-6 mb-6 space-y-2">
|
||||
{(value as string[]).map((item, index) => {
|
||||
// Handle bold text in list items
|
||||
const parts = item.split(/(\*\*.*?\*\*)/g);
|
||||
return (
|
||||
<li key={index}>
|
||||
{parts.map((part, i) => {
|
||||
if (part.startsWith('**') && part.endsWith('**')) {
|
||||
return <strong key={i} className="text-[var(--text-primary)]">{part.slice(2, -2)}</strong>;
|
||||
}
|
||||
return part;
|
||||
})}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
// Fallback for other string keys that might be paragraphs
|
||||
if (typeof value === 'string' && !key.includes('_title') && !key.includes('_desc')) {
|
||||
return <p key={key} className="mb-4">{value}</p>;
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<PublicLayout
|
||||
variant="default"
|
||||
contentPadding="md"
|
||||
headerProps={{
|
||||
showThemeToggle: true,
|
||||
showAuthButtons: true,
|
||||
showLanguageSelector: true,
|
||||
variant: "default"
|
||||
}}
|
||||
>
|
||||
<article className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
|
||||
{/* Back Link */}
|
||||
<Link
|
||||
to="/blog"
|
||||
className="inline-flex items-center gap-2 text-[var(--text-tertiary)] hover:text-[var(--color-primary)] mb-8 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<span>{t('common:actions.back')}</span>
|
||||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
<header className="mb-12">
|
||||
<div className="flex flex-wrap items-center gap-4 mb-6">
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-[var(--color-primary)]/10 text-[var(--color-primary)]">
|
||||
{t(`blog:${post.categoryKey}`)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 text-[var(--text-tertiary)]">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span>
|
||||
{new Date(post.date).toLocaleDateString(i18n.language, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[var(--text-tertiary)]">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>{t('blog:post.read_time', { time: post.readTime })}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="text-4xl lg:text-5xl font-extrabold text-[var(--text-primary)] mb-6 leading-tight">
|
||||
{t(`blog:${post.titleKey}`)}
|
||||
</h1>
|
||||
|
||||
<div className="flex items-center gap-3 pb-8 border-b border-[var(--border-primary)]">
|
||||
<div className="w-12 h-12 rounded-full bg-[var(--bg-tertiary)] flex items-center justify-center text-[var(--text-secondary)]">
|
||||
<User className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-[var(--text-primary)]">
|
||||
{t(`blog:${post.authorKey}`)}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--text-tertiary)]">
|
||||
{t('blog:post.author_role', { defaultValue: 'Contributor' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
{renderContent()}
|
||||
|
||||
{/* Footer Tags */}
|
||||
<div className="mt-12 pt-8 border-t border-[var(--border-primary)]">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{post.tagsKeys.map((tagKey) => (
|
||||
<span
|
||||
key={tagKey}
|
||||
className="inline-flex items-center gap-1 px-3 py-1 rounded-full bg-[var(--bg-tertiary)] text-[var(--text-secondary)] text-sm"
|
||||
>
|
||||
<Tag className="w-3 h-3" />
|
||||
{t(`blog:${tagKey}`)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</PublicLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlogPostPage;
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
Building,
|
||||
Package,
|
||||
BarChart3,
|
||||
ForkKnife,
|
||||
|
||||
ChefHat,
|
||||
CreditCard,
|
||||
Bell,
|
||||
@@ -295,10 +295,8 @@ const DemoPage = () => {
|
||||
// Full success - navigate immediately
|
||||
clearInterval(progressInterval);
|
||||
setTimeout(() => {
|
||||
const targetUrl = tier === 'enterprise'
|
||||
? `/app/tenants/${sessionData.virtual_tenant_id}/enterprise`
|
||||
: `/app/tenants/${sessionData.virtual_tenant_id}/dashboard`;
|
||||
navigate(targetUrl);
|
||||
// Navigate to the main dashboard which will automatically route to enterprise or bakery dashboard based on subscription tier
|
||||
navigate('/app/dashboard');
|
||||
}, 1000);
|
||||
return;
|
||||
} else if (statusData.status === 'PARTIAL' || statusData.status === 'partial') {
|
||||
@@ -582,9 +580,8 @@ const DemoPage = () => {
|
||||
{demoOptions.map((option) => (
|
||||
<Card
|
||||
key={option.id}
|
||||
className={`cursor-pointer hover:shadow-xl transition-all border-2 ${
|
||||
selectedTier === option.id ? 'border-primary bg-primary/5' : 'border-gray-200 dark:border-gray-700'
|
||||
}`}
|
||||
className={`cursor-pointer hover:shadow-xl transition-all border-2 ${selectedTier === option.id ? 'border-primary bg-primary/5' : 'border-gray-200 dark:border-gray-700'
|
||||
}`}
|
||||
onClick={() => setSelectedTier(option.id)}
|
||||
>
|
||||
<CardHeader>
|
||||
@@ -679,62 +676,69 @@ const DemoPage = () => {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Loading Progress */}
|
||||
{/* Loading Progress Modal */}
|
||||
{creatingTier !== null && (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-center text-[var(--text-primary)]">Configurando Tu Demo</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>Progreso total</span>
|
||||
<span>{cloneProgress.overall}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${cloneProgress.overall}%` }}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
{creatingTier === 'enterprise' && (
|
||||
<div className="space-y-3 mt-4">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="font-medium">Obrador Central</span>
|
||||
<span>{cloneProgress.parent}%</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{cloneProgress.children.map((progress, index) => (
|
||||
<div key={index} className="text-center">
|
||||
<div className="text-xs text-[var(--text-tertiary)] mb-1">Outlet {index + 1}</div>
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-blue-500 h-1.5 rounded-full transition-all duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<div className="text-xs mt-1">{progress}%</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between text-sm mt-2">
|
||||
<span className="font-medium">Distribución</span>
|
||||
<span>{cloneProgress.distribution}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-purple-500 h-1.5 rounded-full transition-all duration-300"
|
||||
style={{ width: `${cloneProgress.distribution}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Modal
|
||||
isOpen={creatingTier !== null}
|
||||
onClose={() => { }}
|
||||
size="md"
|
||||
>
|
||||
<ModalHeader
|
||||
title="Configurando Tu Demo"
|
||||
showCloseButton={false}
|
||||
/>
|
||||
<ModalBody padding="lg">
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>Progreso total</span>
|
||||
<span>{cloneProgress.overall}%</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${cloneProgress.overall}%` }}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div className="text-center text-sm text-[var(--text-secondary)] mt-4">
|
||||
{getLoadingMessage(creatingTier, cloneProgress.overall)}
|
||||
</div>
|
||||
|
||||
{creatingTier === 'enterprise' && (
|
||||
<div className="space-y-3 mt-4">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="font-medium">Obrador Central</span>
|
||||
<span>{cloneProgress.parent}%</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{cloneProgress.children.map((progress, index) => (
|
||||
<div key={index} className="text-center">
|
||||
<div className="text-xs text-[var(--text-tertiary)] mb-1">Outlet {index + 1}</div>
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-blue-500 h-1.5 rounded-full transition-all duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<div className="text-xs mt-1">{progress}%</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between text-sm mt-2">
|
||||
<span className="font-medium">Distribución</span>
|
||||
<span>{cloneProgress.distribution}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-purple-500 h-1.5 rounded-full transition-all duration-300"
|
||||
style={{ width: `${cloneProgress.distribution}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* Error Alert */}
|
||||
@@ -798,11 +802,9 @@ const DemoPage = () => {
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
const tierUrl = partialWarning.tier === 'enterprise'
|
||||
? `/demo/${partialWarning.sessionData.session_id}/enterprise`
|
||||
: `/demo/${partialWarning.sessionData.session_id}`;
|
||||
// Navigate to the main dashboard which will automatically route to enterprise or bakery dashboard based on subscription tier
|
||||
setPartialWarning(null);
|
||||
navigate(tierUrl);
|
||||
navigate('/app/dashboard');
|
||||
}}
|
||||
>
|
||||
Continuar con Demo Parcial
|
||||
@@ -881,11 +883,9 @@ const DemoPage = () => {
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
const tierUrl = timeoutModal.tier === 'enterprise'
|
||||
? `/demo/${timeoutModal.sessionData.session_id}/enterprise`
|
||||
: `/demo/${timeoutModal.sessionData.session_id}`;
|
||||
// Navigate to the main dashboard which will automatically route to enterprise or bakery dashboard based on subscription tier
|
||||
setTimeoutModal(null);
|
||||
navigate(tierUrl);
|
||||
navigate('/app/dashboard');
|
||||
}}
|
||||
>
|
||||
Iniciar con Datos Parciales
|
||||
@@ -905,42 +905,6 @@ const DemoPage = () => {
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* Comparison Section */}
|
||||
<div className="mt-16">
|
||||
<h2 className="text-3xl font-bold text-center mb-8 text-[var(--text-primary)]">Comparación de Funcionalidades</h2>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg overflow-hidden">
|
||||
<div className="grid grid-cols-3 divide-x divide-[var(--border-primary)]">
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-700">
|
||||
<h3 className="font-semibold text-center text-[var(--text-primary)]">Función</h3>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="text-center font-semibold text-blue-600 dark:text-blue-400">Professional</div>
|
||||
<div className="text-center text-sm text-[var(--text-tertiary)]">Individual Bakery</div>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="text-center font-semibold text-purple-600 dark:text-purple-400">Enterprise</div>
|
||||
<div className="text-center text-sm text-[var(--text-tertiary)]">Chain of Bakeries</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{[
|
||||
{ feature: 'Número Máximo de Ubicaciones', professional: '1', enterprise: 'Ilimitado' },
|
||||
{ feature: 'Gestión de Inventario', professional: '✓', enterprise: '✓ Agregado' },
|
||||
{ feature: 'Forecasting con IA', professional: 'Personalizado', enterprise: 'Agregado + Individual' },
|
||||
{ feature: 'Planificación de Producción', professional: '✓', enterprise: '✓ Centralizada' },
|
||||
{ feature: 'Transferencias Internas', professional: '×', enterprise: '✓ Optimizadas' },
|
||||
{ feature: 'Logística y Rutas', professional: '×', enterprise: '✓ Optimización VRP' },
|
||||
{ feature: 'Dashboard Multi-ubicación', professional: '×', enterprise: '✓ Visión de Red' },
|
||||
{ feature: 'Reportes Consolidados', professional: '×', enterprise: '✓ Nivel de Red' }
|
||||
].map((row, index) => (
|
||||
<div key={index} className={`grid grid-cols-3 divide-x divide-[var(--border-primary)] ${index % 2 === 0 ? 'bg-gray-50 dark:bg-gray-700' : 'bg-white dark:bg-gray-800'}`}>
|
||||
<div className="p-3 text-sm text-[var(--text-secondary)]">{row.feature}</div>
|
||||
<div className="p-3 text-center text-sm text-[var(--text-secondary)]">{row.professional}</div>
|
||||
<div className="p-3 text-center text-sm text-[var(--text-secondary)]">{row.enterprise}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</PublicLayout>
|
||||
|
||||
49
frontend/src/pages/public/UnauthorizedPage.tsx
Normal file
49
frontend/src/pages/public/UnauthorizedPage.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import { ROUTES } from '../../router/routes.config';
|
||||
|
||||
const UnauthorizedPage: React.FC = () => (
|
||||
<div className="flex items-center justify-center min-h-screen bg-bg-primary">
|
||||
<div className="text-center max-w-md mx-auto px-6">
|
||||
<div className="mb-6">
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-color-error rounded-full flex items-center justify-center">
|
||||
<svg
|
||||
className="w-8 h-8 text-text-inverse"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.732 19.5c-.77.833.192 2.5 1.732 2.5z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-text-primary mb-2">
|
||||
Acceso no autorizado
|
||||
</h1>
|
||||
<p className="text-text-secondary mb-6">
|
||||
No tienes permisos para acceder a esta página. Contacta con tu administrador si crees que esto es un error.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<button
|
||||
onClick={() => window.history.back()}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
Volver atrás
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.location.href = ROUTES.DASHBOARD}
|
||||
className="btn btn-outline"
|
||||
>
|
||||
Ir al Panel de Control
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default UnauthorizedPage;
|
||||
Reference in New Issue
Block a user