diff --git a/apps/api/prisma/migrations/20260621140000_add_plan_duration_months/migration.sql b/apps/api/prisma/migrations/20260621140000_add_plan_duration_months/migration.sql new file mode 100644 index 00000000..ebdd71db --- /dev/null +++ b/apps/api/prisma/migrations/20260621140000_add_plan_duration_months/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Plan" ADD COLUMN "durationMonths" INTEGER NOT NULL DEFAULT 12; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index accf30bd..568b861f 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -107,14 +107,15 @@ model Subscription { } model Plan { - id String @id @default(cuid()) - name String - interval String - price Int - currency String @default("INR") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - subscriptions Subscription[] + id String @id @default(cuid()) + name String + interval String + durationMonths Int @default(12) + price Int + currency String @default("INR") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + subscriptions Subscription[] } model WeeklySession { diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index 1f6b186c..81f034ae 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -15,20 +15,36 @@ async function main() { // await prisma.user.deleteMany(); // await prisma.plan.deleteMany(); - // Create test plan (1 rupee test plan) + // Create test plan (1 rupee test plan - 1 year) const testPlan = await prisma.plan.upsert({ where: { id: '385b8215-d70f-473e-81c9-68a673c0d2fc-test' }, - update: {}, + update: { durationMonths: 12, name: 'Pro' }, create: { id: '385b8215-d70f-473e-81c9-68a673c0d2fc-test', - name: 'Test Plan', + name: 'Pro', interval: 'yearly', + durationMonths: 12, price: 100, // 1 rupee in paise currency: 'INR', }, }); console.log('✅ Created test plan:', testPlan.id); + // Create 4-year test plan (dummy plan for the 48-month duration) + const fourYearPlan = await prisma.plan.upsert({ + where: { id: '385b8215-d70f-473e-81c9-68a673c0d2fc-4yr-test' }, + update: { durationMonths: 48, name: 'Pro+' }, + create: { + id: '385b8215-d70f-473e-81c9-68a673c0d2fc-4yr-test', + name: 'Pro+', + interval: 'yearly', + durationMonths: 48, + price: 100, // 1 rupee in paise (test price) + currency: 'INR', + }, + }); + console.log('✅ Created 4-year test plan:', fourYearPlan.id); + // Create test user const testUser = await prisma.user.upsert({ where: { email: 'test@example.com' }, diff --git a/apps/api/scripts/rename-plan-display-names.ts b/apps/api/scripts/rename-plan-display-names.ts new file mode 100644 index 00000000..0fea4c92 --- /dev/null +++ b/apps/api/scripts/rename-plan-display-names.ts @@ -0,0 +1,31 @@ +/** + * one-off: rename Plan.display names to Pro / Pro+ by duration. + * run: npx tsx scripts/rename-plan-display-names.ts + */ +import { PrismaClient } from "@prisma/client"; + +const prisma = new PrismaClient(); + +async function main() { + const yearly = await prisma.plan.updateMany({ + where: { durationMonths: 12 }, + data: { name: "Pro" }, + }); + + const fourYear = await prisma.plan.updateMany({ + where: { durationMonths: 48 }, + data: { name: "Pro+" }, + }); + + console.log(`updated ${yearly.count} yearly plan(s) → Pro`); + console.log(`updated ${fourYear.count} 4-year plan(s) → Pro+`); +} + +main() + .catch((error) => { + console.error(error); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/apps/api/scripts/seed-pro-modules.ts b/apps/api/scripts/seed-pro-modules.ts index 98816c05..c335c324 100644 --- a/apps/api/scripts/seed-pro-modules.ts +++ b/apps/api/scripts/seed-pro-modules.ts @@ -102,7 +102,7 @@ async function main(): Promise { let plan = await prisma.plan.findFirst({ where: { name: "Pro (dev seed)" } }); if (!plan) { plan = await prisma.plan.create({ - data: { name: "Pro (dev seed)", interval: "yearly", price: 0, currency: "INR" }, + data: { name: "Pro (dev seed)", interval: "yearly", durationMonths: 12, price: 0, currency: "INR" }, }); } diff --git a/apps/api/src/services/payment.service.ts b/apps/api/src/services/payment.service.ts index 0e5377ce..a534e99e 100644 --- a/apps/api/src/services/payment.service.ts +++ b/apps/api/src/services/payment.service.ts @@ -30,6 +30,31 @@ function isTransientDbError(error: unknown): boolean { return false; } +function resolvePlanDurationMonths(plan: { + durationMonths?: number | null; + interval?: string | null; +}): number { + if ( + typeof plan.durationMonths === "number" && + Number.isFinite(plan.durationMonths) && + plan.durationMonths > 0 + ) { + return plan.durationMonths; + } + + switch ((plan.interval ?? "").toLowerCase()) { + case "monthly": + return 1; + case "quarterly": + return 3; + case "yearly": + case "annual": + return 12; + default: + return 12; + } +} + interface CreateOrderInput { amount: number; currency: string; @@ -283,28 +308,24 @@ export const paymentService = { throw new Error("Plan not found"); } - // Calculate end date - Currently only yearly plan is supported + // Calculate end date from the plan's duration. Prefer the explicit + // durationMonths field; fall back to interpreting the interval string so + // older plans without a duration still resolve to a sensible length. const startDate = new Date(); const endDate = new Date(startDate); - // Set subscription for 1 year (yearly plan) - endDate.setFullYear(endDate.getFullYear() + 1); - - // Future plan intervals (commented out for now): - // switch (plan.interval.toLowerCase()) { - // case "monthly": - // endDate.setMonth(endDate.getMonth() + 1); - // break; - // case "quarterly": - // endDate.setMonth(endDate.getMonth() + 3); - // break; - // case "yearly": - // case "annual": - // endDate.setFullYear(endDate.getFullYear() + 1); - // break; - // default: - // endDate.setFullYear(endDate.getFullYear() + 1); - // } + const durationMonths = resolvePlanDurationMonths(plan); + // Add months with year wraparound, clamping to the last valid day of the + // target month so month-end start dates (e.g. Jan 31, or Feb 29 on a leap + // year) don't overflow into the following month. + const monthsFromYearStart = endDate.getMonth() + durationMonths; + const targetYear = + endDate.getFullYear() + Math.floor(monthsFromYearStart / 12); + const targetMonth = ((monthsFromYearStart % 12) + 12) % 12; + endDate.setFullYear(targetYear, targetMonth, endDate.getDate()); + if (endDate.getMonth() !== targetMonth) { + endDate.setDate(0); + } // Check if user already has an active subscription for this payment const existingSubscription = await prisma.subscription.findFirst({ diff --git a/apps/api/src/services/user.service.ts b/apps/api/src/services/user.service.ts index 26ac2251..01307392 100644 --- a/apps/api/src/services/user.service.ts +++ b/apps/api/src/services/user.service.ts @@ -32,6 +32,9 @@ export const userService = { include: { plan: true, }, + orderBy: { + startDate: "desc", + }, }); return { @@ -40,6 +43,7 @@ export const userService = { ? { id: subscription.id, planName: subscription.plan?.name, + durationMonths: subscription.plan?.durationMonths ?? null, startDate: subscription.startDate, endDate: subscription.endDate, status: subscription.status, diff --git a/apps/web/public/assets/jackedaj.jpg b/apps/web/public/assets/jackedaj.jpg new file mode 100644 index 00000000..f44d4cc2 Binary files /dev/null and b/apps/web/public/assets/jackedaj.jpg differ diff --git a/apps/web/src/app/(main)/(landing)/pitch/page.tsx b/apps/web/src/app/(main)/(landing)/pitch/page.tsx index f5f48105..d22e6e87 100644 --- a/apps/web/src/app/(main)/(landing)/pitch/page.tsx +++ b/apps/web/src/app/(main)/(landing)/pitch/page.tsx @@ -2,20 +2,12 @@ import React from "react"; import Link from "next/link"; -import { usePathname } from "next/navigation"; import Footer from "@/components/landing-sections/footer"; -import PaymentFlow from "@/components/payment/PaymentFlow"; import PrimaryButton from "@/components/ui/custom-button"; import Header from "@/components/ui/header"; const Pitch = () => { - const pathname = usePathname(); - const premiumPlanId = process.env.NEXT_PUBLIC_YEARLY_PREMIUM_PLAN_ID; - const planIdOk = - typeof premiumPlanId === "string" && premiumPlanId.trim().length > 0; - const callbackUrl = `${pathname}#invest`; - return ( <>
@@ -240,10 +232,18 @@ const Pitch = () => {

-
-

- but how will you be able to give personal attention and do all - of that if the number of opensox pro members grows in the future? +
+

+ + but how will you be able to give personal attention and do all + of that if the number of opensox pro members grows in the + future? +

i thought about this a lot. and made a hard decision to{" "} @@ -315,23 +315,9 @@ const Pitch = () => { how to invest?

- {planIdOk ? ( -
- -
- ) : ( - - im in - - )} + + im in +
diff --git a/apps/web/src/app/(main)/(landing)/pricing/page.tsx b/apps/web/src/app/(main)/(landing)/pricing/page.tsx index 73782f29..b2c7cb92 100644 --- a/apps/web/src/app/(main)/(landing)/pricing/page.tsx +++ b/apps/web/src/app/(main)/(landing)/pricing/page.tsx @@ -1,13 +1,12 @@ "use client"; import Header from "@/components/ui/header"; -import { Check, CornerDownRight, Target, Terminal } from "lucide-react"; +import PrimaryButton from "@/components/ui/custom-button"; +import { Check, Terminal, X } from "lucide-react"; import Image from "next/image"; import Link from "next/link"; import React, { useEffect } from "react"; -import { usePathname } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; import { motion } from "framer-motion"; -import { ShineBorder } from "@/components/ui/shine-borders"; -import PrimaryButton from "@/components/ui/custom-button"; import Features from "@/components/features/features"; import dynamic from "next/dynamic"; import { trpc } from "@/lib/trpc"; @@ -30,102 +29,141 @@ const PaymentFlow = dynamic( loading: () => null, }, ); -const opensoxFeatures = [ + +type TierKey = "free" | "pro1" | "pro4"; + +type FeatureValue = boolean | string; + +interface ComparisonFeature { + name: string; + upcoming?: boolean; + free: FeatureValue; + pro: FeatureValue; + proPlus: FeatureValue; +} + +const comparisonFeatures: ComparisonFeature[] = [ { - id: 1, - title: "Opensox Advanced search tool", - description: - "One and only tool in the market that let you find open source with blizzing speed and scary accuracy. It will have:", - features: [ - "Faster and accurate search of projects", - "Higher accuracy (so that you exactly land on your dream open source project)", - "Advanced filters like, GSOC, YC, funding, hire contributors, trending, niche (like AI, Core ML, Web3, MERN), bounties, and many more.", - ], + name: "Project search (legacy)", + free: true, + pro: true, + proPlus: true, }, { - id: 2, - title: "30 days Opensox challenge sheet", - description: [ - "A comprehensive sheet of 30+ modules along with detailed videos to give you a clear path to start rocking in open source.", - "It will contain videos, resouces and hand made docs.", - <> - In each of the 30 steps, you will learn, then apply, If stuck, - we'll help and then we'll do an accountability check.{" "} - - Check here. - - , - ], - features: [], + name: "Access to the general community", + free: true, + pro: true, + proPlus: true, + }, + { name: "OSS sheet", free: true, pro: true, proPlus: true }, + { name: "Onboarding call", free: false, pro: true, proPlus: true }, + { + name: "OSS guidance (jobs, GSoC, LFX, etc.)", + free: false, + pro: true, + proPlus: true, + }, + { + name: "Pro community", + free: false, + pro: true, + proPlus: true, + }, + { name: "Weekly live sessions", free: false, pro: true, proPlus: true }, + { name: "Unlimited QnAs", free: false, pro: true, proPlus: true }, + { name: "Weekly contests", free: false, pro: true, proPlus: true }, + { + name: "Pro modules", + free: false, + pro: true, + proPlus: true, }, -]; - -type WhySubItem = { kind: "text"; content: string } | { kind: "pro_slots" }; - -const whySub: WhySubItem[] = [ { - kind: "text", - content: - "Currently, Opensox 2.0 is in progress (70% done) so till the launch, we are offering Pro plan at a discounted price - $49 for the whole year", + name: "Hand-picked OSS projects", + free: false, + pro: true, + proPlus: true, }, - { kind: "pro_slots" }, { - kind: "text", - content: - "After the launch, this $49 offer be removed and Opensox Pro will be around ~ $89 for whole year.", + name: "Session recordings", + free: false, + pro: true, + proPlus: true, }, + { name: "Private thread", free: false, pro: true, proPlus: true }, { - kind: "text", - content: "The price of the dollar is constantly increasing.", + name: "Updates on open source, jobs, tech", + free: false, + pro: true, + proPlus: true, + }, + { name: "Daily stand-ups", free: false, pro: true, proPlus: true }, + { name: "Pro References", free: false, pro: true, proPlus: true }, + { + name: "First principles mega-module (20+ modules)", + upcoming: true, + free: false, + pro: false, + proPlus: true, + }, + { + name: "Build in public mega-module (20+ modules)", + upcoming: true, + free: false, + pro: false, + proPlus: true, }, ]; -const freePlanCard = { - whatYouGetImmediately: [ - "Free filters to search projects (tech stack, competition, activity, etc)", - "Access to the general community", - ], - whatYouGetAfterLaunch: [ - "Everything mentioned above", - "30 days opensox challenge sheet", - ], -}; - -const premiumPlanCard = { - whatYouGetImmediately: [ - "Everything in free plan +", - "1:1 session on finding remote jobs and internships in open-source companies.", - "Quick doubts resolution.", - "Personalized guidance for GSoC, LFX, Outreachy, etc", - "Access to Pro Discord where you can ask anything anytime.", - "Support to enhance skills for open source", - "GSOC proposal, resume reviews, etc.", - "Upcoming Pro features", - ], - whatYouGetAfterLaunch: [ - "Everything mentioned above", - "Advanced tool with Pro filters to find open source projects", - "30 days opensox challenge sheet", - "Upcoming Pro features.", - ], -}; +interface PlanTier { + key: TierKey; + name: string; + price: string; + originalPrice?: string; + period: string; + planId?: string; + paymentDescription?: string; +} const Pricing = () => { const pathname = usePathname(); const callbackUrl = `${pathname}#pro-price-card`; - const premiumPlanId = process.env.NEXT_PUBLIC_YEARLY_PREMIUM_PLAN_ID; - const planIdOk = - typeof premiumPlanId === "string" && premiumPlanId.length > 0; - const { data: proMemberCountData } = trpc.payment.getProMemberCount.useQuery( - { planId: premiumPlanId ?? "" }, - { enabled: planIdOk }, - ); + const yearlyPlanId = process.env.NEXT_PUBLIC_YEARLY_PREMIUM_PLAN_ID; + const fourYearPlanId = process.env.NEXT_PUBLIC_4YEAR_PREMIUM_PLAN_ID; + + // NOTE: `price` below is a display label only. The actual amount charged and + // the "≈ ₹…" line both come from the plan record (Plan.price) via + // getPublicPlan, so the record is the single source of truth. When changing a + // headline price here, update the matching Plan record's price (and keep the + // USD label consistent with it) — otherwise the card will advertise one price + // and charge another. + const tiers: PlanTier[] = [ + { + key: "free", + name: "Free", + price: "$0", + period: "forever", + }, + { + key: "pro1", + name: "Pro", + price: "$49", + originalPrice: "$89", + period: "/ year", + planId: yearlyPlanId, + paymentDescription: "Annual Subscription", + }, + { + key: "pro4", + name: "Pro+", + price: "$99", + originalPrice: "$199", + period: "/ 4 years", + planId: fourYearPlanId, + paymentDescription: "4 Year Subscription", + }, + ]; useEffect(() => { const handleHashScroll = () => { @@ -158,238 +196,113 @@ const Pricing = () => { return ( <> -