Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 50 additions & 1 deletion app/components/EditSubscriptionModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ const subscriptionSchema = z.object({
currency: z.string().min(1, 'Currency is required'),
domain: z.string().url('Invalid URL'),
icon: z.string().optional(),
nextPaymentDate: z.string().optional(),
billingCycle: z.enum(['monthly', 'yearly']).optional(),
})

const EditSubscriptionModal: React.FC<EditSubscriptionModalProps> = ({
Expand All @@ -52,19 +54,31 @@ const EditSubscriptionModal: React.FC<EditSubscriptionModalProps> = ({
currency: 'USD',
domain: '',
icon: '',
nextPaymentDate: '',
billingCycle: 'monthly' as const,
},
})

useEffect(() => {
if (editingSubscription) {
reset(editingSubscription)
reset({
name: editingSubscription.name,
price: editingSubscription.price,
currency: editingSubscription.currency,
domain: editingSubscription.domain,
icon: editingSubscription.icon || '',
nextPaymentDate: editingSubscription.nextPaymentDate || '',
billingCycle: editingSubscription.billingCycle || 'monthly',
})
} else {
reset({
name: '',
price: 0,
currency: 'USD',
domain: '',
icon: '',
nextPaymentDate: '',
billingCycle: 'monthly' as const,
})
}
}, [editingSubscription, reset])
Expand All @@ -78,6 +92,8 @@ const EditSubscriptionModal: React.FC<EditSubscriptionModalProps> = ({
currency: watchedFields.currency || 'USD',
domain: watchedFields.domain || 'https://example.com',
icon: watchedFields.icon,
nextPaymentDate: watchedFields.nextPaymentDate,
billingCycle: watchedFields.billingCycle,
}

const onSubmit = (data: Omit<Subscription, 'id'>) => {
Expand Down Expand Up @@ -166,6 +182,39 @@ const EditSubscriptionModal: React.FC<EditSubscriptionModalProps> = ({
/>
<p className="text-red-500 text-xs h-4">{errors.domain?.message || '\u00A0'}</p>
</div>
<div>
<Label htmlFor="billingCycle">Billing Cycle</Label>
<Controller
name="billingCycle"
control={control}
render={({ field }) => (
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger id="billingCycle">
<SelectValue placeholder="Select billing cycle" />
</SelectTrigger>
<SelectContent>
<SelectItem value="monthly">Monthly</SelectItem>
<SelectItem value="yearly">Yearly</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div>
<Label htmlFor="nextPaymentDate">Next Payment Date (optional)</Label>
<Controller
name="nextPaymentDate"
control={control}
render={({ field }) => (
<Input
id="nextPaymentDate"
type="date"
{...field}
value={field.value || ''}
/>
)}
/>
</div>
</div>
<div className="my-auto">
<SubscriptionCard subscription={previewSubscription} onEdit={() => {}} onDelete={() => {}} />
Expand Down
78 changes: 67 additions & 11 deletions app/components/SubscriptionCard.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { motion } from 'framer-motion'
import { Edit, Trash2 } from 'lucide-react'
import { Calendar, Edit, Trash2 } from 'lucide-react'
import type React from 'react'
import { Badge } from '~/components/ui/badge'
import { Button } from '~/components/ui/button'
import { Card, CardContent } from '~/components/ui/card'
import { LinkPreview } from '~/components/ui/link-preview'
import { Switch } from '~/components/ui/switch'
import type { Subscription } from '~/store/subscriptionStore'

interface SubscriptionCardProps {
Expand All @@ -14,7 +16,7 @@ interface SubscriptionCardProps {
}

const SubscriptionCard: React.FC<SubscriptionCardProps> = ({ subscription, onEdit, onDelete, className }) => {
const { id, name, price, currency, domain, icon } = subscription
const { id, name, price, currency, domain, icon, nextPaymentDate, billingCycle } = subscription

// Sanitize the domain URL
const sanitizeDomain = (domain: string) => {
Expand All @@ -31,6 +33,34 @@ const SubscriptionCard: React.FC<SubscriptionCardProps> = ({ subscription, onEdi
// Use custom icon if available, otherwise fall back to domain favicon
const logoUrl = icon || defaultLogoUrl

// Format date for display
const formatDate = (dateString: string | undefined) => {
if (!dateString) return null
try {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
} catch {
return null
}
}

// Check if payment is overdue
const isOverdue = (dateString: string | undefined) => {
if (!dateString) return false
try {
const paymentDate = new Date(dateString)
const today = new Date()
today.setHours(0, 0, 0, 0)
paymentDate.setHours(0, 0, 0, 0)
return paymentDate < today
} catch {
return false
}
}

const formattedDate = formatDate(nextPaymentDate)
const overdue = isOverdue(nextPaymentDate)

return (
<motion.div
whileHover={{ scale: 1.03 }}
Expand All @@ -39,28 +69,54 @@ const SubscriptionCard: React.FC<SubscriptionCardProps> = ({ subscription, onEdi
className={`group ${className}`}
>
<Card className="bg-card hover:bg-card/80 transition-all duration-200 shadow-md hover:shadow-lg relative h-full">
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex space-x-2">
<Button variant="outline" size="icon" onClick={() => onEdit(id)} className="bg-background hover:bg-muted">
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex space-x-2 z-10">
<Button variant="outline" size="icon" onClick={() => onEdit(id)} className="bg-background hover:bg-muted h-8 w-8">
<Edit className="h-4 w-4" />
<span className="sr-only">Edit</span>
</Button>
<Button
variant="outline"
size="icon"
onClick={() => onDelete(id)}
className="bg-background hover:bg-muted text-destructive hover:text-destructive/80"
className="bg-background hover:bg-muted text-destructive hover:text-destructive/80 h-8 w-8"
>
<Trash2 className="h-4 w-4" />
<span className="sr-only">Delete</span>
</Button>
</div>
<LinkPreview url={sanitizedDomain}>
<CardContent className="flex flex-col items-center justify-center p-4 sm:p-6 h-full">
<img src={logoUrl} alt={`${name} logo`} className="w-16 h-16 mb-3 rounded-full shadow-md" />
<h3 className="text-xl sm:text-1xl font-bold mb-2 text-card-foreground max-w-full text-wrap-balance overflow-wrap-break-word line-clamp-1 text-center">
{name}
</h3>
<p className="text-md sm:text-sm font-semibold text-card-foreground text-center">{`${currency} ${price}`}</p>
<CardContent className="flex flex-col items-start justify-between p-4 sm:p-6 h-full min-h-[280px]">
<div className="w-full flex flex-col items-start gap-3">
<div className="flex items-center gap-3 w-full">
<img src={logoUrl} alt={`${name} logo`} className="w-12 h-12 rounded-full shadow-md flex-shrink-0" />
<div className="flex-1 min-w-0">
<h3 className="text-lg font-bold text-card-foreground truncate">
{name}
</h3>
<div className="flex items-center gap-2 mt-1">
<Switch checked={true} className="scale-75" />
</div>
</div>
</div>
<div className="w-full flex items-baseline gap-1">
<span className="text-3xl font-bold text-card-foreground">{currency} {price.toFixed(2)}</span>
<span className="text-sm text-muted-foreground">per {billingCycle === 'yearly' ? 'Yearly' : 'Monthly'}</span>
</div>
<Badge variant="secondary" className="text-xs">
Software
</Badge>
<Badge variant="outline" className="text-xs bg-green-50 text-green-700 border-green-200">
Active
</Badge>
</div>
{formattedDate && (
<div className="w-full flex items-center gap-2 text-sm text-muted-foreground mt-4 pt-4 border-t">
<Calendar className="h-4 w-4" />
<span className={overdue ? 'text-destructive font-semibold' : ''}>
Next Payment: {formattedDate}
</span>
</div>
)}
</CardContent>
</LinkPreview>
</Card>
Expand Down
36 changes: 32 additions & 4 deletions app/store/subscriptionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export interface Subscription {
currency: string
domain: string
icon?: string
nextPaymentDate?: string // ISO date string
billingCycle?: 'monthly' | 'yearly' // Added for future calculation of next payment
}

interface SubscriptionStore {
Expand All @@ -21,9 +23,33 @@ interface SubscriptionStore {
}

export const defaultSubscriptions: Subscription[] = [
{ id: '1', name: 'Netflix', price: 15.99, currency: 'USD', domain: 'https://netflix.com' },
{ id: '2', name: 'Spotify', price: 9.99, currency: 'USD', domain: 'https://spotify.com' },
{ id: '3', name: 'Amazon Prime', price: 14.99, currency: 'USD', domain: 'https://amazon.com' },
{
id: '1',
name: 'Netflix',
price: 15.99,
currency: 'USD',
domain: 'https://netflix.com',
nextPaymentDate: '2025-10-15',
billingCycle: 'monthly'
},
{
id: '2',
name: 'Spotify',
price: 9.99,
currency: 'USD',
domain: 'https://spotify.com',
nextPaymentDate: '2025-09-20',
billingCycle: 'monthly'
},
{
id: '3',
name: 'Amazon Prime',
price: 14.99,
currency: 'USD',
domain: 'https://amazon.com',
nextPaymentDate: '2025-11-01',
billingCycle: 'yearly'
},
{ id: '4', name: 'Disney+', price: 7.99, currency: 'USD', domain: 'https://disneyplus.com' },
{ id: '5', name: 'YouTube Premium', price: 11.99, currency: 'USD', domain: 'https://youtube.com' },
{ id: '6', name: 'Hulu', price: 7.99, currency: 'USD', domain: 'https://hulu.com' },
Expand Down Expand Up @@ -135,7 +161,9 @@ function isValidSubscription(sub: any): sub is Subscription {
typeof sub.price === 'number' &&
typeof sub.currency === 'string' &&
typeof sub.domain === 'string' &&
(sub.icon === undefined || typeof sub.icon === 'string')
(sub.icon === undefined || typeof sub.icon === 'string') &&
(sub.nextPaymentDate === undefined || typeof sub.nextPaymentDate === 'string') &&
(sub.billingCycle === undefined || sub.billingCycle === 'monthly' || sub.billingCycle === 'yearly')
)
}

Expand Down
Loading
Loading