@@ -16,15 +16,19 @@ import { useInvoiceCustomization } from "@/lib/customization";
1616import PaymentProgress from "@/components/PaymentProgress" ;
1717import PayModal from "@/components/PayModal" ;
1818import PaymentMethodSelector from "@/components/PaymentMethodSelector" ;
19+ import PaymentChannelPanel from "@/components/PaymentChannelPanel" ;
1920import CoCreatorPanel from "@/components/CoCreatorPanel" ;
2021import AuditLogTable from "@/components/AuditLogTable" ;
2122import DisputeTimeline from "@/components/DisputeTimeline" ;
2223import CountdownTimer from "@/components/CountdownTimer" ;
2324import RecipientPieChart from "@/components/RecipientPieChart" ;
2425import InvoicePDF from "@/components/InvoicePDF" ;
2526import PaymentCertificate from "@/components/PaymentCertificate" ;
27+ import PaymentExport from "@/components/PaymentExport" ;
2628import AchievementCard from "@/components/AchievementCard" ;
2729import PaymentSourceBar from "@/components/PaymentSourceBar" ;
30+ import ReputationBadge from "@/components/ReputationBadge" ;
31+ import VerifiedCreatorBadge from "@/components/VerifiedCreatorBadge" ;
2832import VersionHistory from "@/components/VersionHistory" ;
2933import InstallmentPanel from "@/components/InstallmentPanel" ;
3034import InstallmentTracker from "@/components/InstallmentTracker" ;
@@ -66,6 +70,13 @@ interface Props {
6670type InvoicePayment = Payment & { pending ?: boolean ; clientKey ?: string } ;
6771type InvoiceView = Omit < InvoiceWithVesting , "payments" > & { payments : InvoicePayment [ ] } ;
6872
73+ type PaymentChannelState = {
74+ invoiceId : string ;
75+ payer : string ;
76+ balance : bigint ;
77+ opened : boolean ;
78+ } ;
79+
6980function mergeWithServer ( server : Invoice , local : InvoiceView | null ) : InvoiceView {
7081 const pending = ( local ?. payments ?? [ ] ) . filter ( ( p ) => p . pending ) ;
7182 const unmatchedPending = pending . filter (
@@ -101,6 +112,9 @@ export default function InvoiceDetailPage({ params }: Props) {
101112 const [ showCancelModal , setShowCancelModal ] = useState ( false ) ;
102113 const [ showPayModal , setShowPayModal ] = useState ( false ) ;
103114 const [ locale , setLocale ] = useState < Locale > ( "en" ) ;
115+ const [ channelState , setChannelState ] = useState < PaymentChannelState | null > ( null ) ;
116+ const [ channelLoading , setChannelLoading ] = useState ( false ) ;
117+ const [ channelError , setChannelError ] = useState < string | null > ( null ) ;
104118
105119 // Payment retry state
106120 const [ lastFailedPayment , setLastFailedPayment ] = useState < { amount : bigint ; fee ?: bigint } | null > ( null ) ;
@@ -169,6 +183,64 @@ export default function InvoiceDetailPage({ params }: Props) {
169183 } ) ;
170184 } ;
171185
186+ const channelStorageKey = ( invoiceId : string , payer : string ) =>
187+ `payment-channel-${ invoiceId } -${ payer } ` ;
188+
189+ const persistChannelState = ( state : PaymentChannelState | null ) => {
190+ if ( typeof window === "undefined" ) return ;
191+ const key = channelStorageKey ( id , publicKey ?? "" ) ;
192+ if ( ! state ) {
193+ localStorage . removeItem ( key ) ;
194+ return ;
195+ }
196+ localStorage . setItem (
197+ key ,
198+ JSON . stringify ( {
199+ invoiceId : state . invoiceId ,
200+ payer : state . payer ,
201+ balance : state . balance . toString ( ) ,
202+ opened : state . opened ,
203+ } )
204+ ) ;
205+ } ;
206+
207+ const loadChannelState = ( ) => {
208+ if ( typeof window === "undefined" || ! publicKey ) return null ;
209+ const raw = localStorage . getItem ( channelStorageKey ( id , publicKey ) ) ;
210+ if ( ! raw ) return null ;
211+ try {
212+ const parsed = JSON . parse ( raw ) as {
213+ invoiceId : string ;
214+ payer : string ;
215+ balance : string | number ;
216+ opened : boolean ;
217+ } ;
218+
219+ if ( parsed . invoiceId !== id || parsed . payer !== publicKey ) return null ;
220+ return {
221+ invoiceId : parsed . invoiceId ,
222+ payer : parsed . payer ,
223+ balance : BigInt ( parsed . balance ) ,
224+ opened : parsed . opened ,
225+ } as PaymentChannelState ;
226+ } catch {
227+ return null ;
228+ }
229+ } ;
230+
231+ const syncChannelState = ( state : PaymentChannelState | null ) => {
232+ setChannelState ( state ) ;
233+ persistChannelState ( state ) ;
234+ } ;
235+
236+ useEffect ( ( ) => {
237+ if ( ! publicKey ) return ;
238+ const stored = loadChannelState ( ) ;
239+ if ( stored ) {
240+ setChannelState ( stored ) ;
241+ }
242+ } , [ id , publicKey ] ) ;
243+
172244 useEffect ( ( ) => {
173245 load ( ) . catch ( ( e ) => setError ( String ( e ) ) ) ;
174246 getFreighterPublicKey ( ) . then ( setPublicKey ) . catch ( ( ) => null ) ;
@@ -208,11 +280,26 @@ export default function InvoiceDetailPage({ params }: Props) {
208280 }
209281 } , [ amountLocked , invoice ] ) ;
210282
283+ const applyChannelBalance = ( amount : bigint ) => {
284+ if ( ! channelState ?. opened || channelState . balance <= 0n ) return null ;
285+ const used = amount <= channelState . balance ? amount : channelState . balance ;
286+ const remaining = channelState . balance - used ;
287+ const nextState : PaymentChannelState = {
288+ ...channelState ,
289+ balance : remaining > 0n ? remaining : 0n ,
290+ opened : remaining > 0n ,
291+ } ;
292+ syncChannelState ( nextState . opened ? nextState : null ) ;
293+ return channelState ;
294+ } ;
295+
211296 const handlePay = async ( e : React . FormEvent ) => {
212297 e . preventDefault ( ) ;
213298 if ( ! publicKey || ! invoice ) return ;
214299 const amount = parseAmount ( payAmount ) ;
215300 const clientKey = `opt-${ Date . now ( ) } ` ;
301+ const originalChannel = channelState ;
302+ const channelUsed = applyChannelBalance ( amount ) ;
216303 setError ( null ) ;
217304 setInvoice ( ( prev ) => {
218305 if ( ! prev ) return prev ;
@@ -244,6 +331,9 @@ export default function InvoiceDetailPage({ params }: Props) {
244331 window . dispatchEvent ( new CustomEvent ( "usdc-balance-refresh" ) ) ;
245332 await load ( ) ;
246333 } catch ( err ) {
334+ if ( channelUsed && originalChannel ) {
335+ syncChannelState ( originalChannel ) ;
336+ }
247337 setInvoice ( ( prev ) => {
248338 if ( ! prev ) return prev ;
249339 const pending = prev . payments . find ( ( p ) => p . clientKey === clientKey ) ;
@@ -262,6 +352,39 @@ export default function InvoiceDetailPage({ params }: Props) {
262352 }
263353 } ;
264354
355+ const payWithChannel = async ( amount : bigint , email ?: string ) => {
356+ if ( ! publicKey ) return ;
357+ const originalChannel = channelState ;
358+ const channelUsed = applyChannelBalance ( amount ) ;
359+ try {
360+ const result = await splitClient . pay ( { payer : publicKey , invoiceId : id , amount } ) ;
361+ setTxHash ( result . txHash ) ;
362+ if ( email ) {
363+ try {
364+ await fetch ( "/api/send-confirmation" , {
365+ method : "POST" ,
366+ headers : { "Content-Type" : "application/json" } ,
367+ body : JSON . stringify ( {
368+ email,
369+ invoiceId : id ,
370+ txHash : result . txHash ,
371+ amount : formatAmount ( amount ) ,
372+ } ) ,
373+ } ) ;
374+ } catch ( err ) {
375+ console . error ( "Failed to send confirmation email:" , err ) ;
376+ }
377+ }
378+ await load ( ) ;
379+ return result ;
380+ } catch ( err ) {
381+ if ( channelUsed && originalChannel ) {
382+ syncChannelState ( originalChannel ) ;
383+ }
384+ throw err ;
385+ }
386+ } ;
387+
265388 const handleSetReminder = ( e : React . FormEvent ) => {
266389 e . preventDefault ( ) ;
267390 if ( ! reminderDate ) return ;
@@ -449,6 +572,11 @@ export default function InvoiceDetailPage({ params }: Props) {
449572 < PaymentProgress funded = { invoice . funded } total = { total } />
450573 < p className = "text-sm text-gray-600 dark:text-gray-400 mt-1" >
451574 { formatAmount ( invoice . funded ) } / { formatAmount ( total ) } USDC funded
575+ { channelState ?. opened && (
576+ < span className = "text-indigo-300 ml-2" >
577+ · Channel balance: { formatAmount ( channelState . balance ) } USDC
578+ </ span >
579+ ) }
452580 </ p >
453581 { invoice . deadline > 0 && (
454582 < div className = "flex items-center gap-2 mt-3" >
@@ -598,6 +726,46 @@ export default function InvoiceDetailPage({ params }: Props) {
598726 < CoCreatorPanel invoice = { invoice } publicKey = { publicKey } onUpdate = { load } />
599727 ) }
600728
729+ { /* Payment channel panel for frequent payers */ }
730+ { invoice . status === "Pending" && publicKey && publicKey !== invoice . creator && (
731+ < PaymentChannelPanel
732+ invoiceId = { id }
733+ publicKey = { publicKey }
734+ channelState = { channelState }
735+ onOpen = { async ( ) => {
736+ if ( ! publicKey ) return ;
737+ setChannelLoading ( true ) ;
738+ setChannelError ( null ) ;
739+ try {
740+ const result = await ( splitClient as any ) . openChannel ( { payer : publicKey , invoiceId : id } ) ;
741+ const balance = result ?. balance != null ? BigInt ( result . balance ) : 0n ;
742+ syncChannelState ( { invoiceId : id , payer : publicKey , balance, opened : true } ) ;
743+ await load ( ) ;
744+ } catch ( err ) {
745+ setChannelError ( String ( err ) ) ;
746+ } finally {
747+ setChannelLoading ( false ) ;
748+ }
749+ } }
750+ onClose = { async ( ) => {
751+ if ( ! publicKey ) return ;
752+ setChannelLoading ( true ) ;
753+ setChannelError ( null ) ;
754+ try {
755+ await ( splitClient as any ) . closeChannel ( { payer : publicKey , invoiceId : id } ) ;
756+ syncChannelState ( null ) ;
757+ await load ( ) ;
758+ } catch ( err ) {
759+ setChannelError ( String ( err ) ) ;
760+ } finally {
761+ setChannelLoading ( false ) ;
762+ }
763+ } }
764+ loading = { channelLoading }
765+ error = { channelError }
766+ />
767+ ) }
768+
601769 { /* Pay button → opens modal */ }
602770 { invoice . status === "Pending" && publicKey && (
603771 < section aria-labelledby = "pay-heading" className = "mb-8" >
@@ -653,6 +821,11 @@ export default function InvoiceDetailPage({ params }: Props) {
653821 publicKey = { publicKey }
654822 onSuggest = { setPayAmount }
655823 />
824+ { channelState ?. opened && channelState . balance > 0n && (
825+ < p className = "text-sm text-gray-400 mt-2" >
826+ This payment will use up to < span className = "text-indigo-300" > { formatAmount ( channelState . balance ) } USDC</ span > from your open payment channel.
827+ </ p >
828+ ) }
656829 </ div >
657830 { error && (
658831 < div id = "pay-error" role = "alert" className = "flex flex-col gap-2" >
@@ -696,28 +869,7 @@ export default function InvoiceDetailPage({ params }: Props) {
696869 total = { total }
697870 publicKey = { publicKey }
698871 onPay = { async ( amount , email ) => {
699- const result = await splitClient . pay ( { payer : publicKey , invoiceId : id , amount } ) ;
700- setTxHash ( result . txHash ) ;
701-
702- // Send confirmation email if provided
703- if ( email ) {
704- try {
705- await fetch ( "/api/send-confirmation" , {
706- method : "POST" ,
707- headers : { "Content-Type" : "application/json" } ,
708- body : JSON . stringify ( {
709- email,
710- invoiceId : id ,
711- txHash : result . txHash ,
712- amount : formatAmount ( amount ) ,
713- } ) ,
714- } ) ;
715- } catch ( err ) {
716- console . error ( "Failed to send confirmation email:" , err ) ;
717- }
718- }
719-
720- await load ( ) ;
872+ await payWithChannel ( amount , email ) ;
721873 } }
722874 onClose = { ( ) => setShowPayModal ( false ) }
723875 />
0 commit comments