@@ -19,17 +19,41 @@ interface RecurringSupport {
1919 assetCode : string ;
2020 frequency : string ;
2121 nextRunAt : string ;
22- status : string ;
22+ status : "active" | "paused" | "cancelled" ;
2323 createdAt : string ;
2424}
2525
26+ type ActionTarget = { id : string ; action : "cancel" | "pause" | "resume" } ;
27+
28+ function StatusBadge ( { status } : { status : RecurringSupport [ "status" ] } ) {
29+ if ( status === "active" ) {
30+ return (
31+ < span className = "inline-flex items-center rounded-full bg-emerald-500/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-emerald-400" >
32+ Active
33+ </ span >
34+ ) ;
35+ }
36+ if ( status === "paused" ) {
37+ return (
38+ < span className = "inline-flex items-center rounded-full bg-yellow-500/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-yellow-400" >
39+ Paused
40+ </ span >
41+ ) ;
42+ }
43+ return (
44+ < span className = "inline-flex items-center rounded-full bg-white/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-white/40" >
45+ { status }
46+ </ span >
47+ ) ;
48+ }
49+
2650export default function RecurringPage ( ) {
2751 const router = useRouter ( ) ;
2852 const [ subscriptions , setSubscriptions ] = useState < RecurringSupport [ ] > ( [ ] ) ;
2953 const [ loading , setLoading ] = useState ( true ) ;
3054 const [ error , setError ] = useState < string | null > ( null ) ;
31- const [ cancelTarget , setCancelTarget ] = useState < string | null > ( null ) ;
32- const [ cancelling , setCancelling ] = useState < string | null > ( null ) ;
55+ const [ actionTarget , setActionTarget ] = useState < ActionTarget | null > ( null ) ;
56+ const [ processing , setProcessing ] = useState < string | null > ( null ) ;
3357 const [ toast , setToast ] = useState < { message : string ; type : "success" | "error" } | null > ( null ) ;
3458
3559 useEffect ( ( ) => {
@@ -52,12 +76,13 @@ export default function RecurringPage() {
5276 } , [ router ] ) ;
5377
5478 async function handleCancel ( id : string ) {
55- setCancelling ( id ) ;
79+ setProcessing ( id ) ;
5680 try {
5781 const res = await apiFetch ( `${ API_BASE_URL } /v1/recurring-support/${ id } ` , {
5882 method : "DELETE" ,
5983 } ) ;
6084 if ( ! res . ok ) throw new Error ( "Failed to cancel subscription" ) ;
85+ // Remove cancelled subscription from list
6186 setSubscriptions ( ( prev ) => prev . filter ( ( s ) => s . id !== id ) ) ;
6287 setToast ( { message : "Subscription cancelled" , type : "success" } ) ;
6388 } catch ( err : unknown ) {
@@ -66,11 +91,150 @@ export default function RecurringPage() {
6691 type : "error" ,
6792 } ) ;
6893 } finally {
69- setCancelling ( null ) ;
70- setCancelTarget ( null ) ;
94+ setProcessing ( null ) ;
95+ setActionTarget ( null ) ;
7196 }
7297 }
7398
99+ async function handlePatch ( id : string , status : "paused" | "active" ) {
100+ setProcessing ( id ) ;
101+ try {
102+ const res = await apiFetch ( `${ API_BASE_URL } /v1/recurring-support/${ id } ` , {
103+ method : "PATCH" ,
104+ headers : { "Content-Type" : "application/json" } ,
105+ body : JSON . stringify ( { status } ) ,
106+ } ) ;
107+ if ( ! res . ok ) throw new Error ( `Failed to ${ status === "paused" ? "pause" : "resume" } subscription` ) ;
108+ setSubscriptions ( ( prev ) =>
109+ prev . map ( ( s ) => ( s . id === id ? { ...s , status } : s ) ) ,
110+ ) ;
111+ setToast ( {
112+ message : status === "paused" ? "Subscription paused" : "Subscription resumed" ,
113+ type : "success" ,
114+ } ) ;
115+ } catch ( err : unknown ) {
116+ setToast ( {
117+ message : err instanceof Error ? err . message : "Action failed" ,
118+ type : "error" ,
119+ } ) ;
120+ } finally {
121+ setProcessing ( null ) ;
122+ setActionTarget ( null ) ;
123+ }
124+ }
125+
126+ // Split into active and paused groups for clear visual separation
127+ const activeSubscriptions = subscriptions . filter ( ( s ) => s . status === "active" ) ;
128+ const pausedSubscriptions = subscriptions . filter ( ( s ) => s . status === "paused" ) ;
129+
130+ function renderConfirmBar ( sub : RecurringSupport ) {
131+ if ( ! actionTarget || actionTarget . id !== sub . id ) return null ;
132+
133+ const { action } = actionTarget ;
134+
135+ const labelMap = {
136+ cancel : { question : "Cancel this drip?" , confirm : "Yes, cancel" , confirming : "Cancelling…" } ,
137+ pause : { question : "Pause this drip?" , confirm : "Yes, pause" , confirming : "Pausing…" } ,
138+ resume : { question : "Resume this drip?" , confirm : "Yes, resume" , confirming : "Resuming…" } ,
139+ } ;
140+ const labels = labelMap [ action ] ;
141+ const isProcessing = processing === sub . id ;
142+
143+ return (
144+ < div className = "flex items-center gap-2 flex-shrink-0" >
145+ < span className = "text-xs text-white/50" > { labels . question } </ span >
146+ < button
147+ onClick = { ( ) => {
148+ if ( action === "cancel" ) handleCancel ( sub . id ) ;
149+ else if ( action === "pause" ) handlePatch ( sub . id , "paused" ) ;
150+ else handlePatch ( sub . id , "active" ) ;
151+ } }
152+ disabled = { isProcessing }
153+ className = "rounded-lg bg-red-500/20 px-3 py-1.5 text-xs font-medium text-red-300 hover:bg-red-500/30 disabled:opacity-50"
154+ >
155+ { isProcessing ? labels . confirming : labels . confirm }
156+ </ button >
157+ < button
158+ onClick = { ( ) => setActionTarget ( null ) }
159+ className = "rounded-lg px-3 py-1.5 text-xs text-white/50 hover:text-white"
160+ >
161+ Keep it
162+ </ button >
163+ </ div >
164+ ) ;
165+ }
166+
167+ function renderActions ( sub : RecurringSupport ) {
168+ if ( actionTarget ?. id === sub . id ) return renderConfirmBar ( sub ) ;
169+
170+ return (
171+ < div className = "flex items-center gap-2 flex-shrink-0" >
172+ { sub . status === "active" ? (
173+ < button
174+ onClick = { ( ) => setActionTarget ( { id : sub . id , action : "pause" } ) }
175+ className = "rounded-lg border border-white/10 px-3 py-1.5 text-xs font-medium text-white/70 hover:border-yellow-400/40 hover:text-yellow-300"
176+ >
177+ Pause
178+ </ button >
179+ ) : (
180+ < button
181+ onClick = { ( ) => setActionTarget ( { id : sub . id , action : "resume" } ) }
182+ className = "rounded-lg border border-white/10 px-3 py-1.5 text-xs font-medium text-white/70 hover:border-emerald-400/40 hover:text-emerald-300"
183+ >
184+ Resume
185+ </ button >
186+ ) }
187+ < button
188+ onClick = { ( ) => setActionTarget ( { id : sub . id , action : "cancel" } ) }
189+ className = "rounded-lg border border-white/10 px-3 py-1.5 text-xs font-medium text-white/70 hover:border-red-400/40 hover:text-red-300"
190+ >
191+ Cancel
192+ </ button >
193+ </ div >
194+ ) ;
195+ }
196+
197+ function renderSubscriptionCard ( sub : RecurringSupport ) {
198+ return (
199+ < li
200+ key = { sub . id }
201+ className = "flex items-center justify-between gap-4 rounded-2xl border border-white/10 bg-white/5 p-4"
202+ >
203+ < div className = "flex items-center gap-3 min-w-0" >
204+ { sub . profileAvatarUrl ? (
205+ < Image
206+ src = { sub . profileAvatarUrl }
207+ alt = { sub . profileDisplayName }
208+ width = { 40 }
209+ height = { 40 }
210+ className = "h-10 w-10 rounded-full object-cover flex-shrink-0"
211+ />
212+ ) : (
213+ < div className = "h-10 w-10 rounded-full bg-white/10 flex items-center justify-center text-sm text-white/60 flex-shrink-0" >
214+ { sub . profileDisplayName ?. [ 0 ] ?. toUpperCase ( ) ?? "?" }
215+ </ div >
216+ ) }
217+ < div className = "min-w-0" >
218+ < div className = "flex items-center gap-2" >
219+ < p className = "text-sm font-medium text-white truncate" >
220+ { sub . profileDisplayName }
221+ </ p >
222+ < StatusBadge status = { sub . status } />
223+ </ div >
224+ < p className = "text-xs text-white/40" >
225+ { sub . amount } { sub . assetCode } · { sub . frequency } · next{ " " }
226+ { new Date ( sub . nextRunAt ) . toLocaleDateString ( ) }
227+ </ p >
228+ </ div >
229+ </ div >
230+
231+ { renderActions ( sub ) }
232+ </ li >
233+ ) ;
234+ }
235+
236+ const hasAny = subscriptions . length > 0 ;
237+
74238 return (
75239 < AppShell >
76240 < div className = "mx-auto max-w-3xl px-4 py-10" >
@@ -82,73 +246,37 @@ export default function RecurringPage() {
82246 { loading && < p className = "text-sm text-white/40" > Loading…</ p > }
83247 { error && < p className = "text-sm text-red-400" > { error } </ p > }
84248
85- { ! loading && ! error && subscriptions . length === 0 && (
249+ { ! loading && ! error && ! hasAny && (
86250 < EmptyState
87251 title = "No recurring support yet"
88252 description = "When you set up a recurring drip to a creator, it will show up here."
89253 />
90254 ) }
91255
92- { ! loading && subscriptions . length > 0 && (
93- < ul className = "space-y-3" >
94- { subscriptions . map ( ( sub ) => (
95- < li
96- key = { sub . id }
97- className = "flex items-center justify-between gap-4 rounded-2xl border border-white/10 bg-white/5 p-4"
98- >
99- < div className = "flex items-center gap-3 min-w-0" >
100- { sub . profileAvatarUrl ? (
101- < Image
102- src = { sub . profileAvatarUrl }
103- alt = { sub . profileDisplayName }
104- width = { 40 }
105- height = { 40 }
106- className = "h-10 w-10 rounded-full object-cover flex-shrink-0"
107- />
108- ) : (
109- < div className = "h-10 w-10 rounded-full bg-white/10 flex items-center justify-center text-sm text-white/60 flex-shrink-0" >
110- { sub . profileDisplayName ?. [ 0 ] ?. toUpperCase ( ) ?? "?" }
111- </ div >
112- ) }
113- < div className = "min-w-0" >
114- < p className = "text-sm font-medium text-white truncate" >
115- { sub . profileDisplayName }
116- </ p >
117- < p className = "text-xs text-white/40" >
118- { sub . amount } { sub . assetCode } · { sub . frequency } · next{ " " }
119- { new Date ( sub . nextRunAt ) . toLocaleDateString ( ) }
120- </ p >
121- </ div >
122- </ div >
123-
124- { cancelTarget === sub . id ? (
125- < div className = "flex items-center gap-2 flex-shrink-0" >
126- < span className = "text-xs text-white/50" > Cancel?</ span >
127- < button
128- onClick = { ( ) => handleCancel ( sub . id ) }
129- disabled = { cancelling === sub . id }
130- className = "rounded-lg bg-red-500/20 px-3 py-1.5 text-xs font-medium text-red-300 hover:bg-red-500/30"
131- >
132- { cancelling === sub . id ? "Cancelling…" : "Yes, cancel" }
133- </ button >
134- < button
135- onClick = { ( ) => setCancelTarget ( null ) }
136- className = "rounded-lg px-3 py-1.5 text-xs text-white/50 hover:text-white"
137- >
138- Keep it
139- </ button >
140- </ div >
141- ) : (
142- < button
143- onClick = { ( ) => setCancelTarget ( sub . id ) }
144- className = "flex-shrink-0 rounded-lg border border-white/10 px-3 py-1.5 text-xs font-medium text-white/70 hover:border-red-400/40 hover:text-red-300"
145- >
146- Cancel
147- </ button >
148- ) }
149- </ li >
150- ) ) }
151- </ ul >
256+ { ! loading && hasAny && (
257+ < div className = "space-y-8" >
258+ { activeSubscriptions . length > 0 && (
259+ < section >
260+ < h2 className = "text-xs font-semibold uppercase tracking-widest text-white/40 mb-3" >
261+ Active ({ activeSubscriptions . length } )
262+ </ h2 >
263+ < ul className = "space-y-3" >
264+ { activeSubscriptions . map ( renderSubscriptionCard ) }
265+ </ ul >
266+ </ section >
267+ ) }
268+
269+ { pausedSubscriptions . length > 0 && (
270+ < section >
271+ < h2 className = "text-xs font-semibold uppercase tracking-widest text-white/40 mb-3" >
272+ Paused ({ pausedSubscriptions . length } )
273+ </ h2 >
274+ < ul className = "space-y-3" >
275+ { pausedSubscriptions . map ( renderSubscriptionCard ) }
276+ </ ul >
277+ </ section >
278+ ) }
279+ </ div >
152280 ) }
153281 </ div >
154282
0 commit comments