Compare commits

..

4 Commits

Author SHA1 Message Date
2749520e10 fix: overflow workaround 2023-10-10 23:16:38 +11:00
8c023b092d fix: incorrect parameter 2023-10-10 15:05:14 +11:00
8d2badf75e fix: various items 2023-10-10 15:05:14 +11:00
9d6e149f56 refactor: update single player references 2023-10-10 15:05:14 +11:00
30 changed files with 80 additions and 618 deletions

View File

@ -68,7 +68,6 @@ NEXT_PRIVATE_STRIPE_API_KEY=
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET= NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET=
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID= NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID=
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID= NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID=
NEXT_PUBLIC_STRIPE_FREE_PLAN_ID=
# [[FEATURES]] # [[FEATURES]]
# OPTIONAL: Leave blank to disable PostHog and feature flags. # OPTIONAL: Leave blank to disable PostHog and feature flags.

View File

@ -1,55 +0,0 @@
tasks:
- init: |
npm i &&
npm run dx:up &&
cp .env.example .env &&
set -a; source .env &&
export NEXTAUTH_URL="$(gp url 3000)" &&
export NEXT_PUBLIC_WEBAPP_URL="$(gp url 3000)" &&
export NEXT_PUBLIC_MARKETING_URL="$(gp url 3001)"
command: npm run d
ports:
- port: 3000
visibility: public
onOpen: open-preview
- port: 3001
visibility: public
onOpen: open-preview
- port: 9000
visibility: public
onOpen: ignore
- port: 1100
visibility: private
onOpen: ignore
- port: 2500
visibility: private
onOpen: ignore
- port: 54320
visibility: private
onOpen: ignore
github:
prebuilds:
master: true
pullRequests: true
pullRequestsFromForks: true
addCheck: true
addComment: true
addBadge: true
vscode:
extensions:
- aaron-bond.better-comments
- bradlc.vscode-tailwindcss
- dbaeumer.vscode-eslint
- esbenp.prettier-vscode
- mikestead.dotenv
- unifiedjs.vscode-mdx
- GitHub.copilot-chat
- GitHub.copilot-labs
- GitHub.copilot
- GitHub.vscode-pull-request-github
- Prisma.prisma
- VisualStudioExptTeam.vscodeintellicode

View File

