diff --git a/bun.lockb b/bun.lockb index 02479d7..d26fccf 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/src/app/(dashboard)/add-subscription-dialog.tsx b/src/app/(dashboard)/add-subscription-dialog.tsx index f82b313..63816da 100644 --- a/src/app/(dashboard)/add-subscription-dialog.tsx +++ b/src/app/(dashboard)/add-subscription-dialog.tsx @@ -5,13 +5,11 @@ import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, - DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; import { Select, SelectContent, @@ -19,7 +17,6 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { toast } from "sonner"; import type { InputType } from "@/server/api/root"; import type { BillingCycle } from "@prisma/client"; import { @@ -40,8 +37,22 @@ import { ChevronDownIcon, Loader2, PlusCircle } from "lucide-react"; import { Calendar } from "@/components/ui/calendar"; import { format } from "date-fns"; import { CalendarIcon } from "lucide-react"; -import { addDays } from "date-fns"; import { api } from "@/trpc/react"; +import { AddPaymentMethodForm } from "@/components/add-payment-method-form"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +import { Checkbox } from "@/components/ui/checkbox"; +import { baseSubscriptionSchema } from "@/schemas"; type AddSubscriptionDialogProps = { isOpen: boolean; @@ -114,6 +125,11 @@ const mockServices = [ }, ]; +type SubscriptionFormSchema = z.infer & { + isTrial: boolean; + trialEndDate?: Date; +}; + export function AddSubscriptionDialog({ isOpen, onClose, @@ -133,19 +149,35 @@ export function AddSubscriptionDialog({ paymentMethodId: null, }); - const [trialEndDate, setTrialEndDate] = useState( - addDays(new Date(), 30), - ); - const [searchResults, setSearchResults] = useState([]); const [isSearching, setIsSearching] = useState(false); const [userInput, setUserInput] = useState(""); const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [isCustomService, setIsCustomService] = useState(false); + const [showPaymentMethodForm, setShowPaymentMethodForm] = useState(false); const { data: paymentMethods } = api.paymentMethod.getAll.useQuery(); const { data: user } = api.user.getCurrent.useQuery(); + const form = useForm({ + resolver: zodResolver( + baseSubscriptionSchema.extend({ + isTrial: z.boolean(), + trialEndDate: z.date().optional(), + }), + ), + defaultValues: { + name: initialData?.name ?? "", + price: initialData ? initialData.price / 100 : 0, + billingCycle: initialData?.billingCycle ?? "Monthly", + startDate: initialData?.startDate ?? new Date(), + paymentMethodId: initialData?.paymentMethodId ?? null, + autoRenew: true, + isTrial: initialData?.isTrial ?? false, + trialEndDate: initialData?.isTrial ? initialData.endDate : undefined, + }, + }); + useEffect(() => { if (initialData && isOpen) { setNewSubscription({ @@ -154,11 +186,8 @@ export function AddSubscriptionDialog({ paymentMethodId: initialData.paymentMethodId ?? null, }); setUserInput(initialData.name); - if (initialData.isTrial && initialData.endDate) { - setTrialEndDate(initialData.endDate); - } } else if (isOpen && user?.defaultPaymentMethodId) { - setNewSubscription(prev => ({ + setNewSubscription((prev) => ({ ...prev, paymentMethodId: user.defaultPaymentMethodId, })); @@ -183,35 +212,34 @@ export function AddSubscriptionDialog({ } }, [userInput]); - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (newSubscription.name && newSubscription.price > 0) { - const subscriptionData = { - ...newSubscription, - price: newSubscription.price * 100, - ...(newSubscription.isTrial && { endDate: trialEndDate }), - }; + useEffect(() => { + if (isOpen && user?.defaultPaymentMethodId) { + form.setValue("paymentMethodId", user.defaultPaymentMethodId); + } + }, [isOpen, user?.defaultPaymentMethodId, form]); + + const onSubmit = (data: SubscriptionFormSchema) => { + const baseData = { + ...data, + price: data.price * 100, + autoRenew: true, + paymentMethodId: + data.paymentMethodId === "none" ? null : data.paymentMethodId, + }; - if (initialData?.id) { - onUpdateSubscription?.({ ...subscriptionData, id: initialData.id }); - } else { - onAddSubscription(subscriptionData); - } + const subscriptionData = data.isTrial + ? { ...baseData, isTrial: true as const, endDate: data.trialEndDate } + : { ...baseData, isTrial: false as const }; - setNewSubscription({ - name: "", - price: 0, - billingCycle: "Monthly", - isTrial: false as const, - startDate: new Date(), - paymentMethodId: null, - }); - setTrialEndDate(addDays(new Date(), 30)); - setUserInput(""); - setIsCustomService(false); + if (initialData?.id) { + onUpdateSubscription?.({ ...subscriptionData, id: initialData.id }); } else { - toast.error("Please fill in all fields correctly."); + onAddSubscription(subscriptionData); } + + form.reset(); + setUserInput(""); + setIsCustomService(false); }; const handleServiceSelect = (service: (typeof mockServices)[0]) => { @@ -240,266 +268,321 @@ export function AddSubscriptionDialog({ setUserInput(""); }; - const handleTrialToggle = (checked: boolean) => { - if (checked) { - setNewSubscription({ - ...newSubscription, - isTrial: true as const, - endDate: trialEndDate, - }); + const handlePaymentMethodAdded = (paymentMethodId: string) => { + setShowPaymentMethodForm(false); + form.setValue("paymentMethodId", paymentMethodId); + }; + + const handlePaymentMethodChange = (value: string) => { + if (value === "add_new") { + setShowPaymentMethodForm(true); } else { - setNewSubscription({ - ...newSubscription, - isTrial: false as const, - }); + form.setValue("paymentMethodId", value); + } + }; + + const closeDialog = () => { + onClose(); + setTimeout(() => setShowPaymentMethodForm(false), 1000); + }; + + const closeAddPaymentMethodDialog = () => { + setShowPaymentMethodForm(false); + if (user?.defaultPaymentMethodId) { + form.setValue("paymentMethodId", user.defaultPaymentMethodId); } }; return ( - - - - - {initialData ? "Edit Subscription" : "Add New Subscription"} - - - {initialData - ? "Enter the details of your subscription. This will not affect historical data - only the most recent period will be updated." - : "Enter the details of your new subscription"} - - -
-
-
-
-
-
- - - setNewSubscription({ - ...newSubscription, - price: parseFloat(e.target.value), - }) - } - className="col-span-4" - required /> -
-
- - - - - - - - setNewSubscription({ - ...newSubscription, - startDate: date ?? new Date(), - }) - } - initialFocus - /> - - -
-
- - -
-
- -
- handleTrialToggle(e.target.checked)} - className="mr-2" - /> - -
-
- - {newSubscription.isTrial && ( -
- - - - + + + + + + + + + )} + /> + ( + + Billing Cycle + - setNewSubscription({ - ...newSubscription, - paymentMethodId: value === "none" ? null : value, - }) - } - > - - - - - No Payment Method - {paymentMethods?.map((method) => ( - + + + + + + Weekly + Biweekly + Monthly + Yearly + + + + + )} + /> + ( + + + + +
+ Trial Period + + This is a trial subscription + +
+
+ )} + /> + {form.watch("isTrial") && ( + ( + + Trial End Date + + + + + + + + + + + + + )} + /> + )} + ( + + Payment Method + -
- - - - -
+ + + + + + + No Payment Method + +
+ + Add Payment Method +
+
+ {paymentMethods?.map((method) => ( + + {method.name}{" "} + {method.id === user?.defaultPaymentMethodId + ? "(Default)" + : ""} + + ))} +
+ + + + )} + /> + + + + + + )}
); diff --git a/src/components/add-payment-method-form.tsx b/src/components/add-payment-method-form.tsx index 2f62a24..490ce20 100644 --- a/src/components/add-payment-method-form.tsx +++ b/src/components/add-payment-method-form.tsx @@ -21,7 +21,7 @@ import { } from "@/lib/schema/paymentMethod"; import { api } from "@/trpc/react"; -export function AddPaymentMethodForm() { +export function AddPaymentMethodForm({ onSuccess }: { onSuccess?: (paymentMethodId: string) => void }) { const utils = api.useUtils(); const { mutate: createPaymentMethod, isPending } = @@ -33,6 +33,7 @@ export function AddPaymentMethodForm() { form.reset(); void utils.paymentMethod.getAll.invalidate(); void utils.user.getCurrent.invalidate(); + onSuccess?.(data.id); }, onError: (error) => { toast.error( diff --git a/src/schemas.ts b/src/schemas.ts new file mode 100644 index 0000000..829f010 --- /dev/null +++ b/src/schemas.ts @@ -0,0 +1,16 @@ +import { BillingCycle } from "@prisma/client"; + +import { z } from "zod"; + +export const baseSubscriptionSchema = z.object({ + name: z.string().min(2, { + message: "Name must be at least 2 characters.", + }), + price: z.number().positive({ + message: "Price must be a positive number.", + }), + billingCycle: z.nativeEnum(BillingCycle), + autoRenew: z.boolean().default(true), + startDate: z.date(), + paymentMethodId: z.string().nullable(), + }); \ No newline at end of file diff --git a/src/server/api/routers/subscriptions.ts b/src/server/api/routers/subscriptions.ts index d007382..a467928 100644 --- a/src/server/api/routers/subscriptions.ts +++ b/src/server/api/routers/subscriptions.ts @@ -1,9 +1,10 @@ import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; -import { BillingCycle } from "@prisma/client"; +import type { BillingCycle } from "@prisma/client"; import { addDays, endOfDay, startOfDay, startOfMonth } from "date-fns"; import { toSubscriptionWithLatestPeriod, type SubscriptionWithLatestPeriod } from "@/types"; import type { SubscriptionPeriod } from "@prisma/client"; +import { baseSubscriptionSchema } from "@/schemas"; const BILLING_CYCLE_DAYS: Record = { Weekly: 7, @@ -13,15 +14,6 @@ const BILLING_CYCLE_DAYS: Record = { Unknown: 30, }; -const baseSubscriptionSchema = z.object({ - name: z.string().min(1), - price: z.number().positive(), - billingCycle: z.nativeEnum(BillingCycle), - autoRenew: z.boolean().default(true), - startDate: z.date(), - paymentMethodId: z.string().nullable(), -}); - export const subscriptionRouter = createTRPCRouter({ create: protectedProcedure .input(z.discriminatedUnion('isTrial', [