From 5fdc5e4ccc1c9d92c72c40187b23814abc904380 Mon Sep 17 00:00:00 2001 From: Julien Genestoux Date: Tue, 19 Mar 2024 17:41:44 -0400 Subject: [PATCH] feat(unlock-app): allowing promocode to be passed (#13489) * allowing promocode to be passed as part of the checkout config or in the URL * adding docs about the use of the promo option --- docs/docs/tools/checkout/configuration.md | 1 + packages/core/src/schema.ts | 28 ++++-- .../interface/checkout/main/Promo.tsx | 99 +++++++++++++++---- 3 files changed, 99 insertions(+), 29 deletions(-) diff --git a/docs/docs/tools/checkout/configuration.md b/docs/docs/tools/checkout/configuration.md index 8765edb1e61..e0cfe645381 100644 --- a/docs/docs/tools/checkout/configuration.md +++ b/docs/docs/tools/checkout/configuration.md @@ -46,6 +46,7 @@ The `paywallConfig` is a JSON object which includes a set of customizations for - `expectedAddress`: _optional string_. If set, the user will be asked to switch their wallet address before proceeding. This is useful if you want to ensure that the user is using the same address as the one they used to purchase a membership. - `skipSelect`: _optional boolean_. Skip selection screen if only single lock is available. +- `promo`: _optional string_. If set, it is used to pre-fill the promo code field on the checkout. a `promo` query string can also be passed as a query param when using the checkout URL. (note: requires the use of the discount code hook!) ### Locks diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 7759a441a95..db92efc377f 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -84,10 +84,16 @@ export const PaywallLockConfig = z.object({ }) .optional(), promo: z - .boolean({ - description: - 'If enabled, the user will be prompted to enter an optional promo code in order to receive discounts. Warning: This only works if the lock is connected to a hook that will handle the promo codes. This cannot be used at the same time as the "Password Required" option above', - }) + .union([ + z.boolean({ + description: + 'If enabled, the user will be prompted to enter an optional promo code in order to receive discounts. Warning: This only works if the lock is connected to a hook that will handle the promo codes. This cannot be used at the same time as the "Password Required" option above', + }), + z.string({ + description: + 'If set, the user optional promo code field will be pre-filled with this value. Warning: This only works if the lock is connected to a hook that will handle the promo codes. This cannot be used at the same time as the "Password Required" option above', + }), + ]) .optional(), emailRequired: z .boolean({ @@ -236,10 +242,16 @@ export const PaywallConfig = z }) .optional(), promo: z - .boolean({ - description: - 'If enabled, the user will be prompted to enter an optional promo code in order to receive discounts. Warning: This only works if the lock is connected to a hook that will handle the promo codes. This cannot be used at the same time as the "Password Required" option above', - }) + .union([ + z.boolean({ + description: + 'If enabled, the user will be prompted to enter an optional promo code in order to receive discounts. Warning: This only works if the lock is connected to a hook that will handle the promo codes. This cannot be used at the same time as the "Password Required" option above', + }), + z.string({ + description: + 'If set, the user optional promo code field will be pre-filled with this value. Warning: This only works if the lock is connected to a hook that will handle the promo codes. This cannot be used at the same time as the "Password Required" option above', + }), + ]) .optional(), emailRequired: z .boolean({ diff --git a/unlock-app/src/components/interface/checkout/main/Promo.tsx b/unlock-app/src/components/interface/checkout/main/Promo.tsx index c29b1f3d98b..10aee892802 100644 --- a/unlock-app/src/components/interface/checkout/main/Promo.tsx +++ b/unlock-app/src/components/interface/checkout/main/Promo.tsx @@ -14,9 +14,14 @@ import { getEthersWalletFromPassword } from '~/utils/strings' import { Web3Service } from '@unlock-protocol/unlock-js' import { useDebounce } from 'react-use' import LoadingIcon from '../../Loading' +import { useRouter } from 'next/router' interface Props { injectedProvider: unknown checkoutService: CheckoutService + recipients: string[] + lock: any + promoCode?: string + send: (obj: any) => void } interface FormData { @@ -25,13 +30,33 @@ interface FormData { const web3Service = new Web3Service(networks) -export function Promo({ injectedProvider, checkoutService }: Props) { +export const computePromoData = async (promo: string, recipients: string[]) => { + const privateKeyAccount = await getEthersWalletFromPassword(promo) + return Promise.all( + recipients.map((address) => { + const messageHash = ethers.utils.solidityKeccak256( + ['string'], + [address.toLowerCase()] + ) + const messageHashBinary = ethers.utils.arrayify(messageHash) + return privateKeyAccount.signMessage(messageHashBinary) + }) + ) +} + +export function PromoContent({ + recipients, + lock, + promoCode, + send, + injectedProvider, + checkoutService, +}: Props) { const { account } = useAuth() - const [state, send] = useActor(checkoutService) const [hookAddress, setHookAddress] = useState() - const [code, setCode] = useState() + const [code, setCode] = useState(promoCode) + const [promoCodeLoading, setPromoCodeLoading] = useState(false) const [promoCodeDetails, setPromoCodeDetails] = useState() - const { recipients, lock } = state.context const { register, handleSubmit, @@ -54,6 +79,7 @@ export function Promo({ injectedProvider, checkoutService }: Props) { useDebounce( async () => { if (hookAddress && code) { + setPromoCodeLoading(true) const privateKeyFromAccount = await getEthersWalletFromPassword(code) const promoCodeDetails = await web3Service.getDiscountHookWithCapValues( { @@ -64,26 +90,17 @@ export function Promo({ injectedProvider, checkoutService }: Props) { } ) setPromoCodeDetails(promoCodeDetails) + setPromoCodeLoading(false) } }, - 300, - [code] + 100, + [code, hookAddress, lock] ) const onSubmit = async (formData: FormData) => { try { const { promo } = formData - const privateKeyAccount = await getEthersWalletFromPassword(promo) - const data = await Promise.all( - users.map((address) => { - const messageHash = ethers.utils.solidityKeccak256( - ['string'], - [address.toLowerCase()] - ) - const messageHashBinary = ethers.utils.arrayify(messageHash) - return privateKeyAccount.signMessage(messageHashBinary) - }) - ) + const data = await computePromoData(promo, users) send({ type: 'SUBMIT_DATA', data, @@ -119,6 +136,11 @@ export function Promo({ injectedProvider, checkoutService }: Props) { return null } + let error = errors.promo?.message + if (!error && code && promoCodeDetails?.discount === 0) { + error = "We couldn't find a discount for this code. " + } + return ( @@ -128,11 +150,17 @@ export function Promo({ injectedProvider, checkoutService }: Props) { // @ts-ignore iconRight={iconRight} label="Enter Promo Code" - description="If you have a promo code to receive discounts, please enter it now." + description={ + !promoCodeDetails?.discount + ? 'If you have a promo code to receive discounts, please enter it now.' + : '' + } type="text" size="small" - {...register('promo')} - error={errors.promo?.message} + {...register('promo', { + value: code, + })} + error={error} onChange={(event: React.ChangeEvent) => { setCode(event.target.value) setPromoCodeDetails(undefined) @@ -149,7 +177,7 @@ export function Promo({ injectedProvider, checkoutService }: Props) { type="submit" form="promo" className="w-full" - disabled={isSubmitting} + disabled={isSubmitting || promoCodeLoading} loading={isSubmitting} onClick={handleSubmit(onSubmit)} > @@ -161,3 +189,32 @@ export function Promo({ injectedProvider, checkoutService }: Props) { ) } + +interface PromoProps { + injectedProvider: unknown + checkoutService: CheckoutService +} + +export function Promo({ injectedProvider, checkoutService }: PromoProps) { + const [state, send] = useActor(checkoutService) + const { query } = useRouter() + const { recipients, lock, paywallConfig } = state.context + + let promoCode = '' + if (query?.promo) { + promoCode = query?.promo.toString() + } else if (typeof paywallConfig.locks[lock!.address].promo === 'string') { + promoCode = paywallConfig.locks[lock!.address].promo as string + } + + return ( + + ) +}