mirror of
https://github.com/documenso/documenso.git
synced 2025-11-15 09:12:02 +10:00
feat: add single player mode
This commit is contained in:
@ -2,7 +2,7 @@
|
||||
const path = require('path');
|
||||
const { withContentlayer } = require('next-contentlayer');
|
||||
|
||||
const { parsed: env } = require('dotenv').config({
|
||||
require('dotenv').config({
|
||||
path: path.join(__dirname, '../../.env.local'),
|
||||
});
|
||||
|
||||
@ -10,9 +10,13 @@ const { parsed: env } = require('dotenv').config({
|
||||
const config = {
|
||||
experimental: {
|
||||
serverActions: true,
|
||||
serverActionsBodySizeLimit: '10mb',
|
||||
},
|
||||
reactStrictMode: true,
|
||||
transpilePackages: ['@documenso/lib', '@documenso/prisma', '@documenso/trpc', '@documenso/ui'],
|
||||
env: {
|
||||
NEXT_PUBLIC_PROJECT: 'marketing',
|
||||
},
|
||||
modularizeImports: {
|
||||
'lucide-react': {
|
||||
transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}',
|
||||
|
||||
@ -18,14 +18,16 @@
|
||||
"@hookform/resolvers": "^3.1.0",
|
||||
"contentlayer": "^0.3.4",
|
||||
"framer-motion": "^10.12.8",
|
||||
"lucide-react": "^0.214.0",
|
||||
"lucide-react": "^0.277.0",
|
||||
"micro": "^10.0.1",
|
||||
"next": "13.4.12",
|
||||
"next": "13.4.19",
|
||||
"next-auth": "4.22.3",
|
||||
"next-contentlayer": "^0.3.4",
|
||||
"next-plausible": "^3.10.1",
|
||||
"perfect-freehand": "^1.2.0",
|
||||
"posthog-js": "^1.77.3",
|
||||
"react": "18.2.0",
|
||||
"react-confetti": "^6.1.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-hook-form": "^7.43.9",
|
||||
"react-icons": "^4.8.0",
|
||||
|
||||
@ -66,7 +66,7 @@ export default async function OpenPage() {
|
||||
.then((res) => ZStargazersLiveResponse.parse(res));
|
||||
|
||||
return (
|
||||
<div className="mx-auto mt-12 max-w-screen-lg">
|
||||
<div className="mx-auto mt-6 max-w-screen-lg sm:mt-12">
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<h1 className="text-3xl font-bold lg:text-5xl">Open Startup</h1>
|
||||
|
||||
|
||||
@ -20,7 +20,7 @@ export type PricingPageProps = {
|
||||
|
||||
export default function PricingPage() {
|
||||
return (
|
||||
<div className="mt-12">
|
||||
<div className="mt-6 sm:mt-12">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-bold lg:text-5xl">Pricing</h1>
|
||||
|
||||
|
||||
@ -0,0 +1,29 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { getDocumentAndRecipientByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
|
||||
import SinglePlayerModeSuccess from '~/components/(marketing)/single-player-mode/single-player-mode-success';
|
||||
|
||||
export type SinglePlayerModeSuccessPageProps = {
|
||||
params: {
|
||||
token?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function SinglePlayerModeSuccessPage({
|
||||
params: { token },
|
||||
}: SinglePlayerModeSuccessPageProps) {
|
||||
if (!token) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const document = await getDocumentAndRecipientByToken({
|
||||
token,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!document || document.status !== 'COMPLETED') {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
return <SinglePlayerModeSuccess document={document} />;
|
||||
}
|
||||
237
apps/marketing/src/app/(marketing)/single-player-mode/page.tsx
Normal file
237
apps/marketing/src/app/(marketing)/single-player-mode/page.tsx
Normal file
@ -0,0 +1,237 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { Field, Prisma, Recipient } from '@documenso/prisma/client';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
||||
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
|
||||
import { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
|
||||
import { AddSignatureFormPartial } from '@documenso/ui/primitives/document-flow/add-signature';
|
||||
import { TAddSignatureFormSchema } from '@documenso/ui/primitives/document-flow/add-signature.types';
|
||||
import {
|
||||
DocumentFlowFormContainer,
|
||||
DocumentFlowFormContainerHeader,
|
||||
} from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||
import { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { createSinglePlayerDocument } from '~/components/(marketing)/single-player-mode/create-single-player-document.action';
|
||||
|
||||
type SinglePlayerModeStep = 'fields' | 'sign';
|
||||
|
||||
export default function SinglePlayerModePage() {
|
||||
const analytics = useAnalytics();
|
||||
const router = useRouter();
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const [uploadedFile, setUploadedFile] = useState<{ name: string; file: string } | null>();
|
||||
|
||||
const [step, setStep] = useState<SinglePlayerModeStep>('fields');
|
||||
const [fields, setFields] = useState<Field[]>([]);
|
||||
|
||||
const documentFlow: Record<SinglePlayerModeStep, DocumentFlowStep> = {
|
||||
fields: {
|
||||
title: 'Add document',
|
||||
description: 'Upload a document and add fields.',
|
||||
stepIndex: 1,
|
||||
onBackStep: uploadedFile
|
||||
? () => {
|
||||
setUploadedFile(null);
|
||||
setFields([]);
|
||||
}
|
||||
: undefined,
|
||||
onNextStep: () => setStep('sign'),
|
||||
},
|
||||
sign: {
|
||||
title: 'Sign',
|
||||
description: 'Enter your details.',
|
||||
stepIndex: 2,
|
||||
onBackStep: () => setStep('fields'),
|
||||
},
|
||||
};
|
||||
|
||||
const currentDocumentFlow = documentFlow[step];
|
||||
|
||||
useEffect(() => {
|
||||
analytics.startSessionRecording('marketing_session_recording_spm');
|
||||
|
||||
return () => {
|
||||
analytics.stopSessionRecording();
|
||||
};
|
||||
}, [analytics]);
|
||||
|
||||
/**
|
||||
* Insert the selected fields into the local state.
|
||||
*/
|
||||
const onFieldsSubmit = (data: TAddFieldsFormSchema) => {
|
||||
if (!uploadedFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFields(
|
||||
data.fields.map((field, i) => ({
|
||||
id: i,
|
||||
documentId: -1,
|
||||
recipientId: -1,
|
||||
type: field.type,
|
||||
page: field.pageNumber,
|
||||
positionX: new Prisma.Decimal(field.pageX),
|
||||
positionY: new Prisma.Decimal(field.pageY),
|
||||
width: new Prisma.Decimal(field.pageWidth),
|
||||
height: new Prisma.Decimal(field.pageHeight),
|
||||
customText: '',
|
||||
inserted: false,
|
||||
})),
|
||||
);
|
||||
|
||||
analytics.capture('Marketing: SPM - Fields added');
|
||||
|
||||
documentFlow.fields.onNextStep?.();
|
||||
};
|
||||
|
||||
/**
|
||||
* Create, sign and send the document.
|
||||
*/
|
||||
const onSignSubmit = async (data: TAddSignatureFormSchema) => {
|
||||
if (!uploadedFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const documentToken = await createSinglePlayerDocument({
|
||||
document: uploadedFile.file,
|
||||
documentName: uploadedFile.name,
|
||||
signer: data,
|
||||
fields: fields.map((field) => ({
|
||||
page: field.page,
|
||||
type: field.type,
|
||||
positionX: field.positionX.toNumber(),
|
||||
positionY: field.positionY.toNumber(),
|
||||
width: field.width.toNumber(),
|
||||
height: field.height.toNumber(),
|
||||
})),
|
||||
});
|
||||
|
||||
analytics.capture('Marketing: SPM - Document signed', {
|
||||
signer: data.email,
|
||||
});
|
||||
|
||||
router.push(`/single-player-mode/${documentToken}/success`);
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description: 'Please try again later.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const placeholderRecipient: Recipient = {
|
||||
id: -1,
|
||||
documentId: -1,
|
||||
email: '',
|
||||
name: '',
|
||||
token: '',
|
||||
expired: null,
|
||||
signedAt: null,
|
||||
readStatus: 'OPENED',
|
||||
signingStatus: 'NOT_SIGNED',
|
||||
sendStatus: 'NOT_SENT',
|
||||
};
|
||||
|
||||
const onFileDrop = async (file: File) => {
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const base64String = Buffer.from(arrayBuffer).toString('base64');
|
||||
|
||||
setUploadedFile({
|
||||
name: file.name,
|
||||
file: `data:application/pdf;base64,${base64String}`,
|
||||
});
|
||||
|
||||
analytics.capture('Marketing: SPM - Document uploaded');
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description: 'Please try again later.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-6 sm:mt-12">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-bold lg:text-5xl">Single Player Mode</h1>
|
||||
|
||||
<p className="mt-4 text-lg leading-normal text-[#31373D]">
|
||||
View our{' '}
|
||||
<Link
|
||||
href={'/pricing'}
|
||||
target="_blank"
|
||||
className="font-semibold transition-colors hover:text-[#31373D]/80"
|
||||
>
|
||||
community plan
|
||||
</Link>{' '}
|
||||
for exclusive features, including the ability to collaborate with multiple signers.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 grid w-full grid-cols-12 gap-8">
|
||||
<div className="col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7">
|
||||
{uploadedFile ? (
|
||||
<Card gradient>
|
||||
<CardContent className="p-2">
|
||||
<LazyPDFViewer document={uploadedFile.file} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<DocumentDropzone className="h-[80vh] max-h-[60rem]" onDrop={onFileDrop} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
||||
<DocumentFlowFormContainer onSubmit={(e) => e.preventDefault()}>
|
||||
<DocumentFlowFormContainerHeader
|
||||
title={currentDocumentFlow.title}
|
||||
description={currentDocumentFlow.description}
|
||||
/>
|
||||
|
||||
{/* Add fields to PDF page. */}
|
||||
{step === 'fields' && (
|
||||
<fieldset disabled={!uploadedFile} className="flex h-full flex-col">
|
||||
<AddFieldsFormPartial
|
||||
documentFlow={documentFlow.fields}
|
||||
hideRecipients={true}
|
||||
recipients={uploadedFile ? [placeholderRecipient] : []}
|
||||
numberOfSteps={Object.keys(documentFlow).length}
|
||||
fields={fields}
|
||||
onSubmit={onFieldsSubmit}
|
||||
/>
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
{/* Enter user details and signature. */}
|
||||
{step === 'sign' && (
|
||||
<AddSignatureFormPartial
|
||||
documentFlow={documentFlow.sign}
|
||||
numberOfSteps={Object.keys(documentFlow).length}
|
||||
fields={fields}
|
||||
onSubmit={onSignSubmit}
|
||||
requireName={Boolean(fields.find((field) => field.type === 'NAME'))}
|
||||
requireSignature={Boolean(fields.find((field) => field.type === 'SIGNATURE'))}
|
||||
/>
|
||||
)}
|
||||
</DocumentFlowFormContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,12 +1,19 @@
|
||||
import { Inter } from 'next/font/google';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
import { Caveat, Inter } from 'next/font/google';
|
||||
|
||||
import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import { getAllAnonymousFlags } from '@documenso/lib/universal/get-feature-flag';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Toaster } from '@documenso/ui/primitives/toaster';
|
||||
|
||||
import { PlausibleProvider } from '~/providers/plausible';
|
||||
import { PostHogPageview } from '~/providers/posthog';
|
||||
|
||||
import './globals.css';
|
||||
|
||||
const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' });
|
||||
const fontCaveat = Caveat({ subsets: ['latin'], variable: '--font-signature' });
|
||||
|
||||
export const metadata = {
|
||||
title: 'Documenso - The Open Source DocuSign Alternative',
|
||||
@ -32,9 +39,15 @@ export const metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const flags = await getAllAnonymousFlags();
|
||||
|
||||
return (
|
||||
<html lang="en" className={fontInter.variable} suppressHydrationWarning>
|
||||
<html
|
||||
lang="en"
|
||||
className={cn(fontInter.variable, fontCaveat.variable)}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<head>
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
@ -42,9 +55,15 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
</head>
|
||||
|
||||
<Suspense>
|
||||
<PostHogPageview />
|
||||
</Suspense>
|
||||
|
||||
<body>
|
||||
<PlausibleProvider>{children}</PlausibleProvider>
|
||||
<Toaster />
|
||||
<FeatureFlagProvider initialFlags={flags}>
|
||||
<PlausibleProvider>{children}</PlausibleProvider>
|
||||
<Toaster />
|
||||
</FeatureFlagProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
BIN
apps/marketing/src/assets/signing-celebration.png
Normal file
BIN
apps/marketing/src/assets/signing-celebration.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 MiB |
@ -10,6 +10,7 @@ import { usePlausible } from 'next-plausible';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
@ -43,9 +44,11 @@ export type ClaimPlanDialogProps = {
|
||||
|
||||
export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialogProps) => {
|
||||
const params = useSearchParams();
|
||||
const { toast } = useToast();
|
||||
const analytics = useAnalytics();
|
||||
const event = usePlausible();
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const [open, setOpen] = useState(() => params?.get('cancelled') === 'true');
|
||||
|
||||
const {
|
||||
@ -73,10 +76,12 @@ export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialog
|
||||
]);
|
||||
|
||||
event('claim-plan-pricing');
|
||||
analytics.capture('Marketing: Claim plan', { planId, email });
|
||||
|
||||
window.location.href = redirectUrl;
|
||||
} catch (error) {
|
||||
event('claim-plan-failed');
|
||||
analytics.capture('Marketing: Claim plan failure', { planId, email });
|
||||
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
|
||||
@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import Confetti from 'react-confetti';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||
import { useWindowSize } from '@documenso/lib/client-only/hooks/use-window-size';
|
||||
|
||||
export default function ConfettiScreen({
|
||||
numberOfPieces: numberOfPiecesProp = 200,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof Confetti> & { duration?: number }) {
|
||||
const isMounted = useIsMounted();
|
||||
const { width, height } = useWindowSize();
|
||||
|
||||
const [numberOfPieces, setNumberOfPieces] = useState(numberOfPiecesProp);
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.duration) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setNumberOfPieces(0);
|
||||
}, props.duration);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [props.duration]);
|
||||
|
||||
if (!isMounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<Confetti
|
||||
{...props}
|
||||
className="w-full"
|
||||
numberOfPieces={numberOfPieces}
|
||||
width={width}
|
||||
height={height}
|
||||
/>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@ -17,6 +17,7 @@ const SOCIAL_LINKS = [
|
||||
|
||||
const FOOTER_LINKS = [
|
||||
{ href: '/pricing', text: 'Pricing' },
|
||||
{ href: '/single-player-mode', text: 'Single Player Mode' },
|
||||
{ href: '/blog', text: 'Blog' },
|
||||
{ href: '/open', text: 'Open' },
|
||||
{ href: 'https://shop.documenso.com', text: 'Shop', target: '_blank' },
|
||||
|
||||
@ -5,6 +5,7 @@ import { HTMLAttributes, useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
import { HamburgerMenu } from './mobile-hamburger';
|
||||
@ -15,11 +16,26 @@ export type HeaderProps = HTMLAttributes<HTMLElement>;
|
||||
export const Header = ({ className, ...props }: HeaderProps) => {
|
||||
const [isHamburgerMenuOpen, setIsHamburgerMenuOpen] = useState(false);
|
||||
|
||||
const { getFlag } = useFeatureFlags();
|
||||
|
||||
const isSinglePlayerModeMarketingEnabled = getFlag('marketing_header_single_player_mode');
|
||||
|
||||
return (
|
||||
<header className={cn('flex items-center justify-between', className)} {...props}>
|
||||
<Link href="/" className="z-10" onClick={() => setIsHamburgerMenuOpen(false)}>
|
||||
<Image src="/logo.png" alt="Documenso Logo" width={170} height={25} />
|
||||
</Link>
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link href="/" className="z-10" onClick={() => setIsHamburgerMenuOpen(false)}>
|
||||
<Image src="/logo.png" alt="Documenso Logo" width={170} height={25} />
|
||||
</Link>
|
||||
|
||||
{isSinglePlayerModeMarketingEnabled && (
|
||||
<Link
|
||||
href="/single-player-mode"
|
||||
className="bg-primary rounded-full px-2 py-1 text-xs font-semibold sm:px-3"
|
||||
>
|
||||
Try now!
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="hidden items-center gap-x-6 md:flex">
|
||||
<Link href="/pricing" className="text-sm font-semibold text-[#8D8D8D] hover:text-[#6D6D6D]">
|
||||
|
||||
@ -6,7 +6,9 @@ import Link from 'next/link';
|
||||
import { Variants, motion } from 'framer-motion';
|
||||
import { Github } from 'lucide-react';
|
||||
import { usePlausible } from 'next-plausible';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
@ -51,6 +53,10 @@ const HeroTitleVariants: Variants = {
|
||||
export const Hero = ({ className, ...props }: HeroProps) => {
|
||||
const event = usePlausible();
|
||||
|
||||
const { getFlag } = useFeatureFlags();
|
||||
|
||||
const heroMarketingCTA = getFlag('marketing_landing_hero_cta');
|
||||
|
||||
const onSignUpClick = () => {
|
||||
const el = document.getElementById('email');
|
||||
|
||||
@ -122,23 +128,40 @@ export const Hero = ({ className, ...props }: HeroProps) => {
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
variants={HeroTitleVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
className="mt-8 flex flex-col items-center justify-center gap-x-6 gap-y-4"
|
||||
>
|
||||
<Link
|
||||
href="https://www.producthunt.com/posts/documenso?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-documenso"
|
||||
target="_blank"
|
||||
>
|
||||
<img
|
||||
src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=395047&theme=light&period=daily"
|
||||
alt="Documenso - The open source DocuSign alternative | Product Hunt"
|
||||
style={{ width: '250px', height: '54px' }}
|
||||
/>
|
||||
</Link>
|
||||
</motion.div>
|
||||
{match(heroMarketingCTA)
|
||||
.with('spm', () => (
|
||||
<motion.div
|
||||
variants={HeroTitleVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
className="border-primary mx-auto mt-8 w-fit rounded-xl border-2 bg-white transition-colors hover:bg-slate-50/60"
|
||||
>
|
||||
<Link href="/single-player-mode" className="block px-4 py-2 text-center">
|
||||
<h2 className="text-xs font-semibold text-[#727272]">Single Player Mode</h2>
|
||||
<h1 className="font-semibold leading-5 text-[#606060]">Self sign documents here</h1>
|
||||
</Link>
|
||||
</motion.div>
|
||||
))
|
||||
.with('productHunt', () => (
|
||||
<motion.div
|
||||
variants={HeroTitleVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
className="mt-8 flex flex-col items-center justify-center gap-x-6 gap-y-4"
|
||||
>
|
||||
<Link
|
||||
href="https://www.producthunt.com/posts/documenso?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-documenso"
|
||||
target="_blank"
|
||||
>
|
||||
<img
|
||||
src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=395047&theme=light&period=daily"
|
||||
alt="Documenso - The open source DocuSign alternative | Product Hunt"
|
||||
style={{ width: '250px', height: '54px' }}
|
||||
/>
|
||||
</Link>
|
||||
</motion.div>
|
||||
))
|
||||
.otherwise(() => null)}
|
||||
|
||||
<motion.div
|
||||
className="mt-12"
|
||||
|
||||
@ -14,6 +14,10 @@ export type MobileNavigationProps = {
|
||||
};
|
||||
|
||||
export const MENU_NAVIGATION_LINKS = [
|
||||
{
|
||||
href: '/single-player-mode',
|
||||
text: 'Single Player Mode',
|
||||
},
|
||||
{
|
||||
href: '/blog',
|
||||
text: 'Blog',
|
||||
|
||||
@ -0,0 +1,218 @@
|
||||
'use server';
|
||||
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { DateTime } from 'luxon';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { match } from 'ts-pattern';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { render } from '@documenso/email/render';
|
||||
import { DocumentSelfSignedEmailTemplate } from '@documenso/email/templates/document-self-signed';
|
||||
import { FROM_ADDRESS, FROM_NAME, SERVICE_USER_EMAIL } from '@documenso/lib/constants/email';
|
||||
import { insertFieldInPDF } from '@documenso/lib/server-only/pdf/insert-field-in-pdf';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import {
|
||||
DocumentDataType,
|
||||
DocumentStatus,
|
||||
FieldType,
|
||||
Prisma,
|
||||
ReadStatus,
|
||||
SendStatus,
|
||||
SigningStatus,
|
||||
} from '@documenso/prisma/client';
|
||||
|
||||
const ZCreateSinglePlayerDocumentSchema = z.object({
|
||||
document: z.string(),
|
||||
documentName: z.string(),
|
||||
signer: z.object({
|
||||
email: z.string().email().min(1),
|
||||
name: z.string(),
|
||||
signature: z.string(),
|
||||
}),
|
||||
fields: z.array(
|
||||
z.object({
|
||||
page: z.number(),
|
||||
type: z.nativeEnum(FieldType),
|
||||
positionX: z.number(),
|
||||
positionY: z.number(),
|
||||
width: z.number(),
|
||||
height: z.number(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export type TCreateSinglePlayerDocumentSchema = z.infer<typeof ZCreateSinglePlayerDocumentSchema>;
|
||||
|
||||
/**
|
||||
* Create and self signs a document.
|
||||
*
|
||||
* Returns the document token.
|
||||
*/
|
||||
export const createSinglePlayerDocument = async (
|
||||
value: TCreateSinglePlayerDocumentSchema,
|
||||
): Promise<string> => {
|
||||
const { signer, fields, document, documentName } = ZCreateSinglePlayerDocumentSchema.parse(value);
|
||||
|
||||
const doc = await PDFDocument.load(document);
|
||||
const createdAt = new Date();
|
||||
|
||||
const isBase64 = signer.signature.startsWith('data:image/png;base64,');
|
||||
const signatureImageAsBase64 = isBase64 ? signer.signature : null;
|
||||
const typedSignature = !isBase64 ? signer.signature : null;
|
||||
|
||||
// Update the document with the fields inserted.
|
||||
for (const field of fields) {
|
||||
const isSignatureField = field.type === FieldType.SIGNATURE;
|
||||
|
||||
await insertFieldInPDF(doc, {
|
||||
...mapField(field, signer),
|
||||
Signature: isSignatureField
|
||||
? {
|
||||
created: createdAt,
|
||||
signatureImageAsBase64,
|
||||
typedSignature,
|
||||
// Dummy data.
|
||||
id: -1,
|
||||
recipientId: -1,
|
||||
fieldId: -1,
|
||||
}
|
||||
: null,
|
||||
// Dummy data.
|
||||
id: -1,
|
||||
documentId: -1,
|
||||
recipientId: -1,
|
||||
});
|
||||
}
|
||||
|
||||
const pdfBytes = await doc.save();
|
||||
|
||||
const documentToken = await prisma.$transaction(async (tx) => {
|
||||
const documentToken = nanoid();
|
||||
|
||||
// Fetch service user who will be the owner of the document.
|
||||
const serviceUser = await tx.user.findFirstOrThrow({
|
||||
where: {
|
||||
email: SERVICE_USER_EMAIL,
|
||||
},
|
||||
});
|
||||
|
||||
const documentDataBytes = Buffer.from(pdfBytes).toString('base64');
|
||||
|
||||
const { id: documentDataId } = await tx.documentData.create({
|
||||
data: {
|
||||
type: DocumentDataType.BYTES_64,
|
||||
data: documentDataBytes,
|
||||
initialData: documentDataBytes,
|
||||
},
|
||||
});
|
||||
|
||||
// Create document.
|
||||
const document = await tx.document.create({
|
||||
data: {
|
||||
title: documentName,
|
||||
status: DocumentStatus.COMPLETED,
|
||||
documentDataId,
|
||||
userId: serviceUser.id,
|
||||
createdAt,
|
||||
},
|
||||
});
|
||||
|
||||
// Create recipient.
|
||||
const recipient = await tx.recipient.create({
|
||||
data: {
|
||||
documentId: document.id,
|
||||
name: signer.name,
|
||||
email: signer.email,
|
||||
token: documentToken,
|
||||
signedAt: createdAt,
|
||||
readStatus: ReadStatus.OPENED,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
sendStatus: SendStatus.SENT,
|
||||
},
|
||||
});
|
||||
|
||||
// Create fields and signatures.
|
||||
await Promise.all(
|
||||
fields.map(async (field) => {
|
||||
const insertedField = await tx.field.create({
|
||||
data: {
|
||||
documentId: document.id,
|
||||
recipientId: recipient.id,
|
||||
...mapField(field, signer),
|
||||
},
|
||||
});
|
||||
|
||||
if (field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) {
|
||||
await tx.signature.create({
|
||||
data: {
|
||||
fieldId: insertedField.id,
|
||||
signatureImageAsBase64,
|
||||
typedSignature,
|
||||
recipientId: recipient.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return documentToken;
|
||||
});
|
||||
|
||||
// Todo: Handle `downloadLink`
|
||||
const template = createElement(DocumentSelfSignedEmailTemplate, {
|
||||
downloadLink: 'https://documenso.com',
|
||||
documentName: documentName,
|
||||
assetBaseUrl: process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000',
|
||||
});
|
||||
|
||||
// Send email to signer.
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: signer.email,
|
||||
name: signer.name,
|
||||
},
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: 'Document signed',
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
|
||||
return documentToken;
|
||||
};
|
||||
|
||||
/**
|
||||
* Map the fields provided by the user to fields compatible with Prisma.
|
||||
*
|
||||
* Signature fields are handled separately.
|
||||
*
|
||||
* @param field The field passed in by the user.
|
||||
* @param signer The details of the person who is signing this document.
|
||||
* @returns A field compatible with Prisma.
|
||||
*/
|
||||
const mapField = (
|
||||
field: TCreateSinglePlayerDocumentSchema['fields'][number],
|
||||
signer: TCreateSinglePlayerDocumentSchema['signer'],
|
||||
) => {
|
||||
const customText = match(field.type)
|
||||
.with(FieldType.DATE, () => DateTime.now().toFormat('yyyy-MM-dd hh:mm a'))
|
||||
.with(FieldType.EMAIL, () => signer.email)
|
||||
.with(FieldType.NAME, () => signer.name)
|
||||
.otherwise(() => '');
|
||||
|
||||
return {
|
||||
type: field.type,
|
||||
page: field.page,
|
||||
positionX: new Prisma.Decimal(field.positionX),
|
||||
positionY: new Prisma.Decimal(field.positionY),
|
||||
width: new Prisma.Decimal(field.width),
|
||||
height: new Prisma.Decimal(field.height),
|
||||
customText,
|
||||
inserted: true,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,129 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Share } from 'lucide-react';
|
||||
|
||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||
import { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
|
||||
import DocumentDialog from '@documenso/ui/components/document/document-dialog';
|
||||
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
|
||||
import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import signingCelebration from '~/assets/signing-celebration.png';
|
||||
import ConfettiScreen from '~/components/(marketing)/confetti-screen';
|
||||
|
||||
import { DocumentStatus } from '.prisma/client';
|
||||
|
||||
interface SinglePlayerModeSuccessProps {
|
||||
className?: string;
|
||||
document: DocumentWithRecipient;
|
||||
}
|
||||
|
||||
export default function SinglePlayerModeSuccess({
|
||||
className,
|
||||
document,
|
||||
}: SinglePlayerModeSuccessProps) {
|
||||
const [showDocumentDialog, setShowDocumentDialog] = useState(false);
|
||||
const [isFetchingDocumentFile, setIsFetchingDocumentFile] = useState(false);
|
||||
const [documentFile, setDocumentFile] = useState<string | null>(null);
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleShowDocumentDialog = async () => {
|
||||
if (isFetchingDocumentFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsFetchingDocumentFile(true);
|
||||
|
||||
try {
|
||||
const data = await getFile(document.documentData);
|
||||
|
||||
setDocumentFile(Buffer.from(data).toString('base64'));
|
||||
|
||||
setShowDocumentDialog(true);
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Something went wrong.',
|
||||
description: 'We were unable to retrieve the document at this time. Please try again.',
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
}
|
||||
|
||||
setIsFetchingDocumentFile(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo({ top: 0 });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[calc(100vh-10rem)] flex-col items-center justify-center sm:min-h-[calc(100vh-13rem)]">
|
||||
<ConfettiScreen duration={3000} gravity={0.075} initialVelocityY={50} wind={0.005} />
|
||||
|
||||
<h2 className="text-center text-2xl font-semibold leading-normal md:text-3xl lg:mb-2 lg:text-4xl">
|
||||
You have signed
|
||||
</h2>
|
||||
<h3 className="text-foreground/80 mb-6 text-center text-lg font-semibold md:text-xl lg:mb-8 lg:text-3xl">
|
||||
{document.title}
|
||||
</h3>
|
||||
|
||||
<SigningCard3D
|
||||
name={document.Recipient.name || document.Recipient.email}
|
||||
signingCelebrationImage={signingCelebration}
|
||||
/>
|
||||
|
||||
<div className="mt-8 w-full">
|
||||
<div className={cn('flex flex-col items-center', className)}>
|
||||
<div className="grid w-full max-w-sm grid-cols-2 gap-4">
|
||||
{/* TODO: Hook this up */}
|
||||
<Button variant="outline" className="flex-1" disabled>
|
||||
<Share className="mr-2 h-5 w-5" />
|
||||
Share
|
||||
</Button>
|
||||
|
||||
<DocumentDownloadButton
|
||||
className="flex-1"
|
||||
fileName={document.title}
|
||||
documentData={document.documentData}
|
||||
disabled={document.status !== DocumentStatus.COMPLETED}
|
||||
/>
|
||||
|
||||
<Button
|
||||
onClick={async () => handleShowDocumentDialog()}
|
||||
loading={isFetchingDocumentFile}
|
||||
className="col-span-2"
|
||||
>
|
||||
Show document
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground/60 mt-36 text-center text-sm">
|
||||
View the{' '}
|
||||
<Link
|
||||
href="/pricing"
|
||||
target="_blank"
|
||||
className="text-documenso-700 hover:text-documenso-600 whitespace-nowrap"
|
||||
>
|
||||
community plan
|
||||
</Link>{' '}
|
||||
to access the full range of features provided by Documenso
|
||||
</p>
|
||||
|
||||
<DocumentDialog
|
||||
document={documentFile ?? ''}
|
||||
open={showDocumentDialog}
|
||||
onOpenChange={setShowDocumentDialog}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,27 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function useWindowSize() {
|
||||
const [size, setSize] = useState({
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
|
||||
const onResize = () => {
|
||||
setSize({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
onResize();
|
||||
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return size;
|
||||
}
|
||||
7
apps/marketing/src/pages/api/feature-flag/all.ts
Normal file
7
apps/marketing/src/pages/api/feature-flag/all.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import handlerFeatureFlagAll from '@documenso/lib/server-only/feature-flags/all';
|
||||
|
||||
export const config = {
|
||||
runtime: 'edge',
|
||||
};
|
||||
|
||||
export default handlerFeatureFlagAll;
|
||||
7
apps/marketing/src/pages/api/feature-flag/get.ts
Normal file
7
apps/marketing/src/pages/api/feature-flag/get.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import handlerFeatureFlagGet from '@documenso/lib/server-only/feature-flags/get';
|
||||
|
||||
export const config = {
|
||||
runtime: 'edge',
|
||||
};
|
||||
|
||||
export default handlerFeatureFlagGet;
|
||||
39
apps/marketing/src/providers/posthog.tsx
Normal file
39
apps/marketing/src/providers/posthog.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { usePathname, useSearchParams } from 'next/navigation';
|
||||
|
||||
import posthog from 'posthog-js';
|
||||
|
||||
import { extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
|
||||
|
||||
export function PostHogPageview() {
|
||||
const postHogConfig = extractPostHogConfig();
|
||||
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
if (typeof window !== 'undefined' && postHogConfig) {
|
||||
posthog.init(postHogConfig.key, {
|
||||
api_host: postHogConfig.host,
|
||||
disable_session_recording: true,
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!postHogConfig || !pathname) {
|
||||
return;
|
||||
}
|
||||
|
||||
let url = window.origin + pathname;
|
||||
if (searchParams && searchParams.toString()) {
|
||||
url = url + `?${searchParams.toString()}`;
|
||||
}
|
||||
posthog.capture('$pageview', {
|
||||
$current_url: url,
|
||||
});
|
||||
}, [pathname, searchParams, postHogConfig]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@ -2,7 +2,7 @@
|
||||
const path = require('path');
|
||||
const { version } = require('./package.json');
|
||||
|
||||
const { parsed: env } = require('dotenv').config({
|
||||
require('dotenv').config({
|
||||
path: path.join(__dirname, '../../.env.local'),
|
||||
});
|
||||
|
||||
@ -22,6 +22,7 @@ const config = {
|
||||
],
|
||||
env: {
|
||||
APP_VERSION: version,
|
||||
NEXT_PUBLIC_PROJECT: 'web',
|
||||
},
|
||||
modularizeImports: {
|
||||
'lucide-react': {
|
||||
|
||||
@ -21,10 +21,11 @@
|
||||
"@tanstack/react-query": "^4.29.5",
|
||||
"formidable": "^2.1.1",
|
||||
"framer-motion": "^10.12.8",
|
||||
"lucide-react": "^0.214.0",
|
||||
"lucide-react": "^0.277.0",
|
||||
"luxon": "^3.4.0",
|
||||
"micro": "^10.0.1",
|
||||
"next": "13.4.12",
|
||||
"nanoid": "^4.0.2",
|
||||
"next": "13.4.19",
|
||||
"next-auth": "4.22.3",
|
||||
"next-plausible": "^3.10.1",
|
||||
"next-themes": "^0.2.1",
|
||||
@ -49,4 +50,4 @@
|
||||
"@types/react": "18.2.18",
|
||||
"@types/react-dom": "18.2.7"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -55,21 +55,18 @@ export const EditDocumentForm = ({
|
||||
title: 'Add Signers',
|
||||
description: 'Add the people who will sign the document.',
|
||||
stepIndex: 1,
|
||||
onSubmit: () => onAddSignersFormSubmit,
|
||||
},
|
||||
fields: {
|
||||
title: 'Add Fields',
|
||||
description: 'Add all relevant fields for each recipient.',
|
||||
stepIndex: 2,
|
||||
onBackStep: () => setStep('signers'),
|
||||
onSubmit: () => onAddFieldsFormSubmit,
|
||||
},
|
||||
subject: {
|
||||
title: 'Add Subject',
|
||||
description: 'Add the subject and message you wish to send to signers.',
|
||||
stepIndex: 3,
|
||||
onBackStep: () => setStep('fields'),
|
||||
onSubmit: () => onAddSubjectFormSubmit,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -4,12 +4,12 @@ import { redirect } from 'next/navigation';
|
||||
import { createCustomer } from '@documenso/ee/server-only/stripe/create-customer';
|
||||
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-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 { 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 { getServerComponentFlag } from '~/helpers/get-server-component-feature-flag';
|
||||
|
||||
export default async function BillingSettingsPage() {
|
||||
const user = await getRequiredServerComponentSession();
|
||||
|
||||
@ -1,78 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { HTMLAttributes, useState } from 'react';
|
||||
|
||||
import { Download } from 'lucide-react';
|
||||
|
||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||
import { DocumentData } from '@documenso/prisma/client';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type DownloadButtonProps = HTMLAttributes<HTMLButtonElement> & {
|
||||
disabled?: boolean;
|
||||
fileName?: string;
|
||||
documentData?: DocumentData;
|
||||
};
|
||||
|
||||
export const DownloadButton = ({
|
||||
className,
|
||||
fileName,
|
||||
documentData,
|
||||
disabled,
|
||||
...props
|
||||
}: DownloadButtonProps) => {
|
||||
const { toast } = useToast();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const onDownloadClick = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
if (!documentData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bytes = await getFile(documentData);
|
||||
|
||||
const blob = new Blob([bytes], {
|
||||
type: 'application/pdf',
|
||||
});
|
||||
|
||||
const link = window.document.createElement('a');
|
||||
|
||||
link.href = window.URL.createObjectURL(blob);
|
||||
link.download = fileName || 'document.pdf';
|
||||
|
||||
link.click();
|
||||
|
||||
window.URL.revokeObjectURL(link.href);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while downloading your document.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className={className}
|
||||
disabled={disabled || !documentData}
|
||||
onClick={onDownloadClick}
|
||||
loading={isLoading}
|
||||
{...props}
|
||||
>
|
||||
<Download className="mr-2 h-5 w-5" />
|
||||
Download
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@ -8,10 +8,11 @@ import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document
|
||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||
import { DocumentStatus, FieldType } from '@documenso/prisma/client';
|
||||
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
|
||||
import { SigningCard } from '@documenso/ui/components/signing-card';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { DownloadButton } from './download-button';
|
||||
import { SigningCard } from './signing-card';
|
||||
import signingCelebration from '~/assets/signing-celebration.png';
|
||||
|
||||
export type CompletedSigningPageProps = {
|
||||
params: {
|
||||
@ -53,7 +54,7 @@ export default async function CompletedSigningPage({
|
||||
return (
|
||||
<div className="flex flex-col items-center pt-24">
|
||||
{/* Card with recipient */}
|
||||
<SigningCard name={recipientName} />
|
||||
<SigningCard name={recipientName} signingCelebrationImage={signingCelebration} />
|
||||
|
||||
<div className="mt-6">
|
||||
{match(document.status)
|
||||
@ -94,7 +95,7 @@ export default async function CompletedSigningPage({
|
||||
Share
|
||||
</Button>
|
||||
|
||||
<DownloadButton
|
||||
<DocumentDownloadButton
|
||||
className="flex-1"
|
||||
fileName={document.title}
|
||||
documentData={documentData}
|
||||
@ -103,7 +104,7 @@ export default async function CompletedSigningPage({
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground/60 mt-36 text-sm">
|
||||
Want so send slick signing links like this one?{' '}
|
||||
Want to send slick signing links like this one?{' '}
|
||||
<Link href="https://documenso.com" className="text-documenso-700 hover:text-documenso-600">
|
||||
Check out Documenso.
|
||||
</Link>
|
||||
|
||||
@ -1,67 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Image from 'next/image';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
|
||||
import signingCelebration from '~/assets/signing-celebration.png';
|
||||
|
||||
export type SigningCardProps = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export const SigningCard = ({ name }: SigningCardProps) => {
|
||||
return (
|
||||
<div className="relative w-full max-w-xs md:max-w-sm">
|
||||
<Card
|
||||
className="group mx-auto flex aspect-[21/9] w-full items-center justify-center"
|
||||
degrees={-145}
|
||||
gradient
|
||||
>
|
||||
<CardContent
|
||||
className="font-signature p-6 text-center"
|
||||
style={{
|
||||
container: 'main',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="text-muted-foreground/60 group-hover:text-primary/80 break-all font-semibold duration-300"
|
||||
style={{
|
||||
fontSize: `max(min(4rem, ${(100 / name.length / 2).toFixed(4)}cqw), 1.875rem)`,
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<motion.div
|
||||
className="absolute -inset-32 -z-10 flex items-center justify-center md:-inset-44 xl:-inset-60 2xl:-inset-80"
|
||||
initial={{
|
||||
opacity: 0,
|
||||
scale: 0.8,
|
||||
}}
|
||||
animate={{
|
||||
scale: 1,
|
||||
opacity: 0.5,
|
||||
}}
|
||||
transition={{
|
||||
delay: 0.5,
|
||||
duration: 0.5,
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={signingCelebration}
|
||||
alt="background pattern"
|
||||
className="w-full"
|
||||
style={{
|
||||
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 67%)',
|
||||
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 67%)',
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -2,12 +2,11 @@
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { useFieldPageCoords } from '@documenso/lib/client-only/hooks/use-field-page-coords';
|
||||
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
|
||||
import { useFieldPageCoords } from '~/hooks/use-field-page-coords';
|
||||
|
||||
export type SignatureFieldProps = {
|
||||
field: FieldWithSignature;
|
||||
loading?: boolean;
|
||||
|
||||
@ -2,15 +2,15 @@ import { Suspense } from 'react';
|
||||
|
||||
import { Caveat, Inter } from 'next/font/google';
|
||||
|
||||
import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import { LocaleProvider } from '@documenso/lib/client-only/providers/locale';
|
||||
import { getServerComponentAllFlags } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
||||
import { getLocale } from '@documenso/lib/server-only/headers/get-locale';
|
||||
import { TrpcProvider } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Toaster } from '@documenso/ui/primitives/toaster';
|
||||
import { TooltipProvider } from '@documenso/ui/primitives/tooltip';
|
||||
|
||||
import { getServerComponentAllFlags } from '~/helpers/get-server-component-feature-flag';
|
||||
import { FeatureFlagProvider } from '~/providers/feature-flag';
|
||||
import { ThemeProvider } from '~/providers/next-theme';
|
||||
import { PlausibleProvider } from '~/providers/plausible';
|
||||
import { PostHogPageview } from '~/providers/posthog';
|
||||
|
||||
@ -16,6 +16,7 @@ import {
|
||||
import { signOut } from 'next-auth/react';
|
||||
import { useTheme } from 'next-themes';
|
||||
|
||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
||||
import { recipientInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { User } from '@documenso/prisma/client';
|
||||
@ -30,8 +31,6 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@documenso/ui/primitives/dropdown-menu';
|
||||
|
||||
import { useFeatureFlags } from '~/providers/feature-flag';
|
||||
|
||||
export type ProfileDropdownProps = {
|
||||
user: User;
|
||||
};
|
||||
|
||||
@ -7,11 +7,10 @@ import { usePathname } from 'next/navigation';
|
||||
|
||||
import { CreditCard, Key, User } from 'lucide-react';
|
||||
|
||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { useFeatureFlags } from '~/providers/feature-flag';
|
||||
|
||||
export type DesktopNavProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||
|
||||
@ -7,11 +7,10 @@ import { usePathname } from 'next/navigation';
|
||||
|
||||
import { CreditCard, Key, User } from 'lucide-react';
|
||||
|
||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { useFeatureFlags } from '~/providers/feature-flag';
|
||||
|
||||
export type MobileNavProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
||||
|
||||
@ -1,79 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { LOCAL_FEATURE_FLAGS, isFeatureFlagEnabled } from '@documenso/lib/constants/feature-flags';
|
||||
|
||||
import { TFeatureFlagValue, ZFeatureFlagValueSchema } from '~/providers/feature-flag.types';
|
||||
|
||||
/**
|
||||
* Evaluate whether a flag is enabled for the current user.
|
||||
*
|
||||
* @param flag The flag to evaluate.
|
||||
* @param options See `GetFlagOptions`.
|
||||
* @returns Whether the flag is enabled, or the variant value of the flag.
|
||||
*/
|
||||
export const getFlag = async (
|
||||
flag: string,
|
||||
options?: GetFlagOptions,
|
||||
): Promise<TFeatureFlagValue> => {
|
||||
const requestHeaders = options?.requestHeaders ?? {};
|
||||
|
||||
if (!isFeatureFlagEnabled()) {
|
||||
return LOCAL_FEATURE_FLAGS[flag] ?? true;
|
||||
}
|
||||
|
||||
const url = new URL(`${process.env.NEXT_PUBLIC_WEBAPP_URL}/api/feature-flag/get`);
|
||||
url.searchParams.set('flag', flag);
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
...requestHeaders,
|
||||
},
|
||||
next: {
|
||||
revalidate: 60,
|
||||
},
|
||||
})
|
||||
.then(async (res) => res.json())
|
||||
.then((res) => ZFeatureFlagValueSchema.parse(res))
|
||||
.catch(() => false);
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all feature flags for the current user if possible.
|
||||
*
|
||||
* @param options See `GetFlagOptions`.
|
||||
* @returns A record of flags and their values for the user derived from the headers.
|
||||
*/
|
||||
export const getAllFlags = async (
|
||||
options?: GetFlagOptions,
|
||||
): Promise<Record<string, TFeatureFlagValue>> => {
|
||||
const requestHeaders = options?.requestHeaders ?? {};
|
||||
|
||||
if (!isFeatureFlagEnabled()) {
|
||||
return LOCAL_FEATURE_FLAGS;
|
||||
}
|
||||
|
||||
const url = new URL(`${process.env.NEXT_PUBLIC_WEBAPP_URL}/api/feature-flag/all`);
|
||||
|
||||
return fetch(url, {
|
||||
headers: {
|
||||
...requestHeaders,
|
||||
},
|
||||
next: {
|
||||
revalidate: 60,
|
||||
},
|
||||
})
|
||||
.then(async (res) => res.json())
|
||||
.then((res) => z.record(z.string(), ZFeatureFlagValueSchema).parse(res))
|
||||
.catch(() => LOCAL_FEATURE_FLAGS);
|
||||
};
|
||||
|
||||
interface GetFlagOptions {
|
||||
/**
|
||||
* The headers to attach to the request to evaluate flags.
|
||||
*
|
||||
* The authenticated user will be derived from the headers if possible.
|
||||
*/
|
||||
requestHeaders: Record<string, string>;
|
||||
}
|
||||
@ -1,16 +0,0 @@
|
||||
import { PostHog } from 'posthog-node';
|
||||
|
||||
import { extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
|
||||
|
||||
export default function PostHogServerClient() {
|
||||
const postHogConfig = extractPostHogConfig();
|
||||
|
||||
if (!postHogConfig) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new PostHog(postHogConfig.key, {
|
||||
host: postHogConfig.host,
|
||||
fetch: async (...args) => fetch(...args),
|
||||
});
|
||||
}
|
||||
@ -1,26 +0,0 @@
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
import { getAllFlags, getFlag } from './get-feature-flag';
|
||||
|
||||
/**
|
||||
* Evaluate whether a flag is enabled for the current user in a server component.
|
||||
*
|
||||
* @param flag The flag to evaluate.
|
||||
* @returns Whether the flag is enabled, or the variant value of the flag.
|
||||
*/
|
||||
export const getServerComponentFlag = async (flag: string) => {
|
||||
return await getFlag(flag, {
|
||||
requestHeaders: Object.fromEntries(headers().entries()),
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all feature flags for the current user from a server component.
|
||||
*
|
||||
* @returns A record of flags and their values for the user derived from the headers.
|
||||
*/
|
||||
export const getServerComponentAllFlags = async () => {
|
||||
return await getAllFlags({
|
||||
requestHeaders: Object.fromEntries(headers().entries()),
|
||||
});
|
||||
};
|
||||
@ -1,18 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function useDebouncedValue<T>(value: T, delay: number) {
|
||||
// State and setters for debounced value
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
@ -1,78 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { Field } from '@documenso/prisma/client';
|
||||
|
||||
export const useFieldPageCoords = (field: Field) => {
|
||||
const [coords, setCoords] = useState({
|
||||
x: 0,
|
||||
y: 0,
|
||||
height: 0,
|
||||
width: 0,
|
||||
});
|
||||
|
||||
const calculateCoords = useCallback(() => {
|
||||
const $page = document.querySelector<HTMLElement>(
|
||||
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`,
|
||||
);
|
||||
|
||||
if (!$page) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { top, left, height, width } = getBoundingClientRect($page);
|
||||
|
||||
// X and Y are percentages of the page's height and width
|
||||
const fieldX = (Number(field.positionX) / 100) * width + left;
|
||||
const fieldY = (Number(field.positionY) / 100) * height + top;
|
||||
|
||||
const fieldHeight = (Number(field.height) / 100) * height;
|
||||
const fieldWidth = (Number(field.width) / 100) * width;
|
||||
|
||||
setCoords({
|
||||
x: fieldX,
|
||||
y: fieldY,
|
||||
height: fieldHeight,
|
||||
width: fieldWidth,
|
||||
});
|
||||
}, [field.height, field.page, field.positionX, field.positionY, field.width]);
|
||||
|
||||
useEffect(() => {
|
||||
calculateCoords();
|
||||
}, [calculateCoords]);
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => {
|
||||
calculateCoords();
|
||||
};
|
||||
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
};
|
||||
}, [calculateCoords]);
|
||||
|
||||
useEffect(() => {
|
||||
const $page = document.querySelector<HTMLElement>(
|
||||
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`,
|
||||
);
|
||||
|
||||
if (!$page) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
calculateCoords();
|
||||
});
|
||||
|
||||
observer.observe($page);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [calculateCoords, field.page]);
|
||||
|
||||
return coords;
|
||||
};
|
||||
@ -1,44 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getToken } from 'next-auth/jwt';
|
||||
|
||||
import { LOCAL_FEATURE_FLAGS } from '@documenso/lib/constants/feature-flags';
|
||||
|
||||
import PostHogServerClient from '~/helpers/get-post-hog-server-client';
|
||||
|
||||
import { extractDistinctUserId, mapJwtToFlagProperties } from './get';
|
||||
import handlerFeatureFlagAll from '@documenso/lib/server-only/feature-flags/all';
|
||||
|
||||
export const config = {
|
||||
runtime: 'edge',
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all the evaluated feature flags based on the current user if possible.
|
||||
*/
|
||||
export default async function handler(req: Request) {
|
||||
const requestHeaders = Object.fromEntries(req.headers.entries());
|
||||
|
||||
const nextReq = new NextRequest(req, {
|
||||
headers: requestHeaders,
|
||||
});
|
||||
|
||||
const token = await getToken({ req: nextReq });
|
||||
|
||||
const postHog = PostHogServerClient();
|
||||
|
||||
// Return the local feature flags if PostHog is not enabled, true by default.
|
||||
// The front end should not call this API if PostHog is not enabled to reduce network requests.
|
||||
if (!postHog) {
|
||||
return NextResponse.json(LOCAL_FEATURE_FLAGS);
|
||||
}
|
||||
|
||||
const distinctId = extractDistinctUserId(token, nextReq);
|
||||
|
||||
const featureFlags = await postHog.getAllFlags(distinctId, mapJwtToFlagProperties(token));
|
||||
|
||||
const res = NextResponse.json(featureFlags);
|
||||
|
||||
res.headers.set('Cache-Control', 'public, s-maxage=60, stale-while-revalidate=300');
|
||||
|
||||
return res;
|
||||
}
|
||||
export default handlerFeatureFlagAll;
|
||||
|
||||
@ -1,122 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { JWT, getToken } from 'next-auth/jwt';
|
||||
|
||||
import { LOCAL_FEATURE_FLAGS, extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
|
||||
import PostHogServerClient from '~/helpers/get-post-hog-server-client';
|
||||
import handlerFeatureFlagGet from '@documenso/lib/server-only/feature-flags/get';
|
||||
|
||||
export const config = {
|
||||
runtime: 'edge',
|
||||
};
|
||||
|
||||
/**
|
||||
* Evaluate a single feature flag based on the current user if possible.
|
||||
*
|
||||
* @param req The request with a query parameter `flag`. Example request URL: /api/feature-flag/get?flag=flag-name
|
||||
* @returns A Response with the feature flag value.
|
||||
*/
|
||||
export default async function handler(req: Request) {
|
||||
const { searchParams } = new URL(req.url ?? '');
|
||||
const flag = searchParams.get('flag');
|
||||
|
||||
const requestHeaders = Object.fromEntries(req.headers.entries());
|
||||
|
||||
const nextReq = new NextRequest(req, {
|
||||
headers: requestHeaders,
|
||||
});
|
||||
|
||||
const token = await getToken({ req: nextReq });
|
||||
|
||||
if (!flag) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Missing flag query parameter.',
|
||||
},
|
||||
{
|
||||
status: 400,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const postHog = PostHogServerClient();
|
||||
|
||||
// Return the local feature flags if PostHog is not enabled, true by default.
|
||||
// The front end should not call this API if PostHog is disabled to reduce network requests.
|
||||
if (!postHog) {
|
||||
return NextResponse.json(LOCAL_FEATURE_FLAGS[flag] ?? true);
|
||||
}
|
||||
|
||||
const distinctId = extractDistinctUserId(token, nextReq);
|
||||
|
||||
const featureFlag = await postHog.getFeatureFlag(flag, distinctId, mapJwtToFlagProperties(token));
|
||||
|
||||
const res = NextResponse.json(featureFlag);
|
||||
|
||||
res.headers.set('Cache-Control', 'public, s-maxage=60, stale-while-revalidate=300');
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a JWT to properties which are consumed by PostHog to evaluate feature flags.
|
||||
*
|
||||
* @param jwt The JWT of the current user.
|
||||
* @returns A map of properties which are consumed by PostHog.
|
||||
*/
|
||||
export const mapJwtToFlagProperties = (
|
||||
jwt?: JWT | null,
|
||||
): {
|
||||
groups?: Record<string, string>;
|
||||
personProperties?: Record<string, string>;
|
||||
groupProperties?: Record<string, Record<string, string>>;
|
||||
} => {
|
||||
return {
|
||||
personProperties: {
|
||||
email: jwt?.email ?? '',
|
||||
},
|
||||
groupProperties: {
|
||||
// Add properties to group users into different groups, such as billing plan.
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract a distinct ID from a JWT and request.
|
||||
*
|
||||
* Will fallback to a random ID if no ID could be extracted from either the JWT or request.
|
||||
*
|
||||
* @param jwt The JWT of the current user.
|
||||
* @param request Request potentially containing a PostHog `distinct_id` cookie.
|
||||
* @returns A distinct user ID.
|
||||
*/
|
||||
export const extractDistinctUserId = (jwt: JWT | null, request: NextRequest): string => {
|
||||
const config = extractPostHogConfig();
|
||||
|
||||
const email = jwt?.email;
|
||||
const userId = jwt?.id.toString();
|
||||
|
||||
let fallbackDistinctId = nanoid();
|
||||
|
||||
if (config) {
|
||||
try {
|
||||
const postHogCookie = JSON.parse(
|
||||
request.cookies.get(`ph_${config.key}_posthog`)?.value ?? '',
|
||||
);
|
||||
|
||||
const postHogDistinctId = postHogCookie['distinct_id'];
|
||||
|
||||
if (typeof postHogDistinctId === 'string') {
|
||||
fallbackDistinctId = postHogDistinctId;
|
||||
}
|
||||
} catch {
|
||||
// Do nothing.
|
||||
}
|
||||
}
|
||||
|
||||
return email ?? userId ?? fallbackDistinctId;
|
||||
};
|
||||
export default handlerFeatureFlagGet;
|
||||
|
||||
@ -1,96 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
FEATURE_FLAG_POLL_INTERVAL,
|
||||
LOCAL_FEATURE_FLAGS,
|
||||
isFeatureFlagEnabled,
|
||||
} from '@documenso/lib/constants/feature-flags';
|
||||
|
||||
import { getAllFlags } from '~/helpers/get-feature-flag';
|
||||
|
||||
import { TFeatureFlagValue } from './feature-flag.types';
|
||||
|
||||
export type FeatureFlagContextValue = {
|
||||
getFlag: (_key: string) => TFeatureFlagValue;
|
||||
};
|
||||
|
||||
export const FeatureFlagContext = createContext<FeatureFlagContextValue | null>(null);
|
||||
|
||||
export const useFeatureFlags = () => {
|
||||
const context = useContext(FeatureFlagContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useFeatureFlags must be used within a FeatureFlagProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export function FeatureFlagProvider({
|
||||
children,
|
||||
initialFlags,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
initialFlags: Record<string, TFeatureFlagValue>;
|
||||
}) {
|
||||
const [flags, setFlags] = useState(initialFlags);
|
||||
|
||||
const getFlag = useCallback(
|
||||
(flag: string) => {
|
||||
if (!isFeatureFlagEnabled()) {
|
||||
return LOCAL_FEATURE_FLAGS[flag] ?? true;
|
||||
}
|
||||
|
||||
return flags[flag] ?? false;
|
||||
},
|
||||
[flags],
|
||||
);
|
||||
|
||||
/**
|
||||
* Refresh the flags every `FEATURE_FLAG_POLL_INTERVAL` amount of time if the window is focused.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isFeatureFlagEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (document.hasFocus()) {
|
||||
void getAllFlags().then((newFlags) => setFlags(newFlags));
|
||||
}
|
||||
}, FEATURE_FLAG_POLL_INTERVAL);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Refresh the flags when the window is focused.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isFeatureFlagEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onFocus = () => void getAllFlags().then((newFlags) => setFlags(newFlags));
|
||||
|
||||
window.addEventListener('focus', onFocus);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('focus', onFocus);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<FeatureFlagContext.Provider
|
||||
value={{
|
||||
getFlag,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</FeatureFlagContext.Provider>
|
||||
);
|
||||
}
|
||||
@ -1,10 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZFeatureFlagValueSchema = z.union([
|
||||
z.boolean(),
|
||||
z.string(),
|
||||
z.number(),
|
||||
z.undefined(),
|
||||
]);
|
||||
|
||||
export type TFeatureFlagValue = z.infer<typeof ZFeatureFlagValueSchema>;
|
||||
Reference in New Issue
Block a user