@ -3,8 +3,8 @@ import { useNavigate, useParams } from 'react-router-dom'
import { formatINR } from '@sims/domain'
import { Badge , Button , DataTable , EmptyState , Notice , PageHeader , Toolbar } from '@sims/ui'
import {
documentAction, fetchPdfBlob , getClient , getDocumentFull ,
type Client , type Doc ,
createShare, documentAction, downloadDocumentPdf , fetchPdfBlob , getClient , getDocumentFull ,
revokeShare , type Client , type Doc , type Share ,
} from '../api'
import { DOC_TONE , useData } from './Clients'
import { PaymentForm } from './ClientDetail'
@ -143,6 +143,8 @@ export function DocumentView() {
< / Toolbar >
{ actionErr !== undefined && < Notice tone = "err" > { actionErr } < / Notice > }
< ShareActions doc = { doc } client = { client . data } shares = { full . data . shares } onChange = { full . reload } / >
{ paying && (
< PaymentForm
clientId = { doc . clientId }
@ -191,3 +193,130 @@ export function DocumentView() {
< / div >
)
}
/** True while a share is still usable — not revoked and not past its expiry. */
function isLive ( s : Share ) : boolean {
return ! s . revoked && ( s . expiresAt === null || s . expiresAt > new Date ( ) . toISOString ( ) )
}
/ * *
* Share & download action group : download the PDF ( attachment ) , mint / copy a public
* ` /share/<token> ` view link , list the live links with one - click revoke , and hand the
* link to WhatsApp click - to - chat or the native share sheet ( falling back to Download
* where ` navigator.share ` is unavailable ) . The public link is same - origin — served by
* the hq server ( in production it also serves this SPA ; in dev vite proxies ` /share ` ) .
* /
function ShareActions ( props : {
doc : Doc ; client : Client | undefined ; shares : Share [ ] ; onChange : ( ) = > void
} ) {
const { doc , client , shares , onChange } = props
const [ busy , setBusy ] = useState < string | undefined > ( )
const [ err , setErr ] = useState < string | undefined > ( )
const [ copied , setCopied ] = useState < string | undefined > ( )
const shareUrl = ( token : string ) : string = > ` ${ window . location . origin } /share/ ${ token } `
const live = shares . filter ( isLive )
const primary = live [ 0 ] // server lists newest-first (uuidv7 id DESC)
const run = ( key : string , p : Promise < unknown > ) = > {
setErr ( undefined )
setBusy ( key )
p . then ( ( ) = > onChange ( ) ) . catch ( ( e : Error ) = > setErr ( e . message ) ) . finally ( ( ) = > setBusy ( undefined ) )
}
const onDownload = ( ) = > {
setErr ( undefined )
setBusy ( 'download' )
downloadDocumentPdf ( doc . id , doc . docNo )
. catch ( ( e : Error ) = > setErr ( e . message ) )
. finally ( ( ) = > setBusy ( undefined ) )
}
const onCopy = ( url : string ) = > {
void navigator . clipboard ? . writeText ( url ) . then (
( ) = > { setCopied ( url ) ; window . setTimeout ( ( ) = > setCopied ( undefined ) , 1800 ) } ,
( ) = > setErr ( 'Could not copy — select the link and copy it manually.' ) ,
)
}
// Greeting + doc type/no + total + the view link — the message that rides the share.
const message = ( url : string ) : string = > {
const who = client ? . name !== undefined && client . name !== '' ? ` Dear ${ client . name } , ` : 'Hello,'
const no = doc . docNo ? ? '(draft)'
return ` ${ who } \ n \ nPlease find your ${ TITLE [ doc . docType ] } ${ no } for ${ formatINR ( doc . payablePaise ) } . \ n `
+ ` \ nView it here: ${ url } `
}
const onWhatsApp = ( url : string ) = > {
const digits = ( client ? . contacts . find ( ( c ) = > c . phone !== undefined && c . phone !== '' ) ? . phone ? ? '' )
. replace ( /\D/g , '' )
// With digits → chat opens on that number; without → WhatsApp asks which contact.
window . open ( ` https://wa.me/ ${ digits } ?text= ${ encodeURIComponent ( message ( url ) ) } ` , '_blank' , 'noopener' )
}
const onNativeShare = ( url : string ) = > {
const nav = navigator as Navigator & { share ? : ( d : ShareData ) = > Promise < void > }
if ( typeof nav . share === 'function' ) {
void nav . share ( { title : ` ${ TITLE [ doc . docType ] } ${ doc . docNo ? ? '' } ` . trim ( ) , text : message ( url ) , url } )
. catch ( ( ) = > { /* user dismissed the sheet — nothing to do */ } )
} else {
onDownload ( ) // desktop / unsupported → save the PDF instead
}
}
return (
< section style = { { marginTop : 14 } } >
< h3 style = { { margin : '0 0 8px' } } > Share & amp ; download < / h3 >
< Toolbar >
< Button onClick = { onDownload } > { busy === 'download' ? 'Preparing…' : 'Download PDF' } < / Button >
{ primary === undefined ? (
< Button tone = "primary" onClick = { ( ) = > run ( 'create' , createShare ( doc . id ) ) } >
{ busy === 'create' ? 'Creating…' : 'Create share link' }
< / Button >
) : (
< >
< Button onClick = { ( ) = > onCopy ( shareUrl ( primary . token ) ) } >
{ copied === shareUrl ( primary . token ) ? 'Copied ✓' : 'Copy link' }
< / Button >
< Button onClick = { ( ) = > onWhatsApp ( shareUrl ( primary . token ) ) } > WhatsApp < / Button >
< Button onClick = { ( ) = > onNativeShare ( shareUrl ( primary . token ) ) } > Share … < / Button >
< Button onClick = { ( ) = > run ( 'create' , createShare ( doc . id ) ) } >
{ busy === 'create' ? 'Creating…' : 'New link' }
< / Button >
< / >
) }
< / Toolbar >
{ err !== undefined && < Notice tone = "err" > { err } < / Notice > }
{ live . length === 0 ? (
< p style = { { color : 'var(--text-dim)' , fontSize : 13 , margin : '2px 0 0' } } >
No active link yet — create one to share this { TITLE [ doc . docType ] . toLowerCase ( ) } by link , WhatsApp , or the share sheet .
< / p >
) : (
< div style = { { marginTop : 8 , display : 'flex' , flexDirection : 'column' , gap : 6 } } >
{ live . map ( ( s ) = > (
< div
key = { s . id }
style = { {
display : 'flex' , alignItems : 'center' , gap : 10 , flexWrap : 'wrap' ,
border : '1px solid var(--border)' , borderRadius : 8 , padding : '8px 10px' ,
} }
>
< code style = { { fontFamily : 'var(--mono)' , fontSize : 12.5 , wordBreak : 'break-all' , flex : 1 , minWidth : 220 } } >
{ shareUrl ( s . token ) }
< / code >
< span style = { { fontSize : 12 , color : 'var(--text-dim)' , whiteSpace : 'nowrap' } } >
{ s . expiresAt === null ? 'No expiry' : ` Expires ${ new Date ( s . expiresAt ) . toLocaleDateString ( ) } ` }
< / span >
< Button onClick = { ( ) = > onCopy ( shareUrl ( s . token ) ) } >
{ copied === shareUrl ( s . token ) ? 'Copied ✓' : 'Copy' }
< / Button >
< Button tone = "danger" onClick = { ( ) = > run ( ` revoke: ${ s . id } ` , revokeShare ( doc . id , s . id ) ) } >
{ busy === ` revoke: ${ s . id } ` ? 'Revoking…' : 'Revoke' }
< / Button >
< / div >
) ) }
< / div >
) }
< / section >
)
}