Improve web signing flow and locale handling

This commit is contained in:
2026-04-23 21:45:24 -05:00
parent b24d17b98e
commit 24fe8d0a0b
13 changed files with 312 additions and 42 deletions
+7 -1
View File
@@ -2,10 +2,16 @@
## Versión actual ## Versión actual
- Versión base: `1.2.8` - Versión base: `1.2.9`
## Historial ## Historial
### 1.2.9 - 2026-04-23
- Web evita el refresco manual después de subir PDFs y actualiza la lista de documentos en caliente.
- El sello visible de firma usa el huso horario y locale detectados en el navegador del usuario.
- `Firmar ahora` agrega selector con botón para elegir documento y botón `+` para cargar un PDF desde el mismo flujo.
### 1.2.8 - 2026-04-23 ### 1.2.8 - 2026-04-23
- Android simplifica la pantalla biométrica: se ocultan `Face ID`, `PIN` y `SMS OTP`. - Android simplifica la pantalla biométrica: se ocultan `Face ID`, `PIN` y `SMS OTP`.
+1 -1
View File
@@ -1 +1 @@
1.2.8 1.2.9
+2 -2
View File
@@ -18,8 +18,8 @@ android {
applicationId "net.linkedcorp.linkedsign" applicationId "net.linkedcorp.linkedsign"
minSdk 26 minSdk 26
targetSdk 35 targetSdk 35
versionCode 10208 versionCode 10209
versionName "1.2.8" versionName "1.2.9"
buildConfigField "String", "API_BASE_URL", "\"${apiBaseUrl}\"" buildConfigField "String", "API_BASE_URL", "\"${apiBaseUrl}\""
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "linkedsign-backend", "name": "linkedsign-backend",
"version": "1.2.8", "version": "1.2.9",
"private": true, "private": true,
"description": "Initial backend for LinkedSign", "description": "Initial backend for LinkedSign",
"main": "src/server.js", "main": "src/server.js",
@@ -8,18 +8,21 @@ const getCtx = (req) => ({
userName: req.user?.name || req.user?.email, userName: req.user?.name || req.user?.email,
userEmail: req.user?.email || null, userEmail: req.user?.email || null,
frontendUrl: req.headers.origin || null, frontendUrl: req.headers.origin || null,
clientLocale: req.body?.clientLocale || null,
clientTimeZone: req.body?.clientTimeZone || null,
clientCountry: req.body?.clientCountry || null,
}); });
const sign = async (req, res, next) => { const sign = async (req, res, next) => {
try { try {
const { documentId, certificateId, certPassword, reason, location, page, rect, signatureStyle, qrPosition, qrBackground, qrBorder, signatureImageBase64 } = req.body; const { documentId, certificateId, certPassword, reason, location, page, rect, signatureStyle, qrPosition, qrBackground, qrBorder, signatureImageBase64, clientLocale, clientTimeZone, clientCountry } = req.body;
const docId = Number(documentId); const docId = Number(documentId);
const certId = Number(certificateId); const certId = Number(certificateId);
if (!docId || !certId || isNaN(docId) || isNaN(certId)) { if (!docId || !certId || isNaN(docId) || isNaN(certId)) {
const e = new Error('documentId and certificateId are required.'); e.statusCode = 400; throw e; const e = new Error('documentId and certificateId are required.'); e.statusCode = 400; throw e;
} }
const result = await signaturesService.sign( const result = await signaturesService.sign(
{ documentId: docId, certificateId: certId, certPassword, reason, location, page, rect, signatureStyle, qrPosition, qrBackground, qrBorder, signatureImageBase64 }, { documentId: docId, certificateId: certId, certPassword, reason, location, page, rect, signatureStyle, qrPosition, qrBackground, qrBorder, signatureImageBase64, clientLocale, clientTimeZone, clientCountry },
req.user.id, req.user.id,
getCtx(req) getCtx(req)
); );
@@ -9,7 +9,7 @@ const { signPdf } = require('../signing/signing.service');
const auditService = require('../audit/audit.service'); const auditService = require('../audit/audit.service');
const env = require('../../config/env'); const env = require('../../config/env');
const sign = async ({ documentId, certificateId, certPassword, reason, location, page, rect, signatureStyle, qrPosition, qrBackground, qrBorder, signatureImageBase64 }, userId, context = {}) => { const sign = async ({ documentId, certificateId, certPassword, reason, location, page, rect, signatureStyle, qrPosition, qrBackground, qrBorder, signatureImageBase64, clientLocale, clientTimeZone, clientCountry }, userId, context = {}) => {
const doc = await documentsRepository.findById(documentId); const doc = await documentsRepository.findById(documentId);
if (!doc) { const e = new Error('Document not found.'); e.statusCode = 404; throw e; } if (!doc) { const e = new Error('Document not found.'); e.statusCode = 404; throw e; }
if (doc.user_id !== userId) { const e = new Error('Access denied.'); e.statusCode = 403; throw e; } if (doc.user_id !== userId) { const e = new Error('Access denied.'); e.statusCode = 403; throw e; }
@@ -50,6 +50,9 @@ const sign = async ({ documentId, certificateId, certPassword, reason, location,
qrBorder: qrBorder || 'none', qrBorder: qrBorder || 'none',
signatureImageBase64: signatureImageBase64 || null, signatureImageBase64: signatureImageBase64 || null,
verificationUrl, verificationUrl,
clientLocale: clientLocale || context.clientLocale || null,
clientTimeZone: clientTimeZone || context.clientTimeZone || null,
clientCountry: clientCountry || context.clientCountry || null,
})); }));
} catch (signErr) { } catch (signErr) {
const msg = signErr.message || ''; const msg = signErr.message || '';
+34 -3
View File
@@ -39,6 +39,34 @@ function getSignatureWidth(style, signerName = '') {
return Math.max(170, Math.min(270, Math.round(calculatedWidth))); return Math.max(170, Math.min(270, Math.round(calculatedWidth)));
} }
function buildLocalizedSignatureTimestamp({ locale, timeZone } = {}) {
const now = new Date();
const normalizedLocale = locale || 'es-EC';
const options = timeZone ? { timeZone } : {};
try {
return {
dateStr: now.toLocaleDateString(normalizedLocale, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
...options,
}),
timeStr: now.toLocaleTimeString(normalizedLocale, {
hour: '2-digit',
minute: '2-digit',
hour12: true,
...options,
}),
};
} catch {
return {
dateStr: now.toLocaleDateString('es-EC', { day: '2-digit', month: '2-digit', year: 'numeric' }),
timeStr: now.toLocaleTimeString('es-EC', { hour: '2-digit', minute: '2-digit', hour12: true }),
};
}
}
// ── Style: CLASSIC (blue bar + DS badge) ────────────────────────────────────── // ── Style: CLASSIC (blue bar + DS badge) ──────────────────────────────────────
async function drawStyleClassic(pdfDoc, page, sx, sy, sw, sh, { signerName, reason, dateStr, timeStr, location }) { async function drawStyleClassic(pdfDoc, page, sx, sy, sw, sh, { signerName, reason, dateStr, timeStr, location }) {
const f1 = await pdfDoc.embedFont(StandardFonts.HelveticaBold); const f1 = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
@@ -222,6 +250,8 @@ const signPdf = async ({
qrBackground = 'transparent', qrBackground = 'transparent',
qrBorder = 'none', qrBorder = 'none',
verificationUrl = null, verificationUrl = null,
clientLocale = null,
clientTimeZone = null,
outputDir = null, outputDir = null,
outputBaseName = null, outputBaseName = null,
}) => { }) => {
@@ -258,9 +288,10 @@ const signPdf = async ({
}; };
const { x: sx, y: sy, width: sw, height: sh } = sigRect; const { x: sx, y: sy, width: sw, height: sh } = sigRect;
const now = new Date(); const { dateStr, timeStr } = buildLocalizedSignatureTimestamp({
const dateStr = now.toLocaleDateString('es-EC', { day: '2-digit', month: '2-digit', year: 'numeric' }); locale: clientLocale,
const timeStr = now.toLocaleTimeString('es-EC', { hour: '2-digit', minute: '2-digit', hour12: true }); timeZone: clientTimeZone,
});
const info = { signerName, reason, dateStr, timeStr, location, signatureImageBase64, verificationUrl, qrPosition, qrBackground, qrBorder }; const info = { signerName, reason, dateStr, timeStr, location, signatureImageBase64, verificationUrl, qrPosition, qrBackground, qrBorder };
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "1.2.8", "version": "1.2.9",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
import { Document, Page, pdfjs } from 'react-pdf'; import { Document, Page, pdfjs } from 'react-pdf';
import 'react-pdf/dist/Page/AnnotationLayer.css'; import 'react-pdf/dist/Page/AnnotationLayer.css';
import 'react-pdf/dist/Page/TextLayer.css'; import 'react-pdf/dist/Page/TextLayer.css';
import { detectClientSignatureContext } from '../../utils/clientSignatureContext';
pdfjs.GlobalWorkerOptions.workerSrc = new URL( pdfjs.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs', 'pdfjs-dist/build/pdf.worker.min.mjs',
@@ -28,6 +29,7 @@ export default function PdfSignaturePlacer({
signatureImage = null, signatureImage = null,
onPlaced, onPlaced,
}) { }) {
const clientSignatureContext = detectClientSignatureContext();
const [numPages, setNumPages] = useState(null); const [numPages, setNumPages] = useState(null);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [sigBox, setSigBox] = useState(null); // { x, y } in container px const [sigBox, setSigBox] = useState(null); // { x, y } in container px
@@ -120,7 +122,9 @@ export default function PdfSignaturePlacer({
}); });
}; };
const previewDate = new Date().toLocaleDateString('es-EC'); const previewDate = new Date().toLocaleDateString(clientSignatureContext.locale, {
timeZone: clientSignatureContext.timeZone,
});
const renderPreview = () => { const renderPreview = () => {
if (signatureStyle === 'qr') { if (signatureStyle === 'qr') {
+32
View File
@@ -1039,6 +1039,26 @@ select {
padding: 24px; padding: 24px;
} }
.select-action-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: stretch;
}
.select-action-buttons {
display: flex;
gap: 8px;
}
.btn-square {
min-width: 44px;
width: 44px;
padding-left: 0;
padding-right: 0;
justify-content: center;
}
.sign-form-card form { display: flex; flex-direction: column; gap: 14px; } .sign-form-card form { display: flex; flex-direction: column; gap: 14px; }
.sign-info-panel { display: flex; flex-direction: column; gap: 14px; } .sign-info-panel { display: flex; flex-direction: column; gap: 14px; }
@@ -2282,6 +2302,18 @@ select {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.select-action-row {
grid-template-columns: 1fr;
}
.select-action-buttons {
width: 100%;
}
.select-action-buttons .btn {
flex: 1;
}
.steps-bar { .steps-bar {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
+15 -10
View File
@@ -48,19 +48,21 @@ export default function DocumentsPage() {
const [creatingFlow, setCreatingFlow] = useState(false); const [creatingFlow, setCreatingFlow] = useState(false);
const [deletingInvitationId, setDeletingInvitationId] = useState(null); const [deletingInvitationId, setDeletingInvitationId] = useState(null);
const loadDocs = () => { const loadDocs = async ({ silent = false } = {}) => {
setLoading(true); if (!silent) setLoading(true);
Promise.all([ try {
const [documentsResponse, signedResponse] = await Promise.all([
listDocuments({ limit: 50 }), listDocuments({ limit: 50 }),
listSignedDocuments(), listSignedDocuments(),
]) ]);
.then(([documentsResponse, signedResponse]) => {
setDocs(documentsResponse.data.data.rows); setDocs(documentsResponse.data.data.rows);
setTotal(documentsResponse.data.data.total); setTotal(documentsResponse.data.data.total);
setSignedDocs(signedResponse.data.data || []); setSignedDocs(signedResponse.data.data || []);
}) } catch {
.catch(() => toast.error('Error al cargar documentos')) toast.error('Error al cargar documentos');
.finally(() => setLoading(false)); } finally {
if (!silent) setLoading(false);
}
}; };
useEffect(() => { loadDocs(); }, []); useEffect(() => { loadDocs(); }, []);
@@ -79,9 +81,12 @@ export default function DocumentsPage() {
if (file.type !== 'application/pdf') { toast.error('Solo se permiten archivos PDF'); return; } if (file.type !== 'application/pdf') { toast.error('Solo se permiten archivos PDF'); return; }
setUploading(true); setUploading(true);
try { try {
await uploadDocument(file); const { data } = await uploadDocument(file);
const createdDoc = data.data;
setDocs((currentDocs) => [createdDoc, ...currentDocs.filter((doc) => doc.id !== createdDoc.id)]);
setTotal((currentTotal) => currentTotal + 1);
toast.success('Documento subido correctamente'); toast.success('Documento subido correctamente');
loadDocs(); void loadDocs({ silent: true });
} catch (err) { } catch (err) {
toast.error(err.response?.data?.error || 'Error al subir documento'); toast.error(err.response?.data?.error || 'Error al subir documento');
} finally { } finally {
@@ -1,13 +1,14 @@
import { useEffect, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { listDocuments, downloadDocument } from '../../api/documents'; import { listDocuments, downloadDocument, uploadDocument } from '../../api/documents';
import { listCertificates } from '../../api/certificates'; import { listCertificates } from '../../api/certificates';
import { signDocument, getSignatures, downloadSigned } from '../../api/signatures'; import { signDocument, getSignatures, downloadSigned } from '../../api/signatures';
import { PenTool, FileText, Shield, CheckCircle, MapPin, Download } from 'lucide-react'; import { PenTool, FileText, Shield, CheckCircle, MapPin, Download, Check, Plus } from 'lucide-react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import PasswordModal from '../../components/ui/PasswordModal'; import PasswordModal from '../../components/ui/PasswordModal';
import PdfSignaturePlacer from '../../components/ui/PdfSignaturePlacer'; import PdfSignaturePlacer from '../../components/ui/PdfSignaturePlacer';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { detectClientSignatureContext } from '../../utils/clientSignatureContext';
const STEPS = ['Selección', 'Posición', 'Firmar']; const STEPS = ['Selección', 'Posición', 'Firmar'];
const normalizeSignatureStyle = (value) => (value === 'image' ? 'qr' : (value || 'qr')); const normalizeSignatureStyle = (value) => (value === 'image' ? 'qr' : (value || 'qr'));
@@ -15,13 +16,16 @@ const normalizeSignatureStyle = (value) => (value === 'image' ? 'qr' : (value ||
export default function SignaturesPage() { export default function SignaturesPage() {
const { user } = useAuth(); const { user } = useAuth();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const clientSignatureContext = detectClientSignatureContext();
const initialDocumentId = searchParams.get('docId') || '';
const [docs, setDocs] = useState([]); const [docs, setDocs] = useState([]);
const [certs, setCerts] = useState([]); const [certs, setCerts] = useState([]);
const [documentPickerId, setDocumentPickerId] = useState(initialDocumentId);
const [form, setForm] = useState({ const [form, setForm] = useState({
documentId: searchParams.get('docId') || '', documentId: initialDocumentId,
certificateId: '', certificateId: '',
reason: 'Firmado digitalmente', reason: 'Firmado digitalmente',
location: '', location: clientSignatureContext.defaultLocation,
signatureStyle: normalizeSignatureStyle(user?.signaturePreferences?.signatureStyle), signatureStyle: normalizeSignatureStyle(user?.signaturePreferences?.signatureStyle),
qrPosition: user?.signaturePreferences?.qrPosition || 'left', qrPosition: user?.signaturePreferences?.qrPosition || 'left',
qrBackground: user?.signaturePreferences?.qrBackground || 'transparent', qrBackground: user?.signaturePreferences?.qrBackground || 'transparent',
@@ -34,6 +38,9 @@ export default function SignaturesPage() {
const [result, setResult] = useState(null); const [result, setResult] = useState(null);
const [sigs, setSigs] = useState([]); const [sigs, setSigs] = useState([]);
const [pendingSign, setPendingSign] = useState(null); const [pendingSign, setPendingSign] = useState(null);
const [uploadingDocument, setUploadingDocument] = useState(false);
const [pendingLocalDocument, setPendingLocalDocument] = useState(null);
const documentInputRef = useRef(null);
useEffect(() => { useEffect(() => {
Promise.all([ Promise.all([
@@ -59,16 +66,76 @@ export default function SignaturesPage() {
})); }));
}, [user?.signaturePreferences]); }, [user?.signaturePreferences]);
useEffect(() => {
if (!clientSignatureContext.defaultLocation) return;
setForm((currentForm) => (
currentForm.location
? currentForm
: { ...currentForm, location: clientSignatureContext.defaultLocation }
));
}, [clientSignatureContext.defaultLocation]);
const handleChange = (e) => setForm(f => ({ ...f, [e.target.name]: e.target.value })); const handleChange = (e) => setForm(f => ({ ...f, [e.target.name]: e.target.value }));
const handleChooseDocument = () => {
if (!documentPickerId) {
toast.error('Selecciona un documento del listado');
return;
}
if (documentPickerId === '__local__') {
if (!pendingLocalDocument) {
toast.error('Selecciona un PDF local válido');
return;
}
setForm((currentForm) => ({ ...currentForm, documentId: '' }));
toast.success('PDF local seleccionado');
return;
}
setForm((currentForm) => ({ ...currentForm, documentId: documentPickerId }));
toast.success('Documento seleccionado');
};
const handleUploadFromSignFlow = async (event) => {
const file = event.target.files?.[0];
if (!file) return;
if (file.type !== 'application/pdf') {
toast.error('Solo se permiten archivos PDF');
event.target.value = '';
return;
}
setUploadingDocument(true);
try {
setPendingLocalDocument({
file,
title: file.name.replace(/\.pdf$/i, ''),
});
setDocumentPickerId('__local__');
setForm((currentForm) => ({ ...currentForm, documentId: '' }));
toast.success('Documento cargado localmente. Se guardará cuando completes la firma.');
} catch {
toast.error('No se pudo preparar el documento');
} finally {
setUploadingDocument(false);
event.target.value = '';
}
};
// Step 0 → 1: load PDF for placement // Step 0 → 1: load PDF for placement
const handleGoToPlace = async () => { const handleGoToPlace = async () => {
if (!form.documentId || !form.certificateId) { const usingLocalDocument = documentPickerId === '__local__' && pendingLocalDocument;
if ((!form.documentId && !usingLocalDocument) || !form.certificateId) {
toast.error('Selecciona documento y certificado'); return; toast.error('Selecciona documento y certificado'); return;
} }
try { try {
if (usingLocalDocument) {
setPdfBlob(pendingLocalDocument.file);
} else {
const { data } = await downloadDocument(form.documentId); const { data } = await downloadDocument(form.documentId);
setPdfBlob(data); setPdfBlob(data);
}
setSigPlacement(null); setSigPlacement(null);
setStep(1); setStep(1);
} catch { toast.error('No se pudo cargar el documento'); } } catch { toast.error('No se pudo cargar el documento'); }
@@ -81,18 +148,37 @@ export default function SignaturesPage() {
}; };
const doSign = async (certPassword = '', fromModal = false) => { const doSign = async (certPassword = '', fromModal = false) => {
if (!form.documentId || !form.certificateId) { const usingLocalDocument = documentPickerId === '__local__' && pendingLocalDocument;
if ((!form.documentId && !usingLocalDocument) || !form.certificateId) {
toast.error('Faltan datos de documento o certificado. Vuelve al paso 1.'); toast.error('Faltan datos de documento o certificado. Vuelve al paso 1.');
setStep(0); setStep(0);
return; return;
} }
setSigning(true); setSigning(true);
let workingDocumentId = Number(form.documentId) || null;
try {
if (!workingDocumentId && usingLocalDocument) {
const { data: uploadResponse } = await uploadDocument(pendingLocalDocument.file);
const createdDoc = uploadResponse.data;
workingDocumentId = Number(createdDoc.id);
setDocs((currentDocs) => [createdDoc, ...currentDocs.filter((doc) => doc.id !== createdDoc.id)]);
}
} catch (error) {
setSigning(false);
toast.error(error.response?.data?.error || 'No se pudo guardar el documento antes de firmar');
return;
}
const payload = { const payload = {
documentId: Number(form.documentId), documentId: workingDocumentId,
certificateId: Number(form.certificateId), certificateId: Number(form.certificateId),
certPassword, certPassword,
reason: form.reason, reason: form.reason,
location: form.location, location: form.location,
clientLocale: clientSignatureContext.locale,
clientTimeZone: clientSignatureContext.timeZone,
clientCountry: clientSignatureContext.countryCode,
page: sigPlacement?.page ?? 0, page: sigPlacement?.page ?? 0,
rect: sigPlacement?.rect ?? null, rect: sigPlacement?.rect ?? null,
signatureStyle: form.signatureStyle, signatureStyle: form.signatureStyle,
@@ -105,6 +191,8 @@ export default function SignaturesPage() {
setResult(data.data); setResult(data.data);
toast.success('¡Documento firmado exitosamente!'); toast.success('¡Documento firmado exitosamente!');
setDocs(prev => prev.filter(d => d.id !== payload.documentId)); setDocs(prev => prev.filter(d => d.id !== payload.documentId));
setPendingLocalDocument(null);
setDocumentPickerId('');
setForm(f => ({ ...f, documentId: '', certificateId: '' })); setForm(f => ({ ...f, documentId: '', certificateId: '' }));
setSigPlacement(null); setSigPlacement(null);
setPdfBlob(null); setPdfBlob(null);
@@ -125,6 +213,9 @@ export default function SignaturesPage() {
const selectedCert = certs.find(c => c.id === Number(form.certificateId)); const selectedCert = certs.find(c => c.id === Number(form.certificateId));
const selectedDoc = docs.find(d => d.id === Number(form.documentId)); const selectedDoc = docs.find(d => d.id === Number(form.documentId));
const selectedDocumentTitle = documentPickerId === '__local__'
? pendingLocalDocument?.title
: selectedDoc?.title;
return ( return (
<div className="page"> <div className="page">
@@ -158,11 +249,38 @@ export default function SignaturesPage() {
<div className="sign-form-card"> <div className="sign-form-card">
<div className="field"> <div className="field">
<label><FileText size={14} /> Documento</label> <label><FileText size={14} /> Documento</label>
<select name="documentId" value={form.documentId} onChange={handleChange} required> <div className="select-action-row">
<select name="documentPickerId" value={documentPickerId} onChange={(event) => setDocumentPickerId(event.target.value)} required>
<option value="">Selecciona un documento...</option> <option value="">Selecciona un documento...</option>
{docs.map(d => <option key={d.id} value={d.id}>{d.title}</option>)} {docs.map(d => <option key={d.id} value={d.id}>{d.title}</option>)}
</select> </select>
<div className="select-action-buttons">
<button type="button" className="btn btn-secondary btn-square" title="Elegir documento" onClick={handleChooseDocument}>
<Check size={16} />
</button>
<button
type="button"
className="btn btn-primary btn-square"
title="Agregar PDF"
onClick={() => documentInputRef.current?.click()}
disabled={uploadingDocument}
>
<Plus size={16} />
</button>
</div>
</div>
<input
ref={documentInputRef}
type="file"
accept=".pdf,application/pdf"
style={{ display: 'none' }}
onChange={handleUploadFromSignFlow}
/>
{docs.length === 0 && <small className="field-hint">No hay documentos pendientes.</small>} {docs.length === 0 && <small className="field-hint">No hay documentos pendientes.</small>}
{selectedDocumentTitle && (
<small className="field-hint">Seleccionado: {selectedDocumentTitle}</small>
)}
{uploadingDocument && <small className="field-hint">Subiendo documento...</small>}
</div> </div>
<div className="field"> <div className="field">
<label><Shield size={14} /> Certificado digital</label> <label><Shield size={14} /> Certificado digital</label>
@@ -0,0 +1,68 @@
const COUNTRY_BY_TIMEZONE = {
'America/Guayaquil': 'EC',
'America/Bogota': 'CO',
'America/Lima': 'PE',
'America/Caracas': 'VE',
'America/Santiago': 'CL',
'America/Buenos_Aires': 'AR',
'America/Sao_Paulo': 'BR',
'America/Mexico_City': 'MX',
'America/Panama': 'PA',
'America/Costa_Rica': 'CR',
'America/El_Salvador': 'SV',
'America/Managua': 'NI',
'America/Tegucigalpa': 'HN',
'America/Guatemala': 'GT',
'America/La_Paz': 'BO',
'America/Asuncion': 'PY',
'America/Montevideo': 'UY',
'America/Puerto_Rico': 'PR',
'America/New_York': 'US',
'America/Chicago': 'US',
'America/Denver': 'US',
'America/Los_Angeles': 'US',
'Europe/Madrid': 'ES',
};
const getClientLocale = () => {
if (typeof navigator === 'undefined') return 'es-EC';
return navigator.languages?.[0] || navigator.language || 'es-EC';
};
const getRegionFromLocale = (locale) => {
try {
const normalized = String(locale || '').replace('_', '-');
if (typeof Intl.Locale === 'function') {
return new Intl.Locale(normalized).region || null;
}
const parts = normalized.split('-');
return parts[1] || null;
} catch {
return null;
}
};
const getCountryName = (countryCode, locale) => {
if (!countryCode) return '';
try {
const display = new Intl.DisplayNames([locale || 'es-EC'], { type: 'region' });
return display.of(countryCode) || countryCode;
} catch {
return countryCode;
}
};
export const detectClientSignatureContext = () => {
const locale = getClientLocale();
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
const countryCode = getRegionFromLocale(locale) || COUNTRY_BY_TIMEZONE[timeZone] || null;
const countryName = getCountryName(countryCode, locale);
return {
locale,
timeZone,
countryCode,
countryName,
defaultLocation: countryName || '',
};
};