Initial LinkedSign platform
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { FileText, Shield, PenTool, LayoutDashboard, LogOut, Palette, Menu, X, KeyRound, Search, Plus, Users, Settings, ScrollText } from 'lucide-react';
|
||||
import ChangePasswordModal from '../ui/ChangePasswordModal';
|
||||
|
||||
const navSections = [
|
||||
{
|
||||
label: 'Principal',
|
||||
items: [
|
||||
{ to: '/dashboard', icon: LayoutDashboard, label: 'Dashboard' },
|
||||
{ to: '/documents', icon: FileText, label: 'Mis documentos' },
|
||||
{ to: '/signatures', icon: PenTool, label: 'Firmar ahora' },
|
||||
{ to: '/certificates', icon: Users, label: 'Firmantes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Sistema',
|
||||
items: [
|
||||
{ to: '/signature-style', icon: Settings, label: 'Configuración' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const topbarTitles = {
|
||||
'/dashboard': 'Dashboard',
|
||||
'/documents': 'Mis documentos',
|
||||
'/certificates': 'Firmantes',
|
||||
'/signature-style': 'Configuración',
|
||||
'/signatures': 'Firmar ahora',
|
||||
};
|
||||
|
||||
export default function Layout({ children }) {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [showChangePassword, setShowChangePassword] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMobileMenuOpen(false);
|
||||
}, [location.pathname]);
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const currentTitle = topbarTitles[location.pathname] || 'FirmaDoc';
|
||||
|
||||
return (
|
||||
<div className="app-layout">
|
||||
{showChangePassword && (
|
||||
<ChangePasswordModal onClose={() => setShowChangePassword(false)} />
|
||||
)}
|
||||
<header className="mobile-topbar">
|
||||
<button
|
||||
className="mobile-menu-toggle"
|
||||
type="button"
|
||||
onClick={() => setMobileMenuOpen(true)}
|
||||
aria-label="Abrir menú"
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
<div className="mobile-topbar-brand">
|
||||
<FileText size={18} />
|
||||
<span>FirmaDoc</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{mobileMenuOpen && (
|
||||
<button
|
||||
type="button"
|
||||
className="mobile-sidebar-backdrop"
|
||||
aria-label="Cerrar menú"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<aside className={`sidebar ${mobileMenuOpen ? 'sidebar--mobile-open' : ''}`}>
|
||||
<div className="sidebar-brand">
|
||||
<div className="sidebar-brand-mark">
|
||||
<FileText size={18} />
|
||||
</div>
|
||||
<div className="sidebar-brand-copy">
|
||||
<span>FirmaDoc</span>
|
||||
<small>Plataforma web</small>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="mobile-sidebar-close"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
aria-label="Cerrar menú"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<nav className="sidebar-nav">
|
||||
{navSections.map((section) => (
|
||||
<div key={section.label} className="sidebar-group">
|
||||
<div className="nav-group-label">{section.label}</div>
|
||||
{section.items.map(({ to, icon: Icon, label }) => (
|
||||
<Link
|
||||
key={to}
|
||||
to={to}
|
||||
className={`nav-item ${location.pathname.startsWith(to) ? 'active' : ''}`}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
<Icon size={15} />
|
||||
<span>{label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<div className="sidebar-group sidebar-group--disabled">
|
||||
<div className="nav-group-label">Extra</div>
|
||||
<span className="nav-item nav-item--static">
|
||||
<ScrollText size={15} />
|
||||
<span>Auditoría</span>
|
||||
</span>
|
||||
</div>
|
||||
</nav>
|
||||
<div className="sidebar-footer">
|
||||
<button
|
||||
type="button"
|
||||
className="user-info-button"
|
||||
onClick={() => setShowChangePassword(true)}
|
||||
title="Cambiar contraseña"
|
||||
>
|
||||
<div className="user-info">
|
||||
<div className="user-avatar">{user?.name?.[0]?.toUpperCase()}</div>
|
||||
<div className="user-details">
|
||||
<span className="user-name">{user?.name}</span>
|
||||
<span className="user-email">{user?.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
<KeyRound size={16} />
|
||||
</button>
|
||||
<button className="btn-logout" onClick={handleLogout} title="Cerrar sesión">
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
<main className="main-content">
|
||||
<div className="topbar">
|
||||
<div className="topbar-title">{currentTitle}</div>
|
||||
<div className="search-box">
|
||||
<Search size={13} />
|
||||
<input placeholder="Buscar documentos..." aria-label="Buscar documentos" />
|
||||
</div>
|
||||
<button className="btn btn-primary topbar-cta" type="button" onClick={() => navigate('/documents')}>
|
||||
<Plus size={14} />
|
||||
Nuevo documento
|
||||
</button>
|
||||
</div>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { KeyRound } from 'lucide-react';
|
||||
import { changePassword } from '../../api/auth';
|
||||
|
||||
const getPasswordChecks = (password = '') => ([
|
||||
{ label: 'Al menos 8 caracteres', valid: password.length >= 8 },
|
||||
{ label: 'Una mayúscula', valid: /[A-Z]/.test(password) },
|
||||
{ label: 'Una minúscula', valid: /[a-z]/.test(password) },
|
||||
{ label: 'Un número', valid: /\d/.test(password) },
|
||||
{ label: 'Un carácter especial', valid: /[^A-Za-z0-9]/.test(password) },
|
||||
]);
|
||||
|
||||
export default function ChangePasswordModal({ onClose }) {
|
||||
const [form, setForm] = useState({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const passwordChecks = useMemo(() => getPasswordChecks(form.newPassword), [form.newPassword]);
|
||||
const isPasswordStrong = passwordChecks.every((check) => check.valid);
|
||||
|
||||
const handleChange = (event) => {
|
||||
const { name, value } = event.target;
|
||||
setForm((currentForm) => ({ ...currentForm, [name]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!isPasswordStrong) {
|
||||
toast.error('La nueva contraseña no cumple con los requisitos de seguridad');
|
||||
return;
|
||||
}
|
||||
|
||||
if (form.newPassword !== form.confirmPassword) {
|
||||
toast.error('Las contraseñas nuevas no coinciden');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await changePassword({
|
||||
currentPassword: form.currentPassword,
|
||||
newPassword: form.newPassword,
|
||||
});
|
||||
toast.success('Te enviamos un correo para confirmar el cambio de contraseña');
|
||||
onClose();
|
||||
} catch (error) {
|
||||
toast.error(error.response?.data?.error || 'No se pudo cambiar la contraseña');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-card" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<div className="modal-title">
|
||||
<KeyRound size={18} />
|
||||
<span>Cambiar contraseña</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="modal-desc">
|
||||
Usa una contraseña segura. Debe incluir mayúscula, minúscula, número, carácter especial y mínimo 8 caracteres.
|
||||
</p>
|
||||
<form className="auth-form" onSubmit={handleSubmit}>
|
||||
<div className="field">
|
||||
<label>Contraseña actual</label>
|
||||
<input type="password" name="currentPassword" value={form.currentPassword} onChange={handleChange} required autoFocus />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Nueva contraseña</label>
|
||||
<input type="password" name="newPassword" value={form.newPassword} onChange={handleChange} required />
|
||||
<div className="password-rules">
|
||||
{passwordChecks.map((check) => (
|
||||
<div
|
||||
key={check.label}
|
||||
className={`password-rule ${check.valid ? 'password-rule--ok' : 'password-rule--pending'}`}
|
||||
>
|
||||
<span>{check.valid ? '✓' : '•'}</span>
|
||||
<span>{check.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Confirmar nueva contraseña</label>
|
||||
<input type="password" name="confirmPassword" value={form.confirmPassword} onChange={handleChange} required />
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn btn-ghost" onClick={onClose}>Cancelar</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||||
{saving ? 'Guardando...' : 'Guardar contraseña'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { KeyRound, X } from 'lucide-react';
|
||||
|
||||
export default function PasswordModal({ title, description, onConfirm, onCancel }) {
|
||||
const [password, setPassword] = useState('');
|
||||
const inputRef = useRef();
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => inputRef.current?.focus(), 50);
|
||||
}, []);
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
onConfirm(password);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onCancel}>
|
||||
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<div className="modal-title">
|
||||
<KeyRound size={18} />
|
||||
<span>{title || 'Contraseña requerida'}</span>
|
||||
</div>
|
||||
<button className="btn-icon" onClick={onCancel}><X size={16} /></button>
|
||||
</div>
|
||||
{description && <p className="modal-desc">{description}</p>}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="field">
|
||||
<label>Contraseña del certificado</label>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Ingresa la contraseña..."
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn btn-ghost" onClick={onCancel}>Cancelar</button>
|
||||
<button type="submit" className="btn btn-primary">Confirmar</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { Document, Page, pdfjs } from 'react-pdf';
|
||||
import 'react-pdf/dist/Page/AnnotationLayer.css';
|
||||
import 'react-pdf/dist/Page/TextLayer.css';
|
||||
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/build/pdf.worker.min.mjs',
|
||||
import.meta.url,
|
||||
).toString();
|
||||
|
||||
const SIG_W = 200;
|
||||
const SIG_H = 60;
|
||||
|
||||
const getSignatureWidth = (style, signerName = '') => {
|
||||
if (style !== 'qr') return SIG_W;
|
||||
const normalizedName = signerName.trim();
|
||||
const calculatedWidth = 78 + (normalizedName.length * 4.9);
|
||||
return Math.max(170, Math.min(270, Math.round(calculatedWidth)));
|
||||
};
|
||||
|
||||
export default function PdfSignaturePlacer({
|
||||
pdfBlob,
|
||||
signerName,
|
||||
signatureStyle = 'classic',
|
||||
qrPosition = 'left',
|
||||
qrBackground = 'transparent',
|
||||
qrBorder = 'none',
|
||||
signatureImage = null,
|
||||
onPlaced,
|
||||
}) {
|
||||
const [numPages, setNumPages] = useState(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [sigBox, setSigBox] = useState(null); // { x, y } in container px
|
||||
const containerRef = useRef(null);
|
||||
const [pageSize, setPageSize] = useState(null); // rendered px size
|
||||
const signatureWidth = getSignatureWidth(signatureStyle, signerName);
|
||||
|
||||
const getCanvasMetrics = () => {
|
||||
const container = containerRef.current;
|
||||
const canvas = container?.querySelector('.react-pdf__Page__canvas');
|
||||
if (!container || !canvas) return null;
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const canvasRect = canvas.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
containerRect,
|
||||
canvasRect,
|
||||
offsetX: canvasRect.left - containerRect.left,
|
||||
offsetY: canvasRect.top - containerRect.top,
|
||||
width: canvasRect.width,
|
||||
height: canvasRect.height,
|
||||
};
|
||||
};
|
||||
|
||||
const handleDocLoad = ({ numPages }) => setNumPages(numPages);
|
||||
|
||||
const handlePageLoad = (page) => {
|
||||
setPageSize({ width: page.width, height: page.height });
|
||||
};
|
||||
|
||||
const handleContainerClick = (e) => {
|
||||
const metrics = getCanvasMetrics();
|
||||
if (!metrics) return;
|
||||
|
||||
const x = e.clientX - metrics.canvasRect.left;
|
||||
const y = e.clientY - metrics.canvasRect.top;
|
||||
const clampedX = Math.min(Math.max(x, 0), metrics.width - signatureWidth);
|
||||
const clampedY = Math.min(Math.max(y, 0), metrics.height - SIG_H);
|
||||
|
||||
setSigBox({
|
||||
x: metrics.offsetX + clampedX,
|
||||
y: metrics.offsetY + clampedY,
|
||||
});
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!sigBox || !pageSize) return;
|
||||
const metrics = getCanvasMetrics();
|
||||
if (!metrics) return;
|
||||
|
||||
const relativeX = sigBox.x - metrics.offsetX;
|
||||
const relativeY = sigBox.y - metrics.offsetY;
|
||||
// PDF coordinates: origin bottom-left, Y inverted
|
||||
const scaleX = pageSize.pdfWidth / metrics.width;
|
||||
const scaleY = pageSize.pdfHeight / metrics.height;
|
||||
const pdfX = relativeX * scaleX;
|
||||
const pdfY = pageSize.pdfHeight - (relativeY + SIG_H) * scaleY;
|
||||
onPlaced({
|
||||
page: currentPage - 1,
|
||||
rect: {
|
||||
x: Math.round(pdfX),
|
||||
y: Math.round(pdfY),
|
||||
width: Math.round(signatureWidth * scaleX),
|
||||
height: Math.round(SIG_H * scaleY),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const previewDate = new Date().toLocaleDateString('es-EC');
|
||||
|
||||
const renderPreview = () => {
|
||||
if (signatureStyle === 'qr') {
|
||||
return (
|
||||
<div className="sig-preview-body sig-preview-body--qr">
|
||||
{qrPosition === 'left' && (
|
||||
<div className="sig-preview-qr-box" data-background={qrBackground} data-border={qrBorder}>
|
||||
<div className="sig-preview-qr-pattern" />
|
||||
<span>QR</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="sig-preview-text">
|
||||
<strong>{signerName}</strong>
|
||||
<small>Verificación digital</small>
|
||||
</div>
|
||||
{qrPosition === 'right' && (
|
||||
<div className="sig-preview-qr-box" data-background={qrBackground} data-border={qrBorder}>
|
||||
<div className="sig-preview-qr-pattern" />
|
||||
<span>QR</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (signatureStyle === 'professional') {
|
||||
return (
|
||||
<div className="sig-preview-body sig-preview-body--professional">
|
||||
<div className="sig-preview-pro-header">FIRMA DIGITAL</div>
|
||||
<div className="sig-preview-pro-content">
|
||||
<strong>{signerName}</strong>
|
||||
<small>{previewDate}</small>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (signatureStyle === 'image') {
|
||||
return (
|
||||
<div className="sig-preview-body sig-preview-body--image">
|
||||
<div className="sig-preview-image-area">
|
||||
{signatureImage
|
||||
? <img src={signatureImage} alt="firma" className="sig-preview-image" />
|
||||
: <span className="sig-preview-image-placeholder">Firma</span>}
|
||||
</div>
|
||||
<div className="sig-preview-text">
|
||||
<strong>{signerName}</strong>
|
||||
<small>Firma digital · {previewDate}</small>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sig-preview-body">
|
||||
<span className="sig-preview-badge">DS</span>
|
||||
<div className="sig-preview-text">
|
||||
<strong>{signerName}</strong>
|
||||
<small>Firma digital · {previewDate}</small>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pdf-placer">
|
||||
<div className="pdf-placer-toolbar">
|
||||
<span className="pdf-placer-hint">
|
||||
Haz clic en el documento para colocar la firma
|
||||
</span>
|
||||
<div className="pdf-placer-pages">
|
||||
<button className="btn btn-ghost" disabled={currentPage <= 1}
|
||||
onClick={() => { setCurrentPage(p => p - 1); setSigBox(null); }}>‹</button>
|
||||
<span>Pág. {currentPage} / {numPages || '…'}</span>
|
||||
<button className="btn btn-ghost" disabled={currentPage >= numPages}
|
||||
onClick={() => { setCurrentPage(p => p + 1); setSigBox(null); }}>›</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="pdf-placer-canvas"
|
||||
onClick={handleContainerClick}
|
||||
style={{ cursor: 'crosshair', position: 'relative', display: 'inline-block' }}
|
||||
>
|
||||
<Document file={pdfBlob} onLoadSuccess={handleDocLoad}>
|
||||
<Page
|
||||
pageNumber={currentPage}
|
||||
width={600}
|
||||
onLoadSuccess={(p) => setPageSize({
|
||||
width: p.width, height: p.height,
|
||||
pdfWidth: p.originalWidth, pdfHeight: p.originalHeight,
|
||||
})}
|
||||
/>
|
||||
</Document>
|
||||
|
||||
{sigBox && (
|
||||
<div
|
||||
className={`sig-preview-box sig-preview-box--${signatureStyle}`}
|
||||
data-background={signatureStyle === 'qr' ? qrBackground : undefined}
|
||||
data-border={signatureStyle === 'qr' ? qrBorder : undefined}
|
||||
style={{ left: sigBox.x, top: sigBox.y, width: signatureWidth, height: SIG_H }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{signatureStyle === 'classic' && <div className="sig-preview-bar" />}
|
||||
{renderPreview()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sigBox && (
|
||||
<div className="pdf-placer-actions">
|
||||
<button className="btn btn-ghost" onClick={() => setSigBox(null)}>Limpiar</button>
|
||||
<button className="btn btn-primary" onClick={handleConfirm}>
|
||||
Confirmar posición
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
|
||||
export default function PrivateRoute({ children }) {
|
||||
const { user, loading } = useAuth();
|
||||
if (loading) return <div className="loading-screen"><div className="spinner" /></div>;
|
||||
if (!user) return <Navigate to="/login" replace />;
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import { useRef } from 'react';
|
||||
import { QrCode, Briefcase, ImageIcon, Stamp, PanelsLeftBottom, PanelsRightBottom, CircleOff, Square } from 'lucide-react';
|
||||
|
||||
const getQrPreviewWidth = (name = '') => {
|
||||
const extraWidth = Math.max(0, name.trim().length - 18) * 4;
|
||||
return Math.min(320, 220 + extraWidth);
|
||||
};
|
||||
|
||||
const STYLES = [
|
||||
{
|
||||
id: 'classic',
|
||||
label: 'Clásico',
|
||||
icon: Stamp,
|
||||
desc: 'Barra azul, insignia DS y campos de texto',
|
||||
preview: ({ name, date }) => (
|
||||
<div className="sig-preview sig-preview--classic">
|
||||
<div className="sp-bar" />
|
||||
<div className="sp-body">
|
||||
<div className="sp-badge">DS</div>
|
||||
<div className="sp-info">
|
||||
<strong>{name}</strong>
|
||||
<span>Razon: Firmado digitalmente</span>
|
||||
<span>Fecha: {date}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sp-watermark">Firma digital</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'qr',
|
||||
label: 'Código QR',
|
||||
icon: QrCode,
|
||||
desc: 'QR de verificación con datos de la firma',
|
||||
preview: ({ name, date, qrPosition = 'left', qrBackground = 'transparent', qrBorder = 'none' }) => (
|
||||
<div
|
||||
className="sig-preview sig-preview--qr"
|
||||
data-background={qrBackground}
|
||||
data-border={qrBorder}
|
||||
style={{ width: `${getQrPreviewWidth(name)}px`, maxWidth: '100%' }}
|
||||
>
|
||||
{qrPosition === 'left' && (
|
||||
<>
|
||||
<div className="sp-qr-box" data-background={qrBackground} data-border={qrBorder}>
|
||||
<svg viewBox="0 0 10 10" className="sp-qr-svg" xmlns="http://www.w3.org/2000/svg">
|
||||
{[0,1,2,3,4,5,6,7,8,9].map(r =>
|
||||
[0,1,2,3,4,5,6,7,8,9].map(c => {
|
||||
const v = ((r * 3 + c * 7 + r ^ c) % 3 === 0);
|
||||
return v ? <rect key={`${r}-${c}`} x={c} y={r} width={1} height={1} fill="#1f1b6e" /> : null;
|
||||
})
|
||||
)}
|
||||
<rect x={0} y={0} width={3} height={3} fill="none" stroke="#1f1b6e" strokeWidth={0.3} />
|
||||
<rect x={7} y={0} width={3} height={3} fill="none" stroke="#1f1b6e" strokeWidth={0.3} />
|
||||
<rect x={0} y={7} width={3} height={3} fill="none" stroke="#1f1b6e" strokeWidth={0.3} />
|
||||
</svg>
|
||||
<span className="sp-qr-hint">Escanear</span>
|
||||
</div>
|
||||
<div className="sp-divider-v" />
|
||||
</>
|
||||
)}
|
||||
<div className="sp-info sp-info--qr">
|
||||
<strong>{name}</strong>
|
||||
<span>Firmado digitalmente</span>
|
||||
<span>{date}</span>
|
||||
</div>
|
||||
{qrPosition === 'right' && (
|
||||
<>
|
||||
<div className="sp-divider-v" />
|
||||
<div className="sp-qr-box" data-background={qrBackground} data-border={qrBorder}>
|
||||
<svg viewBox="0 0 10 10" className="sp-qr-svg" xmlns="http://www.w3.org/2000/svg">
|
||||
{[0,1,2,3,4,5,6,7,8,9].map(r =>
|
||||
[0,1,2,3,4,5,6,7,8,9].map(c => {
|
||||
const v = ((r * 3 + c * 7 + r ^ c) % 3 === 0);
|
||||
return v ? <rect key={`${r}-${c}`} x={c} y={r} width={1} height={1} fill="#1f1b6e" /> : null;
|
||||
})
|
||||
)}
|
||||
<rect x={0} y={0} width={3} height={3} fill="none" stroke="#1f1b6e" strokeWidth={0.3} />
|
||||
<rect x={7} y={0} width={3} height={3} fill="none" stroke="#1f1b6e" strokeWidth={0.3} />
|
||||
<rect x={0} y={7} width={3} height={3} fill="none" stroke="#1f1b6e" strokeWidth={0.3} />
|
||||
</svg>
|
||||
<span className="sp-qr-hint">Escanear</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'professional',
|
||||
label: 'Profesional',
|
||||
icon: Briefcase,
|
||||
desc: 'Cabecera oscura formal con campos organizados',
|
||||
preview: ({ name, date }) => (
|
||||
<div className="sig-preview sig-preview--professional">
|
||||
<div className="sp-pro-header">FIRMA DIGITAL <span>Firmado electrónicamente</span></div>
|
||||
<div className="sp-pro-name">{name}</div>
|
||||
<div className="sp-pro-line" />
|
||||
<div className="sp-pro-fields">
|
||||
<div><b>Razon:</b><span>Firmado digitalmente</span></div>
|
||||
<div><b>Fecha:</b><span>{date}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'image',
|
||||
label: 'Imagen',
|
||||
icon: ImageIcon,
|
||||
desc: 'Sube tu firma manuscrita o imagen personalizada',
|
||||
preview: ({ name, date, image }) => (
|
||||
<div className="sig-preview sig-preview--image">
|
||||
<div className="sp-img-bar" />
|
||||
<div className="sp-img-area">
|
||||
{image
|
||||
? <img src={image} alt="firma" className="sp-sig-img" />
|
||||
: <span className="sp-img-placeholder">Tu firma aquí</span>
|
||||
}
|
||||
</div>
|
||||
<div className="sp-divider-v" />
|
||||
<div className="sp-info">
|
||||
<strong>{name}</strong>
|
||||
<span>Firmado digitalmente</span>
|
||||
<span>{date}</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export default function SignatureStylePicker({
|
||||
value,
|
||||
onChange,
|
||||
signatureImage,
|
||||
onImageChange,
|
||||
signerName,
|
||||
qrPosition = 'left',
|
||||
onQrPositionChange,
|
||||
qrBackground = 'transparent',
|
||||
onQrBackgroundChange,
|
||||
qrBorder = 'none',
|
||||
onQrBorderChange,
|
||||
}) {
|
||||
const fileRef = useRef();
|
||||
const today = new Date().toLocaleDateString('es-EC');
|
||||
|
||||
const handleImageFile = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
if (!file.type.startsWith('image/')) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => onImageChange(ev.target.result);
|
||||
reader.readAsDataURL(file);
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="style-picker">
|
||||
<label className="field-label-standalone">Estilo de firma visual</label>
|
||||
<div className="style-picker-grid">
|
||||
{STYLES.map(s => {
|
||||
const Icon = s.icon;
|
||||
const isActive = value === s.id;
|
||||
return (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
className={`style-card ${isActive ? 'style-card--active' : ''}`}
|
||||
onClick={() => onChange(s.id)}
|
||||
>
|
||||
<div className="style-card-header">
|
||||
<Icon size={14} />
|
||||
<span>{s.label}</span>
|
||||
{isActive && <div className="style-card-check">✓</div>}
|
||||
</div>
|
||||
<div className="style-card-preview">
|
||||
{s.preview({
|
||||
name: signerName || 'Milton González H.',
|
||||
date: today,
|
||||
image: signatureImage,
|
||||
qrPosition,
|
||||
qrBackground,
|
||||
qrBorder,
|
||||
})}
|
||||
</div>
|
||||
<p className="style-card-desc">{s.desc}</p>
|
||||
{s.id === 'qr' && isActive && (
|
||||
<div className="style-card-inline-tools">
|
||||
<div className="style-inline-group">
|
||||
<button
|
||||
type="button"
|
||||
className={`style-icon-toggle ${qrBackground === 'transparent' ? 'style-icon-toggle--active' : ''}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onQrBackgroundChange?.('transparent');
|
||||
}}
|
||||
title="Fondo transparente"
|
||||
aria-label="Fondo transparente"
|
||||
>
|
||||
<CircleOff size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`style-icon-toggle ${qrBackground === 'soft' ? 'style-icon-toggle--active' : ''}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onQrBackgroundChange?.('soft');
|
||||
}}
|
||||
title="Fondo suave"
|
||||
aria-label="Fondo suave"
|
||||
>
|
||||
<Square size={15} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="style-inline-group">
|
||||
<button
|
||||
type="button"
|
||||
className={`style-icon-toggle ${qrBorder === 'none' ? 'style-icon-toggle--active' : ''}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onQrBorderChange?.('none');
|
||||
}}
|
||||
title="Sin borde"
|
||||
aria-label="Sin borde"
|
||||
>
|
||||
<CircleOff size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`style-icon-toggle ${qrBorder === 'line' ? 'style-icon-toggle--active' : ''}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onQrBorderChange?.('line');
|
||||
}}
|
||||
title="Con borde"
|
||||
aria-label="Con borde"
|
||||
>
|
||||
<Square size={15} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`style-icon-toggle ${qrPosition === 'left' ? 'style-icon-toggle--active' : ''}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onQrPositionChange?.('left');
|
||||
}}
|
||||
title="QR a la izquierda"
|
||||
aria-label="QR a la izquierda"
|
||||
>
|
||||
<PanelsLeftBottom size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`style-icon-toggle ${qrPosition === 'right' ? 'style-icon-toggle--active' : ''}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onQrPositionChange?.('right');
|
||||
}}
|
||||
title="QR a la derecha"
|
||||
aria-label="QR a la derecha"
|
||||
>
|
||||
<PanelsRightBottom size={15} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{value === 'image' && (
|
||||
<div className="image-upload-area">
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => fileRef.current.click()}>
|
||||
<ImageIcon size={14} /> {signatureImage ? 'Cambiar imagen' : 'Subir firma (PNG/JPG)'}
|
||||
</button>
|
||||
{signatureImage && (
|
||||
<button type="button" className="btn btn-ghost btn-sm danger" onClick={() => onImageChange(null)}>
|
||||
Quitar imagen
|
||||
</button>
|
||||
)}
|
||||
<small className="field-hint">Fondo transparente (PNG) recomendado para mejor resultado</small>
|
||||
<input ref={fileRef} type="file" accept="image/png,image/jpeg,image/webp" style={{ display: 'none' }} onChange={handleImageFile} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user