Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/web/app/(app)/admin/AdminUpgradeUserForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions apps/web/app/(app)/admin/validation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
20 changes: 10 additions & 10 deletions apps/web/app/(app)/premium/Pricing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ export function Pricing() {
</RadioGroup>

<div className="ml-1">
<Badge>SAVE up to 36%!</Badge>
<Badge>Save up to 40%!</Badge>
</div>
</div>

Expand All @@ -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";
Expand Down Expand Up @@ -195,7 +193,9 @@ export function Pricing() {

{!!tier.discount?.[frequency.value] && (
<Badge>
SAVE {tier.discount[frequency.value].toFixed(0)}%
<span className="tracking-wide">
SAVE {tier.discount[frequency.value].toFixed(0)}%
</span>
</Badge>
)}
</p>
Expand Down Expand Up @@ -356,7 +356,7 @@ function LifetimePricing(props: {

function Badge({ children }: { children: React.ReactNode }) {
return (
<p className="rounded-full bg-blue-600/10 px-2.5 py-1 text-xs font-bold leading-5 text-blue-600">
<p className="rounded-full bg-blue-600/10 px-2.5 py-1 text-xs font-semibold leading-5 text-blue-600">
{children}
</p>
);
Expand Down
42 changes: 28 additions & 14 deletions apps/web/app/(app)/premium/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export const frequencies = [
];

export const pricing: Record<PremiumTier, number> = {
[PremiumTier.BASIC_MONTHLY]: 10,
[PremiumTier.BASIC_ANNUALLY]: 6,
[PremiumTier.PRO_MONTHLY]: 14,
[PremiumTier.PRO_ANNUALLY]: 9,
[PremiumTier.BUSINESS_MONTHLY]: 22,
Expand All @@ -15,6 +17,8 @@ export const pricing: Record<PremiumTier, number> = {
};

export const pricingAdditonalEmail: Record<PremiumTier, number> = {
[PremiumTier.BASIC_MONTHLY]: 2,
[PremiumTier.BASIC_ANNUALLY]: 1.5,
[PremiumTier.PRO_MONTHLY]: 3,
[PremiumTier.PRO_ANNUALLY]: 2.5,
[PremiumTier.BUSINESS_MONTHLY]: 3.5,
Expand All @@ -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",
Expand All @@ -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,
Expand All @@ -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",
],
Expand All @@ -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,
Expand All @@ -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",
],
Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/(app)/usage/usage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: <CoinsIcon className="h-4 w-4" />,
Expand Down
1 change: 1 addition & 0 deletions apps/web/app/api/user/me/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ async function getUser(userId: string) {
lemonSqueezySubscriptionId: true,
lemonSqueezyRenewsAt: true,
unsubscribeCredits: true,
bulkUnsubscribeAccess: true,
aiAutomationAccess: true,
coldEmailBlockerAccess: true,
tier: true,
Expand Down
33 changes: 17 additions & 16 deletions apps/web/components/PremiumAlert.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,30 +18,31 @@ import { PremiumTier } from "@prisma/client";

export function usePremium() {
const swrResponse = useSWR<UserResponse>("/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,
};
Expand Down
20 changes: 17 additions & 3 deletions apps/web/env.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(""),
Expand All @@ -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"),
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
5 changes: 4 additions & 1 deletion apps/web/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -342,6 +343,8 @@ enum ColdEmailSetting {
}

enum PremiumTier {
BASIC_MONTHLY
BASIC_ANNUALLY
PRO_MONTHLY
PRO_ANNUALLY
BUSINESS_MONTHLY
Expand Down
2 changes: 1 addition & 1 deletion apps/web/utils/actions/premium.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
});
Expand Down
24 changes: 17 additions & 7 deletions apps/web/utils/premium/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = !!(
Expand All @@ -60,7 +68,7 @@ export const hasAiAccess = (
};

export const hasColdEmailAccess = (
coldEmailBlockerAccess?: Premium["coldEmailBlockerAccess"],
coldEmailBlockerAccess?: FeatureAccess | null,
openAIApiKey?: string | null,
) => {
const hasColdEmailAccess = !!(
Expand All @@ -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;
Expand Down
Loading