11"use client"
22
33import { useState , useEffect , useRef } from "react"
4- import { Edit2 , Trash2 , Mail , Clock , Copy , Lock , Users , Calendar , Check , Download , FileText , Upload , PauseCircle , PlayCircle , AlertTriangle , ShieldAlert , AlertCircle } from "lucide-react"
4+ import { Edit2 , Trash2 , Mail , Clock , Copy , Lock , LockOpen , Users , Calendar , Check , Download , FileText , Upload , PauseCircle , PlayCircle , ShieldAlert , AlertCircle } from "lucide-react"
55import { exportAllCSV , exportActiveCSV , exportDateRangeCSV } from "@/lib/csv-export"
66import { downloadSubscriptionPDF } from "@/lib/pdf-report"
77import CSVImportModal from "@/components/modals/csv-import-modal"
@@ -19,6 +19,19 @@ import { useUserSettings } from "@/components/providers/user-settings-provider"
1919import { formatCurrency } from "@/lib/currency-utils"
2020import { formatDate , getDaysDifference } from "@/lib/timezone-utils"
2121import { fetchCalendarToken as getCalendarToken , downloadCalendarExport , getCalendarFeedUrl , updateCalendarPreferences } from "@/lib/api/calendar"
22+ import { SortableSubscriptionList } from "@/components/subscriptions/sortable-subscription-list"
23+ import {
24+ PriorityBadge ,
25+ SortableDragHandle ,
26+ SortableItemShell ,
27+ } from "@/components/subscriptions/subscription-priority-ui"
28+ import { useSubscriptionPriorityOrder } from "@/hooks/use-subscription-priority-order"
29+ import {
30+ getGlobalPriorityRank ,
31+ sortByPriorityOrder ,
32+ type PriorityRank ,
33+ } from "@/lib/subscription-priority-order"
34+ import type { CSSProperties , ReactNode } from "react"
2235
2336interface SubscriptionsPageProps {
2437 subscriptions ?: any [ ]
@@ -66,7 +79,7 @@ export default function SubscriptionsPage({
6679 const [ isSearching , setIsSearching ] = useState ( false )
6780 const [ advancedFilters , setAdvancedFilters ] = useState < FilterState > ( EMPTY_FILTERS )
6881 const [ filterEmail , setFilterEmail ] = useState ( "all" )
69- const [ sortBy , setSortBy ] = useState ( "name " )
82+ const [ sortBy , setSortBy ] = useState ( "priority " )
7083 const [ showKeyboardHelp , setShowKeyboardHelp ] = useState ( false )
7184 const [ showDuplicatesOnly , setShowDuplicatesOnly ] = useState ( false )
7285 const [ showUnusedOnly , setShowUnusedOnly ] = useState ( false )
@@ -103,7 +116,7 @@ export default function SubscriptionsPage({
103116 setShowExportMenu ( false )
104117 setExportingPDF ( true )
105118 try {
106- await downloadSubscriptionPDF ( filtered )
119+ await downloadSubscriptionPDF ( displayed )
107120 } finally {
108121 setExportingPDF ( false )
109122 }
@@ -173,6 +186,14 @@ export default function SubscriptionsPage({
173186 setTimeout ( ( ) => setCopied ( false ) , 2000 )
174187 }
175188
189+ const subscriptionIds = ( subscriptions || [ ] ) . map ( ( sub : any ) => sub . id )
190+ const {
191+ priorityOrder,
192+ isLoading : priorityOrderLoading ,
193+ isSaving : priorityOrderSaving ,
194+ reorder : reorderPriority ,
195+ } = useSubscriptionPriorityOrder ( { subscriptionIds } )
196+
176197 const filtered = ( subscriptions || [ ] ) . filter ( ( sub : any ) => {
177198 const matchesSearch = sub . name . toLowerCase ( ) . includes ( debouncedSearchTerm . toLowerCase ( ) )
178199 const matchesCategory =
@@ -197,20 +218,23 @@ export default function SubscriptionsPage({
197218 return matchesSearch && matchesCategory && matchesStatus && matchesEmail && matchesPrice
198219 } )
199220
200- if ( sortBy === "price-high" ) {
201- filtered . sort ( ( a , b ) => b . price - a . price )
221+ let displayed = filtered
222+ if ( sortBy === "priority" ) {
223+ displayed = sortByPriorityOrder ( filtered , priorityOrder )
224+ } else if ( sortBy === "price-high" ) {
225+ displayed = [ ...filtered ] . sort ( ( a , b ) => b . price - a . price )
202226 } else if ( sortBy === "price-low" ) {
203- filtered . sort ( ( a , b ) => a . price - b . price )
227+ displayed = [ ... filtered ] . sort ( ( a , b ) => a . price - b . price )
204228 } else if ( sortBy === "renewal" ) {
205- filtered . sort ( ( a , b ) => a . renewsIn - b . renewsIn )
229+ displayed = [ ... filtered ] . sort ( ( a , b ) => a . renewsIn - b . renewsIn )
206230 } else {
207- filtered . sort ( ( a , b ) => a . name . localeCompare ( b . name ) )
231+ displayed = [ ... filtered ] . sort ( ( a , b ) => a . name . localeCompare ( b . name ) )
208232 }
209233
210- const totalCost = filtered . reduce ( ( sum : number , sub : any ) => sum + sub . price , 0 )
234+ const totalCost = displayed . reduce ( ( sum : number , sub : any ) => sum + sub . price , 0 )
211235
212236 const hasNoSubscriptions = ! subscriptions || subscriptions . length === 0
213- const hasNoResults = filtered . length === 0 && subscriptions && subscriptions . length > 0
237+ const hasNoResults = displayed . length === 0 && subscriptions && subscriptions . length > 0
214238
215239 // Active trials sorted by urgency (soonest expiry first)
216240 const activeTrials = ( subscriptions || [ ] )
@@ -232,7 +256,40 @@ export default function SubscriptionsPage({
232256 )
233257 }
234258
235- const shouldVirtualize = filtered . length > 100
259+ const shouldVirtualize = displayed . length > 100 && sortBy !== "priority"
260+ const isPrioritySort = sortBy === "priority"
261+ const filteredIds = displayed . map ( ( sub : any ) => sub . id )
262+
263+ const renderSubscriptionCard = ( sub : any , options ?: { priorityRank ?: PriorityRank ; dragHandle ?: ReactNode ; shellStyle ?: CSSProperties ; shellRef ?: ( node : HTMLElement | null ) => void ; isDragging ?: boolean } ) => (
264+ < ErrorBoundary
265+ fallback = { < BrokenCardPlaceholder name = { sub ?. name } darkMode = { darkMode } /> }
266+ >
267+ < div
268+ ref = { options ?. shellRef }
269+ style = { options ?. shellStyle }
270+ className = { `transition-shadow duration-200 ${ options ?. isDragging ? "shadow-xl ring-2 ring-[#FFD166]/40 rounded-xl" : "" } ` }
271+ >
272+ < SubscriptionCard
273+ subscription = { sub }
274+ onDelete = { onDelete }
275+ onManage = { onManage }
276+ selectedSubscriptions = { selectedSubscriptions }
277+ onToggleSelect = { onToggleSelect }
278+ darkMode = { darkMode }
279+ isDuplicate = { duplicates . some ( ( dup : any ) => dup . subscriptions . some ( ( s : any ) => s . id === sub . id ) ) }
280+ unusedInfo = { unusedSubscriptions . find ( ( unused : any ) => unused . id === sub . id ) }
281+ onCancel = { ( s ) => setSelectedSubForCancel ( s ) }
282+ guide = { guides . find ( ( g ) => g . service_name . toLowerCase ( ) === sub . name . toLowerCase ( ) ) }
283+ onPause = { onPause }
284+ onResume = { onResume }
285+ onCancelTrial = { onCancelTrial }
286+ onConvertTrial = { onConvertTrial }
287+ priorityRank = { options ?. priorityRank }
288+ dragHandle = { options ?. dragHandle }
289+ />
290+ </ div >
291+ </ ErrorBoundary >
292+ )
236293
237294 return (
238295 < div >
@@ -344,7 +401,7 @@ export default function SubscriptionsPage({
344401 CSV
345402 </ p >
346403 < button
347- onClick = { ( ) => { exportAllCSV ( filtered ) ; setShowExportMenu ( false ) } }
404+ onClick = { ( ) => { exportAllCSV ( displayed ) ; setShowExportMenu ( false ) } }
348405 className = { `w-full flex items-center gap-2 px-3 py-2 text-sm text-left transition-colors ${
349406 darkMode ? "text-gray-300 hover:bg-[#374151]" : "text-gray-700 hover:bg-gray-50"
350407 } `}
@@ -473,6 +530,7 @@ export default function SubscriptionsPage({
473530 : "bg-white border-gray-300 text-gray-900 focus:ring-black"
474531 } `}
475532 >
533+ < option value = "priority" > Personal Priority</ option >
476534 < option value = "name" > Sort by Name</ option >
477535 < option value = "price-high" > Price: High to Low</ option >
478536 < option value = "price-low" > Price: Low to High</ option >
@@ -490,11 +548,20 @@ export default function SubscriptionsPage({
490548 />
491549 </ div >
492550
551+ { isPrioritySort && ! hasNoResults && (
552+ < p className = { `mb-3 text-sm ${ darkMode ? "text-gray-400" : "text-gray-600" } ` } >
553+ Drag cards to set your personal priority. Top 3 subscriptions are highlighted.
554+ { priorityOrderSaving ? " Saving order…" : null }
555+ </ p >
556+ ) }
557+
493558 { /* Live region for search result count */ }
494559 < div role = "status" aria-live = "polite" aria-atomic = "true" className = "sr-only" >
495560 { ! isSearching && debouncedSearchTerm
496- ? `Showing ${ filtered . length } of ${ subscriptions . length } subscriptions`
497- : "" }
561+ ? `Showing ${ displayed . length } of ${ subscriptions . length } subscriptions`
562+ : isPrioritySort && priorityOrderSaving
563+ ? "Saving subscription priority order"
564+ : "" }
498565 </ div >
499566
500567 { /* Active Trials Section */ }
@@ -555,58 +622,42 @@ export default function SubscriptionsPage({
555622 < >
556623 { shouldVirtualize ? (
557624 < VirtualizedList
558- items = { filtered }
625+ items = { displayed }
559626 itemHeight = { 80 }
560627 containerHeight = { 600 }
561- renderItem = { ( sub : any , index : number ) => (
562- < ErrorBoundary
563- fallback = { < BrokenCardPlaceholder name = { sub ?. name } darkMode = { darkMode } /> }
564- >
565- < SubscriptionCard
566- key = { sub . id }
567- subscription = { sub }
568- onDelete = { onDelete }
569- onManage = { onManage }
570- selectedSubscriptions = { selectedSubscriptions }
571- onToggleSelect = { onToggleSelect }
572- darkMode = { darkMode }
573- isDuplicate = { duplicates . some ( ( dup : any ) => dup . subscriptions . some ( ( s : any ) => s . id === sub . id ) ) }
574- unusedInfo = { unusedSubscriptions . find ( ( unused : any ) => unused . id === sub . id ) }
575- onCancel = { ( s ) => setSelectedSubForCancel ( s ) }
576- guide = { guides . find ( ( g ) => g . service_name . toLowerCase ( ) === sub . name . toLowerCase ( ) ) }
577- onPause = { onPause }
578- onResume = { onResume }
579- onCancelTrial = { onCancelTrial }
580- onConvertTrial = { onConvertTrial }
581- />
582- </ ErrorBoundary >
628+ renderItem = { ( sub : any ) => renderSubscriptionCard ( sub ) }
629+ />
630+ ) : isPrioritySort && ! priorityOrderLoading ? (
631+ < SortableSubscriptionList
632+ items = { displayed }
633+ darkMode = { darkMode }
634+ onReorder = { ( fromIndex , toIndex ) =>
635+ reorderPriority ( fromIndex , toIndex , filteredIds )
636+ }
637+ renderItem = { ( sub : any ) => (
638+ < SortableItemShell id = { sub . id } >
639+ { ( { setNodeRef, style, isDragging, attributes, listeners } ) =>
640+ renderSubscriptionCard ( sub , {
641+ priorityRank : getGlobalPriorityRank ( sub . id , priorityOrder ) ,
642+ dragHandle : (
643+ < SortableDragHandle
644+ attributes = { attributes }
645+ listeners = { listeners }
646+ darkMode = { darkMode }
647+ label = { `Drag to reorder ${ sub . name } ` }
648+ />
649+ ) ,
650+ shellRef : setNodeRef ,
651+ shellStyle : style ,
652+ isDragging,
653+ } )
654+ }
655+ </ SortableItemShell >
583656 ) }
584657 />
585658 ) : (
586659 < div className = "space-y-3" >
587- { filtered . map ( ( sub : any ) => (
588- < ErrorBoundary
589- key = { sub . id }
590- fallback = { < BrokenCardPlaceholder name = { sub ?. name } darkMode = { darkMode } /> }
591- >
592- < SubscriptionCard
593- subscription = { sub }
594- onDelete = { onDelete }
595- onManage = { onManage }
596- selectedSubscriptions = { selectedSubscriptions }
597- onToggleSelect = { onToggleSelect }
598- darkMode = { darkMode }
599- isDuplicate = { duplicates . some ( ( dup : any ) => dup . subscriptions . some ( ( s : any ) => s . id === sub . id ) ) }
600- unusedInfo = { unusedSubscriptions . find ( ( unused : any ) => unused . id === sub . id ) }
601- onCancel = { ( s ) => setSelectedSubForCancel ( s ) }
602- guide = { guides . find ( ( g ) => g . service_name . toLowerCase ( ) === sub . name . toLowerCase ( ) ) }
603- onPause = { onPause }
604- onResume = { onResume }
605- onCancelTrial = { onCancelTrial }
606- onConvertTrial = { onConvertTrial }
607- />
608- </ ErrorBoundary >
609- ) ) }
660+ { displayed . map ( ( sub : any ) => renderSubscriptionCard ( sub ) ) }
610661 </ div >
611662 ) }
612663 </ >
@@ -739,6 +790,8 @@ interface SubscriptionCardProps {
739790 onResume ?: ( subscription : any ) => void
740791 onCancelTrial ?: ( id : string ) => void
741792 onConvertTrial ?: ( id : string ) => void
793+ priorityRank ?: PriorityRank
794+ dragHandle ?: ReactNode
742795}
743796
744797export function SubscriptionCard ( {
@@ -756,7 +809,11 @@ export function SubscriptionCard({
756809 onResume,
757810 onCancelTrial,
758811 onConvertTrial,
812+ priorityRank,
813+ dragHandle,
759814} : SubscriptionCardProps ) {
815+ const { settings } = useUserSettings ( )
816+ const currency = settings . currency
760817 const isPaused = sub . status === "paused"
761818
762819 const statusLabel =
@@ -789,6 +846,7 @@ export function SubscriptionCard({
789846 aria-label = { `${ sub . name } , ${ sub . category } , $${ sub . price } /month, ${ statusLabel } ${ isDuplicate ? ", duplicate" : "" } ${ unusedInfo ? ", unused" : "" } ` }
790847 >
791848 < div className = "flex items-center gap-4 flex-1" >
849+ { dragHandle }
792850 { selectedSubscriptions && onToggleSelect && (
793851 < input
794852 type = "checkbox"
@@ -808,6 +866,7 @@ export function SubscriptionCard({
808866 < div className = "flex-1" >
809867 < div className = "flex items-center gap-2" >
810868 < h4 className = { `font-semibold ${ darkMode ? "text-white" : "text-gray-900" } ` } > { sub . name } </ h4 >
869+ { priorityRank && < PriorityBadge rank = { priorityRank } darkMode = { darkMode } /> }
811870 < div
812871 className = "flex items-center gap-1 text-xs text-gray-500"
813872 title = {
0 commit comments