mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 16:23:06 +10:00
Compare commits
4 Commits
feat/strip
...
feat/singl
| Author | SHA1 | Date | |
|---|---|---|---|
| 2749520e10 | |||
| 8c023b092d | |||
| 8d2badf75e | |||
| 9d6e149f56 |
@ -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.
|
||||||
|
|||||||
55
.gitpod.yml
55
.gitpod.yml
@ -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
|
|
||||||
1
apps/marketing/process-env.d.ts
vendored
1
apps/marketing/process-env.d.ts
vendored
@ -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;
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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',
|
||||||
@ -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' },
|
||||||
|
|||||||
@ -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!
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
1
apps/web/process-env.d.ts
vendored
1
apps/web/process-env.d.ts
vendored
@ -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;
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -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`,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -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`,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|||||||
@ -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;
|
|
||||||
};
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -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;
|
|
||||||
};
|
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 ?? '', {
|
||||||
|
|||||||
7
packages/lib/server-only/stripe/stripe.d.ts
vendored
7
packages/lib/server-only/stripe/stripe.d.ts
vendored
@ -1,7 +0,0 @@
|
|||||||
declare module 'stripe' {
|
|
||||||
namespace Stripe {
|
|
||||||
interface Product {
|
|
||||||
features?: Array<{ name: string }>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
export const toHumanPrice = (price: number) => {
|
|
||||||
return Number(price / 100).toFixed(2);
|
|
||||||
};
|
|
||||||
@ -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");
|
|
||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
1
packages/tsconfig/process-env.d.ts
vendored
1
packages/tsconfig/process-env.d.ts
vendored
@ -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;
|
||||||
|
|||||||
@ -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} />
|
||||||
|
|||||||
@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user