Skip to content
Draft
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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,13 @@
/node_modules
/.pnp
.pnp.js
package-lock.json

# testing
/coverage
/test-results/
/playwright-report/
/playwright/.cache/

# database
/prisma/db.sqlite
Expand Down
112 changes: 108 additions & 4 deletions app/components/AddSubscriptionPopover.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
import { Button } from '@/components/ui/button'
import { Calendar } from '@/components/ui/calendar'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { zodResolver } from '@hookform/resolvers/zod'
import { useLoaderData } from '@remix-run/react'
import { PlusCircle } from 'lucide-react'
import { CalendarIcon, PlusCircle } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { Controller, useForm } from 'react-hook-form'
import { toast } from 'sonner'
import { z } from 'zod'
import { cn } from '~/lib/utils'
import type { loader } from '~/routes/_index'
import type { Subscription } from '~/store/subscriptionStore'
import type { BillingCycle, Subscription } from '~/store/subscriptionStore'
import { initializeNextPaymentDate } from '~/utils/nextPaymentDate'
import { IconUrlInput } from './IconFinder'

interface AddSubscriptionPopoverProps {
Expand All @@ -24,6 +28,9 @@ const subscriptionSchema = z.object({
currency: z.string().min(1, 'Currency is required'),
icon: z.string().optional(),
domain: z.string().url('Invalid URL'),
billingCycle: z.enum(['monthly', 'yearly', 'weekly', 'daily']).optional(),
nextPaymentDate: z.string().optional(),
showNextPayment: z.boolean().optional(),
})

type SubscriptionFormValues = z.infer<typeof subscriptionSchema>
Expand All @@ -32,6 +39,7 @@ export const AddSubscriptionPopover: React.FC<AddSubscriptionPopoverProps> = ({
const { rates } = useLoaderData<typeof loader>()
const [open, setOpen] = useState(false)
const [shouldFocus, setShouldFocus] = useState(false)
const [calendarOpen, setCalendarOpen] = useState(false)

const {
register,
Expand All @@ -41,6 +49,7 @@ export const AddSubscriptionPopover: React.FC<AddSubscriptionPopoverProps> = ({
setFocus,
setValue,
watch,
control,
} = useForm<SubscriptionFormValues>({
resolver: zodResolver(subscriptionSchema),
defaultValues: {
Expand All @@ -49,10 +58,16 @@ export const AddSubscriptionPopover: React.FC<AddSubscriptionPopoverProps> = ({
price: 0,
currency: 'USD',
domain: '',
billingCycle: undefined,
nextPaymentDate: undefined,
showNextPayment: false,
},
})

const iconValue = watch('icon')
const billingCycleValue = watch('billingCycle')
const showNextPaymentValue = watch('showNextPayment')
const nextPaymentDateValue = watch('nextPaymentDate')

useEffect(() => {
if (shouldFocus) {
Expand All @@ -61,6 +76,17 @@ export const AddSubscriptionPopover: React.FC<AddSubscriptionPopoverProps> = ({
}
}, [shouldFocus, setFocus])

// Auto-calculate next payment date when billing cycle changes
useEffect(() => {
if (billingCycleValue && showNextPaymentValue) {
const currentDate = nextPaymentDateValue
if (!currentDate) {
const newDate = initializeNextPaymentDate(billingCycleValue)
setValue('nextPaymentDate', newDate)
}
}
}, [billingCycleValue, showNextPaymentValue, nextPaymentDateValue, setValue])

const onSubmit = (data: SubscriptionFormValues) => {
addSubscription(data)
toast.success(`${data.name} added successfully.`)
Expand All @@ -74,6 +100,16 @@ export const AddSubscriptionPopover: React.FC<AddSubscriptionPopoverProps> = ({
}
}, [open, setFocus])

const formatDateForDisplay = (dateString: string | undefined) => {
if (!dateString) return 'Pick a date'
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})
}

return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
Expand All @@ -82,7 +118,7 @@ export const AddSubscriptionPopover: React.FC<AddSubscriptionPopoverProps> = ({
Add Subscription
</Button>
</PopoverTrigger>
<PopoverContent className="w-80">
<PopoverContent className="w-80 max-h-[80vh] overflow-y-auto">
<form onSubmit={handleSubmit(onSubmit)}>
<h3 className="font-medium text-lg mb-4">Add Subscription</h3>
<div className="space-y-4">
Expand Down Expand Up @@ -134,6 +170,74 @@ export const AddSubscriptionPopover: React.FC<AddSubscriptionPopoverProps> = ({
<Input id="domain" {...register('domain')} className={errors.domain ? 'border-red-500' : ''} />
<p className="text-red-500 text-xs h-4">{errors.domain?.message}</p>
</div>
<div>
<Label htmlFor="billingCycle">Billing Cycle (optional)</Label>
<Select onValueChange={(value) => setValue('billingCycle', value as BillingCycle)}>
<SelectTrigger id="billingCycle">
<SelectValue placeholder="Select billing cycle" />
</SelectTrigger>
<SelectContent>
<SelectItem value="daily">Daily</SelectItem>
<SelectItem value="weekly">Weekly</SelectItem>
<SelectItem value="monthly">Monthly</SelectItem>
<SelectItem value="yearly">Yearly</SelectItem>
</SelectContent>
</Select>
</div>
{billingCycleValue && (
<>
<div className="flex items-center space-x-2">
<Controller
name="showNextPayment"
control={control}
render={({ field }) => (
<Switch id="showNextPayment" checked={field.value} onCheckedChange={field.onChange} />
)}
/>
<Label htmlFor="showNextPayment" className="cursor-pointer">
Show next payment date
</Label>
</div>
{showNextPaymentValue && (
<div>
<Label htmlFor="nextPaymentDate">Next Payment Date</Label>
<Controller
name="nextPaymentDate"
control={control}
render={({ field }) => (
<Popover open={calendarOpen} onOpenChange={setCalendarOpen}>
<PopoverTrigger asChild>
<Button
id="nextPaymentDate"
variant="outline"
className={cn(
'w-full justify-start text-left font-normal',
!field.value && 'text-muted-foreground',
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{formatDateForDisplay(field.value)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={field.value ? new Date(field.value) : undefined}
onSelect={(date) => {
field.onChange(date?.toISOString().split('T')[0])
setCalendarOpen(false)
}}
disabled={(date) => date < new Date(new Date().setHours(0, 0, 0, 0))}
initialFocus
/>
</PopoverContent>
</Popover>
)}
/>
</div>
)}
</>
)}
</div>
<div className="flex justify-end mt-4">
<Button type="submit" className="contain-content">
Expand Down
122 changes: 119 additions & 3 deletions app/components/EditSubscriptionModal.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useLoaderData } from '@remix-run/react'
import { CalendarIcon } from 'lucide-react'
import type React from 'react'
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
import { Controller, useForm } from 'react-hook-form'
import * as z from 'zod'
import { Button } from '~/components/ui/button'
import { Calendar } from '~/components/ui/calendar'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '~/components/ui/dialog'
import { Input } from '~/components/ui/input'
import { Label } from '~/components/ui/label'
import { Popover, PopoverContent, PopoverTrigger } from '~/components/ui/popover'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '~/components/ui/select'
import { Switch } from '~/components/ui/switch'
import { cn } from '~/lib/utils'
import type { loader } from '~/routes/_index'
import type { Subscription } from '~/store/subscriptionStore'
import type { BillingCycle, Subscription } from '~/store/subscriptionStore'
import { initializeNextPaymentDate } from '~/utils/nextPaymentDate'
import { IconUrlInput } from './IconFinder'
import SubscriptionCard from './SubscriptionCard'

Expand All @@ -27,6 +33,9 @@ const subscriptionSchema = z.object({
currency: z.string().min(1, 'Currency is required'),
domain: z.string().url('Invalid URL'),
icon: z.string().optional(),
billingCycle: z.enum(['monthly', 'yearly', 'weekly', 'daily']).optional(),
nextPaymentDate: z.string().optional(),
showNextPayment: z.boolean().optional(),
})

const EditSubscriptionModal: React.FC<EditSubscriptionModalProps> = ({
Expand All @@ -36,6 +45,7 @@ const EditSubscriptionModal: React.FC<EditSubscriptionModalProps> = ({
editingSubscription,
}) => {
const { rates } = useLoaderData<typeof loader>()
const [calendarOpen, setCalendarOpen] = useState(false)

const {
control,
Expand All @@ -52,6 +62,9 @@ const EditSubscriptionModal: React.FC<EditSubscriptionModalProps> = ({
currency: 'USD',
domain: '',
icon: '',
billingCycle: undefined as BillingCycle | undefined,
nextPaymentDate: undefined as string | undefined,
showNextPayment: false,
},
})

Expand All @@ -65,29 +78,56 @@ const EditSubscriptionModal: React.FC<EditSubscriptionModalProps> = ({
currency: 'USD',
domain: '',
icon: '',
billingCycle: undefined,
nextPaymentDate: undefined,
showNextPayment: false,
})
}
}, [editingSubscription, reset])

const watchedFields = watch()

// Auto-calculate next payment date when billing cycle changes
useEffect(() => {
if (watchedFields.billingCycle && watchedFields.showNextPayment) {
const currentDate = watchedFields.nextPaymentDate
if (!currentDate) {
const newDate = initializeNextPaymentDate(watchedFields.billingCycle)
setValue('nextPaymentDate', newDate)
}
}
}, [watchedFields.billingCycle, watchedFields.showNextPayment, setValue])

const previewSubscription: Subscription = {
id: 'preview',
name: watchedFields.name || 'Example Subscription',
price: watchedFields.price || 0,
currency: watchedFields.currency || 'USD',
domain: watchedFields.domain || 'https://example.com',
icon: watchedFields.icon,
billingCycle: watchedFields.billingCycle,
nextPaymentDate: watchedFields.nextPaymentDate,
showNextPayment: watchedFields.showNextPayment,
}

const onSubmit = (data: Omit<Subscription, 'id'>) => {
onSave(data)
onClose()
}

const formatDateForDisplay = (dateString: string | undefined) => {
if (!dateString) return 'Pick a date'
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})
}

return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[600px] lg:max-w-[800px]">
<DialogContent className="sm:max-w-[600px] lg:max-w-[800px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editingSubscription ? 'Edit Subscription' : 'Add Subscription'}</DialogTitle>
</DialogHeader>
Expand Down Expand Up @@ -166,6 +206,82 @@ 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 (optional)</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="daily">Daily</SelectItem>
<SelectItem value="weekly">Weekly</SelectItem>
<SelectItem value="monthly">Monthly</SelectItem>
<SelectItem value="yearly">Yearly</SelectItem>
</SelectContent>
</Select>
)}
/>
<p className="text-red-500 text-xs h-4">{'\u00A0'}</p>
</div>
{watchedFields.billingCycle && (
<>
<div className="flex items-center space-x-2 mb-2">
<Controller
name="showNextPayment"
control={control}
render={({ field }) => (
<Switch id="showNextPayment" checked={field.value} onCheckedChange={field.onChange} />
)}
/>
<Label htmlFor="showNextPayment" className="cursor-pointer">
Show next payment date
</Label>
</div>
{watchedFields.showNextPayment && (
<div>
<Label htmlFor="nextPaymentDate">Next Payment Date</Label>
<Controller
name="nextPaymentDate"
control={control}
render={({ field }) => (
<Popover open={calendarOpen} onOpenChange={setCalendarOpen}>
<PopoverTrigger asChild>
<Button
id="nextPaymentDate"
variant="outline"
className={cn(
'w-full justify-start text-left font-normal',
!field.value && 'text-muted-foreground',
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{formatDateForDisplay(field.value)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={field.value ? new Date(field.value) : undefined}
onSelect={(date) => {
field.onChange(date?.toISOString().split('T')[0])
setCalendarOpen(false)
}}
disabled={(date) => date < new Date(new Date().setHours(0, 0, 0, 0))}
initialFocus
/>
</PopoverContent>
</Popover>
)}
/>
<p className="text-red-500 text-xs h-4">{'\u00A0'}</p>
</div>
)}
</>
)}
</div>
<div className="my-auto">
<SubscriptionCard subscription={previewSubscription} onEdit={() => {}} onDelete={() => {}} />
Expand Down
Loading