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?
+
+
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 (
<>
-
-
-
-
-
-
-
- What is Opensox 2.0?
-
-
-
-
- {opensoxFeatures.map((feature, index) => {
- // render first item (LCP element) immediately without animation
- const isLCPElement = index === 0;
-
- if (isLCPElement) {
- return (
- -
-
-
-
- {index + 1}
-
-
-
- {feature.title}
-
-
-
- {Array.isArray(feature.description) ? (
-
- {feature.description.map(
- (sentence, sentenceIndex) => (
-
- {sentence}
-
- ),
- )}
-
- ) : (
-
- {feature.description}
-
- )}
-
-
-
- {feature.features.map((feature, featureIndex) => {
- return (
- -
-
- {feature}
-
- );
- })}
-
-
-
- );
- }
-
- return (
-
-
-
-
- {index + 1}
-
-
-
- {feature.title}
-
-
-
- {Array.isArray(feature.description) ? (
-
- {feature.description.map(
- (sentence, sentenceIndex) => (
-
- {sentence}
-
- ),
- )}
-
- ) : (
-
{feature.description}
- )}
-
-
-
- {feature.features.map((feature, featureIndex) => {
- return (
- -
-
- {feature}
-
- );
- })}
-
-
-
- );
- })}
-
-
-
-
-
-
- Why should you subscribe to Opensox Pro now?
-
-
-
-
- {whySub.map((sub, index) => {
- return (
-
-
- {sub.kind === "pro_slots" ? (
- planIdOk ? (
-
- This offer is only available for the first 200 (
- {proMemberCountData !== undefined ? (
-
- {proMemberCountData.count} slots booked
-
- ) : (
- …
- )}
- ) users
-
- ) : (
-
- This offer is only available for the first 200 users
-
- )
- ) : (
- sub.content
- )}
-
- );
- })}
-
-
+
+ {/* SECTION 1 - hero */}
+
+
+
-
-
-
+
+
+
+ Opensox{" "}
+
+ Pro.
+
+
+
+ a small and effective ecosystem for{" "}
+
+ limited
+ {" "}
+ people.
+
+
+ learning Open Source.{" "}
+ Build in Public.{" "}
+ First Principles.
+
+
+
+
+
-
-
+
+ directly managed by jackedAJ (no TAs.)
+
+
-
-
-
-
-
- See what our Pro customers said about us.
-
-
+
+
+ {/* SECTION 2 - pricing comparison */}
+
+
-
- For any doubts or queries, feel free to ping us at{" "}
+
+
+
+
+ {/* SECTION 3 - testimonials */}
+
+
+
- opensoxlabs@gmail.com
+ see more
-
+
+
+
+ For any doubts or queries, feel free to ping us at{" "}
+
+ opensoxlabs@gmail.com
+
@@ -399,237 +312,301 @@ const Pricing = () => {
export default Pricing;
-const PricingCard = () => {
- return (
-
-
-
-
-
-
-
+const FEATURE_LABEL_WIDTH = "w-[9.5rem] shrink-0";
-
-
- Free
-
-
-
-
-
-
-
- Get Started
-
-
-
-
-
-
- What you get immediately:
-
-
- {freePlanCard.whatYouGetImmediately.map((item, index) => {
- return (
-
- {" "}
- {item}
-
- );
- })}
-
-
-
-
- What you get after the launch:
-
-
- {freePlanCard.whatYouGetAfterLaunch.map((item, index) => {
- return (
-
- {" "}
- {item}
-
- );
- })}
-
-
-
-
-
-
-
- );
+const FEATURE_ROW_LAYOUT =
+ "flex items-start gap-4 border-b border-border py-3.5 lg:grid lg:grid-cols-[minmax(0,max-content)_1fr] lg:items-center lg:gap-6";
+
+const MOBILE_TABLE_SCROLL =
+ "-mx-4 overflow-x-auto overscroll-x-contain px-4 lg:mx-0 lg:overflow-visible lg:px-0";
+
+const PLAN_COLUMNS_GAP = "gap-6 lg:gap-10";
+
+const PlanColumns = ({
+ children,
+ className = "",
+ align = "end",
+ role,
+}: {
+ children: React.ReactNode;
+ className?: string;
+ align?: "start" | "end";
+ role?: string;
+}) => (
+
+ {children}
+
+);
+
+const PlanColumn = ({
+ children,
+ className = "",
+ role,
+}: {
+ children: React.ReactNode;
+ className?: string;
+ role?: string;
+}) => (
+
+ {children}
+
+);
+
+const UpcomingBadge = () => (
+
+ Upcoming
+
+);
+
+const FeatureCell = ({ value }: { value: FeatureValue }) => {
+ if (value === true) {
+ return (
+
+ );
+ }
+
+ if (value === false) {
+ return (
+
+ );
+ }
+
+ return {value};
};
-const SecondaryPricingCard = ({ callbackUrl }: { callbackUrl: string }) => {
- const premiumPlanId = process.env.NEXT_PUBLIC_YEARLY_PREMIUM_PLAN_ID;
- const planIdOk =
- typeof premiumPlanId === "string" && premiumPlanId.length > 0;
+const PRICING_PLAN_BUTTON_CLASS =
+ "w-full !py-2.5 !text-sm !font-semibold";
+
+const PlanColumnHeader = ({
+ tier,
+ callbackUrl,
+}: {
+ tier: PlanTier;
+ callbackUrl: string;
+}) => {
+ const router = useRouter();
+ const planIdOk = typeof tier.planId === "string" && tier.planId.length > 0;
+ const isPaid = tier.key !== "free";
const { data: publicPlan } = trpc.payment.getPublicPlan.useQuery(
- { planId: premiumPlanId ?? "" },
- { enabled: planIdOk },
+ { planId: tier.planId as string },
+ {
+ enabled: isPaid && planIdOk,
+ staleTime: 5 * 60 * 1000,
+ },
);
- const { data: proMemberCountData } = trpc.payment.getProMemberCount.useQuery(
- { planId: premiumPlanId ?? "" },
- { enabled: planIdOk },
+ return (
+
+
+
+ {tier.name}
+
+
+ {isPaid ? (
+
+ {tier.price}
+
+ {tier.period}
+
+
+ ) : (
+
+ {tier.price}
+
+ )}
+ {tier.originalPrice ? (
+
+ {tier.originalPrice}
+
+ ) : (
+
+
+
+ )}
+ {isPaid && planIdOk ? (
+ publicPlan ? (
+
+ {formatApproxPlanPrice(publicPlan.price, publicPlan.currency)}
+
+ ) : (
+
+
+
+ )
+ ) : null}
+
+
+
+ {!isPaid ? (
+
router.push("/dashboard/home")}
+ >
+ Start free
+
+ ) : (
+
+ )}
+
+
);
+};
+
+const PricingComparison = ({
+ tiers,
+ callbackUrl,
+}: {
+ tiers: PlanTier[];
+ callbackUrl: string;
+}) => {
+ const freeTier = tiers.find((t) => t.key === "free")!;
+ const proTier = tiers.find((t) => t.key === "pro1")!;
+ const proPlusTier = tiers.find((t) => t.key === "pro4")!;
return (
-
-
-
-
-
-
-
-
+
+
+
+ Choose your{" "}
+
+ plan.
+
+
+
+ Go Pro or Pro+ to join the ecosystem.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Choose your{" "}
+
+ plan.
+
+
+
+ Go Pro or Pro+ to join the ecosystem.
+
+
+
+
+
+
- {planIdOk ? (
-
- {proMemberCountData !== undefined ? (
- <>
-
-
-
-
-
-
-
- {proMemberCountData.count}
- {" "}
- shareholders invested
-
- >
- ) : (
-
…
- )}
-
- ) : null}
-
-
+
+
+
-
-
-
- $49{" "}
-
- $89
- {" "}
- / year
-
-
-
- {publicPlan ? (
-
- {formatApproxPlanPrice(
- publicPlan.price,
- publicPlan.currency,
- )}
-
- ) : null}
-
+
+
+ Feature
+ {freeTier.name}
+ {proTier.name}
+ {proPlusTier.name}
-
-
-
-
(
+
+
- still not sure? read my pitch to you.
-
-
-
-
-
- What you get immediately:
-
-
- {premiumPlanCard.whatYouGetImmediately.map((item, index) => {
- return (
-
- {" "}
- {item}
-
- );
- })}
-
-
-
-
- What you get after the launch:
-
-
- {premiumPlanCard.whatYouGetAfterLaunch.map((item, index) => {
- return (
-
- {" "}
- {item}
-
- );
- })}
+
{feature.name}
+ {feature.upcoming ?
: null}
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+ ))}
+
+
+ Still not sure?{" "}
+
+ Read my pitch to you.
+
+
);
};
@@ -746,8 +723,8 @@ const TestimonialsSection = () => {
return (
-
-
+
+
{groupedTestimonials[1].map((testimonial) => (
{
))}
-
+
{groupedTestimonials[2].map((testimonial) => (
@@ -776,7 +753,7 @@ const TestimonialsSection = () => {
))}
-
+
{groupedTestimonials[3].map((testimonial) => (
(isPaidUser ? "Pro" : "Free"), [isPaidUser]);
+ const plan = useMemo(
+ () => formatSubscriptionPlanLabel(isPaidUser, subscription),
+ [isPaidUser, subscription],
+ );
const joinedOn = useMemo(() => {
if (!subscription?.startDate) return "—";
return new Date(subscription.startDate).toLocaleDateString("en-IN", {
diff --git a/apps/web/src/components/features/explore-features-browser-mockup.tsx b/apps/web/src/components/features/explore-features-browser-mockup.tsx
index f1ad4591..b017cd94 100644
--- a/apps/web/src/components/features/explore-features-browser-mockup.tsx
+++ b/apps/web/src/components/features/explore-features-browser-mockup.tsx
@@ -1,7 +1,7 @@
"use client";
import Image from "next/image";
-import { useState } from "react";
+import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
import type { HeroIcon } from "./explore-features-data";
@@ -19,23 +19,28 @@ export default function ExploreFeaturesBrowserMockup({
className,
}: BrowserMockupProps) {
const [imageError, setImageError] = useState(false);
+
+ useEffect(() => {
+ setImageError(false);
+ }, [imageSrc]);
+
const showFallback = imageError || !imageSrc;
return (
{/* Browser chrome */}
-
+
-
+
app.opensox.ai
@@ -44,7 +49,7 @@ export default function ExploreFeaturesBrowserMockup({
{/* Browser body */}
-
+
{!showFallback ? (
@@ -76,7 +81,7 @@ export default function ExploreFeaturesBrowserMockup({
)}
-
+
);
diff --git a/apps/web/src/components/features/explore-features-dashboard.tsx b/apps/web/src/components/features/explore-features-dashboard.tsx
index e017d8cd..33c344f4 100644
--- a/apps/web/src/components/features/explore-features-dashboard.tsx
+++ b/apps/web/src/components/features/explore-features-dashboard.tsx
@@ -7,7 +7,7 @@ import {
SparklesIcon,
} from "@heroicons/react/24/outline";
import { AnimatePresence, motion } from "framer-motion";
-import Link from "next/link";
+import { usePathname, useRouter } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
import PrimaryButton from "@/components/ui/custom-button";
import { cn } from "@/lib/utils";
@@ -18,6 +18,8 @@ const AUTO_ADVANCE_MS = 5000;
const RESUME_AFTER_MS = 9000;
export default function ExploreFeaturesDashboard() {
+ const router = useRouter();
+ const pathname = usePathname();
const [activeIndex, setActiveIndex] = useState(0);
const autoIntervalRef = useRef
| null>(null);
const resumeTimeoutRef = useRef | null>(null);
@@ -161,6 +163,18 @@ export default function ExploreFeaturesDashboard() {
return () => cancelAnimationFrame(frame);
}, [activeIndex, scrollSidebarToActive]);
+ const handleInvestNow = useCallback(() => {
+ if (pathname === "/pricing") {
+ const element = document.getElementById("pro-price-card");
+ if (element) {
+ element.scrollIntoView({ behavior: "smooth", block: "start" });
+ window.history.replaceState(null, "", "/pricing#pro-price-card");
+ return;
+ }
+ }
+ router.push("/pricing#pro-price-card");
+ }, [pathname, router]);
+
return (
@@ -168,12 +182,13 @@ export default function ExploreFeaturesDashboard() {
Glimpse of what you get in Opensox Pro.
-
-
-
- Invest Now
-
-
+
+
+ Invest Now
+
@@ -218,7 +233,6 @@ export default function ExploreFeaturesDashboard() {
}}
/>
)}
-
setImageError(true)}
diff --git a/apps/web/src/components/ui/custom-button.tsx b/apps/web/src/components/ui/custom-button.tsx
index 108e4644..407fbb89 100644
--- a/apps/web/src/components/ui/custom-button.tsx
+++ b/apps/web/src/components/ui/custom-button.tsx
@@ -29,7 +29,7 @@ const PrimaryButton = ({
"[box-shadow:0px_-2px_0px_0px_#2c04b1_inset]",
"hover:opacity-90 transition-opacity duration-100",
"text-white font-medium",
- classname
+ classname,
)}
transition={animate ? transition : undefined}
>
diff --git a/apps/web/src/content/blog/scale-is-the-problem.mdx b/apps/web/src/content/blog/scale-is-the-problem.mdx
index 520241b5..39d03d2d 100644
--- a/apps/web/src/content/blog/scale-is-the-problem.mdx
+++ b/apps/web/src/content/blog/scale-is-the-problem.mdx
@@ -23,3 +23,5 @@ the cost of scale is authenticity and the genuine vibe.
now that community or project becomes more like a number-obsessed machine and less like a true neighborhood community of people with the same interests and goals.
that's why sometimes scale is the problem.
+
+here's what my scale is
diff --git a/apps/web/src/lib/format-subscription-plan-label.ts b/apps/web/src/lib/format-subscription-plan-label.ts
new file mode 100644
index 00000000..476d5fdb
--- /dev/null
+++ b/apps/web/src/lib/format-subscription-plan-label.ts
@@ -0,0 +1,29 @@
+type SubscriptionPlanInfo = {
+ planName?: string | null;
+ durationMonths?: number | null;
+};
+
+/** dashboard label for the user's active plan tier */
+export function formatSubscriptionPlanLabel(
+ isPaidUser: boolean,
+ subscription: SubscriptionPlanInfo | null | undefined,
+): string {
+ if (!isPaidUser) return "Free";
+
+ if (subscription?.durationMonths === 48) return "Pro+";
+ if (subscription?.durationMonths === 12) return "Pro";
+
+ const planName = (subscription?.planName ?? "").toLowerCase();
+ if (
+ planName.includes("pro+") ||
+ planName.includes("4 year") ||
+ planName.includes("4-year") ||
+ planName.includes("4yr")
+ ) {
+ return "Pro+";
+ }
+
+ if (planName === "pro") return "Pro";
+
+ return "Pro";
+}
diff --git a/apps/web/src/store/useSubscriptionStore.ts b/apps/web/src/store/useSubscriptionStore.ts
index ebb9468c..9a0ba0ff 100644
--- a/apps/web/src/store/useSubscriptionStore.ts
+++ b/apps/web/src/store/useSubscriptionStore.ts
@@ -3,6 +3,7 @@ import { create } from "zustand";
interface Subscription {
id: string;
planName: string | null;
+ durationMonths: number | null;
startDate: Date;
endDate: Date;
status: string;
diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.ts
index f64712ce..17aae8c8 100644
--- a/apps/web/tailwind.config.ts
+++ b/apps/web/tailwind.config.ts
@@ -182,12 +182,7 @@ const config: Config = {
},
fontFamily: {
sans: ["var(--font-geist-sans)", "system-ui", "sans-serif"],
- heading: [
- "Helvetica Neue",
- "Helvetica",
- "Arial",
- "sans-serif",
- ],
+ heading: ["Helvetica Neue", "Helvetica", "Arial", "sans-serif"],
mono: [
"var(--font-dm-mono)",
"Menlo",