@@ -30,6 +30,7 @@ import { Badge } from "@/components/ui/badge";
3030import { Table , TableBody , TableCell , TableHead , TableHeader , TableRow } from "@/components/ui/table" ;
3131import { Dialog , DialogContent , DialogDescription , DialogFooter , DialogHeader , DialogTitle } from "@/components/ui/dialog" ;
3232import { SkeletonImage } from "@/components/ui/skeleton-image" ;
33+ import { Switch } from "@/components/ui/switch" ;
3334import {
3435 AlertDialog ,
3536 AlertDialogAction ,
@@ -69,6 +70,8 @@ export default function SettingsPage() {
6970 defaultValues : {
7071 companyName : "" , rfc : "" , street : "" , exteriorNumber : "" , interiorNumber : "" , state : "" , municipality : "" , neighborhood : "" , zip : "" , phone : "" , phone2 : "" , fax : "" , city : "" , web : "" , contadorEmail : "" ,
7172 taxRegime : "" , commercialMessage : "" , logoUrl : "" , defaultEmailMessage : "" , templateCfdi33 : "costine-33" , templateCfdi40 : "costine-40" , templateRep : "costine-rep" ,
73+ customDomain : "" ,
74+ pacProvider : "" , pacEnvironment : "test" , pacUsername : "" , pacPassword : "" , pacApiKey : "" , pacApiUrl : "" , pacWebhookUrl : "" , pacIsActive : false ,
7275 } ,
7376 } ) ;
7477
@@ -131,9 +134,81 @@ export default function SettingsPage() {
131134 setLoading ( false ) ;
132135 }
133136 } ) ;
134- return ( ) => unsubscribe ( ) ;
137+ return ( ) => unsubscribe ( ) ;
135138 } , [ fetchProfile ] ) ;
136139
140+ const [ verifyingDomain , setVerifyingDomain ] = useState ( false ) ;
141+ const [ domainRecord , setDomainRecord ] = useState < { host : string ; token : string ; recordName : string } | null > ( null ) ;
142+ const [ domainError , setDomainError ] = useState < string | null > ( null ) ;
143+ const [ domainInfoLoaded , setDomainInfoLoaded ] = useState ( false ) ;
144+ const domainValue = profileForm . watch ( 'customDomain' ) ;
145+
146+ const handleShowDomainDns = async ( domainOverride ?: string ) => {
147+ if ( ! user ) {
148+ toast ( { title : "Error" , description : "Debes iniciar sesión." , variant : "destructive" } ) ;
149+ return ;
150+ }
151+ const rawDomain = domainOverride ?? profileForm . getValues ( 'customDomain' ) ;
152+ if ( ! rawDomain ) {
153+ setDomainError ( "Primero ingresa y guarda un dominio." ) ;
154+ return ;
155+ }
156+ try {
157+ setVerifyingDomain ( true ) ;
158+ const { getUserAuth } = await import ( '@/lib/auth-client' ) ;
159+ const { uid, token } = await getUserAuth ( user ) ;
160+ const { getDomainVerificationInfo } = await import ( '@/app/actions/companies' ) ;
161+ const res = await getDomainVerificationInfo ( uid , token , rawDomain ) ;
162+ if ( res . success && res . data ) {
163+ setDomainRecord ( res . data as any ) ;
164+ setDomainError ( null ) ;
165+ } else {
166+ toast ( { title : "No disponible" , description : res . message || "Configura un dominio primero." , variant : "destructive" } ) ;
167+ setDomainError ( res . message || "Configura un dominio primero." ) ;
168+ }
169+ } catch ( error ) {
170+ toast ( { title : "Error" , description : "No se pudo obtener la instrucción DNS." , variant : "destructive" } ) ;
171+ setDomainError ( "No se pudo obtener la instrucción DNS." ) ;
172+ } finally {
173+ setVerifyingDomain ( false ) ;
174+ }
175+ } ;
176+
177+ const handleVerifyDomain = async ( ) => {
178+ if ( ! user ) {
179+ toast ( { title : "Error" , description : "Debes iniciar sesión." , variant : "destructive" } ) ;
180+ return ;
181+ }
182+ try {
183+ setVerifyingDomain ( true ) ;
184+ const { getUserAuth } = await import ( '@/lib/auth-client' ) ;
185+ const { uid, token } = await getUserAuth ( user ) ;
186+ const { verifyCustomDomain, getDomainVerificationInfo } = await import ( '@/app/actions/companies' ) ;
187+ const verify = await verifyCustomDomain ( uid , token ) ;
188+ if ( verify . success ) {
189+ toast ( { title : "Dominio verificado" , description : "El registro TXT fue encontrado." } ) ;
190+ setDomainError ( null ) ;
191+ } else {
192+ toast ( { title : "No verificado" , description : verify . message || "No se encontró el TXT. Espera la propagación y reintenta." , variant : "destructive" } ) ;
193+ setDomainError ( verify . message || "No se encontró el TXT. Espera la propagación y reintenta." ) ;
194+ }
195+ const info = await getDomainVerificationInfo ( uid , token , domainValue ) ;
196+ if ( info . success && info . data ) setDomainRecord ( info . data as any ) ;
197+ } catch ( error ) {
198+ toast ( { title : "Error" , description : "No se pudo verificar el dominio." , variant : "destructive" } ) ;
199+ setDomainError ( "No se pudo verificar el dominio." ) ;
200+ } finally {
201+ setVerifyingDomain ( false ) ;
202+ }
203+ } ;
204+
205+ useEffect ( ( ) => {
206+ if ( domainValue && ! domainInfoLoaded && ! loading ) {
207+ setDomainInfoLoaded ( true ) ;
208+ handleShowDomainDns ( domainValue ) ;
209+ }
210+ } , [ domainValue , domainInfoLoaded , loading ] ) ;
211+
137212 async function onProfileSubmit ( data : ProfileFormValues ) {
138213 if ( ! user ) {
139214 toast ( { title : "Error" , description : "Debes iniciar sesión para guardar los cambios." , variant : "destructive" } ) ;
@@ -338,16 +413,6 @@ export default function SettingsPage() {
338413 </ FormItem >
339414 ) } />
340415 < FormField control = { profileForm . control } name = "defaultEmailMessage" render = { ( { field } ) => ( < FormItem > < FormLabel > Mensaje predefinido para envio</ FormLabel > < FormControl > < Textarea rows = { 4 } { ...field } value = { field . value ?? '' } /> </ FormControl > < FormMessage /> </ FormItem > ) } />
341- < FormField control = { profileForm . control } name = "customDomain" render = { ( { field } ) => (
342- < FormItem className = "md:col-span-2" >
343- < FormLabel > Dominio Personalizado para Links Públicos</ FormLabel >
344- < FormControl > < Input placeholder = "https://tuempresa.com" { ...field } value = { field . value ?? '' } /> </ FormControl >
345- < p className = "text-xs text-muted-foreground" >
346- Configura tu dominio personalizado para que los links de solicitudes de clientes usen tu marca (ej: https://tuempresa.com/solicitud/abc123)
347- </ p >
348- < FormMessage />
349- </ FormItem >
350- ) } />
351416 </ div >
352417 < Separator />
353418 < div className = "space-y-8" >
@@ -394,6 +459,153 @@ export default function SettingsPage() {
394459 </ AccordionContent >
395460 </ AccordionItem >
396461
462+ < AccordionItem value = "pac" >
463+ < AccordionTrigger className = "text-lg font-semibold bg-muted px-4 rounded-t-lg data-[state=closed]:rounded-b-lg" >
464+ PAC / Timbrado
465+ </ AccordionTrigger >
466+ < AccordionContent className = "p-4 border border-t-0 rounded-b-lg space-y-6" >
467+ < div className = "text-sm text-muted-foreground" >
468+ Define las credenciales de tu PAC para timbrar con tu propia cuenta. Se guardan por empresa y se usan al momento de timbrar facturas y pagos.
469+ </ div >
470+ < div className = "grid md:grid-cols-2 gap-4" >
471+ < FormField control = { profileForm . control } name = "pacProvider" render = { ( { field } ) => (
472+ < FormItem >
473+ < FormLabel > Proveedor</ FormLabel >
474+ < FormControl > < Input placeholder = "Ej. FacturaLoPlus, Finkok, SW Sapien" { ...field } value = { field . value ?? '' } /> </ FormControl >
475+ < FormMessage />
476+ </ FormItem >
477+ ) } />
478+ < FormField control = { profileForm . control } name = "pacEnvironment" render = { ( { field } ) => (
479+ < FormItem >
480+ < FormLabel > Ambiente</ FormLabel >
481+ < Select onValueChange = { field . onChange } value = { field . value ?? 'test' } >
482+ < FormControl > < SelectTrigger > < SelectValue placeholder = "Selecciona ambiente" /> </ SelectTrigger > </ FormControl >
483+ < SelectContent >
484+ < SelectItem value = "test" > Pruebas</ SelectItem >
485+ < SelectItem value = "production" > Producción</ SelectItem >
486+ </ SelectContent >
487+ </ Select >
488+ < FormMessage />
489+ </ FormItem >
490+ ) } />
491+ < FormField control = { profileForm . control } name = "pacUsername" render = { ( { field } ) => (
492+ < FormItem >
493+ < FormLabel > Usuario</ FormLabel >
494+ < FormControl > < Input placeholder = "Usuario del PAC" { ...field } value = { field . value ?? '' } /> </ FormControl >
495+ < FormMessage />
496+ </ FormItem >
497+ ) } />
498+ < FormField control = { profileForm . control } name = "pacPassword" render = { ( { field } ) => (
499+ < FormItem >
500+ < FormLabel > Contraseña</ FormLabel >
501+ < FormControl > < Input type = "password" placeholder = "Contraseña/Token secreto" { ...field } value = { field . value ?? '' } /> </ FormControl >
502+ < FormMessage />
503+ </ FormItem >
504+ ) } />
505+ < FormField control = { profileForm . control } name = "pacApiKey" render = { ( { field } ) => (
506+ < FormItem >
507+ < FormLabel > API Key</ FormLabel >
508+ < FormControl > < Input placeholder = "API Key si aplica" { ...field } value = { field . value ?? '' } /> </ FormControl >
509+ < FormMessage />
510+ </ FormItem >
511+ ) } />
512+ < FormField control = { profileForm . control } name = "pacApiUrl" render = { ( { field } ) => (
513+ < FormItem >
514+ < FormLabel > URL del API</ FormLabel >
515+ < FormControl > < Input placeholder = "https://api.tu-pac.com/timbrar" { ...field } value = { field . value ?? '' } /> </ FormControl >
516+ < FormMessage />
517+ </ FormItem >
518+ ) } />
519+ < FormField control = { profileForm . control } name = "pacWebhookUrl" render = { ( { field } ) => (
520+ < FormItem >
521+ < FormLabel > Webhook (opcional)</ FormLabel >
522+ < FormControl > < Input placeholder = "URL para notificaciones del PAC" { ...field } value = { field . value ?? '' } /> </ FormControl >
523+ < FormMessage />
524+ </ FormItem >
525+ ) } />
526+ < FormField control = { profileForm . control } name = "pacIsActive" render = { ( { field } ) => (
527+ < FormItem className = "flex items-center justify-between rounded-lg border p-3" >
528+ < div className = "space-y-0.5" >
529+ < FormLabel > Usar este PAC</ FormLabel >
530+ < p className = "text-xs text-muted-foreground" > Activa para timbrar con estas credenciales.</ p >
531+ </ div >
532+ < FormControl >
533+ < Switch checked = { field . value ?? false } onCheckedChange = { field . onChange } />
534+ </ FormControl >
535+ </ FormItem >
536+ ) } />
537+ </ div >
538+ </ AccordionContent >
539+ </ AccordionItem >
540+
541+ < AccordionItem value = "custom-domain" >
542+ < AccordionTrigger className = "text-lg font-semibold bg-muted px-4 rounded-t-lg data-[state=closed]:rounded-b-lg" >
543+ Dominio Personalizado (requiere verificación DNS)
544+ </ AccordionTrigger >
545+ < AccordionContent className = "p-4 border border-t-0 rounded-b-lg space-y-4" >
546+ < p className = "text-sm text-muted-foreground" >
547+ Usa un dominio tuempresa.com para los links públicos. Debes apuntar un CNAME/A y verificarlo antes de usarlo; no aceptamos dominios no verificados (ej: google.com).
548+ </ p >
549+ < div className = "rounded-lg border bg-muted/40 p-3 text-sm" >
550+ < div className = "text-muted-foreground" > Dominio actual:</ div >
551+ < div className = "font-mono" > { profileForm . watch ( 'customDomain' ) || 'No configurado' } </ div >
552+ </ div >
553+ { domainRecord && (
554+ < div className = "rounded-lg border bg-muted/40 p-3 text-sm space-y-1" >
555+ < div className = "font-semibold" > Registro TXT a crear</ div >
556+ < div > < span className = "text-muted-foreground" > Nombre:</ span > < span className = "font-mono" > { domainRecord . recordName } </ span > </ div >
557+ < div > < span className = "text-muted-foreground" > Valor:</ span > < span className = "font-mono break-all" > { domainRecord . token } </ span > </ div >
558+ < div className = "text-xs text-muted-foreground" > Coloca este TXT en tu DNS y espera propagación.</ div >
559+ </ div >
560+ ) }
561+ < FormField control = { profileForm . control } name = "customDomain" render = { ( { field } ) => (
562+ < FormItem >
563+ < FormLabel > Dominio</ FormLabel >
564+ < FormControl >
565+ < Input
566+ placeholder = "facturas.tuempresa.com"
567+ { ...field }
568+ value = { field . value ?? '' }
569+ className = "max-w-xl"
570+ />
571+ </ FormControl >
572+ < p className = "text-xs text-muted-foreground" >
573+ Puedes escribir solo el dominio; agregamos https:// automáticamente. Configura primero el registro DNS y luego guarda para iniciar verificación.
574+ </ p >
575+ { domainError && (
576+ < p className = "text-xs text-destructive" > { domainError } </ p >
577+ ) }
578+ < FormMessage />
579+ </ FormItem >
580+ ) } />
581+ < div className = "flex flex-wrap gap-3" >
582+ < Button
583+ type = "button"
584+ variant = "outline"
585+ size = "sm"
586+ onClick = { ( ) => handleShowDomainDns ( ) }
587+ disabled = { verifyingDomain || ! domainValue }
588+ >
589+ Obtener registro TXT
590+ </ Button >
591+ < Button
592+ type = "button"
593+ size = "sm"
594+ onClick = { handleVerifyDomain }
595+ disabled = { verifyingDomain || ! domainValue }
596+ >
597+ Verificar dominio
598+ </ Button >
599+ </ div >
600+ < Alert variant = "default" className = "text-sm" >
601+ < AlertTitle > Verificación pendiente</ AlertTitle >
602+ < AlertDescription >
603+ Crea el TXT mostrado, espera propagación y luego presiona "Verificar dominio".
604+ </ AlertDescription >
605+ </ Alert >
606+ </ AccordionContent >
607+ </ AccordionItem >
608+
397609 < AccordionItem value = "sso" >
398610 < AccordionTrigger className = "text-lg font-semibold bg-muted px-4 rounded-t-lg data-[state=closed]:rounded-b-lg" > Inicio de Sesión Único (SSO)</ AccordionTrigger >
399611 < AccordionContent className = "p-4 border border-t-0 rounded-b-lg" >
0 commit comments