diff --git a/apps/web/app/(app)/admin/AdminUpgradeUserForm.tsx b/apps/web/app/(app)/admin/AdminUpgradeUserForm.tsx index 1a1509805e..b605e22f56 100644 --- a/apps/web/app/(app)/admin/AdminUpgradeUserForm.tsx +++ b/apps/web/app/(app)/admin/AdminUpgradeUserForm.tsx @@ -90,6 +90,14 @@ export const AdminUpgradeUserForm = () => { label: PremiumTier.PRO_MONTHLY, value: PremiumTier.PRO_MONTHLY, }, + { + label: PremiumTier.BASIC_ANNUALLY, + value: PremiumTier.BASIC_ANNUALLY, + }, + { + label: PremiumTier.BASIC_MONTHLY, + value: PremiumTier.BASIC_MONTHLY, + }, { label: PremiumTier.LIFETIME, value: PremiumTier.LIFETIME, diff --git a/apps/web/app/(app)/admin/validation.tsx b/apps/web/app/(app)/admin/validation.tsx index d25a823bcd..f7ddbb11fc 100644 --- a/apps/web/app/(app)/admin/validation.tsx +++ b/apps/web/app/(app)/admin/validation.tsx @@ -12,6 +12,8 @@ export const changePremiumStatusSchema = z.object({ .optional() .transform((v) => v || undefined), period: z.enum([ + PremiumTier.BASIC_MONTHLY, + PremiumTier.BASIC_ANNUALLY, PremiumTier.PRO_MONTHLY, PremiumTier.PRO_ANNUALLY, PremiumTier.BUSINESS_MONTHLY, diff --git a/apps/web/app/(app)/premium/Pricing.tsx b/apps/web/app/(app)/premium/Pricing.tsx index a43243d2b3..db55335181 100644 --- a/apps/web/app/(app)/premium/Pricing.tsx +++ b/apps/web/app/(app)/premium/Pricing.tsx @@ -132,7 +132,7 @@ export function Pricing() {
- SAVE up to 36%! + Save up to 40%!
@@ -146,13 +146,11 @@ export function Pricing() { ? isCurrentPlan ? "#" : buildLemonUrl( - tier.checkout - ? attachUserInfo(tier.href[frequency.value], { - id: user.id, - email: user.email!, - name: user.name, - }) - : tier.href[frequency.value], + attachUserInfo(tier.href[frequency.value], { + id: user.id, + email: user.email!, + name: user.name, + }), affiliateCode, ) : "/login?next=/premium"; @@ -195,7 +193,9 @@ export function Pricing() { {!!tier.discount?.[frequency.value] && ( - SAVE {tier.discount[frequency.value].toFixed(0)}% + + SAVE {tier.discount[frequency.value].toFixed(0)}% + )}

@@ -356,7 +356,7 @@ function LifetimePricing(props: { function Badge({ children }: { children: React.ReactNode }) { return ( -

+

{children}

); diff --git a/apps/web/app/(app)/premium/config.ts b/apps/web/app/(app)/premium/config.ts index b9bc3f1dcd..ce03b1d9f5 100644 --- a/apps/web/app/(app)/premium/config.ts +++ b/apps/web/app/(app)/premium/config.ts @@ -7,6 +7,8 @@ export const frequencies = [ ]; export const pricing: Record = { + [PremiumTier.BASIC_MONTHLY]: 10, + [PremiumTier.BASIC_ANNUALLY]: 6, [PremiumTier.PRO_MONTHLY]: 14, [PremiumTier.PRO_ANNUALLY]: 9, [PremiumTier.BUSINESS_MONTHLY]: 22, @@ -15,6 +17,8 @@ export const pricing: Record = { }; export const pricingAdditonalEmail: Record = { + [PremiumTier.BASIC_MONTHLY]: 2, + [PremiumTier.BASIC_ANNUALLY]: 1.5, [PremiumTier.PRO_MONTHLY]: 3, [PremiumTier.PRO_ANNUALLY]: 2.5, [PremiumTier.BUSINESS_MONTHLY]: 3.5, @@ -28,16 +32,32 @@ function discount(monthly: number, annually: number) { export const tiers = [ { - name: "Free", - href: { monthly: "/welcome", annually: "/welcome" }, - price: { monthly: 0, annually: 0 }, - description: "Try Inbox Zero for free.", + name: "Basic", + tiers: { + monthly: PremiumTier.BASIC_MONTHLY, + annually: PremiumTier.BASIC_ANNUALLY, + }, + href: { + monthly: env.NEXT_PUBLIC_BASIC_MONTHLY_PAYMENT_LINK, + annually: env.NEXT_PUBLIC_BASIC_ANNUALLY_PAYMENT_LINK, + }, + price: { monthly: pricing.BASIC_MONTHLY, annually: pricing.BASIC_ANNUALLY }, + priceAdditional: { + monthly: pricingAdditonalEmail.BASIC_MONTHLY, + annually: pricingAdditonalEmail.BASIC_ANNUALLY, + }, + discount: { + monthly: 0, + annually: discount(pricing.BASIC_MONTHLY, pricing.BASIC_ANNUALLY), + }, + description: "Unlimited unsubscribe credits.", features: [ "Bulk email unsubscriber", - `Unsubscribe from ${env.NEXT_PUBLIC_UNSUBSCRIBE_CREDITS} emails per month`, + "Unlimited unsubscribes", + "Unlimited archives", "Email analytics", ], - cta: "Get Started", + cta: "Upgrade", }, { name: "Pro", @@ -49,7 +69,6 @@ export const tiers = [ monthly: env.NEXT_PUBLIC_PRO_MONTHLY_PAYMENT_LINK, annually: env.NEXT_PUBLIC_PRO_ANNUALLY_PAYMENT_LINK, }, - checkout: true, price: { monthly: pricing.PRO_MONTHLY, annually: pricing.PRO_ANNUALLY }, priceAdditional: { monthly: pricingAdditonalEmail.PRO_MONTHLY, @@ -59,11 +78,9 @@ export const tiers = [ monthly: 0, annually: discount(pricing.PRO_MONTHLY, pricing.PRO_ANNUALLY), }, - description: - "Unlimited unsubscribe credits. Unlock AI features when using your own OpenAI key", + description: "Unlock AI features when using your own OpenAI key", features: [ "Everything in free", - "Unlimited unsubscribes", "AI automation when using your own OpenAI API key", "Cold email blocker when using your own OpenAI API key", ], @@ -80,7 +97,6 @@ export const tiers = [ monthly: env.NEXT_PUBLIC_BUSINESS_MONTHLY_PAYMENT_LINK, annually: env.NEXT_PUBLIC_BUSINESS_ANNUALLY_PAYMENT_LINK, }, - checkout: true, price: { monthly: pricing.BUSINESS_MONTHLY, annually: pricing.BUSINESS_ANNUALLY, @@ -96,9 +112,7 @@ export const tiers = [ description: "Unlock full AI-powered email management", features: [ "Everything in pro", - "AI automation", - "Cold email blocker", - "AI categorization", + "Unlimited AI credits", "No need to provide your own OpenAI API key", "Priority support", ], diff --git a/apps/web/app/(app)/usage/usage.tsx b/apps/web/app/(app)/usage/usage.tsx index 7201feaddc..14ec88e349 100644 --- a/apps/web/app/(app)/usage/usage.tsx +++ b/apps/web/app/(app)/usage/usage.tsx @@ -26,7 +26,7 @@ export function Usage(props: { ? "Unlimited" : formatStat( data?.premium?.unsubscribeCredits ?? - env.NEXT_PUBLIC_UNSUBSCRIBE_CREDITS, + env.NEXT_PUBLIC_FREE_UNSUBSCRIBE_CREDITS, ), subvalue: "credits", icon: , diff --git a/apps/web/app/api/user/me/route.ts b/apps/web/app/api/user/me/route.ts index f22714bfae..0edc2756ca 100644 --- a/apps/web/app/api/user/me/route.ts +++ b/apps/web/app/api/user/me/route.ts @@ -23,6 +23,7 @@ async function getUser(userId: string) { lemonSqueezySubscriptionId: true, lemonSqueezyRenewsAt: true, unsubscribeCredits: true, + bulkUnsubscribeAccess: true, aiAutomationAccess: true, coldEmailBlockerAccess: true, tier: true, diff --git a/apps/web/components/PremiumAlert.tsx b/apps/web/components/PremiumAlert.tsx index cef6cfb813..0afff31e1e 100644 --- a/apps/web/components/PremiumAlert.tsx +++ b/apps/web/components/PremiumAlert.tsx @@ -18,30 +18,31 @@ import { PremiumTier } from "@prisma/client"; export function usePremium() { const swrResponse = useSWR("/api/user/me"); + const { data } = swrResponse; - const premium = !!( - swrResponse.data?.premium && - isPremium(swrResponse.data.premium.lemonSqueezyRenewsAt) - ); + const premium = data?.premium; + const openAIApiKey = data?.openAIApiKey; + + const isUserPremium = !!(premium && isPremium(premium.lemonSqueezyRenewsAt)); const isProPlanWithoutApiKey = - (swrResponse.data?.premium?.tier === PremiumTier.PRO_MONTHLY || - swrResponse.data?.premium?.tier === PremiumTier.PRO_ANNUALLY) && - !swrResponse.data?.openAIApiKey; + (premium?.tier === PremiumTier.PRO_MONTHLY || + premium?.tier === PremiumTier.PRO_ANNUALLY) && + !openAIApiKey; return { ...swrResponse, - isPremium: premium, + isPremium: isUserPremium, hasUnsubscribeAccess: - premium || - hasUnsubscribeAccess(swrResponse.data?.premium?.unsubscribeCredits), - hasAiAccess: hasAiAccess( - swrResponse.data?.premium?.aiAutomationAccess, - swrResponse.data?.openAIApiKey, - ), + isUserPremium || + hasUnsubscribeAccess( + premium?.bulkUnsubscribeAccess, + premium?.unsubscribeCredits, + ), + hasAiAccess: hasAiAccess(premium?.aiAutomationAccess, openAIApiKey), hasColdEmailAccess: hasColdEmailAccess( - swrResponse.data?.premium?.coldEmailBlockerAccess, - swrResponse.data?.openAIApiKey, + premium?.coldEmailBlockerAccess, + openAIApiKey, ), isProPlanWithoutApiKey, }; diff --git a/apps/web/env.mjs b/apps/web/env.mjs index 09c366bd34..32105413b0 100644 --- a/apps/web/env.mjs +++ b/apps/web/env.mjs @@ -40,6 +40,11 @@ export const env = createEnv({ NEXT_PUBLIC_LEMON_STORE_ID: z.string().nullish().default("inboxzero"), // lemon plans + // basic + NEXT_PUBLIC_BASIC_MONTHLY_PAYMENT_LINK: z.string(), + NEXT_PUBLIC_BASIC_ANNUALLY_PAYMENT_LINK: z.string(), + NEXT_PUBLIC_BASIC_MONTHLY_VARIANT_ID: z.coerce.number().default(0), + NEXT_PUBLIC_BASIC_ANNUALLY_VARIANT_ID: z.coerce.number().default(0), // pro NEXT_PUBLIC_PRO_MONTHLY_PAYMENT_LINK: z.string().default(""), NEXT_PUBLIC_PRO_ANNUALLY_PAYMENT_LINK: z.string().default(""), @@ -63,7 +68,7 @@ export const env = createEnv({ LICENSE_10_SEAT_VARIANT_ID: z.coerce.number().optional(), LICENSE_25_SEAT_VARIANT_ID: z.coerce.number().optional(), - NEXT_PUBLIC_UNSUBSCRIBE_CREDITS: z.number().default(5), + NEXT_PUBLIC_FREE_UNSUBSCRIBE_CREDITS: z.number().default(5), NEXT_PUBLIC_CALL_LINK: z .string() .default("https://cal.com/team/inbox-zero/feedback"), @@ -85,6 +90,15 @@ export const env = createEnv({ experimental__runtimeEnv: { NEXT_PUBLIC_LEMON_STORE_ID: process.env.NEXT_PUBLIC_LEMON_STORE_ID, + // basic + NEXT_PUBLIC_BASIC_MONTHLY_PAYMENT_LINK: + process.env.NEXT_PUBLIC_BASIC_MONTHLY_PAYMENT_LINK, + NEXT_PUBLIC_BASIC_ANNUALLY_PAYMENT_LINK: + process.env.NEXT_PUBLIC_BASIC_ANNUALLY_PAYMENT_LINK, + NEXT_PUBLIC_BASIC_MONTHLY_VARIANT_ID: + process.env.NEXT_PUBLIC_BASIC_MONTHLY_VARIANT_ID, + NEXT_PUBLIC_BASIC_ANNUALLY_VARIANT_ID: + process.env.NEXT_PUBLIC_BASIC_ANNUALLY_VARIANT_ID, // pro NEXT_PUBLIC_PRO_MONTHLY_PAYMENT_LINK: process.env.NEXT_PUBLIC_PRO_MONTHLY_PAYMENT_LINK, @@ -121,8 +135,8 @@ export const env = createEnv({ NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL, NEXT_PUBLIC_CONTACTS_ENABLED: process.env.NEXT_PUBLIC_CONTACTS_ENABLED, NEXT_PUBLIC_LEMON_STORE_ID: process.env.NEXT_PUBLIC_LEMON_STORE_ID, - NEXT_PUBLIC_UNSUBSCRIBE_CREDITS: - process.env.NEXT_PUBLIC_UNSUBSCRIBE_CREDITS, + NEXT_PUBLIC_FREE_UNSUBSCRIBE_CREDITS: + process.env.NEXT_PUBLIC_FREE_UNSUBSCRIBE_CREDITS, NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN, NEXT_PUBLIC_SUPPORT_EMAIL: process.env.NEXT_PUBLIC_SUPPORT_EMAIL, NEXT_PUBLIC_GTM_ID: process.env.NEXT_PUBLIC_GTM_ID, diff --git a/apps/web/prisma/migrations/20240528181840_premium_basic/migration.sql b/apps/web/prisma/migrations/20240528181840_premium_basic/migration.sql new file mode 100644 index 0000000000..94b91229f6 --- /dev/null +++ b/apps/web/prisma/migrations/20240528181840_premium_basic/migration.sql @@ -0,0 +1,13 @@ +-- AlterEnum +-- This migration adds more than one value to an enum. +-- With PostgreSQL versions 11 and earlier, this is not possible +-- in a single migration. This can be worked around by creating +-- multiple migrations, each migration adding only one value to +-- the enum. + + +ALTER TYPE "PremiumTier" ADD VALUE 'BASIC_MONTHLY'; +ALTER TYPE "PremiumTier" ADD VALUE 'BASIC_ANNUALLY'; + +-- AlterTable +ALTER TABLE "Premium" ADD COLUMN "bulkUnsubscribeAccess" "FeatureAccess"; diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index 0315cd305c..a3835c772f 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -109,9 +109,10 @@ model Premium { tier PremiumTier? // feature access + bulkUnsubscribeAccess FeatureAccess? coldEmailBlockerAccess FeatureAccess? aiAutomationAccess FeatureAccess? - emailAccountsAccess Int? // only used for lifetime + emailAccountsAccess Int? // unsubscribe/ai credits // if `unsubscribeMonth` not set to this month, set to current month @@ -342,6 +343,8 @@ enum ColdEmailSetting { } enum PremiumTier { + BASIC_MONTHLY + BASIC_ANNUALLY PRO_MONTHLY PRO_ANNUALLY BUSINESS_MONTHLY diff --git a/apps/web/utils/actions/premium.ts b/apps/web/utils/actions/premium.ts index 53133b074a..4b65165eb1 100644 --- a/apps/web/utils/actions/premium.ts +++ b/apps/web/utils/actions/premium.ts @@ -51,7 +51,7 @@ export async function decrementUnsubscribeCredit() { where: { id: premium.id }, data: { // reset and use a credit - unsubscribeCredits: env.NEXT_PUBLIC_UNSUBSCRIBE_CREDITS - 1, + unsubscribeCredits: env.NEXT_PUBLIC_FREE_UNSUBSCRIBE_CREDITS - 1, unsubscribeMonth: currentMonth, }, }); diff --git a/apps/web/utils/premium/index.ts b/apps/web/utils/premium/index.ts index 41d4adcb54..636075534e 100644 --- a/apps/web/utils/premium/index.ts +++ b/apps/web/utils/premium/index.ts @@ -42,13 +42,21 @@ export const isAdminForPremium = ( }; export const hasUnsubscribeAccess = ( + bulkUnsubscribeAccess?: FeatureAccess | null, unsubscribeCredits?: number | null, ): boolean => { + if ( + bulkUnsubscribeAccess === FeatureAccess.UNLOCKED || + bulkUnsubscribeAccess === FeatureAccess.UNLOCKED_WITH_API_KEY + ) { + return true; + } + return unsubscribeCredits !== 0; }; export const hasAiAccess = ( - aiAutomationAccess?: Premium["aiAutomationAccess"], + aiAutomationAccess?: FeatureAccess | null, openAIApiKey?: string | null, ) => { const hasAiAccess = !!( @@ -60,7 +68,7 @@ export const hasAiAccess = ( }; export const hasColdEmailAccess = ( - coldEmailBlockerAccess?: Premium["coldEmailBlockerAccess"], + coldEmailBlockerAccess?: FeatureAccess | null, openAIApiKey?: string | null, ) => { const hasColdEmailAccess = !!( @@ -77,11 +85,13 @@ export function isOnHigherTier( tier2?: PremiumTier | null, ) { const tierRanking = { - [PremiumTier.PRO_MONTHLY]: 1, - [PremiumTier.PRO_ANNUALLY]: 2, - [PremiumTier.BUSINESS_MONTHLY]: 3, - [PremiumTier.BUSINESS_ANNUALLY]: 4, - [PremiumTier.LIFETIME]: 5, + [PremiumTier.BASIC_MONTHLY]: 1, + [PremiumTier.BASIC_ANNUALLY]: 2, + [PremiumTier.PRO_MONTHLY]: 3, + [PremiumTier.PRO_ANNUALLY]: 4, + [PremiumTier.BUSINESS_MONTHLY]: 5, + [PremiumTier.BUSINESS_ANNUALLY]: 6, + [PremiumTier.LIFETIME]: 7, }; const tier1Rank = tier1 ? tierRanking[tier1] : 0; diff --git a/apps/web/utils/premium/server.ts b/apps/web/utils/premium/server.ts index 30762e0215..c6e321bcba 100644 --- a/apps/web/utils/premium/server.ts +++ b/apps/web/utils/premium/server.ts @@ -29,13 +29,10 @@ export async function upgradeToPremium(options: { select: { premiumId: true }, }); - const accessLevel = getAccessLevelFromTier(options.tier); - const data = { ...rest, lemonSqueezyRenewsAt, - aiAutomationAccess: accessLevel, - coldEmailBlockerAccess: accessLevel, + ...getTierAccess(options.tier), }; if (user.premiumId) { @@ -81,6 +78,7 @@ export async function cancelPremium(options: { where: { id: options.premiumId }, data: { lemonSqueezyRenewsAt: options.lemonSqueezyEndsAt, + bulkUnsubscribeAccess: null, aiAutomationAccess: null, coldEmailBlockerAccess: null, }, @@ -113,15 +111,30 @@ export async function editEmailAccountsAccess(options: { }); } -function getAccessLevelFromTier(tier: PremiumTier): FeatureAccess { +function getTierAccess(tier: PremiumTier) { switch (tier) { + case PremiumTier.BASIC_MONTHLY: + case PremiumTier.BASIC_ANNUALLY: + return { + bulkUnsubscribeAccess: FeatureAccess.UNLOCKED, + aiAutomationAccess: FeatureAccess.LOCKED, + coldEmailBlockerAccess: FeatureAccess.LOCKED, + }; case PremiumTier.PRO_MONTHLY: case PremiumTier.PRO_ANNUALLY: - return FeatureAccess.UNLOCKED_WITH_API_KEY; + return { + bulkUnsubscribeAccess: FeatureAccess.UNLOCKED, + aiAutomationAccess: FeatureAccess.UNLOCKED_WITH_API_KEY, + coldEmailBlockerAccess: FeatureAccess.UNLOCKED_WITH_API_KEY, + }; case PremiumTier.BUSINESS_MONTHLY: case PremiumTier.BUSINESS_ANNUALLY: case PremiumTier.LIFETIME: - return FeatureAccess.UNLOCKED; + return { + bulkUnsubscribeAccess: FeatureAccess.UNLOCKED, + aiAutomationAccess: FeatureAccess.UNLOCKED, + coldEmailBlockerAccess: FeatureAccess.UNLOCKED, + }; default: throw new Error(`Unknown premium tier: ${tier}`); } diff --git a/turbo.json b/turbo.json index df49c164fd..84fa51bebf 100644 --- a/turbo.json +++ b/turbo.json @@ -34,7 +34,7 @@ "NEXT_PUBLIC_LIFETIME_PAYMENT_LINK", "NEXT_PUBLIC_LIFETIME_VARIANT_ID", - "NEXT_PUBLIC_UNSUBSCRIBE_CREDITS", + "NEXT_PUBLIC_FREE_UNSUBSCRIBE_CREDITS", "NEXT_PUBLIC_CALL_LINK", "NEXT_PUBLIC_POSTHOG_KEY", "NEXT_PUBLIC_CONTACTS_ENABLED",