From 4429bc47a0c11e29136794a6516f5e4bf4f2d7f3 Mon Sep 17 00:00:00 2001 From: Rajat Saxena Date: Sat, 23 Aug 2025 01:47:20 +0530 Subject: [PATCH 01/11] Community membership should unlock included products Fixes #617 --- .../(sidebar)/community/[id]/manage/page.tsx | 28 +- .../[type]/[id]/edit/[planid]/layout.ts | 21 + .../[type]/[id]/edit/[planid]/page.tsx | 117 +++ .../paymentplan/[type]/[id]/new/layout.ts | 21 + .../paymentplan/[type]/[id]/new/page.tsx | 56 ++ .../[type]/[id]/use-entity-validation.ts | 52 ++ .../(sidebar)/product/[id]/manage/page.tsx | 26 +- apps/web/app/api/payment/initiate/route.ts | 11 +- .../admin/payments/payment-plan-form.tsx | 638 +++++++++++++ .../admin/payments/payment-plan-list.tsx | 847 +++--------------- apps/web/graphql/communities/logic.ts | 21 +- apps/web/graphql/communities/types.ts | 7 + apps/web/graphql/courses/helpers.ts | 14 +- apps/web/graphql/courses/logic.ts | 10 +- apps/web/graphql/courses/types/index.ts | 8 + apps/web/graphql/mails/logic.ts | 7 +- apps/web/graphql/pages/helpers.ts | 27 - apps/web/graphql/paymentplans/helpers.ts | 87 ++ apps/web/graphql/paymentplans/logic.ts | 240 +++-- apps/web/graphql/paymentplans/mutation.ts | 61 +- apps/web/graphql/paymentplans/query.ts | 39 +- apps/web/graphql/paymentplans/types.ts | 8 + apps/web/hooks/use-community.ts | 9 + apps/web/hooks/use-payment-plan-operations.ts | 6 +- apps/web/hooks/use-paymentplan.ts | 77 ++ apps/web/hooks/use-product.ts | 25 +- apps/web/models/Community.ts | 3 +- apps/web/models/PaymentPlan.ts | 16 +- apps/web/ui-config/strings.ts | 8 + packages/common-logic/src/models/course.ts | 3 +- .../common-logic/src/models/membership.ts | 1 + packages/common-models/src/community.ts | 2 - packages/common-models/src/course.ts | 2 - packages/common-models/src/membership.ts | 1 + packages/common-models/src/payment-plan.ts | 6 +- 35 files changed, 1606 insertions(+), 899 deletions(-) create mode 100644 apps/web/app/(with-contexts)/dashboard/(sidebar)/paymentplan/[type]/[id]/edit/[planid]/layout.ts create mode 100644 apps/web/app/(with-contexts)/dashboard/(sidebar)/paymentplan/[type]/[id]/edit/[planid]/page.tsx create mode 100644 apps/web/app/(with-contexts)/dashboard/(sidebar)/paymentplan/[type]/[id]/new/layout.ts create mode 100644 apps/web/app/(with-contexts)/dashboard/(sidebar)/paymentplan/[type]/[id]/new/page.tsx create mode 100644 apps/web/app/(with-contexts)/dashboard/(sidebar)/paymentplan/[type]/[id]/use-entity-validation.ts create mode 100644 apps/web/components/admin/payments/payment-plan-form.tsx create mode 100644 apps/web/graphql/paymentplans/helpers.ts create mode 100644 apps/web/hooks/use-paymentplan.ts diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/community/[id]/manage/page.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/community/[id]/manage/page.tsx index a0c541491..274d70cf6 100644 --- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/community/[id]/manage/page.tsx +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/community/[id]/manage/page.tsx @@ -29,7 +29,6 @@ import { Badge, Form, FormField, - getSymbolFromCurrency, Image, Link, MediaSelector, @@ -225,6 +224,8 @@ export default function Page({ planId name type + entityId + entityType oneTimeAmount emiAmount emiTotalInstallments @@ -301,6 +302,8 @@ export default function Page({ planId name type + entityId + entityType oneTimeAmount emiAmount emiTotalInstallments @@ -511,8 +514,8 @@ export default function Page({ const onPlanArchived = async (planId: string) => { const query = ` - mutation ArchivePlan($planId: String!, $entityId: String!, $entityType: MembershipEntityType!) { - plan: archivePlan(planId: $planId, entityId: $entityId, entityType: $entityType) { + mutation ArchivePlan($planId: String!) { + plan: archivePlan(planId: $planId) { planId name type @@ -531,9 +534,6 @@ export default function Page({ query, variables: { planId, - entityId: id, - entityType: - MembershipEntityType.COMMUNITY.toUpperCase(), }, }) .setIsGraphQLEndpoint(true) @@ -789,23 +789,11 @@ export default function Page({ ...plan, type: plan.type.toLowerCase() as PaymentPlanType, }))} - onPlanSubmit={onPlanSubmitted} onPlanArchived={onPlanArchived} - allowedPlanTypes={[ - paymentPlanType.SUBSCRIPTION, - paymentPlanType.FREE, - paymentPlanType.ONE_TIME, - paymentPlanType.EMI, - ]} - currencySymbol={getSymbolFromCurrency( - siteinfo.currencyISOCode || "USD", - )} - currencyISOCode={ - siteinfo.currencyISOCode?.toUpperCase() || "USD" - } onDefaultPlanChanged={onDefaultPlanChanged} defaultPaymentPlanId={defaultPaymentPlan} - paymentMethod={siteinfo.paymentMethod} + entityId={id} + entityType={MembershipEntityType.COMMUNITY} /> diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/paymentplan/[type]/[id]/edit/[planid]/layout.ts b/apps/web/app/(with-contexts)/dashboard/(sidebar)/paymentplan/[type]/[id]/edit/[planid]/layout.ts new file mode 100644 index 000000000..b6bcc0493 --- /dev/null +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/paymentplan/[type]/[id]/edit/[planid]/layout.ts @@ -0,0 +1,21 @@ +import { Metadata, ResolvingMetadata } from "next"; +import { EDIT_PAYMENT_PLAN_HEADER } from "@/ui-config/strings"; + +export async function generateMetadata( + { + params, + }: { + params: any; + }, + parent: ResolvingMetadata, +): Promise { + const { type, id } = params; + + return { + title: `${EDIT_PAYMENT_PLAN_HEADER} | ${(await parent)?.title?.absolute}`, + }; +} + +export default function Layout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/paymentplan/[type]/[id]/edit/[planid]/page.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/paymentplan/[type]/[id]/edit/[planid]/page.tsx new file mode 100644 index 000000000..a4a19a414 --- /dev/null +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/paymentplan/[type]/[id]/edit/[planid]/page.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { useParams, useRouter } from "next/navigation"; +import { useEffect } from "react"; +import { Loader2 } from "lucide-react"; +import { Constants, MembershipEntityType } from "@courselit/common-models"; +import DashboardContent from "@/components/admin/dashboard-content"; +import { PaymentPlanForm } from "@/components/admin/payments/payment-plan-form"; +import { + COMMUNITY_SETTINGS, + EDIT_PAYMENT_PLAN_HEADER, + EDIT_PAYMENT_PLAN_DESCRIPTION, +} from "@/ui-config/strings"; +import { usePaymentPlan } from "@/hooks/use-paymentplan"; +import { useEntityValidation } from "../../use-entity-validation"; +import { truncate } from "@courselit/utils"; + +const { + MembershipEntityType: membershipEntityType, + PaymentPlanType: paymentPlanType, +} = Constants; + +export default function EditPaymentPlanPage() { + const params = useParams(); + const router = useRouter(); + + const entityType = params?.type as MembershipEntityType; + const entityId = params?.id as string; + const planId = params?.planid as string; + const { product, community } = useEntityValidation(entityType, entityId); + + const breadcrumbs = [ + { + label: + entityType === membershipEntityType.COMMUNITY + ? truncate(community?.name || "...", 10) + : truncate(product?.title || "...", 10), + href: `/dashboard/${entityType}/${entityId}`, + }, + { + label: COMMUNITY_SETTINGS, + href: `/dashboard/${entityType === membershipEntityType.COMMUNITY ? "community" : "product"}/${entityId}/manage`, + }, + { label: EDIT_PAYMENT_PLAN_HEADER, href: "#" }, + ]; + + const { paymentPlan, loaded: paymentPlanLoaded } = usePaymentPlan( + planId, + entityId, + entityType, + ); + + useEffect(() => { + if (paymentPlanLoaded && !paymentPlan) { + router.push( + `/dashboard/${entityType === membershipEntityType.COMMUNITY ? "community" : "product"}/${entityId}/manage`, + ); + } + }, [paymentPlanLoaded, paymentPlan, router, entityId, entityType]); + + if (!paymentPlanLoaded) { + return ( + +
+ +
+
+ ); + } + + return ( + +
+
+
+

+ {EDIT_PAYMENT_PLAN_HEADER} +

+

+ {EDIT_PAYMENT_PLAN_DESCRIPTION} " + {paymentPlan?.name || ""}" +

+
+
+
+ +
+ ); +} diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/paymentplan/[type]/[id]/new/layout.ts b/apps/web/app/(with-contexts)/dashboard/(sidebar)/paymentplan/[type]/[id]/new/layout.ts new file mode 100644 index 000000000..492d76dcd --- /dev/null +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/paymentplan/[type]/[id]/new/layout.ts @@ -0,0 +1,21 @@ +import { Metadata, ResolvingMetadata } from "next"; +import { NEW_PAYMENT_PLAN_HEADER } from "@/ui-config/strings"; + +export async function generateMetadata( + { + params, + }: { + params: any; + }, + parent: ResolvingMetadata, +): Promise { + const { type, id } = params; + + return { + title: `${NEW_PAYMENT_PLAN_HEADER} | ${(await parent)?.title?.absolute}`, + }; +} + +export default function Layout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/paymentplan/[type]/[id]/new/page.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/paymentplan/[type]/[id]/new/page.tsx new file mode 100644 index 000000000..b82010be3 --- /dev/null +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/paymentplan/[type]/[id]/new/page.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { useParams } from "next/navigation"; +import { Constants, MembershipEntityType } from "@courselit/common-models"; +import DashboardContent from "@/components/admin/dashboard-content"; +import { PaymentPlanForm } from "@/components/admin/payments/payment-plan-form"; +import { + COMMUNITY_SETTINGS, + NEW_PAYMENT_PLAN_HEADER, + NEW_PAYMENT_PLAN_DESCRIPTION, +} from "@/ui-config/strings"; +import { useEntityValidation } from "../use-entity-validation"; +import { truncate } from "@courselit/utils"; + +const { MembershipEntityType: membershipEntityType } = Constants; + +export default function NewPaymentPlanPage() { + const params = useParams(); + const entityType = params?.type as MembershipEntityType; + const entityId = params?.id as string; + const { product, community } = useEntityValidation(entityType, entityId); + + const breadcrumbs = [ + { + label: + entityType === membershipEntityType.COMMUNITY + ? truncate(community?.name || "...", 10) + : truncate(product?.title || "...", 10), + href: `/dashboard/${entityType}/${entityId}`, + }, + { + label: COMMUNITY_SETTINGS, + href: `/dashboard/${entityType}/${entityId}/manage`, + }, + { label: NEW_PAYMENT_PLAN_HEADER, href: "#" }, + ]; + + return ( + +
+
+
+

+ {NEW_PAYMENT_PLAN_HEADER} +

+

+ {NEW_PAYMENT_PLAN_DESCRIPTION}{" "} + {entityType.toLowerCase()} +

+
+
+
+ +
+ ); +} diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/paymentplan/[type]/[id]/use-entity-validation.ts b/apps/web/app/(with-contexts)/dashboard/(sidebar)/paymentplan/[type]/[id]/use-entity-validation.ts new file mode 100644 index 000000000..831971083 --- /dev/null +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/paymentplan/[type]/[id]/use-entity-validation.ts @@ -0,0 +1,52 @@ +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { Constants, MembershipEntityType } from "@courselit/common-models"; +import { useCommunity } from "@/hooks/use-community"; +import useProduct from "@/hooks/use-product"; + +const { MembershipEntityType: membershipEntityType } = Constants; + +export function useEntityValidation( + entityType: MembershipEntityType, + entityId: string, +) { + const router = useRouter(); + + const { community, loaded: communityLoaded } = useCommunity( + entityType === membershipEntityType.COMMUNITY ? entityId : null, + ); + const { product, loaded: productLoaded } = useProduct( + entityType === membershipEntityType.COURSE ? entityId : null, + ); + + // Redirect if community is not found + useEffect(() => { + if ( + entityType === membershipEntityType.COMMUNITY && + communityLoaded && + !community + ) { + router.push("/dashboard/communities"); + } + }, [communityLoaded, community, entityType, router]); + + // Redirect if product is not found + useEffect(() => { + if ( + entityType === membershipEntityType.COURSE && + productLoaded && + !product + ) { + router.push("/dashboard/products"); + } + }, [productLoaded, product, entityType, router]); + + return { + loaded: + entityType === membershipEntityType.COMMUNITY + ? communityLoaded + : productLoaded, + community, + product, + }; +} diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/page.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/page.tsx index 26df9624d..1541938ae 100644 --- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/page.tsx +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/page.tsx @@ -47,7 +47,6 @@ import { } from "@components/contexts"; import { truncate } from "@ui-lib/utils"; import { - getSymbolFromCurrency, MediaSelector, TextEditor, TextEditorEmptyDoc, @@ -580,16 +579,6 @@ export default function SettingsPage() { ...plan, type: plan.type.toLowerCase() as PaymentPlanType, }))} - onPlanSubmit={async (values) => { - try { - await onPlanSubmitted(values); - } catch (err: any) { - toast({ - title: TOAST_TITLE_ERROR, - description: err.message, - }); - } - }} onPlanArchived={async (id) => { try { await onPlanArchived(id); @@ -601,18 +590,6 @@ export default function SettingsPage() { }); } }} - allowedPlanTypes={[ - paymentPlanType.SUBSCRIPTION, - paymentPlanType.FREE, - paymentPlanType.ONE_TIME, - paymentPlanType.EMI, - ]} - currencySymbol={getSymbolFromCurrency( - siteinfo.currencyISOCode || "USD", - )} - currencyISOCode={ - siteinfo.currencyISOCode?.toUpperCase() || "USD" - } onDefaultPlanChanged={async (id) => { try { await onDefaultPlanChanged(id); @@ -624,7 +601,8 @@ export default function SettingsPage() { } }} defaultPaymentPlanId={defaultPaymentPlan} - paymentMethod={siteinfo.paymentMethod} + entityId={productId} + entityType={MembershipEntityType.COURSE} /> diff --git a/apps/web/app/api/payment/initiate/route.ts b/apps/web/app/api/payment/initiate/route.ts index 2ebbb1986..2408c9ddc 100644 --- a/apps/web/app/api/payment/initiate/route.ts +++ b/apps/web/app/api/payment/initiate/route.ts @@ -67,7 +67,16 @@ export async function POST(req: NextRequest) { ); } - if (!(entity.paymentPlans as unknown as string[]).includes(planId)) { + // Verify the payment plan belongs to this entity + const planExists = await PaymentPlanModel.exists({ + domain: domain._id, + planId: planId, + entityId: id, + entityType: type, + archived: false, + }); + + if (!planExists) { return Response.json( { message: "Invalid payment plan" }, { status: 404 }, diff --git a/apps/web/components/admin/payments/payment-plan-form.tsx b/apps/web/components/admin/payments/payment-plan-form.tsx new file mode 100644 index 000000000..34f3ae88d --- /dev/null +++ b/apps/web/components/admin/payments/payment-plan-form.tsx @@ -0,0 +1,638 @@ +"use client"; + +import { useContext, useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Separator } from "@/components/ui/separator"; +import { Save, Loader2 } from "lucide-react"; +import { + PaymentPlanType, + Constants, + MembershipEntityType, +} from "@courselit/common-models"; +import { usePaymentPlanOperations } from "@/hooks/use-payment-plan-operations"; +import { useRouter } from "next/navigation"; +import { getSymbolFromCurrency, useToast } from "@courselit/components-library"; +import { + BUTTON_SAVE, + BUTTON_SAVING, + FORM_NEW_PRODUCT_TITLE, + FORM_NEW_PRODUCT_TITLE_PLC, + FORM_NEW_PRODUCT_SELECT, + PRICING_FREE_LABEL, + PRICING_PAID_LABEL, + SEO_FORM_DESC_LABEL, +} from "@/ui-config/strings"; +import { SiteInfoContext } from "@components/contexts"; + +const { PaymentPlanType: paymentPlanType } = Constants; + +export const formSchema = z + .object({ + name: z.string().min(1, "Name is required"), + description: z.string().optional(), + type: z.enum([ + paymentPlanType.FREE, + paymentPlanType.ONE_TIME, + paymentPlanType.SUBSCRIPTION, + paymentPlanType.EMI, + ] as const), + oneTimeAmount: z + .number() + .min(0, "Amount cannot be negative") + .optional(), + emiAmount: z.number().min(0, "Amount cannot be negative").optional(), + emiTotalInstallments: z + .number() + .min(0, "Installments cannot be negative") + .optional(), + subscriptionMonthlyAmount: z + .number() + .min(0, "Amount cannot be negative") + .optional(), + subscriptionYearlyAmount: z + .number() + .min(0, "Amount cannot be negative") + .optional(), + subscriptionType: z.enum(["monthly", "yearly"] as const).optional(), + includedProducts: z.array(z.string()).default([]), + }) + .refine( + (data) => { + if (data.type === paymentPlanType.SUBSCRIPTION) { + if (data.subscriptionType === "monthly") { + return ( + data.subscriptionMonthlyAmount !== undefined && + data.subscriptionMonthlyAmount > 0 + ); + } + if (data.subscriptionType === "yearly") { + return ( + data.subscriptionYearlyAmount !== undefined && + data.subscriptionYearlyAmount > 0 + ); + } + } + if (data.type === paymentPlanType.ONE_TIME) { + return ( + data.oneTimeAmount !== undefined && data.oneTimeAmount > 0 + ); + } + if (data.type === paymentPlanType.EMI) { + return ( + data.emiAmount !== undefined && + data.emiAmount > 0 && + data.emiTotalInstallments !== undefined && + data.emiTotalInstallments > 0 + ); + } + return true; + }, + { + message: + "Please fill in all required fields for the selected plan type", + path: ["type"], + }, + ); + +export type PaymentPlanFormData = z.infer; + +interface PaymentPlanFormProps { + initialData?: Partial; + entityId: string; + entityType: MembershipEntityType; +} + +export function PaymentPlanForm({ + initialData, + entityId, + entityType, +}: PaymentPlanFormProps) { + const [planType, setPlanType] = useState( + initialData?.type || paymentPlanType.FREE, + ); + const [subscriptionType, setSubscriptionType] = useState< + "monthly" | "yearly" + >(initialData?.subscriptionType || "monthly"); + const [isFormSubmitting, setIsFormSubmitting] = useState(false); + + const paymentPlanOperations = usePaymentPlanOperations({ + id: entityId, + entityType, + }); + const router = useRouter(); + const { toast } = useToast(); + const siteinfo = useContext(SiteInfoContext); + const currencySymbol = getSymbolFromCurrency( + siteinfo.currencyISOCode || "USD", + ); + const currencyISOCode = siteinfo.currencyISOCode?.toUpperCase() || "USD"; + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + name: "", + description: "", + type: paymentPlanType.FREE, + oneTimeAmount: 0, + emiAmount: 0, + emiTotalInstallments: 0, + subscriptionMonthlyAmount: 0, + subscriptionYearlyAmount: 0, + subscriptionType: "monthly", + includedProducts: [], + ...initialData, + }, + }); + + async function handleSubmit(values: PaymentPlanFormData) { + setIsFormSubmitting(true); + try { + const { planId } = + await paymentPlanOperations.onPlanSubmitted(values); + router.push( + `/dashboard/paymentplan/${entityType?.toLowerCase()}/${entityId}/edit/${planId}`, + ); + } catch (error) { + toast({ + title: "Error", + description: error.message || "Failed to create payment plan", + variant: "destructive", + }); + } finally { + setIsFormSubmitting(false); + } + } + + return ( +
+ + + + + + + + + + + + + ); +} + +// Basic Information Section Component +function BasicInformationSection({ + form, + planType, + setPlanType, +}: { + form: any; + planType: PaymentPlanType; + setPlanType: (type: PaymentPlanType) => void; +}) { + return ( +
+
+ +

+ Configure the basic details of your payment plan +

+
+
+ ( + + {FORM_NEW_PRODUCT_TITLE} + + + + + + )} + /> + + ( + + {SEO_FORM_DESC_LABEL} + +