@ -7,7 +7,6 @@ declare namespace NodeJS {
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string; NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string;
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID: string; NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID: string;
NEXT_PUBLIC_STRIPE_FREE_PLAN_ID?: string;
NEXT_PRIVATE_STRIPE_API_KEY: string; NEXT_PRIVATE_STRIPE_API_KEY: string;
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string; NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;

View File

@ -2,6 +2,8 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { usePathname } from 'next/navigation';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Footer } from '~/components/(marketing)/footer'; import { Footer } from '~/components/(marketing)/footer';
@ -13,6 +15,7 @@ export type MarketingLayoutProps = {
export default function MarketingLayout({ children }: MarketingLayoutProps) { export default function MarketingLayout({ children }: MarketingLayoutProps) {
const [scrollY, setScrollY] = useState(0); const [scrollY, setScrollY] = useState(0);
const pathname = usePathname();
useEffect(() => { useEffect(() => {
const onScroll = () => { const onScroll = () => {
@ -25,7 +28,11 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
}, []); }, []);
return ( return (
<div className="relative max-w-[100vw] overflow-y-auto overflow-x-hidden pt-20 md:pt-28"> <div
className={cn('relative max-w-[100vw] pt-20 md:pt-28', {
'overflow-y-auto overflow-x-hidden': pathname !== '/singleplayer',
})}
>
<div <div
className={cn('fixed left-0 top-0 z-50 w-full bg-transparent', { className={cn('fixed left-0 top-0 z-50 w-full bg-transparent', {
'bg-background/50 backdrop-blur-md': scrollY > 5, 'bg-background/50 backdrop-blur-md': scrollY > 5,

View File

@ -130,7 +130,7 @@ export default function SinglePlayerModePage() {
signer: data.email, signer: data.email,
}); });
router.push(`/single-player-mode/${documentToken}/success`); router.push(`/singleplayer/${documentToken}/success`);
} catch { } catch {
toast({ toast({
title: 'Something went wrong', title: 'Something went wrong',

View File

@ -23,7 +23,7 @@ const SOCIAL_LINKS = [
const FOOTER_LINKS = [ const FOOTER_LINKS = [
{ href: '/pricing', text: 'Pricing' }, { href: '/pricing', text: 'Pricing' },
{ href: '/single-player-mode', text: 'Single Player Mode' }, { href: '/singleplayer', text: 'Singleplayer' },
{ href: '/blog', text: 'Blog' }, { href: '/blog', text: 'Blog' },
{ href: '/open', text: 'Open' }, { href: '/open', text: 'Open' },
{ href: 'https://shop.documenso.com', text: 'Shop', target: '_blank' }, { href: 'https://shop.documenso.com', text: 'Shop', target: '_blank' },

View File

@ -35,7 +35,7 @@ export const Header = ({ className, ...props }: HeaderProps) => {
{isSinglePlayerModeMarketingEnabled && ( {isSinglePlayerModeMarketingEnabled && (
<Link <Link
href="/single-player-mode" href="/singleplayer"
className="bg-primary dark:text-background rounded-full px-2 py-1 text-xs font-semibold sm:px-3" className="bg-primary dark:text-background rounded-full px-2 py-1 text-xs font-semibold sm:px-3"
> >
Try now! Try now!

View File

@ -134,9 +134,9 @@ export const Hero = ({ className, ...props }: HeroProps) => {
variants={HeroTitleVariants} variants={HeroTitleVariants}
initial="initial" initial="initial"
animate="animate" animate="animate"
className="border-primary bg-background hover:bg-muted mx-auto mt-8 w-60 rounded-xl border transition duration-300" className="border-primary bg-background hover:bg-muted mx-auto mt-8 w-60 rounded-xl border transition-colors duration-300"
> >
<Link href="/single-player-mode" className="block px-4 py-2 text-center"> <Link href="/singleplayer" className="block px-4 py-2 text-center">
<h2 className="text-muted-foreground text-xs font-semibold"> <h2 className="text-muted-foreground text-xs font-semibold">
Introducing Single Player Mode Introducing Single Player Mode
</h2> </h2>

View File

@ -17,8 +17,8 @@ export type MobileNavigationProps = {
export const MENU_NAVIGATION_LINKS = [ export const MENU_NAVIGATION_LINKS = [
{ {
href: '/single-player-mode', href: '/singleplayer',
text: 'Single Player Mode', text: 'Singleplayer',
}, },
{ {
href: '/blog', href: '/blog',

View File

@ -7,7 +7,6 @@ declare namespace NodeJS {
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string; NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string;
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID: string; NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID: string;
NEXT_PUBLIC_STRIPE_FREE_PLAN_ID?: string;
NEXT_PRIVATE_STRIPE_API_KEY: string; NEXT_PRIVATE_STRIPE_API_KEY: string;
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string; NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;

View File

@ -1,133 +0,0 @@
'use client';
import { useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { PriceIntervals } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
import { toHumanPrice } from '@documenso/lib/universal/stripe/to-human-price';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent, CardTitle } from '@documenso/ui/primitives/card';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { createCheckout } from './create-checkout.action';
type Interval = keyof PriceIntervals;
const INTERVALS: Interval[] = ['day', 'week', 'month', 'year'];
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const isInterval = (value: unknown): value is Interval => INTERVALS.includes(value as Interval);
const FRIENDLY_INTERVALS: Record<Interval, string> = {
day: 'Daily',
week: 'Weekly',
month: 'Monthly',
year: 'Yearly',
};
const MotionCard = motion(Card);
export type BillingPlansProps = {
prices: PriceIntervals;
};
export const BillingPlans = ({ prices }: BillingPlansProps) => {
const { toast } = useToast();
const isMounted = useIsMounted();
const [interval, setInterval] = useState<Interval>('month');
const [isFetchingCheckoutSession, setIsFetchingCheckoutSession] = useState(false);
const onSubscribeClick = async (priceId: string) => {
try {
setIsFetchingCheckoutSession(true);
const url = await createCheckout({ priceId });
if (!url) {
throw new Error('Unable to create session');
}
window.open(url);
} catch (_err) {
toast({
title: 'Something went wrong',
description: 'An error occurred while trying to create a checkout session.',
variant: 'destructive',
});
} finally {
setIsFetchingCheckoutSession(false);
}
};
return (
<div>
<Tabs value={interval} onValueChange={(value) => isInterval(value) && setInterval(value)}>
<TabsList>
{INTERVALS.map(
(interval) =>
prices[interval].length > 0 && (
<TabsTrigger key={interval} className="min-w-[150px]" value={interval}>
{FRIENDLY_INTERVALS[interval]}
</TabsTrigger>
),
)}
</TabsList>
</Tabs>
<div className="mt-8 grid gap-8 lg:grid-cols-2 2xl:grid-cols-3">
<AnimatePresence mode="wait">
{prices[interval].map((price) => (
<MotionCard
key={price.id}
initial={{ opacity: isMounted ? 0 : 1, y: isMounted ? 20 : 0 }}
animate={{ opacity: 1, y: 0, transition: { duration: 0.3 } }}
exit={{ opacity: 0, transition: { duration: 0.3 } }}
>
<CardContent className="flex h-full flex-col p-6">
<CardTitle>{price.product.name}</CardTitle>
<div className="text-muted-foreground mt-2 text-lg font-medium">
${toHumanPrice(price.unit_amount ?? 0)} {price.currency.toUpperCase()}{' '}
<span className="text-xs">per {interval}</span>
</div>
<div className="text-muted-foreground mt-1.5 text-sm">
{price.product.description}
</div>
{price.product.features && price.product.features.length > 0 && (
<div className="text-muted-foreground mt-4">
<div className="text-sm font-medium">Includes:</div>
<ul className="mt-1 divide-y text-sm">
{price.product.features.map((feature, index) => (
<li key={index} className="py-2">
{feature.name}
</li>
))}
</ul>
</div>
)}
<div className="flex-1" />
<Button
className="mt-4"
loading={isFetchingCheckoutSession}
onClick={() => void onSubscribeClick(price.id)}
>
Subscribe
</Button>
</CardContent>
</MotionCard>
))}
</AnimatePresence>
</div>
</div>
);
};

View File

@ -1,55 +0,0 @@
'use client';
import { useState } from 'react';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { createBillingPortal } from './create-billing-portal.action';
export const BillingPortalButton = () => {
const { toast } = useToast();
const [isFetchingPortalUrl, setIsFetchingPortalUrl] = useState(false);
const handleFetchPortalUrl = async () => {
if (isFetchingPortalUrl) {
return;
}
setIsFetchingPortalUrl(true);
try {
const sessionUrl = await createBillingPortal();
if (!sessionUrl) {
throw new Error('NO_SESSION');
}
window.open(sessionUrl, '_blank');
} catch (e) {
let description =
'We are unable to proceed to the billing portal at this time. Please try again, or contact support.';
if (e.message === 'CUSTOMER_NOT_FOUND') {
description =
'You do not currently have a customer record, this should not happen. Please contact support for assistance.';
}
toast({
title: 'Something went wrong',
description,
variant: 'destructive',
duration: 10000,
});
}
setIsFetchingPortalUrl(false);
};
return (
<Button onClick={async () => handleFetchPortalUrl()} loading={isFetchingPortalUrl}>
Manage Subscription
</Button>
);
};

View File

@ -1,48 +0,0 @@
'use server';
import {
getStripeCustomerByEmail,
getStripeCustomerById,
} from '@documenso/ee/server-only/stripe/get-customer';
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
export const createBillingPortal = async () => {
const { user } = await getRequiredServerComponentSession();
const existingSubscription = await getSubscriptionByUserId({ userId: user.id });
let stripeCustomer: Stripe.Customer | null = null;
// Find the Stripe customer for the current user subscription.
if (existingSubscription) {
stripeCustomer = await getStripeCustomerById(existingSubscription.customerId);
if (!stripeCustomer) {
throw new Error('Missing Stripe customer for subscription');
}
}
// Fallback to check if a Stripe customer already exists for the current user email.
if (!stripeCustomer) {
stripeCustomer = await getStripeCustomerByEmail(user.email);
}
// Create a Stripe customer if it does not exist for the current user.
if (!stripeCustomer) {
stripeCustomer = await stripe.customers.create({
name: user.name ?? undefined,
email: user.email,
metadata: {
userId: user.id,
},
});
}
return getPortalSession({
customerId: stripeCustomer.id,
returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`,
});
};

View File

@ -1,59 +0,0 @@
'use server';
import { getCheckoutSession } from '@documenso/ee/server-only/stripe/get-checkout-session';
import {
getStripeCustomerByEmail,
getStripeCustomerById,
} from '@documenso/ee/server-only/stripe/get-customer';
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
export type CreateCheckoutOptions = {
priceId: string;
};
export const createCheckout = async ({ priceId }: CreateCheckoutOptions) => {
const { user } = await getRequiredServerComponentSession();
const existingSubscription = await getSubscriptionByUserId({ userId: user.id });
let stripeCustomer: Stripe.Customer | null = null;
// Find the Stripe customer for the current user subscription.
if (existingSubscription) {
stripeCustomer = await getStripeCustomerById(existingSubscription.customerId);
if (!stripeCustomer) {
throw new Error('Missing Stripe customer for subscription');
}
return getPortalSession({
customerId: stripeCustomer.id,
returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`,
});
}
// Fallback to check if a Stripe customer already exists for the current user email.
if (!stripeCustomer) {
stripeCustomer = await getStripeCustomerByEmail(user.email);
}
// Create a Stripe customer if it does not exist for the current user.
if (!stripeCustomer) {
stripeCustomer = await stripe.customers.create({
name: user.name ?? undefined,
email: user.email,
metadata: {
userId: user.id,
},
});
}
return getCheckoutSession({
customerId: stripeCustomer.id,
priceId,
returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`,
});
};

View File

@ -1,18 +1,16 @@
import Link from 'next/link';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { match } from 'ts-pattern'; import { createCustomer } from '@documenso/ee/server-only/stripe/create-customer';
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag'; import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id'; import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
import { SubscriptionStatus } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button';
import { LocaleDate } from '~/components/formatter/locale-date'; import { LocaleDate } from '~/components/formatter/locale-date';
import { BillingPlans } from './billing-plans';
import { BillingPortalButton } from './billing-portal-button';
export default async function BillingSettingsPage() { export default async function BillingSettingsPage() {
const { user } = await getRequiredServerComponentSession(); const { user } = await getRequiredServerComponentSession();
@ -23,75 +21,57 @@ export default async function BillingSettingsPage() {
redirect('/settings/profile'); redirect('/settings/profile');
} }
const [subscription, prices] = await Promise.all([ const subscription = await getSubscriptionByUserId({ userId: user.id }).then(async (sub) => {
getSubscriptionByUserId({ userId: user.id }), if (sub) {
getPricesByInterval(), return sub;
]);
let subscriptionProduct: Stripe.Product | null = null;
if (subscription?.planId) {
const foundSubscriptionProduct = (await stripe.products.list()).data.find(
(item) => item.default_price === subscription.planId,
);
subscriptionProduct = foundSubscriptionProduct ?? null;
} }
const isMissingOrInactiveOrFreePlan = !subscription || subscription.status === 'INACTIVE'; // If we don't have a customer record, create one as well as an empty subscription.
return createCustomer({ user });
});
let billingPortalUrl = '';
if (subscription.customerId) {
billingPortalUrl = await getPortalSession({
customerId: subscription.customerId,
returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`,
});
}
return ( return (
<div> <div>
<h3 className="text-lg font-medium">Billing</h3> <h3 className="text-lg font-medium">Billing</h3>
<div className="text-muted-foreground mt-2 text-sm"> <p className="text-muted-foreground mt-2 text-sm">
{isMissingOrInactiveOrFreePlan && ( Your subscription is{' '}
<p> {subscription.status !== SubscriptionStatus.INACTIVE ? 'active' : 'inactive'}.
You are currently on the <span className="font-semibold">Free Plan</span>. {subscription?.periodEnd && (
</p> <>
)}
{!isMissingOrInactiveOrFreePlan &&
match(subscription.status)
.with('ACTIVE', () => (
<p>
{subscriptionProduct ? (
<span>
You are currently subscribed to{' '}
<span className="font-semibold">{subscriptionProduct.name}</span>
</span>
) : (
<span>You currently have an active plan</span>
)}
{subscription.periodEnd && (
<span>
{' '} {' '}
which is set to{' '} Your next payment is due on{' '}
{subscription.cancelAtPeriodEnd ? ( <span className="font-semibold">
<span> <LocaleDate date={subscription.periodEnd} />
end on{' '}
<LocaleDate className="font-semibold" date={subscription.periodEnd} />.
</span>
) : (
<span>
automatically renew on{' '}
<LocaleDate className="font-semibold" date={subscription.periodEnd} />.
</span>
)}
</span> </span>
.
</>
)} )}
</p> </p>
))
.with('PAST_DUE', () => (
<p>Your current plan is past due. Please update your payment information.</p>
))
.otherwise(() => null)}
</div>
<hr className="my-4" /> <hr className="my-4" />
{isMissingOrInactiveOrFreePlan ? <BillingPlans prices={prices} /> : <BillingPortalButton />} {billingPortalUrl && (
<Button asChild>
<Link href={billingPortalUrl}>Manage Subscription</Link>
</Button>
)}
{!billingPortalUrl && (
<p className="text-muted-foreground max-w-[60ch] text-base">
You do not currently have a customer record, this should not happen. Please contact
support for assistance.
</p>
)}
</div> </div>
); );
} }

View File

@ -3,7 +3,6 @@ import { NextApiRequest, NextApiResponse } from 'next';
import { randomBytes } from 'crypto'; import { randomBytes } from 'crypto';
import { readFileSync } from 'fs'; import { readFileSync } from 'fs';
import { buffer } from 'micro'; import { buffer } from 'micro';
import { match } from 'ts-pattern';
import { insertImageInPDF } from '@documenso/lib/server-only/pdf/insert-image-in-pdf'; import { insertImageInPDF } from '@documenso/lib/server-only/pdf/insert-image-in-pdf';
import { insertTextInPDF } from '@documenso/lib/server-only/pdf/insert-text-in-pdf'; import { insertTextInPDF } from '@documenso/lib/server-only/pdf/insert-text-in-pdf';
@ -17,7 +16,6 @@ import {
ReadStatus, ReadStatus,
SendStatus, SendStatus,
SigningStatus, SigningStatus,
SubscriptionStatus,
} from '@documenso/prisma/client'; } from '@documenso/prisma/client';
const log = (...args: unknown[]) => console.log('[stripe]', ...args); const log = (...args: unknown[]) => console.log('[stripe]', ...args);
@ -56,18 +54,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
); );
log('event-type:', event.type); log('event-type:', event.type);
if (event.type === 'customer.subscription.updated') {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const subscription = event.data.object as Stripe.Subscription;
await handleCustomerSubscriptionUpdated(subscription);
return res.status(200).json({
success: true,
message: 'Webhook received',
});
}
if (event.type === 'checkout.session.completed') { if (event.type === 'checkout.session.completed') {
// This is required since we don't want to create a guard for every event type // This is required since we don't want to create a guard for every event type
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
@ -209,29 +195,3 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
message: 'Unhandled webhook event', message: 'Unhandled webhook event',
}); });
} }
const handleCustomerSubscriptionUpdated = async (subscription: Stripe.Subscription) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const { plan } = subscription as unknown as Stripe.SubscriptionItem;
const customerId =
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
const status = match(subscription.status)
.with('active', () => SubscriptionStatus.ACTIVE)
.with('past_due', () => SubscriptionStatus.PAST_DUE)
.otherwise(() => SubscriptionStatus.INACTIVE);
await prisma.subscription.update({
where: {
customerId: customerId,
},
data: {
planId: plan.id,
status,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
periodEnd: new Date(subscription.current_period_end * 1000),
updatedAt: new Date(),
},
});
};

View File

@ -1,31 +0,0 @@
'use server';
import { stripe } from '@documenso/lib/server-only/stripe';
export type GetCheckoutSessionOptions = {
customerId: string;
priceId: string;
returnUrl: string;
};
export const getCheckoutSession = async ({
customerId,
priceId,
returnUrl,
}: GetCheckoutSessionOptions) => {
'use server';
const session = await stripe.checkout.sessions.create({
customer: customerId,
line_items: [
{
price: priceId,
quantity: 1,
},
],
success_url: `${returnUrl}?success=true`,
cancel_url: `${returnUrl}?canceled=true`,
});
return session.url;
};

View File

@ -1,19 +0,0 @@
import { stripe } from '@documenso/lib/server-only/stripe';
export const getStripeCustomerByEmail = async (email: string) => {
const foundStripeCustomers = await stripe.customers.list({
email,
});
return foundStripeCustomers.data[0] ?? null;
};
export const getStripeCustomerById = async (stripeCustomerId: string) => {
try {
const stripeCustomer = await stripe.customers.retrieve(stripeCustomerId);
return !stripeCustomer.deleted ? stripeCustomer : null;
} catch {
return null;
}
};

View File

@ -1,40 +0,0 @@
import Stripe from 'stripe';
import { stripe } from '@documenso/lib/server-only/stripe';
// Utility type to handle usage of the `expand` option.
type PriceWithProduct = Stripe.Price & { product: Stripe.Product };
export type PriceIntervals = Record<Stripe.Price.Recurring.Interval, PriceWithProduct[]>;
export const getPricesByInterval = async () => {
const { data: prices } = await stripe.prices.search({
query: `active:'true' type:'recurring'`,
expand: ['data.product'],
limit: 100,
});
const intervals: PriceIntervals = {
day: [],
week: [],
month: [],
year: [],
};
// Add each price to the correct interval.
for (const price of prices) {
if (price.recurring?.interval) {
// We use `expand` to get the product, but it's not typed as part of the Price type.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
intervals[price.recurring.interval].push(price as PriceWithProduct);
}
}
// Order all prices by unit_amount.
intervals.day.sort((a, b) => Number(a.unit_amount) - Number(b.unit_amount));
intervals.week.sort((a, b) => Number(a.unit_amount) - Number(b.unit_amount));
intervals.month.sort((a, b) => Number(a.unit_amount) - Number(b.unit_amount));
intervals.year.sort((a, b) => Number(a.unit_amount) - Number(b.unit_amount));
return intervals;
};

View File

@ -60,26 +60,17 @@ export const calculateTextScaleSize = (
*/ */
export function useElementScaleSize( export function useElementScaleSize(
container: { width: number; height: number }, container: { width: number; height: number },
child: RefObject<HTMLElement | null>, text: string,
fontSize: number, fontSize: number,
fontFamily: string, fontFamily: string,
) { ) {
const [scalingFactor, setScalingFactor] = useState(1); const [scalingFactor, setScalingFactor] = useState(1);
useEffect(() => { useEffect(() => {
if (!child.current) { const scaleSize = calculateTextScaleSize(container, text, `${fontSize}px`, fontFamily);
return;
}
const scaleSize = calculateTextScaleSize(
container,
child.current.innerText,
`${fontSize}px`,
fontFamily,
);
setScalingFactor(scaleSize); setScalingFactor(scaleSize);
}, [child, container, fontFamily, fontSize]); }, [text, container, fontFamily, fontSize]);
return scalingFactor; return scalingFactor;
} }

View File

@ -1,4 +1,3 @@
/// <reference types="./stripe.d.ts" />
import Stripe from 'stripe'; import Stripe from 'stripe';
export const stripe = new Stripe(process.env.NEXT_PRIVATE_STRIPE_API_KEY ?? '', { export const stripe = new Stripe(process.env.NEXT_PRIVATE_STRIPE_API_KEY ?? '', {

View File

@ -1,7 +0,0 @@
declare module 'stripe' {
namespace Stripe {
interface Product {
features?: Array<{ name: string }>;
}
}
}

View File

@ -1,3 +0,0 @@
export const toHumanPrice = (price: number) => {
return Number(price / 100).toFixed(2);
};

View File

@ -1,17 +0,0 @@
/*
Warnings:
- A unique constraint covering the columns `[userId]` on the table `Subscription` will be added. If there are existing duplicate values, this will fail.
- Made the column `customerId` on table `Subscription` required. This step will fail if there are existing NULL values in that column.
*/
DELETE FROM "Subscription"
WHERE "customerId" IS NULL;
-- AlterTable
ALTER TABLE "Subscription" ADD COLUMN "cancelAtPeriodEnd" BOOLEAN NOT NULL DEFAULT false,
ALTER COLUMN "customerId" SET NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX "Subscription_userId_key" ON "Subscription"("userId");

View File

@ -55,12 +55,11 @@ model Subscription {
status SubscriptionStatus @default(INACTIVE) status SubscriptionStatus @default(INACTIVE)
planId String? planId String?
priceId String? priceId String?
customerId String customerId String?
periodEnd DateTime? periodEnd DateTime?
userId Int @unique userId Int
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
cancelAtPeriodEnd Boolean @default(false)
User User @relation(fields: [userId], references: [id], onDelete: Cascade) User User @relation(fields: [userId], references: [id], onDelete: Cascade)

View File

@ -10,7 +10,6 @@ declare namespace NodeJS {
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string; NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string;
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID: string; NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID: string;
NEXT_PUBLIC_STRIPE_FREE_PLAN_ID?: string;
NEXT_PRIVATE_STRIPE_API_KEY: string; NEXT_PRIVATE_STRIPE_API_KEY: string;
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string; NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;

View File

@ -56,7 +56,7 @@ export const SigningCard3D = ({ className, name, signingCelebrationImage }: Sign
const sheenGradient = useMotionTemplate`linear-gradient( const sheenGradient = useMotionTemplate`linear-gradient(
30deg, 30deg,
transparent, transparent,
rgba(var(--sheen-color) / ${trackMouse ? sheenOpacity : 0}) ${sheenPosition}%, rgba(var(--sheen-color) / ${sheenOpacity}) ${sheenPosition}%,
transparent)`; transparent)`;
const cardRef = useRef<HTMLDivElement>(null); const cardRef = useRef<HTMLDivElement>(null);
@ -98,10 +98,12 @@ export const SigningCard3D = ({ className, name, signingCelebrationImage }: Sign
void animate(cardX, 0, { duration: 2, ease: 'backInOut' }); void animate(cardX, 0, { duration: 2, ease: 'backInOut' });
void animate(cardY, 0, { duration: 2, ease: 'backInOut' }); void animate(cardY, 0, { duration: 2, ease: 'backInOut' });
void animate(sheenOpacity, 0, { duration: 2, ease: 'backInOut' });
setTrackMouse(false); setTrackMouse(false);
}, 1000); }, 1000);
}, },
[cardX, cardY, cardCenterPosition, trackMouse], [cardX, cardY, cardCenterPosition, trackMouse, sheenOpacity],
); );
useEffect(() => { useEffect(() => {
@ -126,7 +128,6 @@ export const SigningCard3D = ({ className, name, signingCelebrationImage }: Sign
transformStyle: 'preserve-3d', transformStyle: 'preserve-3d',
rotateX, rotateX,
rotateY, rotateY,
// willChange: 'transform background-image',
}} }}
> >
<SigningCardContent className="bg-transparent" name={name} /> <SigningCardContent className="bg-transparent" name={name} />

View File

@ -70,25 +70,23 @@ export function SinglePlayerModeSignatureField({
throw new Error('Invalid field type'); throw new Error('Invalid field type');
} }
const $paragraphEl = useRef<HTMLParagraphElement>(null);
const { height, width } = useFieldPageCoords(field); const { height, width } = useFieldPageCoords(field);
const insertedBase64Signature = field.inserted && field.Signature?.signatureImageAsBase64;
const insertedTypeSignature = field.inserted && field.Signature?.typedSignature;
const scalingFactor = useElementScaleSize( const scalingFactor = useElementScaleSize(
{ {
height, height,
width, width,
}, },
$paragraphEl, insertedTypeSignature || '',
maxFontSize, maxFontSize,
fontVariableValue, fontVariableValue,
); );
const fontSize = maxFontSize * scalingFactor; const fontSize = maxFontSize * scalingFactor;
const insertedBase64Signature = field.inserted && field.Signature?.signatureImageAsBase64;
const insertedTypeSignature = field.inserted && field.Signature?.typedSignature;
return ( return (
<SinglePlayerModeFieldCardContainer field={field}> <SinglePlayerModeFieldCardContainer field={field}>
{insertedBase64Signature ? ( {insertedBase64Signature ? (
@ -99,7 +97,6 @@ export function SinglePlayerModeSignatureField({
/> />
) : insertedTypeSignature ? ( ) : insertedTypeSignature ? (
<p <p
ref={$paragraphEl}
style={{ style={{
fontSize: `clamp(${minFontSize}px, ${fontSize}px, ${maxFontSize}px)`, fontSize: `clamp(${minFontSize}px, ${fontSize}px, ${maxFontSize}px)`,
fontFamily: `var(${fontVariable})`, fontFamily: `var(${fontVariable})`,
@ -145,7 +142,7 @@ export function SinglePlayerModeCustomTextField({
height, height,
width, width,
}, },
$paragraphEl, field.customText,
maxFontSize, maxFontSize,
fontVariableValue, fontVariableValue,
); );

View File

@ -34,7 +34,6 @@
"NEXT_PUBLIC_FEATURE_BILLING_ENABLED", "NEXT_PUBLIC_FEATURE_BILLING_ENABLED",
"NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID", "NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID",
"NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID", "NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID",
"NEXT_PUBLIC_STRIPE_FREE_PLAN_ID",
"NEXT_PRIVATE_DATABASE_URL", "NEXT_PRIVATE_DATABASE_URL",
"NEXT_PRIVATE_DIRECT_DATABASE_URL", "NEXT_PRIVATE_DIRECT_DATABASE_URL",
"NEXT_PRIVATE_GOOGLE_CLIENT_ID", "NEXT_PRIVATE_GOOGLE_CLIENT_ID",