From 35e7b0a78e45c28fc7dc8d445b87619464e792ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Voron?= Date: Tue, 21 Jan 2025 15:18:15 +0100 Subject: [PATCH] clients: move checkout components to a dedicated package --- clients/apps/web/package.json | 3 +- .../products/[productId]/ClientPage.tsx | 7 +- .../(header)/products/[productId]/page.tsx | 5 +- .../(header)/benefits/ClientPage.tsx | 2 +- .../(header)/products/benefits/ClientPage.tsx | 2 +- .../checkout/[clientSecret]/ClientPage.tsx | 27 + .../src/app/checkout/[clientSecret]/page.tsx | 43 +- .../src/components/Benefit/BenefitGrant.tsx | 2 +- .../apps/web/src/components/Benefit/utils.tsx | 6 +- .../web/src/components/Checkout/Checkout.tsx | 270 ++++---- .../src/components/Checkout/CheckoutCard.tsx | 190 +---- .../src/components/Checkout/CheckoutInfo.tsx | 19 +- .../components/Checkout/CheckoutLayout.tsx | 2 +- .../Checkout/CheckoutProductInfo.tsx | 13 +- .../Checkout/Embed/CheckoutEmbedClose.tsx | 6 +- .../Checkout/Embed/CheckoutEmbedLayout.tsx | 2 +- .../Checkout/Embed/CheckoutEmbedLoaded.tsx | 6 +- .../Checkout/CheckoutPreview.tsx | 15 +- .../web/src/components/Customization/utils.ts | 70 +- .../src/components/Products/BenefitList.tsx | 8 +- .../components/Products/CreateProductPage.tsx | 2 +- .../components/Products/EditProductPage.tsx | 2 +- .../Subscriptions/ChangePlanModal.tsx | 4 +- clients/apps/web/src/utils/nav.ts | 5 +- clients/apps/web/tailwind.config.js | 1 + clients/examples/checkout-embed/index.html | 6 +- clients/packages/checkout/package.json | 31 +- .../checkout/src/components/AmountLabel.tsx | 38 + .../checkout/src/components}/CheckoutForm.tsx | 650 ++++++------------ .../src/components/CheckoutPricing.tsx | 231 +++++++ .../components/CustomFieldInput.stories.tsx | 141 ++++ .../src/components/CustomFieldInput.tsx | 282 ++++++++ .../checkout/src/components/PolarLogo.tsx | 61 ++ .../src/components/ProductPriceLabel.tsx | 26 + .../packages/checkout/src/components/index.ts | 6 + .../packages/checkout/src/hooks/debounce.ts | 25 + .../checkout/src/hooks/fulfillment.ts | 92 +++ clients/packages/checkout/src/hooks/index.ts | 1 + clients/packages/checkout/src/hooks/sse.ts | 40 ++ .../src/providers/CheckoutFormProvider.tsx | 254 +++++++ .../src/providers/CheckoutProvider.tsx | 156 +++++ .../packages/checkout/src/providers/index.ts | 2 + .../packages/checkout/src/utils/discount.ts | 42 ++ clients/packages/checkout/src/utils/form.ts | 20 + clients/packages/checkout/src/utils/money.ts | 12 + .../packages/checkout/src/utils/product.ts | 16 + clients/packages/checkout/tsconfig.json | 10 +- clients/packages/checkout/tsup.config.ts | 8 +- clients/pnpm-lock.yaml | 62 ++ 49 files changed, 2060 insertions(+), 864 deletions(-) create mode 100644 clients/apps/web/src/app/checkout/[clientSecret]/ClientPage.tsx create mode 100644 clients/packages/checkout/src/components/AmountLabel.tsx rename clients/{apps/web/src/components/Checkout => packages/checkout/src/components}/CheckoutForm.tsx (55%) create mode 100644 clients/packages/checkout/src/components/CheckoutPricing.tsx create mode 100644 clients/packages/checkout/src/components/CustomFieldInput.stories.tsx create mode 100644 clients/packages/checkout/src/components/CustomFieldInput.tsx create mode 100644 clients/packages/checkout/src/components/PolarLogo.tsx create mode 100644 clients/packages/checkout/src/components/ProductPriceLabel.tsx create mode 100644 clients/packages/checkout/src/components/index.ts create mode 100644 clients/packages/checkout/src/hooks/debounce.ts create mode 100644 clients/packages/checkout/src/hooks/fulfillment.ts create mode 100644 clients/packages/checkout/src/hooks/index.ts create mode 100644 clients/packages/checkout/src/hooks/sse.ts create mode 100644 clients/packages/checkout/src/providers/CheckoutFormProvider.tsx create mode 100644 clients/packages/checkout/src/providers/CheckoutProvider.tsx create mode 100644 clients/packages/checkout/src/providers/index.ts create mode 100644 clients/packages/checkout/src/utils/discount.ts create mode 100644 clients/packages/checkout/src/utils/form.ts create mode 100644 clients/packages/checkout/src/utils/money.ts create mode 100644 clients/packages/checkout/src/utils/product.ts diff --git a/clients/apps/web/package.json b/clients/apps/web/package.json index 7e090039bd..f25bd818fc 100644 --- a/clients/apps/web/package.json +++ b/clients/apps/web/package.json @@ -34,6 +34,7 @@ "@polar-sh/api": "workspace:*", "@polar-sh/checkout": "workspace:^", "@polar-sh/mdx": "workspace:*", + "@polar-sh/sdk": "^0.21.2", "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-toast": "^1.2.2", "@react-three/drei": "^9.117.3", @@ -154,4 +155,4 @@ "minimumChangeThreshold": 0, "showDetails": true } -} \ No newline at end of file +} diff --git a/clients/apps/web/src/app/(main)/[organization]/(header)/products/[productId]/ClientPage.tsx b/clients/apps/web/src/app/(main)/[organization]/(header)/products/[productId]/ClientPage.tsx index b8e7a2941e..54240db363 100644 --- a/clients/apps/web/src/app/(main)/[organization]/(header)/products/[productId]/ClientPage.tsx +++ b/clients/apps/web/src/app/(main)/[organization]/(header)/products/[productId]/ClientPage.tsx @@ -1,11 +1,10 @@ 'use client' -import { Checkout } from '@/components/Checkout/Checkout' +import Checkout from '@/components/Checkout/Checkout' import { CheckoutPublic, Organization } from '@polar-sh/api' import { useTheme } from 'next-themes' export default function ClientPage({ - organization, checkout, }: { organization: Organization @@ -14,8 +13,8 @@ export default function ClientPage({ const { resolvedTheme } = useTheme() return ( ) diff --git a/clients/apps/web/src/app/(main)/[organization]/(header)/products/[productId]/page.tsx b/clients/apps/web/src/app/(main)/[organization]/(header)/products/[productId]/page.tsx index 970b45512b..cf79bd7493 100644 --- a/clients/apps/web/src/app/(main)/[organization]/(header)/products/[productId]/page.tsx +++ b/clients/apps/web/src/app/(main)/[organization]/(header)/products/[productId]/page.tsx @@ -1,4 +1,3 @@ -import CheckoutProductInfo from '@/components/Checkout/CheckoutProductInfo' import { getServerSideAPI } from '@/utils/api/serverside' import { isCrawler } from '@/utils/crawlers' import { getStorefrontOrNotFound } from '@/utils/storefront' @@ -75,11 +74,11 @@ export default async function Page({ notFound() } - /* Avoid creating a checkout for crawlers, just render a simple product info page */ + /* Avoid creating a checkout for crawlers */ const headersList = headers() const userAgent = headersList.get('user-agent') if (userAgent && isCrawler(userAgent)) { - return + return <> } let checkout: CheckoutPublic diff --git a/clients/apps/web/src/app/(main)/dashboard/[organization]/(header)/benefits/ClientPage.tsx b/clients/apps/web/src/app/(main)/dashboard/[organization]/(header)/benefits/ClientPage.tsx index 3ca3774c65..9f5049be31 100644 --- a/clients/apps/web/src/app/(main)/dashboard/[organization]/(header)/benefits/ClientPage.tsx +++ b/clients/apps/web/src/app/(main)/dashboard/[organization]/(header)/benefits/ClientPage.tsx @@ -217,7 +217,7 @@ const BenefitRow = ({ benefit, organization }: BenefitRowProps) => { className="flex flex-row items-start gap-x-3 align-middle" > - {resolveBenefitIcon(benefit, 'inherit', 'h-3 w-3')} + {resolveBenefitIcon(benefit.type, 'inherit', 'h-3 w-3')} {benefit.description} diff --git a/clients/apps/web/src/app/(main)/dashboard/[organization]/(header)/products/benefits/ClientPage.tsx b/clients/apps/web/src/app/(main)/dashboard/[organization]/(header)/products/benefits/ClientPage.tsx index dd5cfcce34..4811c19d37 100644 --- a/clients/apps/web/src/app/(main)/dashboard/[organization]/(header)/products/benefits/ClientPage.tsx +++ b/clients/apps/web/src/app/(main)/dashboard/[organization]/(header)/products/benefits/ClientPage.tsx @@ -193,7 +193,7 @@ const BenefitRow = ({ benefit, organization }: BenefitRowProps) => { className="flex flex-row items-start gap-x-3 align-middle" > - {resolveBenefitIcon(benefit, 'inherit', 'h-3 w-3')} + {resolveBenefitIcon(benefit.type, 'inherit', 'h-3 w-3')} {benefit.description} diff --git a/clients/apps/web/src/app/checkout/[clientSecret]/ClientPage.tsx b/clients/apps/web/src/app/checkout/[clientSecret]/ClientPage.tsx new file mode 100644 index 0000000000..caeee05d56 --- /dev/null +++ b/clients/apps/web/src/app/checkout/[clientSecret]/ClientPage.tsx @@ -0,0 +1,27 @@ +'use client' + +import Checkout from '@/components/Checkout/Checkout' +import type { CheckoutPublic } from '@polar-sh/sdk/models/components/checkoutpublic' + +const ClientPage = ({ + checkout, + embed, + theme, + prefilledParameters, +}: { + checkout: CheckoutPublic + embed: boolean + prefilledParameters: Record + theme?: 'light' | 'dark' +}) => { + return ( + + ) +} + +export default ClientPage diff --git a/clients/apps/web/src/app/checkout/[clientSecret]/page.tsx b/clients/apps/web/src/app/checkout/[clientSecret]/page.tsx index ceea7823f0..07ea65b0a4 100644 --- a/clients/apps/web/src/app/checkout/[clientSecret]/page.tsx +++ b/clients/apps/web/src/app/checkout/[clientSecret]/page.tsx @@ -1,9 +1,9 @@ -import { Checkout } from '@/components/Checkout/Checkout' -import CheckoutLayout from '@/components/Checkout/CheckoutLayout' -import { getServerSideAPI } from '@/utils/api/serverside' -import { getCheckoutByClientSecret } from '@/utils/checkout' -import { CheckoutStatus } from '@polar-sh/api' -import { redirect } from 'next/navigation' +import { getServerURL } from '@/utils/api' +import { PolarCore } from '@polar-sh/sdk/core' +import { checkoutsCustomClientGet } from '@polar-sh/sdk/funcs/checkoutsCustomClientGet' +import { ResourceNotFound } from '@polar-sh/sdk/models/errors' +import { notFound } from 'next/navigation' +import ClientPage from './ClientPage' export default async function Page({ params: { clientSecret }, @@ -16,23 +16,28 @@ export default async function Page({ > }) { const embed = _embed === 'true' - const api = getServerSideAPI() + const client = new PolarCore({ serverURL: getServerURL() }) - const checkout = await getCheckoutByClientSecret(api, clientSecret) + const { + ok, + value: checkout, + error, + } = await checkoutsCustomClientGet(client, { clientSecret }) - if (checkout.status !== CheckoutStatus.OPEN) { - redirect(checkout.success_url) + if (!ok) { + if (error instanceof ResourceNotFound) { + notFound() + } else { + throw error + } } return ( - - - + ) } diff --git a/clients/apps/web/src/components/Benefit/BenefitGrant.tsx b/clients/apps/web/src/components/Benefit/BenefitGrant.tsx index 9dd24782bf..8e73083bf4 100644 --- a/clients/apps/web/src/components/Benefit/BenefitGrant.tsx +++ b/clients/apps/web/src/components/Benefit/BenefitGrant.tsx @@ -232,7 +232,7 @@ export const BenefitGrant = ({ api, benefitGrant }: BenefitGrantProps) => {
- {resolveBenefitIcon(benefit, 'small')} + {resolveBenefitIcon(benefit.type, 'small')}
diff --git a/clients/apps/web/src/components/Benefit/utils.tsx b/clients/apps/web/src/components/Benefit/utils.tsx index 04739fe301..278ba94dc3 100644 --- a/clients/apps/web/src/components/Benefit/utils.tsx +++ b/clients/apps/web/src/components/Benefit/utils.tsx @@ -5,7 +5,7 @@ import { KeyOutlined, WebOutlined, } from '@mui/icons-material' -import { BenefitBase, BenefitType } from '@polar-sh/api' +import { BenefitType } from '@polar-sh/api' import { twMerge } from 'tailwind-merge' export type CreatableBenefit = BenefitType @@ -33,11 +33,11 @@ export const resolveBenefitCategoryIcon = ( } export const resolveBenefitIcon = ( - benefit: BenefitBase, + type: BenefitType, fontSize: 'small' | 'inherit' | 'large' | 'medium' = 'small', className?: string, ) => { - return resolveBenefitCategoryIcon(benefit?.type, fontSize, className) + return resolveBenefitCategoryIcon(type, fontSize, className) } export const resolveBenefitTypeDisplayName = (type: BenefitType) => { diff --git a/clients/apps/web/src/components/Checkout/Checkout.tsx b/clients/apps/web/src/components/Checkout/Checkout.tsx index 07b49fb46d..66f9b59eb7 100644 --- a/clients/apps/web/src/components/Checkout/Checkout.tsx +++ b/clients/apps/web/src/components/Checkout/Checkout.tsx @@ -1,153 +1,193 @@ 'use client' -import { api } from '@/utils/api' -import { setValidationErrors } from '@/utils/api/errors' -import { - CheckoutConfirmStripe, - CheckoutPublic, - CheckoutPublicConfirmed, - CheckoutUpdatePublic, - Organization, - ResponseError, - ValidationError, -} from '@polar-sh/api' +import { CheckoutForm, CheckoutPricing } from '@polar-sh/checkout/components' +import { useCheckout, useCheckoutForm } from '@polar-sh/checkout/providers' import { useTheme } from 'next-themes' import ShadowBox, { ShadowBoxOnMd, } from 'polarkit/components/ui/atoms/shadowbox' +import { CheckoutCard } from './CheckoutCard' +import CheckoutProductInfo from './CheckoutProductInfo' + +import { getServerURL } from '@/utils/api' +import { CONFIG } from '@/utils/config' +import { PolarEmbedCheckout } from '@polar-sh/checkout/embed' +import { useCheckoutFulfillmentListener } from '@polar-sh/checkout/hooks' +import { + CheckoutFormProvider, + CheckoutProvider, +} from '@polar-sh/checkout/providers' +import type { CheckoutConfirmStripe } from '@polar-sh/sdk/models/components/checkoutconfirmstripe' +import type { CheckoutPublicConfirmed } from '@polar-sh/sdk/models/components/checkoutpublicconfirmed' +import type { Stripe, StripeElements } from '@stripe/stripe-js' +import { useRouter } from 'next/navigation' import { useCallback, useState } from 'react' -import { FormProvider, useForm } from 'react-hook-form' -import { CheckoutForm } from './CheckoutForm' -import { CheckoutInfo } from './CheckoutInfo' +import CheckoutLayout from './CheckoutLayout' -export interface CheckoutProps { - organization: Organization - checkout: CheckoutPublic +export interface CheckoutInnerProps { embed?: boolean theme?: 'light' | 'dark' - prefilledParameters?: Record } -const unflatten = (entries: Record): Record => - Object.entries(entries).reduce( - (acc, [key, value]) => - key.split('.').reduceRight( - (current, part, index, parts) => ({ - ...current, - [part]: - index === parts.length - 1 - ? value - : { ...current[part], ...current }, - }), - acc, - ), - {} as Record, +const CheckoutInner = ({ embed, theme: wantedTheme }: CheckoutInnerProps) => { + const { resolvedTheme } = useTheme() + const { + checkout, + form, + update, + confirm: _confirm, + loading: confirmLoading, + loadingLabel, + } = useCheckoutForm() + const { client } = useCheckout() + const router = useRouter() + const [listenFulfillment, fullfillmentLabel] = useCheckoutFulfillmentListener( + client, + checkout, ) + const [fullfillmentLoading, setFullfillmentLoading] = useState(false) -export const Checkout = ({ - checkout: _checkout, - organization, - embed, - theme, - prefilledParameters, -}: CheckoutProps) => { - const [checkout, setCheckout] = useState(_checkout) + const loading = fullfillmentLoading || confirmLoading + const label = fullfillmentLabel || loadingLabel + const theme = wantedTheme || (resolvedTheme as 'light' | 'dark' | undefined) - const form = useForm({ - defaultValues: { - ...checkout, - ...(prefilledParameters ? unflatten(prefilledParameters) : {}), - }, - shouldUnregister: true, - }) - const { setError } = form - const { resolvedTheme } = useTheme() - - const onCheckoutUpdate = useCallback( - async (body: CheckoutUpdatePublic): Promise => { + const confirm = useCallback( + async ( + data: CheckoutConfirmStripe, + stripe: Stripe | null, + elements: StripeElements | null, + ) => { + let confirmedCheckout: CheckoutPublicConfirmed try { - const updatedCheckout = await api.checkouts.clientUpdate({ - clientSecret: checkout.client_secret, - body, - }) - setCheckout(updatedCheckout) - return updatedCheckout - } catch (e) { - if (e instanceof ResponseError) { - const body = await e.response.json() - if (e.response.status === 422) { - const validationErrors = body['detail'] as ValidationError[] - setValidationErrors(validationErrors, setError) - } else { - setError('root', { message: e.message }) - } - } - throw e + confirmedCheckout = await _confirm(data, stripe, elements) + } catch (error) { + throw error } - }, - [checkout, setError], - ) - const onCheckoutConfirm = useCallback( - async (body: CheckoutConfirmStripe): Promise => { - try { - const updatedCheckout = await api.checkouts.clientConfirm({ - clientSecret: checkout.client_secret, - body, - }) - setCheckout(updatedCheckout) - return updatedCheckout - } catch (e) { - if (e instanceof ResponseError) { - const body = await e.response.json() - if (e.response.status === 422) { - const validationErrors = body['detail'] as ValidationError[] - setValidationErrors(validationErrors, setError) - } else { - setError('root', { message: e.message }) + const parsedURL = new URL(confirmedCheckout.successUrl) + const isInternalURL = confirmedCheckout.successUrl.startsWith( + CONFIG.FRONTEND_BASE_URL, + ) + + if (isInternalURL) { + if (embed) { + parsedURL.searchParams.set('embed', 'true') + if (theme) { + parsedURL.searchParams.set('theme', theme) } } - throw e } + + parsedURL.searchParams.set( + 'customer_session_token', + confirmedCheckout.customerSessionToken, + ) + + // For external success URL, make sure the checkout is processed before redirecting + // It ensures the user will have an up-to-date status when they are redirected, + // especially if the external URL doesn't implement proper webhook handling + if (!isInternalURL) { + setFullfillmentLoading(true) + await listenFulfillment() + setFullfillmentLoading(false) + } + + if (checkout.embedOrigin) { + PolarEmbedCheckout.postMessage( + { + event: 'success', + successURL: parsedURL.toString(), + redirect: !isInternalURL, + }, + checkout.embedOrigin, + ) + } + + if (isInternalURL || !embed) { + router.push(parsedURL.toString()) + } + + return confirmedCheckout }, - [checkout, setError], + [embed, listenFulfillment, router, theme], ) if (embed) { return ( - - - + + + + ) } return ( - - + + +
+
+ -
- -
- +
) } + +const CheckoutLayoutInner = ({ + embed, + theme, + children, +}: React.PropsWithChildren<{ embed?: boolean; theme?: 'light' | 'dark' }>) => { + const { checkout } = useCheckout() + return ( + + {children} + + ) +} + +const Checkout = ({ + clientSecret, + embed, + theme, + prefilledParameters, +}: { + clientSecret: string + prefilledParameters: Record + embed?: boolean + theme?: 'light' | 'dark' +}) => { + return ( + + + + + + + + ) +} + +export default Checkout diff --git a/clients/apps/web/src/components/Checkout/CheckoutCard.tsx b/clients/apps/web/src/components/Checkout/CheckoutCard.tsx index b3852b0817..3942fb4474 100644 --- a/clients/apps/web/src/components/Checkout/CheckoutCard.tsx +++ b/clients/apps/web/src/components/Checkout/CheckoutCard.tsx @@ -1,198 +1,30 @@ 'use client' import { BenefitList } from '@/components/Products/BenefitList' -import ProductPriceLabel from '@/components/Products/ProductPriceLabel' -import SubscriptionTierRecurringIntervalSwitch from '@/components/Subscriptions/SubscriptionTierRecurringIntervalSwitch' -import useDebouncedCallback from '@/hooks/utils' -import { hasIntervals } from '@/utils/product' -import { AttachMoneyOutlined } from '@mui/icons-material' -import { - CheckoutPublic, - CheckoutUpdatePublic, - SubscriptionRecurringInterval, -} from '@polar-sh/api' -import { formatCurrencyAndAmount } from '@polarkit/lib/money' -import MoneyInput from 'polarkit/components/ui/atoms/moneyinput' +import { CheckoutPricing } from '@polar-sh/checkout/components' +import type { CheckoutPublic } from '@polar-sh/sdk/models/components/checkoutpublic' +import type { CheckoutUpdatePublic } from '@polar-sh/sdk/models/components/checkoutupdatepublic' import ShadowBox from 'polarkit/components/ui/atoms/shadowbox' -import { - Form, - FormField, - FormItem, - FormLabel, - FormMessage, -} from 'polarkit/components/ui/form' -import { useCallback, useMemo, useState } from 'react' -import { SubmitHandler, useForm } from 'react-hook-form' export interface CheckoutCardProps { checkout: CheckoutPublic - onCheckoutUpdate?: (body: CheckoutUpdatePublic) => Promise + update?: (body: CheckoutUpdatePublic) => Promise disabled?: boolean } export const CheckoutCard = ({ checkout, - onCheckoutUpdate, + update, disabled, }: CheckoutCardProps) => { - const { product, product_price: productPrice } = checkout - const [, , hasBothIntervals] = useMemo(() => hasIntervals(product), [product]) - const [recurringInterval, setRecurringInterval] = - useState( - productPrice.type === 'recurring' - ? productPrice.recurring_interval - : SubscriptionRecurringInterval.MONTH, - ) - - const onRecurringIntervalChange = useCallback( - (recurringInterval: SubscriptionRecurringInterval) => { - setRecurringInterval(recurringInterval) - for (const price of product.prices) { - if ( - price.type === 'recurring' && - price.recurring_interval === recurringInterval - ) { - if (price.id === productPrice.id) { - return - } - onCheckoutUpdate?.({ product_price_id: price.id }) - return - } - } - }, - [product, productPrice, onCheckoutUpdate], - ) - - const form = useForm<{ amount: number }>({ - defaultValues: { amount: checkout.amount || 0 }, - }) - const { control, handleSubmit, setValue, trigger } = form - const onAmountChangeSubmit: SubmitHandler<{ amount: number }> = useCallback( - async ({ amount }) => { - onCheckoutUpdate?.({ amount }) - }, - [onCheckoutUpdate], - ) - - const submitAmountUpdate = () => { - handleSubmit(onAmountChangeSubmit)() - } - - const debouncedAmountUpdate = useDebouncedCallback( - async () => { - const isValid = await trigger('amount') - if (isValid) { - submitAmountUpdate() - } - }, - 600, - [onAmountChangeSubmit, submitAmountUpdate, trigger], - ) - - const onAmountChange = (amount: number) => { - setValue('amount', amount) - debouncedAmountUpdate() - } - - let customAmountMinLabel = null - let customAmountMaxLabel = null - if (productPrice.amount_type === 'custom') { - customAmountMinLabel = formatCurrencyAndAmount( - productPrice.minimum_amount || 50, - checkout.currency || 'usd', - ) - - if (productPrice.maximum_amount) { - customAmountMaxLabel = formatCurrencyAndAmount( - productPrice.maximum_amount, - checkout.currency || 'usd', - ) - } - } - + const { product } = checkout return ( -

{product.name}

- {!disabled && hasBothIntervals && ( - - )} -
-

- {productPrice.amount_type !== 'custom' && ( - - )} - {productPrice.amount_type === 'custom' && ( - <> - {disabled ? ( - formatCurrencyAndAmount( - checkout.amount || 0, - checkout.currency || 'usd', - ) - ) : ( -
- - - Name a fair price{' '} - - ({customAmountMinLabel} minimum) - - -
- { - return ( - - - } - /> - - - ) - }} - /> -
-
- - )} - - )} -

-

- Before VAT and taxes -

-
+ {product.benefits.length > 0 ? (
diff --git a/clients/apps/web/src/components/Checkout/CheckoutInfo.tsx b/clients/apps/web/src/components/Checkout/CheckoutInfo.tsx index 3780cb632e..06e42eb391 100644 --- a/clients/apps/web/src/components/Checkout/CheckoutInfo.tsx +++ b/clients/apps/web/src/components/Checkout/CheckoutInfo.tsx @@ -1,30 +1,19 @@ -import { - CheckoutPublic, - CheckoutUpdatePublic, - Organization, -} from '@polar-sh/api' +import type { CheckoutPublic } from '@polar-sh/sdk/models/components/checkoutpublic' import { twMerge } from 'tailwind-merge' import { CheckoutCard } from './CheckoutCard' import CheckoutProductInfo from './CheckoutProductInfo' export interface CheckoutInfoProps { - organization: Organization checkout: CheckoutPublic - onCheckoutUpdate?: (body: CheckoutUpdatePublic) => Promise className?: string } -export const CheckoutInfo = ({ - organization, - checkout, - onCheckoutUpdate, - className, -}: CheckoutInfoProps) => { - const { product } = checkout +export const CheckoutInfo = ({ checkout, className }: CheckoutInfoProps) => { + const { product, organization } = checkout return (
- +
) } diff --git a/clients/apps/web/src/components/Checkout/CheckoutLayout.tsx b/clients/apps/web/src/components/Checkout/CheckoutLayout.tsx index 67ce707248..21ad4b6674 100644 --- a/clients/apps/web/src/components/Checkout/CheckoutLayout.tsx +++ b/clients/apps/web/src/components/Checkout/CheckoutLayout.tsx @@ -1,5 +1,5 @@ import { PolarThemeProvider } from '@/app/providers' -import { CheckoutPublic } from '@polar-sh/api' +import type { CheckoutPublic } from '@polar-sh/sdk/models/components/checkoutpublic' import PublicLayout from '../Layout/PublicLayout' import CheckoutEmbedLayout from './Embed/CheckoutEmbedLayout' diff --git a/clients/apps/web/src/components/Checkout/CheckoutProductInfo.tsx b/clients/apps/web/src/components/Checkout/CheckoutProductInfo.tsx index 97009687eb..e0361bd8c3 100644 --- a/clients/apps/web/src/components/Checkout/CheckoutProductInfo.tsx +++ b/clients/apps/web/src/components/Checkout/CheckoutProductInfo.tsx @@ -1,7 +1,8 @@ import { Slideshow } from '@/components/Products/Slideshow' import { markdownOptions } from '@/utils/markdown' import { organizationPageLink } from '@/utils/nav' -import { CheckoutProduct, Organization } from '@polar-sh/api' +import type { CheckoutProduct } from '@polar-sh/sdk/models/components/checkoutproduct' +import type { Organization } from '@polar-sh/sdk/models/components/organization' import Markdown from 'markdown-to-jsx' import Link from 'next/link' import Avatar from 'polarkit/components/ui/atoms/avatar' @@ -18,27 +19,25 @@ const CheckoutProductInfo = ({ return ( <>
- {organization.profile_settings?.enabled ? ( + {organization.profileSettings?.enabled ? ( ) : ( )}

{product.name}

{product.medias.length > 0 && ( - public_url)} - /> + publicUrl)} /> )} {product.description ? (
diff --git a/clients/apps/web/src/components/Checkout/Embed/CheckoutEmbedClose.tsx b/clients/apps/web/src/components/Checkout/Embed/CheckoutEmbedClose.tsx index 5b1240d3a2..04456914e1 100644 --- a/clients/apps/web/src/components/Checkout/Embed/CheckoutEmbedClose.tsx +++ b/clients/apps/web/src/components/Checkout/Embed/CheckoutEmbedClose.tsx @@ -1,8 +1,8 @@ 'use client' import { XMarkIcon } from '@heroicons/react/24/outline' -import { CheckoutPublic } from '@polar-sh/api' import { PolarEmbedCheckout } from '@polar-sh/checkout/embed' +import type { CheckoutPublic } from '@polar-sh/sdk/models/components/checkoutpublic' import { useCallback, useEffect } from 'react' interface CheckoutEmbedCloseProps { @@ -13,10 +13,10 @@ const CheckoutEmbedClose: React.FC< React.PropsWithChildren > = ({ checkout }) => { const onClose = useCallback(() => { - if (!checkout.embed_origin) { + if (!checkout.embedOrigin) { return } - PolarEmbedCheckout.postMessage({ event: 'close' }, checkout.embed_origin) + PolarEmbedCheckout.postMessage({ event: 'close' }, checkout.embedOrigin) }, [checkout]) useEffect(() => { diff --git a/clients/apps/web/src/components/Checkout/Embed/CheckoutEmbedLayout.tsx b/clients/apps/web/src/components/Checkout/Embed/CheckoutEmbedLayout.tsx index ddbf3d5e9e..0e3c505576 100644 --- a/clients/apps/web/src/components/Checkout/Embed/CheckoutEmbedLayout.tsx +++ b/clients/apps/web/src/components/Checkout/Embed/CheckoutEmbedLayout.tsx @@ -1,4 +1,4 @@ -import { CheckoutPublic } from '@polar-sh/api' +import type { CheckoutPublic } from '@polar-sh/sdk/models/components/checkoutpublic' import CheckoutEmbedClose from './CheckoutEmbedClose' import CheckoutEmbedLoaded from './CheckoutEmbedLoaded' diff --git a/clients/apps/web/src/components/Checkout/Embed/CheckoutEmbedLoaded.tsx b/clients/apps/web/src/components/Checkout/Embed/CheckoutEmbedLoaded.tsx index 8b05628f58..68acd7ede1 100644 --- a/clients/apps/web/src/components/Checkout/Embed/CheckoutEmbedLoaded.tsx +++ b/clients/apps/web/src/components/Checkout/Embed/CheckoutEmbedLoaded.tsx @@ -1,7 +1,7 @@ 'use client' -import { CheckoutPublic } from '@polar-sh/api' import { PolarEmbedCheckout } from '@polar-sh/checkout/embed' +import type { CheckoutPublic } from '@polar-sh/sdk/models/components/checkoutpublic' import { useEffect } from 'react' interface CheckoutEmbedLoadedProps { @@ -12,10 +12,10 @@ const CheckoutEmbedLoaded: React.FC< React.PropsWithChildren > = ({ checkout }) => { useEffect(() => { - if (!checkout.embed_origin) { + if (!checkout.embedOrigin) { return } - PolarEmbedCheckout.postMessage({ event: 'loaded' }, checkout.embed_origin) + PolarEmbedCheckout.postMessage({ event: 'loaded' }, checkout.embedOrigin) }, []) return null diff --git a/clients/apps/web/src/components/Customization/Checkout/CheckoutPreview.tsx b/clients/apps/web/src/components/Customization/Checkout/CheckoutPreview.tsx index 8cd8e034d0..146d8056db 100644 --- a/clients/apps/web/src/components/Customization/Checkout/CheckoutPreview.tsx +++ b/clients/apps/web/src/components/Customization/Checkout/CheckoutPreview.tsx @@ -1,25 +1,21 @@ 'use client' -import { Checkout } from '@/components/Checkout/Checkout' import { BrandingMenu } from '@/components/Layout/Public/BrandingMenu' import TopbarRight from '@/components/Layout/Public/TopbarRight' import { StorefrontHeader } from '@/components/Profile/StorefrontHeader' import { useAuth } from '@/hooks' import { MaintainerOrganizationContext } from '@/providers/maintainerOrganization' import { Product } from '@polar-sh/api' -import { useTheme } from 'next-themes' import ShadowBox from 'polarkit/components/ui/atoms/shadowbox' import { useContext } from 'react' -import { CHECKOUT_PREVIEW, createCheckoutPreview } from '../utils' export interface CheckoutPreviewProps { product?: Product } -export const CheckoutPreview = ({ product }: CheckoutPreviewProps) => { +export const CheckoutPreview = ({}: CheckoutPreviewProps) => { const { organization: org } = useContext(MaintainerOrganizationContext) const { currentUser } = useAuth() - const { resolvedTheme } = useTheme() return ( @@ -37,15 +33,6 @@ export const CheckoutPreview = ({ product }: CheckoutPreviewProps) => { )} -
) diff --git a/clients/apps/web/src/components/Customization/utils.ts b/clients/apps/web/src/components/Customization/utils.ts index f22eefa31a..5c6cec4afa 100644 --- a/clients/apps/web/src/components/Customization/utils.ts +++ b/clients/apps/web/src/components/Customization/utils.ts @@ -1,12 +1,16 @@ import { CheckoutProduct, - CheckoutPublic, CheckoutStatus, CustomerOrder, CustomerSubscription, + Organization, ProductPrice, ProductStorefront, } from '@polar-sh/api' +import { + CheckoutPublic$inboundSchema, + type CheckoutPublic, +} from '@polar-sh/sdk/models/components/checkoutpublic' const PRODUCT_DESCRIPTION = `# Et Tritonia pectora partus praebentem ## Clipeo mentiris arquato obliqua lacerta @@ -23,12 +27,12 @@ export const PRODUCT_PREVIEW: ProductStorefront = { id: '123', is_recurring: false, is_archived: false, - modified_at: new Date().toDateString(), + modified_at: new Date().toISOString(), organization_id: '123', medias: [ { id: '123', - created_at: new Date().toDateString(), + created_at: new Date().toISOString(), public_url: '/assets/docs/og/bg.jpg', is_uploaded: false, service: 'product_media', @@ -43,7 +47,7 @@ export const PRODUCT_PREVIEW: ProductStorefront = { checksum_sha256_base64: '123', checksum_sha256_hex: '123', version: '1', - last_modified_at: new Date().toDateString(), + last_modified_at: new Date().toISOString(), }, ], prices: [ @@ -54,8 +58,8 @@ export const PRODUCT_PREVIEW: ProductStorefront = { type: 'one_time', price_currency: 'usd', is_archived: false, - created_at: new Date().toDateString(), - modified_at: new Date().toDateString(), + created_at: new Date().toISOString(), + modified_at: new Date().toISOString(), product_id: '123', }, ], @@ -66,26 +70,26 @@ export const PRODUCT_PREVIEW: ProductStorefront = { id: '123', description: 'Premium feature', type: 'custom', - created_at: new Date().toDateString(), + created_at: new Date().toISOString(), modified_at: null, selectable: false, deletable: false, organization_id: '123', }, ], - created_at: new Date().toDateString(), + created_at: new Date().toISOString(), } export const SUBSCRIPTION_PRODUCT_PREVIEW: ProductStorefront = { id: '123', is_recurring: false, is_archived: false, - modified_at: new Date().toDateString(), + modified_at: new Date().toISOString(), organization_id: '123', medias: [ { id: '123', - created_at: new Date().toDateString(), + created_at: new Date().toISOString(), public_url: '/assets/docs/og/bg.jpg', is_uploaded: false, service: 'product_media', @@ -100,7 +104,7 @@ export const SUBSCRIPTION_PRODUCT_PREVIEW: ProductStorefront = { checksum_sha256_base64: '123', checksum_sha256_hex: '123', version: '1', - last_modified_at: new Date().toDateString(), + last_modified_at: new Date().toISOString(), }, ], prices: [ @@ -112,8 +116,8 @@ export const SUBSCRIPTION_PRODUCT_PREVIEW: ProductStorefront = { recurring_interval: 'month', price_currency: 'usd', is_archived: false, - created_at: new Date().toDateString(), - modified_at: new Date().toDateString(), + created_at: new Date().toISOString(), + modified_at: new Date().toISOString(), product_id: '123', }, ], @@ -124,21 +128,21 @@ export const SUBSCRIPTION_PRODUCT_PREVIEW: ProductStorefront = { id: '123', description: 'Premium feature', type: 'custom', - created_at: new Date().toDateString(), + created_at: new Date().toISOString(), modified_at: null, selectable: false, deletable: false, organization_id: '123', }, ], - created_at: new Date().toDateString(), + created_at: new Date().toISOString(), } export const ORGANIZATION = { id: '123', name: 'My Organization', slug: 'my-organization', - created_at: new Date().toDateString(), + created_at: new Date().toISOString(), modified_at: null, avatar_url: '/assets/acme.jpg', bio: null, @@ -157,6 +161,7 @@ export const ORGANIZATION = { export const createCheckoutPreview = ( product: CheckoutProduct, price: ProductPrice, + organization: Organization, ): CheckoutPublic => { const amount = price.amount_type === 'custom' @@ -165,13 +170,13 @@ export const createCheckoutPreview = ( ? price.price_amount : 0 - return { + return CheckoutPublic$inboundSchema.parse({ id: '123', - created_at: new Date().toDateString(), - modified_at: new Date().toDateString(), - payment_processor: 'dummy' as 'stripe', + created_at: new Date().toISOString(), + modified_at: new Date().toISOString(), + payment_processor: 'stripe', status: CheckoutStatus.OPEN, - expires_at: new Date().toDateString(), + expires_at: new Date().toISOString(), client_secret: 'CLIENT_SECRET', product: product, product_id: product.id, @@ -197,17 +202,18 @@ export const createCheckoutPreview = ( url: '/checkout/CLIENT_SECRET', success_url: '/checkout/CLIENT_SECRET/confirmation', embed_origin: null, - organization: ORGANIZATION, + organization, attached_custom_fields: [], discount: null, discount_id: null, allow_discount_codes: true, - } + }) } export const CHECKOUT_PREVIEW: CheckoutPublic = createCheckoutPreview( PRODUCT_PREVIEW, PRODUCT_PREVIEW.prices[0], + ORGANIZATION, ) export const ORDER_PREVIEW: CustomerOrder = { @@ -226,25 +232,25 @@ export const ORDER_PREVIEW: CustomerOrder = { ...PRODUCT_PREVIEW, organization: ORGANIZATION, }, - created_at: new Date().toDateString(), - modified_at: new Date().toDateString(), + created_at: new Date().toISOString(), + modified_at: new Date().toISOString(), } export const SUBSCRIPTION_ORDER_PREVIEW: CustomerSubscription = { - created_at: new Date().toDateString(), - modified_at: new Date().toDateString(), + created_at: new Date().toISOString(), + modified_at: new Date().toISOString(), id: '989898989', amount: 10000, currency: 'usd', recurring_interval: 'month', status: 'active', - current_period_start: new Date().toDateString(), + current_period_start: new Date().toISOString(), current_period_end: new Date( new Date().setMonth(new Date().getMonth() + 1), - ).toDateString(), + ).toISOString(), cancel_at_period_end: false, canceled_at: null, - started_at: new Date().toDateString(), + started_at: new Date().toISOString(), ends_at: null, ended_at: null, user_id: '123', @@ -264,8 +270,8 @@ export const SUBSCRIPTION_ORDER_PREVIEW: CustomerSubscription = { recurring_interval: 'month', price_currency: 'usd', is_archived: false, - created_at: new Date().toDateString(), - modified_at: new Date().toDateString(), + created_at: new Date().toISOString(), + modified_at: new Date().toISOString(), product_id: '123', }, discount_id: null, diff --git a/clients/apps/web/src/components/Products/BenefitList.tsx b/clients/apps/web/src/components/Products/BenefitList.tsx index 31f5741b18..bc481ab7fd 100644 --- a/clients/apps/web/src/components/Products/BenefitList.tsx +++ b/clients/apps/web/src/components/Products/BenefitList.tsx @@ -3,7 +3,7 @@ import { KeyboardArrowRight, KeyboardArrowUp, } from '@mui/icons-material' -import { BenefitBase } from '@polar-sh/api' +import { BenefitType } from '@polar-sh/api' import React, { ReactNode, useState } from 'react' import { resolveBenefitIcon } from '../Benefit/utils' @@ -30,7 +30,7 @@ export const BenefitList = ({ benefits, toggle = false, }: { - benefits: BenefitBase[] | undefined + benefits: { id: string; type: BenefitType; description: string }[] | undefined toggle?: boolean }) => { const [showAll, setShowAll] = useState(false) @@ -50,7 +50,7 @@ export const BenefitList = ({ {shown.map((benefit) => ( {benefit.description} @@ -61,7 +61,7 @@ export const BenefitList = ({ toggled.map((benefit) => ( {benefit.description} diff --git a/clients/apps/web/src/components/Products/CreateProductPage.tsx b/clients/apps/web/src/components/Products/CreateProductPage.tsx index 87fed676bc..ac0315562d 100644 --- a/clients/apps/web/src/components/Products/CreateProductPage.tsx +++ b/clients/apps/web/src/components/Products/CreateProductPage.tsx @@ -174,10 +174,10 @@ export const CreateProductPage = ({ organization }: CreateProductPageProps) => {
diff --git a/clients/apps/web/src/components/Products/EditProductPage.tsx b/clients/apps/web/src/components/Products/EditProductPage.tsx index 1597eaad15..9b18fbbf7c 100644 --- a/clients/apps/web/src/components/Products/EditProductPage.tsx +++ b/clients/apps/web/src/components/Products/EditProductPage.tsx @@ -192,10 +192,10 @@ export const EditProductPage = ({
diff --git a/clients/apps/web/src/components/Subscriptions/ChangePlanModal.tsx b/clients/apps/web/src/components/Subscriptions/ChangePlanModal.tsx index f7c903430c..7e63709b07 100644 --- a/clients/apps/web/src/components/Subscriptions/ChangePlanModal.tsx +++ b/clients/apps/web/src/components/Subscriptions/ChangePlanModal.tsx @@ -222,7 +222,7 @@ const ChangePlanModal = ({ {addedBenefits.map((benefit) => (
- {resolveBenefitIcon(benefit, 'inherit', 'h-3 w-3')} + {resolveBenefitIcon(benefit.type, 'inherit', 'h-3 w-3')} {benefit.description}
@@ -239,7 +239,7 @@ const ChangePlanModal = ({ {removedBenefits.map((benefit) => (
- {resolveBenefitIcon(benefit, 'inherit', 'h-3 w-3')} + {resolveBenefitIcon(benefit.type, 'inherit', 'h-3 w-3')} {benefit.description}
diff --git a/clients/apps/web/src/utils/nav.ts b/clients/apps/web/src/utils/nav.ts index 9cbd4355b4..2589d73e5c 100644 --- a/clients/apps/web/src/utils/nav.ts +++ b/clients/apps/web/src/utils/nav.ts @@ -1,8 +1,9 @@ import { CONFIG } from '@/utils/config' -import { Organization } from '@polar-sh/api' +import type { Organization } from '@polar-sh/api' +import type { Organization as OrganizationSDK } from '@polar-sh/sdk/models/components/organization' export const organizationPageLink = ( - org: Organization, + org: Organization | OrganizationSDK, path?: string, ): string => { return `${CONFIG.FRONTEND_BASE_URL}/${org.slug}/${path ?? ''}` diff --git a/clients/apps/web/tailwind.config.js b/clients/apps/web/tailwind.config.js index 076e6917ba..3361495638 100644 --- a/clients/apps/web/tailwind.config.js +++ b/clients/apps/web/tailwind.config.js @@ -37,6 +37,7 @@ module.exports = { content: [ './src/**/*.{ts,tsx}', 'node_modules/polarkit/src/**/*.{ts,tsx}', + 'node_modules/@polar-sh/checkout/src/**/*.{ts,tsx}', '.storybook/**/*.{ts,tsx}', ], darkMode: 'class', diff --git a/clients/examples/checkout-embed/index.html b/clients/examples/checkout-embed/index.html index ff7ba7a141..535199924a 100644 --- a/clients/examples/checkout-embed/index.html +++ b/clients/examples/checkout-embed/index.html @@ -11,11 +11,11 @@

My Product

- Purchase (light mode) - Purchase (dark mode) @@ -23,4 +23,4 @@

My Product

- + \ No newline at end of file diff --git a/clients/packages/checkout/package.json b/clients/packages/checkout/package.json index b55df97e4e..7786a23ddb 100644 --- a/clients/packages/checkout/package.json +++ b/clients/packages/checkout/package.json @@ -13,9 +13,25 @@ "./embed": { "types": "./dist/embed.d.ts", "default": "./dist/embed.js" + }, + "./components": { + "types": "./dist/components/index.d.ts", + "default": "./dist/components/index.js" + }, + "./hooks": { + "types": "./dist/hooks/index.d.ts", + "default": "./dist/hooks/index.js" + }, + "./providers": { + "types": "./dist/providers/index.d.ts", + "default": "./dist/providers/index.js" } }, "devDependencies": { + "@stripe/react-stripe-js": "^3.0.0", + "@stripe/stripe-js": "^5.2.0", + "@types/react": "^18.3.12", + "react": "^18.3.1", "terser": "^5.36.0", "tsconfig": "workspace:*", "tsup": "^8.3.5", @@ -23,5 +39,18 @@ }, "publishConfig": { "access": "public" + }, + "dependencies": { + "@polar-sh/sdk": "^0.21.2", + "event-source-plus": "^0.1.8", + "eventemitter3": "^5.0.1", + "markdown-to-jsx": "^7.7.0", + "polarkit": "workspace:^", + "react-hook-form": "^7.54.2" + }, + "peerDependencies": { + "@stripe/react-stripe-js": "^3.0.0", + "@stripe/stripe-js": "^5.2.0", + "react": "^18" } -} +} \ No newline at end of file diff --git a/clients/packages/checkout/src/components/AmountLabel.tsx b/clients/packages/checkout/src/components/AmountLabel.tsx new file mode 100644 index 0000000000..15f18ec6ac --- /dev/null +++ b/clients/packages/checkout/src/components/AmountLabel.tsx @@ -0,0 +1,38 @@ +import type { SubscriptionRecurringInterval } from '@polar-sh/sdk/models/components/subscriptionrecurringinterval' +import { formatCurrencyAndAmount } from 'polarkit/lib/money' +import { useMemo } from 'react' + +interface AmountLabelProps { + amount: number + currency: string + interval?: SubscriptionRecurringInterval +} + +const AmountLabel: React.FC = ({ + amount, + currency, + interval, +}) => { + const intervalDisplay = useMemo(() => { + if (!interval) { + return '' + } + switch (interval) { + case 'month': + return ' / mo' + case 'year': + return ' / yr' + default: + return '' + } + }, [interval]) + + return ( +
+ {formatCurrencyAndAmount(amount, currency, 0)} + {intervalDisplay} +
+ ) +} + +export default AmountLabel diff --git a/clients/apps/web/src/components/Checkout/CheckoutForm.tsx b/clients/packages/checkout/src/components/CheckoutForm.tsx similarity index 55% rename from clients/apps/web/src/components/Checkout/CheckoutForm.tsx rename to clients/packages/checkout/src/components/CheckoutForm.tsx index 7bd8e2c2b1..50e30ac87b 100644 --- a/clients/apps/web/src/components/Checkout/CheckoutForm.tsx +++ b/clients/packages/checkout/src/components/CheckoutForm.tsx @@ -1,33 +1,20 @@ 'use client' -import { useCheckoutClientSSE } from '@/hooks/sse' -import { CONFIG } from '@/utils/config' -import { getDiscountDisplay } from '@/utils/discount' -import { CloseOutlined } from '@mui/icons-material' -import { - CheckoutConfirmStripe, - CheckoutPublic, - CheckoutPublicConfirmed, - CheckoutStatus, - CheckoutUpdatePublic, -} from '@polar-sh/api' -import { PolarEmbedCheckout } from '@polar-sh/checkout/embed' -import { formatCurrencyAndAmount } from '@polarkit/lib/money' +import type { CheckoutConfirmStripe } from '@polar-sh/sdk/models/components/checkoutconfirmstripe' +import type { CheckoutPublic } from '@polar-sh/sdk/models/components/checkoutpublic' +import type { CheckoutPublicConfirmed } from '@polar-sh/sdk/models/components/checkoutpublicconfirmed' +import type { CheckoutUpdatePublic } from '@polar-sh/sdk/models/components/checkoutupdatepublic' import { Elements, ElementsConsumer, PaymentElement, } from '@stripe/react-stripe-js' import { - ConfirmationToken, loadStripe, Stripe, StripeElements, StripeElementsOptions, - StripeError, } from '@stripe/stripe-js' -import debounce from 'lodash.debounce' -import { useRouter } from 'next/navigation' import Button from 'polarkit/components/ui/atoms/button' import CountryPicker from 'polarkit/components/ui/atoms/countrypicker' import CountryStatePicker from 'polarkit/components/ui/atoms/countrystatepicker' @@ -47,11 +34,13 @@ import { useMemo, useState, } from 'react' -import { useFormContext, WatchObserver } from 'react-hook-form' -import { twMerge } from 'tailwind-merge' -import LogoType from '../Brand/LogoType' -import CustomFieldInput from '../CustomFields/CustomFieldInput' -import AmountLabel from '../Shared/AmountLabel' +import { UseFormReturn, WatchObserver } from 'react-hook-form' +import useDebouncedCallback from '../hooks/debounce' +import { getDiscountDisplay } from '../utils/discount' +import { formatCurrencyNumber } from '../utils/money' +import AmountLabel from './AmountLabel' +import CustomFieldInput from './CustomFieldInput' +import PolarLogo from './PolarLogo' const DetailRow = ({ title, @@ -60,10 +49,7 @@ const DetailRow = ({ }: PropsWithChildren<{ title: string; emphasis?: boolean }>) => { return (
{title} {children} @@ -71,29 +57,50 @@ const DetailRow = ({ ) } +const XIcon = ({ className }: { className?: string }) => { + return ( + + + + + ) +} + interface BaseCheckoutFormProps { - onSubmit: (value: any) => Promise - onCheckoutUpdate?: (body: CheckoutUpdatePublic) => Promise + form: UseFormReturn checkout: CheckoutPublic + confirm: (data: any) => Promise + update: (data: CheckoutUpdatePublic) => Promise + loading: boolean + loadingLabel: string | undefined disabled?: boolean - loading?: boolean - loadingLabel?: string } const BaseCheckoutForm = ({ - onSubmit, - onCheckoutUpdate, + form, checkout, - disabled, + confirm, + update, loading, loadingLabel, + disabled, children, }: React.PropsWithChildren) => { const interval = - checkout.product_price.type === 'recurring' - ? checkout.product_price.recurring_interval + checkout.productPrice.type === 'recurring' + ? checkout.productPrice.recurringInterval : undefined - const form = useFormContext() const { control, handleSubmit, @@ -103,64 +110,64 @@ const BaseCheckoutForm = ({ setValue, formState: { errors }, } = form - const country = watch('customer_billing_address.country') + const country = watch('customerBillingAddress.country') const watcher: WatchObserver = useCallback( async (value, { name, type }) => { - if (type !== 'change' || !name || !onCheckoutUpdate) { + if (type !== 'change' || !name) { return } let payload: CheckoutUpdatePublic = {} // Update Tax ID - if (name === 'customer_tax_id') { + if (name === 'customerTaxId') { payload = { ...payload, - customer_tax_id: value.customer_tax_id, + customerTaxId: value.customerTaxId, // Make sure the address is up-to-date while updating the tax ID - ...(value.customer_billing_address && - value.customer_billing_address.country + ...(value.customerBillingAddress && + value.customerBillingAddress.country ? { - customer_billing_address: { - ...value.customer_billing_address, - country: value.customer_billing_address.country, + customerBillingAddress: { + ...value.customerBillingAddress, + country: value.customerBillingAddress.country, }, } : {}), } - clearErrors('customer_tax_id') + clearErrors('customerTaxId') // Update country, make sure to reset other address fields - } else if (name === 'customer_billing_address.country') { - const { customer_billing_address } = value - if (customer_billing_address && customer_billing_address.country) { + } else if (name === 'customerBillingAddress.country') { + const { customerBillingAddress } = value + if (customerBillingAddress && customerBillingAddress.country) { payload = { ...payload, - customer_billing_address: { - country: customer_billing_address.country, + customerBillingAddress: { + country: customerBillingAddress.country, }, } - resetField('customer_billing_address', { - defaultValue: { country: customer_billing_address.country }, + resetField('customerBillingAddress', { + defaultValue: { country: customerBillingAddress.country }, }) } // Update other address fields - } else if (name.startsWith('customer_billing_address')) { - const { customer_billing_address } = value - if (customer_billing_address && customer_billing_address.country) { + } else if (name.startsWith('customerBillingAddress')) { + const { customerBillingAddress } = value + if (customerBillingAddress && customerBillingAddress.country) { payload = { ...payload, - customer_billing_address: { - ...customer_billing_address, - country: customer_billing_address.country, + customerBillingAddress: { + ...customerBillingAddress, + country: customerBillingAddress.country, }, } - clearErrors('customer_billing_address') + clearErrors('customerBillingAddress') } - } else if (name === 'discount_code') { - const { discount_code } = value - clearErrors('discount_code') + } else if (name === 'discountCode') { + const { discountCode } = value + clearErrors('discountCode') // Ensure we don't submit an empty discount code - if (discount_code === '') { - setValue('discount_code', undefined) + if (discountCode === '') { + setValue('discountCode', undefined) } } @@ -169,44 +176,40 @@ const BaseCheckoutForm = ({ } try { - await onCheckoutUpdate(payload) + await update(payload) } catch {} }, - [clearErrors, resetField, onCheckoutUpdate, setValue], + [clearErrors, resetField, update, setValue], ) - // eslint-disable-next-line react-hooks/exhaustive-deps - const debouncedWatcher = useCallback(debounce(watcher, 500), [watcher]) + const debouncedWatcher = useDebouncedCallback(watcher, 500, [watcher]) - const discountCode = watch('discount_code') + const discountCode = watch('discountCode') const addDiscountCode = useCallback(async () => { - if (!onCheckoutUpdate || !discountCode) { + if (!discountCode) { return } - clearErrors('discount_code') + clearErrors('discountCode') try { - await onCheckoutUpdate({ discount_code: discountCode }) + await update({ discountCode: discountCode }) } catch {} - }, [onCheckoutUpdate, discountCode, clearErrors]) + }, [update, discountCode, clearErrors]) const removeDiscountCode = useCallback(async () => { - if (!onCheckoutUpdate) { - return - } - clearErrors('discount_code') + clearErrors('discountCode') try { - await onCheckoutUpdate({ discount_code: null }) - resetField('discount_code') + await update({ discountCode: null }) + resetField('discountCode') } catch {} - }, [onCheckoutUpdate, clearErrors, resetField]) + }, [update, clearErrors, resetField]) useEffect(() => { const subscription = watch(debouncedWatcher) return () => subscription.unsubscribe() }, [watch, debouncedWatcher]) - const taxId = watch('customer_tax_id') + const taxId = watch('customerTaxId') const [showTaxId, setShowTaxID] = useState(false) const clearTaxId = useCallback(() => { - setValue('customer_tax_id', '') + setValue('customerTaxId', '') setShowTaxID(false) }, [setValue]) useEffect(() => { @@ -222,13 +225,13 @@ const BaseCheckoutForm = ({
@@ -252,11 +255,11 @@ const BaseCheckoutForm = ({ {children} - {checkout.is_payment_form_required && ( + {checkout.isPaymentFormRequired && ( <> ( <> )} - {errors.customer_billing_address?.message && ( + {errors.customerBillingAddress?.message && (

- {errors.customer_billing_address.message} + {errors.customerBillingAddress.message}

)} @@ -432,7 +435,7 @@ const BaseCheckoutForm = ({ {showTaxId && ( (
@@ -453,7 +456,7 @@ const BaseCheckoutForm = ({ variant="secondary" onClick={() => clearTaxId()} > - +
@@ -465,74 +468,73 @@ const BaseCheckoutForm = ({ )} )} - {checkout.allow_discount_codes && - checkout.is_discount_applicable && ( - ( - - -
-
Discount Code
- - Optional - -
-
- -
- { - if (e.key !== 'Enter') return + {checkout.allowDiscountCodes && checkout.isDiscountApplicable && ( + ( + + +
+
Discount Code
+ + Optional + +
+
+ +
+ { + if (e.key !== 'Enter') return - e.preventDefault() - addDiscountCode() - }} - /> -
- {!checkoutDiscounted && discountCode && ( - - )} - {checkoutDiscounted && ( - - )} -
+ e.preventDefault() + addDiscountCode() + }} + /> +
+ {!checkoutDiscounted && discountCode && ( + + )} + {checkoutDiscounted && ( + + )}
- - - - )} - /> - )} - {checkout.attached_custom_fields.map( - ({ custom_field, required }) => ( +
+
+ +
+ )} + /> + )} + {checkout.attachedCustomFields.map( + ({ customField, required }) => ( ( - {!checkout.is_free_product_price && ( + {!checkout.isFreeProductPrice && (
{checkout.amount !== null && checkout.currency ? ( <> @@ -557,23 +559,23 @@ const BaseCheckoutForm = ({ - {formatCurrencyAndAmount( - (checkout.subtotal_amount || 0) - checkout.amount, + {formatCurrencyNumber( + (checkout.subtotalAmount || 0) - checkout.amount, checkout.currency, )} )} - {checkout.tax_amount !== null && ( + {checkout.taxAmount !== null && ( - {formatCurrencyAndAmount( - checkout.tax_amount, + {formatCurrencyNumber( + checkout.taxAmount, checkout.currency, )} )} @@ -593,7 +595,7 @@ const BaseCheckoutForm = ({ disabled={disabled} loading={loading} > - {!checkout.is_payment_form_required + {!checkout.isPaymentFormRequired ? 'Submit' : interval ? 'Subscribe' @@ -619,51 +621,51 @@ const BaseCheckoutForm = ({
Powered by - +
) } interface CheckoutFormProps { + form: UseFormReturn checkout: CheckoutPublic - onCheckoutUpdate?: (body: CheckoutUpdatePublic) => Promise - onCheckoutConfirm?: ( - body: CheckoutConfirmStripe, + update: (data: CheckoutUpdatePublic) => Promise + confirm: ( + data: CheckoutConfirmStripe, + stripe: Stripe | null, + elements: StripeElements | null, ) => Promise + loading: boolean + loadingLabel: string | undefined theme?: 'light' | 'dark' - embed?: boolean } -const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_KEY || '') - const StripeCheckoutForm = (props: CheckoutFormProps) => { - const router = useRouter() - const { setError } = useFormContext() - const { checkout, onCheckoutUpdate, onCheckoutConfirm, theme, embed } = props - const [loading, setLoading] = useState(false) - const [loadingLabel, setLoadingLabel] = useState( - undefined, + const { checkout, update, confirm, loading, loadingLabel, theme } = props + const stripePromise = loadStripe( + (checkout.paymentProcessorMetadata as Record) + .publishable_key, ) const elementsOptions = useMemo(() => { if ( - checkout.is_payment_setup_required && - checkout.is_payment_required && - checkout.total_amount + checkout.isPaymentSetupRequired && + checkout.isPaymentRequired && + checkout.totalAmount ) { return { mode: 'subscription', setupFutureUsage: 'off_session', paymentMethodCreation: 'manual', - amount: checkout.total_amount, + amount: checkout.totalAmount, currency: checkout.currency || 'usd', } - } else if (checkout.is_payment_required && checkout.total_amount) { + } else if (checkout.isPaymentRequired && checkout.totalAmount) { return { mode: 'payment', paymentMethodCreation: 'manual', - amount: checkout.total_amount, + amount: checkout.totalAmount, currency: checkout.currency || 'usd', } } @@ -676,253 +678,6 @@ const StripeCheckoutForm = (props: CheckoutFormProps) => { } }, [checkout]) - const checkoutEvents = useCheckoutClientSSE(checkout.client_secret) - - const onSuccess = useCallback( - async (url: string, customerSessionToken: string) => { - const parsedURL = new URL(url) - const isInternalURL = url.startsWith(CONFIG.FRONTEND_BASE_URL) - - if (isInternalURL) { - if (embed) { - parsedURL.searchParams.set('embed', 'true') - if (theme) { - parsedURL.searchParams.set('theme', theme) - } - } - } - - parsedURL.searchParams.set('customer_session_token', customerSessionToken) - - // For external success URL, make sure the checkout is processed before redirecting - // It ensures the user will have an up-to-date status when they are redirected, - // especially if the external URL doesn't implement proper webhook handling - if (!isInternalURL) { - await new Promise((resolve) => { - let checkoutSuccessful = false - let orderCreated = false - let subscriptionCreated = checkout.product_price.type !== 'recurring' - let webhookEventDelivered = false - - const checkResolution = () => { - if (checkoutSuccessful && orderCreated && subscriptionCreated) { - setLoadingLabel( - `Waiting confirmation from ${checkout.organization.name} `, - ) - } - if ( - checkoutSuccessful && - orderCreated && - subscriptionCreated && - webhookEventDelivered - ) { - resolve() - } - } - - const checkoutUpdatedListener = (data: { - status: CheckoutStatus - }) => { - if (data.status === CheckoutStatus.SUCCEEDED) { - checkoutSuccessful = true - setLoadingLabel('Payment successful! Processing order...') - checkoutEvents.off('checkout.updated', checkoutUpdatedListener) - checkResolution() - } - } - checkoutEvents.on('checkout.updated', checkoutUpdatedListener) - - const orderCreatedListener = () => { - orderCreated = true - checkoutEvents.off('checkout.order_created', orderCreatedListener) - checkResolution() - } - checkoutEvents.on('checkout.order_created', orderCreatedListener) - - const subscriptionCreatedListener = () => { - subscriptionCreated = true - checkoutEvents.off( - 'checkout.subscription_created', - subscriptionCreatedListener, - ) - checkResolution() - } - if (!subscriptionCreated) { - checkoutEvents.on( - 'checkout.subscription_created', - subscriptionCreatedListener, - ) - } - - const webhookEventDeliveredListener = (data?: { - status: CheckoutStatus - }) => { - if (!data || data.status === CheckoutStatus.SUCCEEDED) { - webhookEventDelivered = true - checkoutEvents.off( - 'checkout.webhook_event_delivered', - webhookEventDeliveredListener, - ) - checkResolution() - } - } - checkoutEvents.on( - 'checkout.webhook_event_delivered', - webhookEventDeliveredListener, - ) - // Wait webhook event to be delivered for 30 seconds max - setTimeout( - () => webhookEventDeliveredListener(), - CONFIG.CHECKOUT_EXTERNAL_WEBHOOKS_WAITING_LIMIT_MS, - ) - }) - } - - if (checkout.embed_origin) { - PolarEmbedCheckout.postMessage( - { - event: 'success', - successURL: parsedURL.toString(), - redirect: !isInternalURL, - }, - checkout.embed_origin, - ) - } - - if (isInternalURL || !embed) { - router.push(parsedURL.toString()) - } - }, - [router, embed, theme, checkout, checkoutEvents], - ) - - const onSubmit = async ( - data: CheckoutUpdatePublic, - stripe: Stripe | null, - elements: StripeElements | null, - ) => { - if (!onCheckoutConfirm) { - return - } - - setLoading(true) - - if (checkout.embed_origin) { - PolarEmbedCheckout.postMessage( - { - event: 'confirmed', - }, - checkout.embed_origin, - ) - } - - if (!checkout.is_payment_form_required) { - let updatedCheckout: CheckoutPublicConfirmed - setLoadingLabel('Processing order') - try { - updatedCheckout = await onCheckoutConfirm(data) - } catch (e) { - setLoading(false) - return - } - await onSuccess( - updatedCheckout.success_url, - updatedCheckout.customer_session_token, - ) - return - } - - if (!stripe || !elements) { - setLoading(false) - return - } - - setLoadingLabel('Processing payment') - - const { error: submitError } = await elements.submit() - if (submitError) { - // Don't show validation errors, as they are already shown in their form - if (submitError.type !== 'validation_error') { - setError('root', { message: submitError.message }) - } - setLoading(false) - return - } - - let confirmationToken: ConfirmationToken | undefined - let error: StripeError | undefined - try { - const confirmationTokenResponse = await stripe.createConfirmationToken({ - elements, - params: { - payment_method_data: { - // Stripe requires fields to be explicitly set to null if they are not provided - billing_details: { - name: data.customer_name, - email: data.customer_email, - address: { - line1: data.customer_billing_address?.line1 || null, - line2: data.customer_billing_address?.line2 || null, - postal_code: data.customer_billing_address?.postal_code || null, - city: data.customer_billing_address?.city || null, - state: data.customer_billing_address?.state || null, - country: data.customer_billing_address?.country || null, - }, - phone: null, - }, - }, - }, - }) - confirmationToken = confirmationTokenResponse.confirmationToken - error = confirmationTokenResponse.error - } catch (err) { - setLoading(false) - throw err - } - - if (!confirmationToken || error) { - setError('root', { - message: - error?.message || - 'Failed to create confirmation token, please try again later.', - }) - setLoading(false) - return - } - - let updatedCheckout: CheckoutPublicConfirmed - try { - updatedCheckout = await onCheckoutConfirm({ - ...data, - confirmation_token_id: confirmationToken.id, - }) - } catch (e) { - setLoading(false) - return - } - - setLoadingLabel('Payment successful! Getting your products ready') - - const { intent_status, intent_client_secret } = - updatedCheckout.payment_processor_metadata as Record - - if (intent_status === 'requires_action') { - const { error } = await stripe.handleNextAction({ - clientSecret: intent_client_secret, - }) - if (error) { - setLoading(false) - setError('root', { message: error.message }) - return - } - } - - await onSuccess( - updatedCheckout.success_url, - updatedCheckout.customer_session_token, - ) - } - const inputBoxShadow = theme === 'dark' ? 'none' @@ -938,7 +693,7 @@ const StripeCheckoutForm = (props: CheckoutFormProps) => { options={{ ...elementsOptions, customerSessionClientSecret: ( - checkout.payment_processor_metadata as { + checkout.paymentProcessorMetadata as { customer_session_client_secret?: string } ).customer_session_client_secret, @@ -1019,12 +774,12 @@ const StripeCheckoutForm = (props: CheckoutFormProps) => { onSubmit(data, stripe, elements)} - onCheckoutUpdate={onCheckoutUpdate} + confirm={(data) => confirm(data, stripe, elements)} + update={update} loading={loading} loadingLabel={loadingLabel} > - {checkout.is_payment_form_required && ( + {checkout.isPaymentFormRequired && ( { ) } -const DummyCheckoutForm = ({ checkout }: CheckoutFormProps) => { +const DummyCheckoutForm = (props: CheckoutFormProps) => { + const { checkout } = props return ( {}} - onCheckoutUpdate={async () => checkout} + {...props} + confirm={async () => ({ + ...checkout, + status: 'confirmed', + customerSessionToken: '', + })} + update={async () => checkout} disabled={true} /> ) } -export const CheckoutForm = (props: CheckoutFormProps) => { +const CheckoutForm = (props: CheckoutFormProps) => { const { - checkout: { payment_processor }, + checkout: { paymentProcessor }, } = props - if (payment_processor === 'stripe') { + if (paymentProcessor === 'stripe') { return } return } + +export default CheckoutForm diff --git a/clients/packages/checkout/src/components/CheckoutPricing.tsx b/clients/packages/checkout/src/components/CheckoutPricing.tsx new file mode 100644 index 0000000000..620bbb416b --- /dev/null +++ b/clients/packages/checkout/src/components/CheckoutPricing.tsx @@ -0,0 +1,231 @@ +'use client' + +import type { CheckoutPublic } from '@polar-sh/sdk/models/components/checkoutpublic' +import type { CheckoutUpdatePublic } from '@polar-sh/sdk/models/components/checkoutupdatepublic' +import type { SubscriptionRecurringInterval } from '@polar-sh/sdk/models/components/subscriptionrecurringinterval' +import MoneyInput from 'polarkit/components/ui/atoms/moneyinput' +import { Tabs, TabsList, TabsTrigger } from 'polarkit/components/ui/atoms/tabs' +import { + Form, + FormField, + FormItem, + FormLabel, + FormMessage, +} from 'polarkit/components/ui/form' +import { useCallback, useMemo, useState } from 'react' +import { type SubmitHandler, useForm } from 'react-hook-form' +import useDebouncedCallback from '../hooks/debounce' +import { formatCurrencyNumber } from '../utils/money' +import { hasRecurringIntervals } from '../utils/product' +import ProductPriceLabel from './ProductPriceLabel' + +const DollarSignIcon = ({ className }: { className?: string }) => { + return ( + + + + + ) +} + +interface CheckoutPricingProps { + checkout: CheckoutPublic + update?: (data: CheckoutUpdatePublic) => Promise + disabled?: boolean +} + +const CheckoutPricing = ({ + checkout, + update, + disabled, +}: CheckoutPricingProps) => { + const { product, productPrice } = checkout + const [, , hasBothIntervals] = useMemo( + () => hasRecurringIntervals(product), + [product], + ) + const [recurringInterval, setRecurringInterval] = + useState( + productPrice.type === 'recurring' + ? productPrice.recurringInterval + : 'month', + ) + + const onRecurringIntervalChange = useCallback( + (recurringInterval: SubscriptionRecurringInterval) => { + setRecurringInterval(recurringInterval) + for (const price of product.prices) { + if ( + price.type === 'recurring' && + price.recurringInterval === recurringInterval + ) { + if (price.id === productPrice.id) { + return + } + update?.({ productPriceId: price.id }) + return + } + } + }, + [product, productPrice, update], + ) + + const form = useForm<{ amount: number }>({ + defaultValues: { amount: checkout.amount || 0 }, + }) + const { control, handleSubmit, setValue, trigger } = form + const onAmountChangeSubmit: SubmitHandler<{ amount: number }> = useCallback( + async ({ amount }) => { + update?.({ amount }) + }, + [update], + ) + + const submitAmountUpdate = () => { + handleSubmit(onAmountChangeSubmit)() + } + + const debouncedAmountUpdate = useDebouncedCallback( + async () => { + const isValid = await trigger('amount') + if (isValid) { + submitAmountUpdate() + } + }, + 600, + [onAmountChangeSubmit, submitAmountUpdate, trigger], + ) + + const onAmountChange = (amount: number) => { + setValue('amount', amount) + debouncedAmountUpdate() + } + + let customAmountMinLabel = null + let customAmountMaxLabel = null + if (productPrice.amountType === 'custom') { + customAmountMinLabel = formatCurrencyNumber( + productPrice.minimumAmount || 50, + checkout.currency || 'usd', + ) + + if (productPrice.maximumAmount) { + customAmountMaxLabel = formatCurrencyNumber( + productPrice.maximumAmount, + checkout.currency || 'usd', + ) + } + } + + return ( +
+ {!disabled && hasBothIntervals && ( + void} + value={recurringInterval} + > + + {[ + ['month', 'Monthly Billing'], + ['year', 'Yearly Billing'], + ].map(([value, label]) => { + return ( + + {label} + + ) + })} + + + )} +
+

+ {productPrice.amountType !== 'custom' && ( + + )} + {productPrice.amountType === 'custom' && ( + <> + {disabled ? ( + formatCurrencyNumber( + checkout.amount || 0, + checkout.currency || 'usd', + ) + ) : ( + + + + Name a fair price{' '} + + ({customAmountMinLabel} minimum) + + +
+ { + return ( + + } + /> + + + ) + }} + /> +
+ + + )} + + )} +

+

+ Before VAT and taxes +

+
+
+ ) +} + +export default CheckoutPricing diff --git a/clients/packages/checkout/src/components/CustomFieldInput.stories.tsx b/clients/packages/checkout/src/components/CustomFieldInput.stories.tsx new file mode 100644 index 0000000000..3d21b80f65 --- /dev/null +++ b/clients/packages/checkout/src/components/CustomFieldInput.stories.tsx @@ -0,0 +1,141 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { Form, FormField } from 'polarkit/components/ui/form' +import { useForm } from 'react-hook-form' +import CustomFieldInput from './CustomFieldInput' + +const meta: Meta = { + title: 'CustomFields/CustomFieldInput', + component: CustomFieldInput, + tags: ['autodocs'], +} + +export default meta + +type Story = StoryObj + +const baseCustomField = { + id: '00000000-0000-0000-0000-000000000000', + created_at: '2023-06-29T00:00:00Z', + modified_at: null, + name: 'Custom Field', + slug: 'custom-field', + organization_id: '00000000-0000-0000-0000-000000000000', + metadata: {}, + properties: { + form_label: 'Field Label', + form_help_text: 'This is a help text', + form_placeholder: 'Placeholder', + }, +} +const Default: Story = { + render: (args) => { + const form = useForm() + const { control } = form + return ( +
+ } + /> + + ) + }, +} + +export const TextField: Story = { + ...Default, + args: { + customField: { + ...baseCustomField, + type: 'text', + }, + required: false, + }, +} + +export const TextareaField: Story = { + ...Default, + args: { + customField: { + ...baseCustomField, + type: 'text', + properties: { + ...baseCustomField.properties, + textarea: true, + }, + }, + required: false, + }, +} + +export const NumberField: Story = { + ...Default, + args: { + customField: { + ...baseCustomField, + type: 'number', + }, + required: false, + }, +} + +export const DateField: Story = { + ...Default, + args: { + customField: { + ...baseCustomField, + type: 'date', + }, + required: false, + }, +} + +export const CheckboxField: Story = { + ...Default, + args: { + customField: { + ...baseCustomField, + type: 'checkbox', + }, + required: false, + }, +} + +export const SelectField: Story = { + ...Default, + args: { + customField: { + ...baseCustomField, + type: 'select', + properties: { + ...baseCustomField.properties, + options: [ + { label: 'Option 1', value: 'option-1' }, + { label: 'Option 2', value: 'option-2' }, + { label: 'Option 3', value: 'option-3' }, + ], + }, + }, + required: false, + }, +} + +export const MarkdownLabels: Story = { + ...Default, + args: { + customField: { + ...baseCustomField, + type: 'checkbox', + properties: { + ...baseCustomField.properties, + form_label: + 'I accept the [terms and conditions](https://example.com/terms)', + form_help_text: + 'This is **required** by our *picky* lawyers. It annoys `devs` like us.', + }, + }, + required: true, + }, +} diff --git a/clients/packages/checkout/src/components/CustomFieldInput.tsx b/clients/packages/checkout/src/components/CustomFieldInput.tsx new file mode 100644 index 0000000000..00855f86be --- /dev/null +++ b/clients/packages/checkout/src/components/CustomFieldInput.tsx @@ -0,0 +1,282 @@ +import type { CustomField } from '@polar-sh/sdk/models/components/customfield' +import type { CustomFieldCheckbox } from '@polar-sh/sdk/models/components/customfieldcheckbox' +import type { CustomFieldDate } from '@polar-sh/sdk/models/components/customfielddate' +import type { CustomFieldNumber } from '@polar-sh/sdk/models/components/customfieldnumber' +import type { CustomFieldSelect } from '@polar-sh/sdk/models/components/customfieldselect' +import type { CustomFieldText } from '@polar-sh/sdk/models/components/customfieldtext' +import type { MarkdownToJSX } from 'markdown-to-jsx' +import Markdown from 'markdown-to-jsx' +import Input from 'polarkit/components/ui/atoms/input' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from 'polarkit/components/ui/atoms/select' +import Textarea from 'polarkit/components/ui/atoms/textarea' +import { Checkbox } from 'polarkit/components/ui/checkbox' +import { + FormControl, + FormDescription, + FormItem, + FormLabel, + FormMessage, +} from 'polarkit/components/ui/form' +import type { ControllerRenderProps } from 'react-hook-form' + +const markdownOptions: MarkdownToJSX.Options = { + disableParsingRawHTML: true, + forceBlock: false, + overrides: { + h1: (props: any) => , + h2: (props: any) => , + h3: (props: any) => , + h4: (props: any) => , + h5: (props: any) => , + h6: (props: any) => , + p: (props: any) => , + embed: () => <>, + iframe: () => <>, + img: () => <>, + a: (props: any) => ( + + ), + }, +} + +const FieldLabel = ({ customField }: { customField: CustomField }) => { + return ( + + {customField.properties.formLabel ? ( + + {customField.properties.formLabel} + + ) : ( + customField.name + )} + + ) +} + +const FieldHelpText = ({ customField }: { customField: CustomField }) => { + return customField.properties.formHelpText ? ( + + + {customField.properties.formHelpText} + + + ) : null +} + +interface CustomFieldTextInputProps { + customField: CustomFieldText + required: boolean + field: ControllerRenderProps +} + +const CustomFieldTextInput: React.FC = ({ + customField, + required, + field, +}) => { + if (customField.properties.textarea) { + return ( +