diff --git a/.changeset/dirty-baboons-design.md b/.changeset/dirty-baboons-design.md new file mode 100644 index 00000000..97b37021 --- /dev/null +++ b/.changeset/dirty-baboons-design.md @@ -0,0 +1,5 @@ +--- +"@superfluid-finance/widget": patch +--- + +Add personal data handling to widget diff --git a/.changeset/thin-apples-explode.md b/.changeset/thin-apples-explode.md new file mode 100644 index 00000000..b98b9676 --- /dev/null +++ b/.changeset/thin-apples-explode.md @@ -0,0 +1,5 @@ +--- +"@superfluid-finance/widget": patch +--- + +Expose createWidgetTheme & WidgetThemeOptions diff --git a/.github/workflows/snapshot-release.yml b/.github/workflows/snapshot-release.yml index 2a192a3c..f8183268 100644 --- a/.github/workflows/snapshot-release.yml +++ b/.github/workflows/snapshot-release.yml @@ -25,6 +25,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Build Widget + run: pnpm build:widget + - name: Authenticate with NPM run: echo "//registry.npmjs.org/:_authToken=${{ secrets.NPMJS_TOKEN }}" > .npmrc @@ -32,4 +35,4 @@ jobs: run: pnpm changeset version --snapshot dev - name: Changeset publish - run: pnpm changeset publish --tag dev + run: pnpm changeset publish --tag dev \ No newline at end of file diff --git a/apps/hosted-widget/src/hooks/useLoadFromIPFS.ts b/apps/hosted-widget/src/hooks/useLoadFromIPFS.ts index 4493a2e6..f751371f 100644 --- a/apps/hosted-widget/src/hooks/useLoadFromIPFS.ts +++ b/apps/hosted-widget/src/hooks/useLoadFromIPFS.ts @@ -1,6 +1,9 @@ -import { ThemeOptions } from "@mui/material"; import * as Sentry from "@sentry/nextjs"; -import { PaymentDetails, ProductDetails } from "@superfluid-finance/widget"; +import { + PaymentDetails, + ProductDetails, + WidgetThemeOptions, +} from "@superfluid-finance/widget"; import { useEffect, useState } from "react"; const gateway = "https://cloudflare-ipfs.com"; @@ -11,7 +14,7 @@ type ExportJSON = { productDetails: ProductDetails; paymentDetails: PaymentDetails; layout: (typeof layoutTypes)[number]; - theme: Omit; + theme: WidgetThemeOptions; }; type Result = { diff --git a/apps/widget-builder/src/components/config-editor/ConfigEditor.tsx b/apps/widget-builder/src/components/config-editor/ConfigEditor.tsx index b34e0b72..2254a94a 100644 --- a/apps/widget-builder/src/components/config-editor/ConfigEditor.tsx +++ b/apps/widget-builder/src/components/config-editor/ConfigEditor.tsx @@ -15,6 +15,7 @@ import { } from "@mui/material"; import { paymentDetailsSchema, + personalDataSchema, productDetailsSchema, } from "@superfluid-finance/widget"; import { FC, useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -34,6 +35,7 @@ type ConfigEditorProps = { const schema = z.object({ productDetails: productDetailsSchema, paymentDetails: paymentDetailsSchema, + personalData: personalDataSchema, type: z.enum(["dialog", "drawer", "full-screen", "page"]), }); @@ -103,6 +105,7 @@ const ConfigEditor: FC = ({ value, setValue }) => { setValue("productDetails", parseResult.data.productDetails); setValue("type", parseResult.data.type); setValue("paymentDetails", parseResult.data.paymentDetails); + setValue("personalData", parseResult.data.personalData); setSaved(true); setTimeout(() => { setSaved(false); diff --git a/apps/widget-builder/src/components/nft-deployment-modal/NFTDeploymentDialog.tsx b/apps/widget-builder/src/components/nft-deployment-modal/NFTDeploymentDialog.tsx index 29c2eb18..6fc65cca 100644 --- a/apps/widget-builder/src/components/nft-deployment-modal/NFTDeploymentDialog.tsx +++ b/apps/widget-builder/src/components/nft-deployment-modal/NFTDeploymentDialog.tsx @@ -78,6 +78,7 @@ const NFTDeploymentDialog: FC = ({ /> {`You deployed an NFT contract for ${successfulDeployments} networks.${ diff --git a/apps/widget-builder/src/components/product-editor/ProductEditor.tsx b/apps/widget-builder/src/components/product-editor/ProductEditor.tsx index 6a9cfb09..396c815a 100644 --- a/apps/widget-builder/src/components/product-editor/ProductEditor.tsx +++ b/apps/widget-builder/src/components/product-editor/ProductEditor.tsx @@ -1,6 +1,17 @@ import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh"; -import { Box, Fab, Stack, TextField, Tooltip, Typography } from "@mui/material"; -import { FC } from "react"; +import { + Box, + Fab, + Stack, + TextField, + Tooltip, + Typography, +} from "@mui/material"; +import { + PersonalDataField, + PersonalDataFieldType, +} from "@superfluid-finance/widget/utils"; +import { FC, useState } from "react"; import { Controller, useFieldArray, useFormContext } from "react-hook-form"; import useDemoMode from "../../hooks/useDemoMode"; @@ -11,14 +22,31 @@ import { WidgetProps } from "../widget-preview/WidgetPreview"; const ProductEditor: FC = () => { const { control, watch } = useFormContext(); + const [selectedPersonalDataFields, setSelectedPersonalDataFields] = useState< + Partial> + >({}); + + watch(["paymentDetails.paymentOptions"]); const { fields, append, remove } = useFieldArray({ control, - name: "paymentDetails.paymentOptions", // unique name for your Field Array + name: "personalData", }); - - const [paymentOptions] = watch(["paymentDetails.paymentOptions"]); const { setDemoProductDetails } = useDemoMode(); + const onPersonalDataSelectionChange = (field: PersonalDataField) => { + const isFieldSelected = + selectedPersonalDataFields[field.name as PersonalDataFieldType]; + + const index = fields.findIndex(({ name }) => name === field.name); + + isFieldSelected ? remove(index) : append(field); + + setSelectedPersonalDataFields((prev) => ({ + ...prev, + [field.name]: !isFieldSelected, + })); + }; + return ( <> diff --git a/apps/widget-builder/src/components/widget-preview/WidgetPreview.tsx b/apps/widget-builder/src/components/widget-preview/WidgetPreview.tsx index 70ebd5c0..e126041f 100644 --- a/apps/widget-builder/src/components/widget-preview/WidgetPreview.tsx +++ b/apps/widget-builder/src/components/widget-preview/WidgetPreview.tsx @@ -1,6 +1,7 @@ import { colors, Fab, SelectChangeEvent, ThemeOptions } from "@mui/material"; import SuperfluidWidget, { PaymentOption, + PersonalData, ProductDetails, WalletManager, WidgetProps as WidgetProps_, @@ -55,6 +56,7 @@ export type WidgetProps = { paymentDetails: WidgetProps_["paymentDetails"] & { paymentOptions: PaymentOption[]; }; + personalData: PersonalData; displaySettings: DisplaySettings; type: Layout; }; @@ -74,6 +76,7 @@ export const WidgetContext = createContext({ paymentOptions: [], }, type: "dialog", + personalData: [], displaySettings: { stepperOrientation: "vertical", darkMode: false, @@ -95,6 +98,7 @@ const switchLayout = ( layout: Layout, productDetails: ProductDetails, paymentDetails: WidgetProps_["paymentDetails"], + personalData: WidgetProps_["personalData"], theme: ThemeOptions, walletManager: WalletManager, stepperOrientation: "vertical" | "horizontal", @@ -103,19 +107,40 @@ const switchLayout = ( { console.log("onButtonClick eventListener") }, + } + } + callbacks={ + { + // onButtonClick: () => { console.log("onButtonClick callback") }, + // validatePersonalData: async () => { + // return new Promise((resolve) => { + // setTimeout(() => { + // resolve({ + // email: { + // success: false, + // message: "Async validation failed!", + // }, + // }); + // }, 1000); + // }); + // }, + } + } /> ) : ( = (props) => { - const { displaySettings, paymentDetails, productDetails, type } = props; + const { + displaySettings, + paymentDetails, + productDetails, + personalData, + type, + } = props; const { open, isOpen, setDefaultChain } = useWeb3Modal(); const walletManager = useMemo( @@ -208,6 +239,7 @@ const WidgetPreview: FC = (props) => { type, productDetails, paymentDetails, + personalData, theme, walletManager, displaySettings.stepperOrientation, diff --git a/apps/widget-builder/src/hooks/useDemoMode.ts b/apps/widget-builder/src/hooks/useDemoMode.ts index 3d4e05c0..3e6f1e39 100644 --- a/apps/widget-builder/src/hooks/useDemoMode.ts +++ b/apps/widget-builder/src/hooks/useDemoMode.ts @@ -140,6 +140,7 @@ const defaultDisplaySettings: DisplaySettings = { export const defaultWidgetProps: WidgetProps = { productDetails: defaultProductDetails, paymentDetails: defaultPaymentDetails, + personalData: [], type, displaySettings: defaultDisplaySettings, }; diff --git a/apps/widget-builder/src/pages/builder.tsx b/apps/widget-builder/src/pages/builder.tsx index c0c1db6f..fe0a0d6a 100644 --- a/apps/widget-builder/src/pages/builder.tsx +++ b/apps/widget-builder/src/pages/builder.tsx @@ -74,12 +74,14 @@ export default function Builder() { const { watch, control, getValues, setValue } = formMethods; - const [productDetails, paymentDetails, displaySettings, type] = watch([ - "productDetails", - "paymentDetails", - "displaySettings", - "type", - ]); + const [productDetails, paymentDetails, personalData, displaySettings, type] = + watch([ + "productDetails", + "paymentDetails", + "personalData", + "displaySettings", + "type", + ]); const [isConfigEditorOpen, setConfigEditorOpen] = useState(false); @@ -229,6 +231,7 @@ export default function Builder() { {...{ productDetails, paymentDetails, + personalData, displaySettings, type, }} diff --git a/apps/widget-builder/src/types/export-json.ts b/apps/widget-builder/src/types/export-json.ts index 8863ea11..af2ce7d7 100644 --- a/apps/widget-builder/src/types/export-json.ts +++ b/apps/widget-builder/src/types/export-json.ts @@ -1,5 +1,8 @@ -import { ThemeOptions } from "@mui/material"; -import { ProductDetails, WidgetProps } from "@superfluid-finance/widget"; +import { + ProductDetails, + WidgetProps, + WidgetThemeOptions, +} from "@superfluid-finance/widget"; import { Layout } from "../components/widget-preview/WidgetPreview"; @@ -7,5 +10,5 @@ export type ExportJSON = { productDetails: ProductDetails; paymentDetails: WidgetProps["paymentDetails"]; type: Layout; - theme: Omit; + theme: WidgetThemeOptions; }; diff --git a/packages/widget/package.json b/packages/widget/package.json index e4763cfd..1b3c110b 100644 --- a/packages/widget/package.json +++ b/packages/widget/package.json @@ -24,6 +24,10 @@ "./metadata": { "types": "./dist/index.metadata.d.ts", "default": "./dist/index.metadata.js" + }, + "./utils": { + "types": "./dist/utils.d.ts", + "default": "./dist/utils.js" } }, "main": "dist/index.js", @@ -41,6 +45,9 @@ ], "metadata": [ "./dist/index.metadata.d.ts" + ], + "utils": [ + "./dist/utils.d.ts" ] } }, diff --git a/packages/widget/src/AccountAddressCard.tsx b/packages/widget/src/AccountAddressCard.tsx index 445f3963..62fc26a2 100644 --- a/packages/widget/src/AccountAddressCard.tsx +++ b/packages/widget/src/AccountAddressCard.tsx @@ -13,7 +13,6 @@ import { useCallback, useState } from "react"; import { Address } from "viem"; import { AccountAddress } from "./AccountAddress.js"; -import { runEventListener } from "./EventListeners.js"; import { normalizeIcon } from "./helpers/normalizeIcon.js"; import { copyToClipboard } from "./utils.js"; import { useWidget } from "./WidgetContext.js"; @@ -37,10 +36,10 @@ export function AccountAddressCard({ }).toDataURL(); const [copied, setCopied] = useState(false); - const { eventListeners } = useWidget(); + const { eventHandlers } = useWidget(); const onCopyAddressButtonClick = useCallback( async (checksumAddress: string) => { - runEventListener(eventListeners.onButtonClick, { + eventHandlers.onButtonClick({ type: "switch_network", }); await copyToClipboard(checksumAddress); @@ -48,7 +47,7 @@ export function AccountAddressCard({ const timeoutId = setTimeout(() => setCopied(false), 1000); return () => clearTimeout(timeoutId); }, - [eventListeners.onButtonClick], + [eventHandlers.onButtonClick], ); return ( diff --git a/packages/widget/src/Callbacks.ts b/packages/widget/src/Callbacks.ts new file mode 100644 index 00000000..c987b179 --- /dev/null +++ b/packages/widget/src/Callbacks.ts @@ -0,0 +1,17 @@ +import { EventListeners, PersonalData } from "./index.js"; +import { Errors } from "./utils.js"; + +/** + * A set of blocking callback functions that are called at the appropriate time. + * @example + * { fetch('https://example.com', { method: 'POST', body: props?.data }) }}, + * }} /> + */ + +export interface Callbacks extends EventListeners { + /** Called when the user clicks the "Continue" button in the personal data step. */ + validatePersonalData?: ( + data: PersonalData, + ) => Errors | void | Promise; +} diff --git a/packages/widget/src/CheckoutConfig.ts b/packages/widget/src/CheckoutConfig.ts index 6f08ce98..59431ab8 100644 --- a/packages/widget/src/CheckoutConfig.ts +++ b/packages/widget/src/CheckoutConfig.ts @@ -1,7 +1,7 @@ -import { ThemeOptions } from "@mui/material"; import { SuperTokenList } from "@superfluid-finance/tokenlist"; import { z } from "zod"; +import { Callbacks } from "./Callbacks.js"; import { NetworkAssets, PaymentDetails, @@ -9,7 +9,9 @@ import { ProductDetails, productDetailsSchema, } from "./core/index.js"; +import { personalDataSchema } from "./core/PersonalData.js"; import { EventListeners } from "./EventListeners.js"; +import { WidgetThemeOptions } from "./theme.js"; import { WalletManager } from "./WalletManager.js"; export const checkoutConfigSchema = z.object({ @@ -45,9 +47,8 @@ const widgetPropsSchema = z.object({ /** * The MUI theme object to style the widget. Learn more about it from the MUI documentation: https://mui.com/material-ui/customization/default-theme/ */ - theme: z - .custom>() - .optional(), + theme: z.custom().optional(), + personalData: personalDataSchema.optional(), /** * Whether the stepper UI component inside the widget is vertical or horizontal. Vertical is better supported. */ @@ -64,6 +65,7 @@ const widgetPropsSchema = z.object({ * @inheritdoc EventListeners */ eventListeners: z.custom().optional(), + callbacks: z.custom().optional(), networkAssets: z.custom().optional(), }); diff --git a/packages/widget/src/CheckoutSummary.tsx b/packages/widget/src/CheckoutSummary.tsx index b135121b..2d13cad0 100644 --- a/packages/widget/src/CheckoutSummary.tsx +++ b/packages/widget/src/CheckoutSummary.tsx @@ -1,15 +1,17 @@ import { Box, Button, Stack, Typography, useTheme } from "@mui/material"; import { useCallback, useEffect, useMemo } from "react"; +import { useFormContext } from "react-hook-form"; import { useAccount } from "wagmi"; import { AccountAddressCard } from "./AccountAddressCard.js"; import { useCommandHandler } from "./CommandHandlerContext.js"; import { SubscribeCommand } from "./commands.js"; import { mapTimePeriodToSeconds } from "./core/index.js"; -import { runEventListener } from "./EventListeners.js"; import FlowingBalance from "./FlowingBalance.js"; +import { DraftFormValues } from "./formValues.js"; import StreamIndicator from "./StreamIndicator.js"; import SuccessImage from "./SuccessImage.js"; +import { mapPersonalDataToObject } from "./utils.js"; import { useWidget } from "./WidgetContext.js"; export function CheckoutSummary() { @@ -18,9 +20,12 @@ export function CheckoutSummary() { const { getSuperToken, productDetails: { successURL, successText = "Continue to Merchant" }, - eventListeners, + eventHandlers, } = useWidget(); + const { watch } = useFormContext(); + const [personalData] = watch(["personalData"]); + const { address: accountAddress } = useAccount(); const { commands } = useCommandHandler(); @@ -52,29 +57,30 @@ export function CheckoutSummary() { ); useEffect(() => { - runEventListener(eventListeners.onRouteChange, { + eventHandlers.onRouteChange({ route: "success_summary", + ...mapPersonalDataToObject(personalData), }); - }, [eventListeners.onRouteChange]); + }, [eventHandlers.onRouteChange]); // Note: calling "onSuccess" through the "useEffect" hook is not optimal. // We make the assumption that "CheckoutSummary" is only rendered when the checkout is successful. // A more proper place would be inside a central state machine. useEffect(() => { - runEventListener(eventListeners.onSuccess); - }, [eventListeners.onSuccess]); + eventHandlers.onSuccess(); + }, [eventHandlers.onSuccess]); const onSuccessButtonClick = useCallback(() => { - runEventListener(eventListeners.onSuccessButtonClick); - runEventListener(eventListeners.onButtonClick, { type: "success_button" }); - }, [eventListeners.onSuccessButtonClick, eventListeners.onButtonClick]); + eventHandlers.onSuccessButtonClick(); + eventHandlers.onButtonClick({ type: "success_button" }); + }, [eventHandlers.onSuccessButtonClick, eventHandlers.onButtonClick]); const onOpenSuperfluidDashboardButtonClick = useCallback( () => - runEventListener(eventListeners.onButtonClick, { + eventHandlers.onButtonClick({ type: "superfluid_dashboard", }), - [eventListeners.onButtonClick], + [eventHandlers.onButtonClick], ); return ( diff --git a/packages/widget/src/ContractWriteButton.tsx b/packages/widget/src/ContractWriteButton.tsx index 18a18fb5..b43f4899 100644 --- a/packages/widget/src/ContractWriteButton.tsx +++ b/packages/widget/src/ContractWriteButton.tsx @@ -7,7 +7,6 @@ import { useCallback, useEffect, useState } from "react"; import { useAccount, useNetwork, useSwitchNetwork } from "wagmi"; import { ContractWriteResult } from "./ContractWriteManager.js"; -import { runEventListener } from "./EventListeners.js"; import { normalizeIcon } from "./helpers/normalizeIcon.js"; import { useWidget } from "./WidgetContext.js"; @@ -29,7 +28,7 @@ export default function ContractWriteButton({ transactionResult, currentError, }: ContractWriteButtonProps) { - const { eventListeners } = useWidget(); + const { eventHandlers } = useWidget(); const write = writeResult.write; const isLoading = @@ -44,30 +43,30 @@ export default function ContractWriteButton({ const needsToSwitchNetwork = expectedChainId !== chain?.id; const onSwitchNetworkButtonClick = useCallback(() => { - runEventListener(eventListeners.onButtonClick, { type: "switch_network" }); + eventHandlers.onButtonClick({ type: "switch_network" }); switchNetwork?.(expectedChainId); - }, [switchNetwork, expectedChainId, eventListeners.onButtonClick]); + }, [switchNetwork, expectedChainId, eventHandlers.onButtonClick]); const onContractWriteButtonClick = useCallback(() => { - runEventListener(eventListeners.onButtonClick, { + eventHandlers.onButtonClick({ type: "invoke_transaction", }); write?.(); - }, [write, eventListeners.onButtonClick]); + }, [write, eventHandlers.onButtonClick]); const onRetryTransactionButtonClick = useCallback(() => { - runEventListener(eventListeners.onButtonClick, { + eventHandlers.onButtonClick({ type: "retry_gas_estimation", }); prepareResult.refetch(); - }, [prepareResult.refetch, eventListeners.onButtonClick]); + }, [prepareResult.refetch, eventHandlers.onButtonClick]); const onForceTransactionButtonClick = useCallback(() => { - runEventListener(eventListeners.onButtonClick, { + eventHandlers.onButtonClick({ type: "force_invoke_transaction", }); write!(); - }, [write, eventListeners.onButtonClick]); + }, [write, eventHandlers.onButtonClick]); const isPrepareError = Boolean( currentError && @@ -81,12 +80,12 @@ export default function ContractWriteButton({ (!isLastWrite || connector?.id === "safe"); // Don't show the button for the last contract write, unless Gnosis Safe. It would be confusing to show the success screen when possibly the last TX fails. const onSkipButtonClick = useCallback(() => { - runEventListener(eventListeners.onButtonClick, { + eventHandlers.onButtonClick({ type: "skip_to_next", }); handleNextWrite_(); setAllowNextWriteButton(false); - }, [handleNextWrite_, eventListeners.onButtonClick]); + }, [handleNextWrite_, eventHandlers.onButtonClick]); useEffect(() => { if (transactionResult.isLoading) { diff --git a/packages/widget/src/ContractWriteManager.tsx b/packages/widget/src/ContractWriteManager.tsx index 08fdb38f..4489c13d 100644 --- a/packages/widget/src/ContractWriteManager.tsx +++ b/packages/widget/src/ContractWriteManager.tsx @@ -51,7 +51,7 @@ export function ContractWriteManager({ const prepare = accountAddress && _prepare && contractWrite.chainId === chain?.id; - const { eventListeners } = useWidget(); + const { eventHandlers } = useWidget(); const prepareResult = usePrepareContractWrite({ ...(prepare @@ -87,7 +87,7 @@ export function ContractWriteManager({ : {}), onError: console.error, onSuccess: ({ hash }) => - eventListeners.onTransactionSent?.({ + eventHandlers.onTransactionSent?.({ hash, functionName: contractWrite.functionName as TxFunctionName, }), diff --git a/packages/widget/src/ContractWriteStatus.tsx b/packages/widget/src/ContractWriteStatus.tsx index 8fb6a323..50da81f8 100644 --- a/packages/widget/src/ContractWriteStatus.tsx +++ b/packages/widget/src/ContractWriteStatus.tsx @@ -22,7 +22,6 @@ import { useNetwork } from "wagmi"; import { useCommandHandler } from "./CommandHandlerContext.js"; import { ContractWriteResult } from "./ContractWriteManager.js"; import { errorsABI } from "./core/wagmi-generated.js"; -import { runEventListener } from "./EventListeners.js"; import { normalizeIcon } from "./helpers/normalizeIcon.js"; import { useWidget } from "./WidgetContext.js"; @@ -39,13 +38,13 @@ export function ContractWriteStatus({ index: number; }) { const { writeIndex } = useCommandHandler(); - const { eventListeners } = useWidget(); + const { eventHandlers } = useWidget(); const onViewOnBlockExplorerButtonClick = useCallback(() => { - runEventListener(eventListeners.onButtonClick, { + eventHandlers.onButtonClick({ type: "view_transaction_on_block_explorer", }); - }, [eventListeners.onButtonClick]); + }, [eventHandlers.onButtonClick]); const { contractWrite: { displayTitle }, diff --git a/packages/widget/src/EventListeners.ts b/packages/widget/src/EventListeners.ts index bd69787c..24cbc5fa 100644 --- a/packages/widget/src/EventListeners.ts +++ b/packages/widget/src/EventListeners.ts @@ -45,16 +45,20 @@ export interface EventListeners { | "step_payment_option" | "step_wrap" | "step_review" + | "step_personal_data" | "transactions" | "success_summary"; + data?: Record; }) => void; + /** Called when any personalData fields update. */ + onPersonalDataUpdate?: (props?: { data?: Record }) => void; /** Called when a transaction is executed. */ onTransactionSent?: (props?: { hash?: Hash; functionName?: TxFunctionName; }) => void; /** Called when the checkout is successfully finished. - * @deprecated Use `onTransactionExecuted` instead (filter for `functionName === 'createFlow | 'updateFlow'`). + * @deprecated Use `onTransactionSent` instead (filter for `functionName === 'createFlow | 'updateFlow'`). */ onSuccess?: () => void; /** Called when the merchant's success button is defined in the schema and it's clicked. */ @@ -65,12 +69,35 @@ export interface EventListeners { onPaymentOptionUpdate?: (paymentOption?: PaymentOption) => void; } +/** + * Combines both non-blocking event listeners and blocking event callbacks. + */ +export interface EventHandlers extends Required {} + +/** + * Runs both the non-blocking event listener and the blocking callback. + */ +export const runEventHandlers = ( + listener?: (args?: T) => R, + callback?: (args?: T) => R, +) => { + return (arg?: T) => { + if (listener) { + runEventListener(listener, arg); + } + + if (callback) { + callback(arg); + } + }; +}; + /** * Run the event callback in non-blocking manner. */ -export const runEventListener = ( - func: (args?: T) => void, - args?: T, +const runEventListener = ( + func: (args?: T) => R, + arg?: T, ): void => { - setTimeout(() => func(args), 0); + setTimeout(() => func(arg), 0); }; diff --git a/packages/widget/src/FormEffects.tsx b/packages/widget/src/FormEffects.tsx index 07a89d11..9eeb250d 100644 --- a/packages/widget/src/FormEffects.tsx +++ b/packages/widget/src/FormEffects.tsx @@ -16,7 +16,7 @@ export function FormEffects() { // formState: { isValid, errors }, Creates form state subscription. } = useFormContext(); - const { paymentDetails } = useWidget(); + const { paymentDetails, personalData } = useWidget(); const [network, paymentOptionWithTokenInfo, flowRate] = watch([ "network", @@ -66,6 +66,10 @@ export function FormEffects() { }); }, [paymentOptionWithTokenInfo]); + useEffect(() => { + setValue("personalData", personalData); + }, [personalData]); + // # Change initial wrap amount when flow rate changes. useEffect(() => { if (paymentOptionWithTokenInfo) { @@ -115,12 +119,12 @@ export function FormEffects() { } }, [address]); - const { eventListeners } = useWidget(); + const { eventHandlers } = useWidget(); useEffect(() => { - eventListeners.onPaymentOptionUpdate( + eventHandlers.onPaymentOptionUpdate( paymentOptionWithTokenInfo?.paymentOption, ); - }, [eventListeners.onPaymentOptionUpdate, paymentOptionWithTokenInfo]); + }, [eventHandlers.onPaymentOptionUpdate, paymentOptionWithTokenInfo]); return null; } diff --git a/packages/widget/src/FormProvider.tsx b/packages/widget/src/FormProvider.tsx index 37bb35a9..625a6b8a 100644 --- a/packages/widget/src/FormProvider.tsx +++ b/packages/widget/src/FormProvider.tsx @@ -19,7 +19,8 @@ type Props = { export default function FormProvider({ children }: Props) { const { chain } = useNetwork(); - const { networks, paymentOptionWithTokenInfoList } = useWidget(); + const { networks, personalData, paymentOptionWithTokenInfoList } = + useWidget(); const defaultNetwork = useMemo(() => { if (networks.length === 1) { @@ -59,6 +60,7 @@ export default function FormProvider({ children }: Props) { )}` : "0", enableAutoWrap: false, + personalData, flowRate: defaultPaymentOption?.paymentOption?.flowRate ?? { amountEther: "0", period: "month", diff --git a/packages/widget/src/PersonalDataFields.ts b/packages/widget/src/PersonalDataFields.ts new file mode 100644 index 00000000..b416d83b --- /dev/null +++ b/packages/widget/src/PersonalDataFields.ts @@ -0,0 +1,41 @@ +import { PersonalDataInput } from "./core/PersonalData.js"; +import { serializeRegExp } from "./utils.js"; + +export const EmailField = { + name: "email", + label: "E-mail", + optional: false, + required: { + pattern: serializeRegExp( + /^([a-zA-Z0-9_\-\.]+)@([a-zA-Z0-9_\-\.]+)\.([a-zA-Z]{2,24})$/g, + ), + message: "Invalid email address", + }, +} as const satisfies PersonalDataInput; + +export const EmailWithAliasField = { + name: "email", + label: "E-mail", + optional: false, + required: { + pattern: serializeRegExp( + /^([a-zA-Z0-9_\-\.+\]+)@([a-zA-Z0-9_\-\.]+)\.([a-zA-Z]{2,24})$/g, + ), + message: "Invalid email address", + }, +} as const satisfies PersonalDataInput; + +export const PhoneNumberField = { + name: "phone", + label: "Phone number", + optional: false, + required: { + pattern: serializeRegExp( + /^[\+]?[(]?[0-9]{3}[)]?[-\s\.]?[0-9]{3}[-\s\.]?[0-9]{4,6}$/im, + ), + message: "Invalid phone number", + }, +} as const satisfies PersonalDataInput; + +export type PersonalDataField = typeof EmailField | typeof PhoneNumberField; +export type PersonalDataFieldType = Lowercase; diff --git a/packages/widget/src/StepContentPaymentOption.tsx b/packages/widget/src/StepContentPaymentOption.tsx index a9d001db..a33c1ba5 100644 --- a/packages/widget/src/StepContentPaymentOption.tsx +++ b/packages/widget/src/StepContentPaymentOption.tsx @@ -3,7 +3,6 @@ import { useCallback, useEffect, useState } from "react"; import { Controller, useFormContext } from "react-hook-form"; import { useAccount } from "wagmi"; -import { runEventListener } from "./EventListeners.js"; import FlowRateInput from "./FlowRateInput.js"; import { DraftFormValues } from "./formValues.js"; import NetworkAutocomplete from "./NetworkAutocomplete.js"; @@ -41,20 +40,20 @@ export default function StepContentPaymentOption({ stepIndex }: StepProps) { const { walletManager: { open: openWalletManager }, - eventListeners, + eventHandlers, getNetwork, } = useWidget(); useEffect(() => { - runEventListener(eventListeners.onRouteChange, { + eventHandlers.onRouteChange({ route: "step_payment_option", }); - }, [eventListeners.onRouteChange]); + }, [eventHandlers.onRouteChange]); const onContinue = useCallback(() => { - runEventListener(eventListeners.onButtonClick, { type: "next_step" }); + eventHandlers.onButtonClick({ type: "next_step" }); handleNext(stepIndex); - }, [handleNext, eventListeners.onButtonClick]); + }, [handleNext, eventHandlers.onButtonClick]); return ( - eventListeners.onButtonClick({ type: "connect_wallet" }), - ); + eventHandlers.onButtonClick({ type: "connect_wallet" }); }} > Connect Wallet to Continue diff --git a/packages/widget/src/StepContentPersonalData.tsx b/packages/widget/src/StepContentPersonalData.tsx new file mode 100644 index 00000000..f504285a --- /dev/null +++ b/packages/widget/src/StepContentPersonalData.tsx @@ -0,0 +1,188 @@ +import { Stack, TextField } from "@mui/material"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { FieldArray, useFieldArray, useFormContext } from "react-hook-form"; + +import { PersonalData } from "./core/PersonalData.js"; +import { DraftFormValues } from "./formValues.js"; +import { StepProps } from "./Stepper.js"; +import { useStepper } from "./StepperContext.js"; +import { StepperCTAButton } from "./StepperCTAButton.js"; +import { deserializeRegExp, Errors, mapPersonalDataToObject } from "./utils.js"; +import { useWidget } from "./WidgetContext.js"; + +const validatePersonalData = (inputs: PersonalData) => + inputs.reduce( + (acc, { name, required, value }) => { + if ( + required?.pattern && + !deserializeRegExp(required.pattern).test(value ?? "") + ) { + return { + ...acc, + [name]: { + success: false, + message: required.message, + }, + }; + } + + return { + ...acc, + [name]: { + success: true, + }, + }; + }, + {} as Record, + ); + +export default function StepContentCustomData({ stepIndex }: StepProps) { + const { control: c, setValue } = useFormContext(); + + const { fields, update } = useFieldArray({ + control: c, + name: "personalData", + }); + + const [errors, setErrors] = useState(); + + const [externallyValidating, setExternallyValidating] = useState(false); + + const onChange = useCallback( + ( + field: FieldArray, + value: string, + index: number, + ) => { + update(index, { + ...field, + value, + }); + }, + [fields], + ); + + const { eventHandlers, callbacks } = useWidget(); + const { handleNext } = useStepper(); + + useEffect(() => { + eventHandlers.onRouteChange({ + route: "step_personal_data", + }); + }, [eventHandlers.onRouteChange]); + + useEffect(() => { + eventHandlers.onPersonalDataUpdate({ + ...mapPersonalDataToObject(fields), + }); + }, [eventHandlers.onPersonalDataUpdate, setErrors, fields]); + + const validationResult = useMemo( + () => validatePersonalData(fields), + [fields], + ); + + useEffect(() => { + const result = Object.entries(validationResult).find( + ([_, { success }]) => success, + ); + if (result) { + setErrors({ ...errors, [result[0]]: result[1] }); + } + }, [validationResult]); + + const onContinue = useCallback(async () => { + const isInternallyValid = Object.values(validationResult).every( + (result) => result.success, + ); + + if (isInternallyValid) { + setExternallyValidating(true); + const externalValidationResult = + await callbacks.validatePersonalData(fields); + + const isExternallyValid = Object.values( + externalValidationResult ?? {}, + ).every((result) => result?.success); + + setExternallyValidating(false); + + if (isExternallyValid) { + handleNext(stepIndex); + } else { + setErrors(externalValidationResult as Errors); + } + } else { + setErrors(validationResult); + } + }, [handleNext, stepIndex, validationResult]); + + const validateField = useCallback( + (key: string) => { + if (errors && errors[key]?.success === false) { + return { + hasError: true, + message: errors[key]?.message ?? "", + }; + } + + return { + hasError: false, + }; + }, + [errors], + ); + + return ( + + + + + {fields.map((field, i) => { + const { hasError, message } = validateField(field.name); + + return ( + onChange(field, target.value, i)} + sx={{ + ...(field.size === "half" + ? { + width: "calc(50% - 8px)", + } + : {}), + }} + /> + ); + })} + + + + Continue + + + + + ); +} diff --git a/packages/widget/src/StepContentReview.tsx b/packages/widget/src/StepContentReview.tsx index 90824f6d..91991bf4 100644 --- a/packages/widget/src/StepContentReview.tsx +++ b/packages/widget/src/StepContentReview.tsx @@ -3,7 +3,6 @@ import { Fragment, useCallback, useEffect } from "react"; import { useQuery } from "wagmi"; import { useCommandHandler } from "./CommandHandlerContext.js"; -import { runEventListener } from "./EventListeners.js"; import { CommandPreview } from "./previews/CommandPreview.js"; import { StepProps } from "./Stepper.js"; import { useStepper } from "./StepperContext.js"; @@ -16,16 +15,16 @@ export default function StepContentReview({ stepIndex }: StepProps) { const { handleNext } = useStepper(); - const { eventListeners } = useWidget(); + const { eventHandlers } = useWidget(); useEffect(() => { - runEventListener(eventListeners.onRouteChange, { route: "step_review" }); - }, [eventListeners.onRouteChange]); + eventHandlers.onRouteChange({ route: "step_review" }); + }, [eventHandlers.onRouteChange]); const onContinue = useCallback(() => { - runEventListener(eventListeners.onButtonClick, { type: "next_step" }); + eventHandlers.onButtonClick({ type: "next_step" }); handleNext(stepIndex); - }, [handleNext, eventListeners.onButtonClick, stepIndex]); + }, [handleNext, eventHandlers.onButtonClick, stepIndex]); const commandValidationSchema = useCommandValidationSchema(); diff --git a/packages/widget/src/StepContentTransactions.tsx b/packages/widget/src/StepContentTransactions.tsx index 8b6f6123..8c524bec 100644 --- a/packages/widget/src/StepContentTransactions.tsx +++ b/packages/widget/src/StepContentTransactions.tsx @@ -10,19 +10,24 @@ import { Typography, } from "@mui/material"; import { useCallback, useEffect } from "react"; +import { useFormContext } from "react-hook-form"; import { useCommandHandler } from "./CommandHandlerContext.js"; import ContractWriteButton from "./ContractWriteButton.js"; import { ContractWriteStatus } from "./ContractWriteStatus.js"; -import { runEventListener } from "./EventListeners.js"; +import { DraftFormValues } from "./formValues.js"; import { normalizeIcon } from "./helpers/normalizeIcon.js"; import { StepProps } from "./Stepper.js"; import { useStepper } from "./StepperContext.js"; +import { mapPersonalDataToObject } from "./utils.js"; import { useWidget } from "./WidgetContext.js"; const CloseIcon = normalizeIcon(CloseIcon_); export function StepContentTransactions({ stepIndex }: StepProps) { + const { watch } = useFormContext(); + const [personalData] = watch(["personalData"]); + const { handleBack, handleNext, setActiveStep, totalSteps } = useStepper(); const { @@ -32,11 +37,14 @@ export function StepContentTransactions({ stepIndex }: StepProps) { handleNextWrite: handleNextWrite_, } = useCommandHandler(); // Cleaner to pass with props. - const { eventListeners } = useWidget(); + const { eventHandlers } = useWidget(); useEffect(() => { - runEventListener(eventListeners.onRouteChange, { route: "transactions" }); - }, [eventListeners.onRouteChange]); + eventHandlers.onRouteChange({ + route: "transactions", + ...mapPersonalDataToObject(personalData), + }); + }, [eventHandlers.onRouteChange]); useEffect(() => { if (writeIndex > 0 && writeIndex === contractWriteResults.length) { @@ -46,11 +54,11 @@ export function StepContentTransactions({ stepIndex }: StepProps) { }, [writeIndex, contractWriteResults, handleNext, totalSteps]); const onBack = useCallback(() => { - runEventListener(eventListeners.onButtonClick, { + eventHandlers.onButtonClick({ type: "back_transactions", }); handleBack(stepIndex); - }, [handleBack, eventListeners.onButtonClick, stepIndex]); + }, [handleBack, eventHandlers.onButtonClick, stepIndex]); const total = contractWrites.length; const lastWriteIndex = Math.max(total - 1, 0); diff --git a/packages/widget/src/StepContentWrap.tsx b/packages/widget/src/StepContentWrap.tsx index 3f4c5c3f..40b6fa2d 100644 --- a/packages/widget/src/StepContentWrap.tsx +++ b/packages/widget/src/StepContentWrap.tsx @@ -20,14 +20,16 @@ import { import { Controller, useFormContext } from "react-hook-form"; import { Address, useBalance } from "wagmi"; -import { runEventListener } from "./EventListeners.js"; import { DraftFormValues } from "./formValues.js"; import { UpgradeIcon } from "./previews/CommandPreview.js"; import { StepProps } from "./Stepper.js"; import { useStepper } from "./StepperContext.js"; import { StepperCTAButton } from "./StepperCTAButton.js"; import { TokenAvatar } from "./TokenAvatar.js"; -import { mapFlowRateToDefaultWrapAmount } from "./utils.js"; +import { + mapFlowRateToDefaultWrapAmount, + mapPersonalDataToObject, +} from "./utils.js"; import { useWidget } from "./WidgetContext.js"; interface WrapCardProps extends PropsWithChildren { @@ -107,14 +109,16 @@ export default function StepContentWrap({ stepIndex }: StepProps) { const { paymentDetails } = useWidget(); - const [accountAddress, paymentOptionWithTokenInfo, flowRate] = watch([ - "accountAddress", - "paymentOptionWithTokenInfo", - "flowRate", - ]); + const [accountAddress, paymentOptionWithTokenInfo, flowRate, personalData] = + watch([ + "accountAddress", + "paymentOptionWithTokenInfo", + "flowRate", + "personalData", + ]); const superToken = paymentOptionWithTokenInfo?.superToken; - const { getUnderlyingToken, eventListeners } = useWidget(); + const { getUnderlyingToken, eventHandlers } = useWidget(); // Find the underlying token of the Super Token. const underlyingToken = useMemo(() => { @@ -170,19 +174,22 @@ export default function StepContentWrap({ stepIndex }: StepProps) { const { handleNext } = useStepper(); useEffect(() => { - runEventListener(eventListeners.onRouteChange, { route: "step_wrap" }); - }, [eventListeners.onRouteChange]); + eventHandlers.onRouteChange({ + route: "step_wrap", + ...mapPersonalDataToObject(personalData), + }); + }, [eventHandlers.onRouteChange]); const onContinue = useCallback(() => { - runEventListener(eventListeners.onButtonClick, { type: "next_step" }); + eventHandlers.onButtonClick({ type: "next_step" }); handleNext(stepIndex); - }, [handleNext, eventListeners.onButtonClick, stepIndex]); + }, [handleNext, eventHandlers.onButtonClick, stepIndex]); const onSkipWrapping = useCallback(() => { - runEventListener(eventListeners.onButtonClick, { type: "skip_step" }); + eventHandlers.onButtonClick({ type: "skip_step" }); setValue("wrapAmountInUnits", "" as `${number}`); handleNext(stepIndex); - }, [handleNext, setValue, eventListeners.onButtonClick, stepIndex]); + }, [handleNext, setValue, eventHandlers.onButtonClick, stepIndex]); const onInputFocus = () => setFocusedOnce(true); diff --git a/packages/widget/src/Stepper.tsx b/packages/widget/src/Stepper.tsx index 699c6d2a..b9633fff 100644 --- a/packages/widget/src/Stepper.tsx +++ b/packages/widget/src/Stepper.tsx @@ -13,9 +13,9 @@ import { useMemo, useRef } from "react"; import { useFormContext } from "react-hook-form"; import { CheckoutSummary } from "./CheckoutSummary.js"; -import { runEventListener } from "./EventListeners.js"; import { DraftFormValues } from "./formValues.js"; import StepContentPaymentOption from "./StepContentPaymentOption.js"; +import StepContentPersonalData from "./StepContentPersonalData.js"; import StepContentReview from "./StepContentReview.js"; import { StepContentTransactions } from "./StepContentTransactions.js"; import StepContentWrap from "./StepContentWrap.js"; @@ -27,41 +27,66 @@ export type StepProps = { }; export default function Stepper() { - const { eventListeners } = useWidget(); + const { eventHandlers } = useWidget(); const { watch, formState: { isValid }, } = useFormContext(); - const paymentOptionWithTokenInfo = watch("paymentOptionWithTokenInfo"); - - const visibleSteps = useMemo( - () => - [ - { - buttonText: "Select network and token", - shortText: "Network & Token", - Content: StepContentPaymentOption, - }, - // Add wrap step only when Super Token has an underlying token. - ...(paymentOptionWithTokenInfo?.superToken.extensions.superTokenInfo - .type === "Wrapper" // TODO(KK): Enable native asset wrapping here. - ? [ - { - buttonText: "Wrap to Super Tokens", - shortText: "Wrap", - Content: StepContentWrap, - }, - ] - : []), - { - buttonText: "Review the transaction(s)", - shortText: "Review", - Content: StepContentReview, - }, - ] as const, - [paymentOptionWithTokenInfo], - ); + const [paymentOptionWithTokenInfo, personalData] = watch([ + "paymentOptionWithTokenInfo", + "personalData", + ]); + + const [visibleSteps, walletConnectStep] = useMemo(() => { + const steps = [ + { + optional: false, + buttonText: "Select network and token", + shortText: "Network & Token", + Content: StepContentPaymentOption, + }, + // Add wrap step only when Super Token has an underlying token. + ...(paymentOptionWithTokenInfo?.superToken.extensions.superTokenInfo + .type === "Wrapper" // TODO(KK): Enable native asset wrapping here. + ? [ + { + optional: true, + buttonText: "Wrap to Super Tokens", + shortText: "Wrap", + Content: StepContentWrap, + }, + ] + : []), + { + optional: false, + buttonText: "Review the transaction(s)", + shortText: "Review", + Content: StepContentReview, + }, + ]; + + const hasPersonalData = personalData.length > 0; + if (hasPersonalData) { + const isPersonalDataRequired = personalData.some((x) => !x.optional); + const personalDataStep = { + optional: !isPersonalDataRequired, + buttonText: "Personal info", + shortText: "Personal info", + Content: StepContentPersonalData, + }; + + const summaryStep = steps.length - 1; + const personalDataStepIndex = isPersonalDataRequired ? 0 : summaryStep; + + steps.splice(personalDataStepIndex, 0, personalDataStep); + const walletConnectStep = isPersonalDataRequired ? 1 : 0; + + return [steps, walletConnectStep]; + } else { + return [steps, 0]; + } + }, [paymentOptionWithTokenInfo, personalData]); const container = useRef(null); const totalSteps = visibleSteps.length + 2; // Add confirm and success. TODO(KK): not clean... @@ -72,12 +97,13 @@ export default function Stepper() { {({ activeStep, setActiveStep, orientation }) => { const isTransacting = activeStep === transactionStep; const isFinished = activeStep === summaryStep; const isForm = !isTransacting && !isFinished; - const visualActiveStep = Math.min(2, activeStep); + const visualActiveStep = Math.min(visibleSteps.length - 1, activeStep); return ( <> @@ -122,10 +148,11 @@ export default function Stepper() { return ( { - runEventListener(eventListeners.onButtonClick, { + eventHandlers.onButtonClick({ type: "step_label", }); setActiveStep(index); diff --git a/packages/widget/src/StepperProvider.tsx b/packages/widget/src/StepperProvider.tsx index 3c6a8402..7e52210d 100644 --- a/packages/widget/src/StepperProvider.tsx +++ b/packages/widget/src/StepperProvider.tsx @@ -12,12 +12,14 @@ import { useWidget } from "./WidgetContext.js"; type Props = { children: (contextValue: StepperContextValue) => ChildrenProp; totalSteps: number; + walletConnectStep: number; initialStep?: number; }; export function StepperProvider({ children, totalSteps, + walletConnectStep = 0, initialStep = 0, }: Props) { const [activeStep, setActiveStep] = useState(initialStep); @@ -30,6 +32,7 @@ export function StepperProvider({ (currentStep: number) => { const isStepBeforeReview = currentStep === totalSteps - 4; const nextActiveStep = Math.min(currentStep + 1, totalSteps - 1); + if (isStepBeforeReview) { handleSubmit((formValues) => { submitCommands(formValuesToCommands(formValues as ValidFormValues)); @@ -48,18 +51,23 @@ export function StepperProvider({ const { isConnected } = useAccount(); + const isActiveStepGreaterThanWalletConnectStep = + activeStep > walletConnectStep; useEffect(() => { - if (!isConnected) { - setActiveStep(0); + if (!isConnected && isActiveStepGreaterThanWalletConnectStep) { + setActiveStep(walletConnectStep); } - }, [isConnected]); + }, [walletConnectStep, isConnected]); const { stepper: { orientation }, } = useWidget(); const contextValue = { - activeStep: isConnected ? activeStep : 0, + activeStep: + isConnected || !isActiveStepGreaterThanWalletConnectStep + ? activeStep + : walletConnectStep, setActiveStep, handleNext, handleBack, diff --git a/packages/widget/src/Widget.tsx b/packages/widget/src/Widget.tsx index 1b5c05a6..c4e220f2 100644 --- a/packages/widget/src/Widget.tsx +++ b/packages/widget/src/Widget.tsx @@ -14,13 +14,12 @@ import { ViewProps } from "./WidgetView.js"; * The entrypoint to the Superfluid widget. */ export function Widget({ - productDetails = { - name: "", - }, + productDetails = defaultProductDetails, tokenList = extendedSuperTokenList, - stepper = { orientation: "vertical" }, + stepper = defaultStepper, type = "page", networkAssets = defaultNetworkAssets, + personalData = defaultPersonalData, ...rest }: WidgetProps & Partial) { const props = { @@ -29,6 +28,7 @@ export function Widget({ stepper, type, networkAssets, + personalData, ...rest, }; @@ -58,3 +58,11 @@ export function Widget({ ); } + +const defaultProductDetails = { + name: "", +} as const; + +const defaultStepper = { orientation: "vertical" } as const; + +const defaultPersonalData = [] as any[]; diff --git a/packages/widget/src/WidgetContext.ts b/packages/widget/src/WidgetContext.ts index ed7ad800..cb92b9e4 100644 --- a/packages/widget/src/WidgetContext.ts +++ b/packages/widget/src/WidgetContext.ts @@ -3,9 +3,11 @@ import { SuperTokenInfo, TokenInfo } from "@superfluid-finance/tokenlist"; import { createContext, useContext } from "react"; import { Address } from "viem"; +import { Callbacks } from "./Callbacks.js"; import { CheckoutConfig } from "./CheckoutConfig.js"; import { ChainId, SupportedNetwork } from "./core/index.js"; -import { EventListeners } from "./EventListeners.js"; +import { PersonalData } from "./core/PersonalData.js"; +import { EventHandlers } from "./EventListeners.js"; import { PaymentOptionWithTokenInfo } from "./formValues.js"; import { WalletManager } from "./WalletManager.js"; import { ViewProps } from "./WidgetView.js"; @@ -20,6 +22,7 @@ export type WidgetContextValue = { paymentOptionWithTokenInfoList: ReadonlyArray; walletManager: WalletManager; imageURI?: string; + personalData: PersonalData; stepper: { orientation: Orientation; }; @@ -27,7 +30,8 @@ export type WidgetContextValue = { elevated: boolean; }; type: ViewProps["type"]; - eventListeners: Required; + eventHandlers: EventHandlers; + callbacks: Required>; } & Required; export const WidgetContext = createContext( diff --git a/packages/widget/src/WidgetCore.tsx b/packages/widget/src/WidgetCore.tsx index ec15b7f1..75fa4101 100644 --- a/packages/widget/src/WidgetCore.tsx +++ b/packages/widget/src/WidgetCore.tsx @@ -1,13 +1,6 @@ "use client"; -import { - Alert, - AlertTitle, - createTheme, - ThemeProvider, - Typography, -} from "@mui/material"; -import { deepmerge } from "@mui/utils"; +import { Alert, AlertTitle, ThemeProvider, Typography } from "@mui/material"; import { SuperTokenInfo, TokenInfo } from "@superfluid-finance/tokenlist"; import memoize from "lodash.memoize"; import { nanoid } from "nanoid"; @@ -23,16 +16,17 @@ import { WidgetProps, } from "./CheckoutConfig.js"; import { ChainId, SupportedNetwork, supportedNetworks } from "./core/index.js"; +import { EventHandlers, runEventHandlers } from "./EventListeners.js"; import { PaymentOptionWithTokenInfo } from "./formValues.js"; import { addSuperTokenInfoToPaymentOptions } from "./helpers/addSuperTokenInfoToPaymentOptions.js"; import { filterSuperTokensFromTokenList } from "./helpers/filterSuperTokensFromTokenList.js"; import { mapSupportedNetworksFromPaymentOptions } from "./helpers/mapSupportedNetworksFromPaymentOptions.js"; -import { buildThemeOptions } from "./theme.js"; +import { createWidgetTheme } from "./theme.js"; import { WidgetContext, WidgetContextValue } from "./WidgetContext.js"; import { ViewProps, WidgetView } from "./WidgetView.js"; type Props = WidgetProps & - Required> & + Required> & Partial; export function WidgetCore({ @@ -43,8 +37,10 @@ export function WidgetCore({ walletManager: walletManager_, stepper: stepper_, eventListeners, + callbacks, type, networkAssets, + personalData, ..._viewProps }: Props) { const viewProps: ViewProps = @@ -206,6 +202,40 @@ export function WidgetCore({ [stepper_.orientation], ); + const eventHandlers = useMemo( + () => ({ + onButtonClick: runEventHandlers( + eventListeners?.onButtonClick, + callbacks?.onButtonClick, + ), + onRouteChange: runEventHandlers( + eventListeners?.onRouteChange, + callbacks?.onRouteChange, + ), + onTransactionSent: runEventHandlers( + eventListeners?.onTransactionSent, + callbacks?.onTransactionSent, + ), + onSuccess: runEventHandlers( + eventListeners?.onSuccess, + callbacks?.onSuccess, + ), + onSuccessButtonClick: runEventHandlers( + eventListeners?.onSuccessButtonClick, + callbacks?.onSuccessButtonClick, + ), + onPaymentOptionUpdate: runEventHandlers( + eventListeners?.onPaymentOptionUpdate, + callbacks?.onPaymentOptionUpdate, + ), + onPersonalDataUpdate: runEventHandlers( + eventListeners?.onPersonalDataUpdate, + callbacks?.onPersonalDataUpdate, + ), + }), + [eventListeners, callbacks], + ); + const checkoutState = useMemo( () => ({ getNetwork, @@ -215,6 +245,7 @@ export function WidgetCore({ superTokens, productDetails, paymentDetails, + personalData, tokenList, networks, paymentOptionWithTokenInfoList, @@ -224,15 +255,9 @@ export function WidgetCore({ elevated: !["drawer", "dialog"].includes(viewProps.type), }, type: viewProps.type, - eventListeners: { - onButtonClick: eventListeners?.onButtonClick ?? NOOP_FUNCTION, - onRouteChange: eventListeners?.onRouteChange ?? NOOP_FUNCTION, - onTransactionSent: eventListeners?.onTransactionSent ?? NOOP_FUNCTION, - onSuccess: eventListeners?.onSuccess ?? NOOP_FUNCTION, - onSuccessButtonClick: - eventListeners?.onSuccessButtonClick ?? NOOP_FUNCTION, - onPaymentOptionUpdate: - eventListeners?.onPaymentOptionUpdate ?? NOOP_FUNCTION, + eventHandlers, + callbacks: { + validatePersonalData: callbacks?.validatePersonalData ?? NOOP_FUNCTION, }, }), [ @@ -243,27 +268,19 @@ export function WidgetCore({ superTokens, productDetails, paymentDetails, + personalData, tokenList, networks, walletManager, stepper, viewProps.type, - eventListeners?.onTransactionSent, - eventListeners?.onSuccessButtonClick, - eventListeners?.onRouteChange, - eventListeners?.onTransactionSent, - eventListeners?.onSuccess, - eventListeners?.onPaymentOptionUpdate, + personalData, + eventHandlers, + callbacks, ], ); - const theme = useMemo(() => { - const defaultThemeOptions = buildThemeOptions( - theme_?.palette?.mode || "light", - ); - const themeOptions = deepmerge(defaultThemeOptions, theme_); - return createTheme(themeOptions); - }, [theme_]); + const theme = useMemo(() => createWidgetTheme(theme_), [theme_]); // TODO(KK): debug message about what token list is used? @@ -299,4 +316,4 @@ export function WidgetCore({ ); } -const NOOP_FUNCTION = () => {}; +export function NOOP_FUNCTION() {} diff --git a/packages/widget/src/core/PersonalData.ts b/packages/widget/src/core/PersonalData.ts new file mode 100644 index 00000000..4beea310 --- /dev/null +++ b/packages/widget/src/core/PersonalData.ts @@ -0,0 +1,35 @@ +import { z } from "zod"; + +const RegexSchema = z.string().refine( + (value) => { + try { + new RegExp(value); + return true; + } catch (e) { + return false; + } + }, + { + message: "Invalid regex pattern", + }, +); + +export const personalDataInputSchema = z.object({ + name: z.string(), + label: z.string(), + required: z + .object({ + pattern: RegexSchema, + message: z.string(), + }) + .optional(), + optional: z.boolean().default(false).optional(), + disabled: z.boolean().optional(), + size: z.enum(["half", "full"]).default("full").optional(), + value: z.string().default("").optional(), +}); + +export const personalDataSchema = z.array(personalDataInputSchema); + +export type PersonalDataInput = z.infer; +export interface PersonalData extends z.infer {} diff --git a/packages/widget/src/core/index.ts b/packages/widget/src/core/index.ts index 5de8285f..0c04d492 100644 --- a/packages/widget/src/core/index.ts +++ b/packages/widget/src/core/index.ts @@ -2,6 +2,7 @@ export * from "./NetworkAssets/defaultNetworkAssets.js"; export * from "./NetworkAssets/types.js"; export * from "./PaymentDetails.js"; export * from "./PaymentOption.js"; +export * from "./PersonalData.js"; export * from "./ProductDetails.js"; export * from "./SupportedNetwork.js"; export * from "./TimePeriod.js"; diff --git a/packages/widget/src/formValues.ts b/packages/widget/src/formValues.ts index 4e85da10..8979ba8b 100644 --- a/packages/widget/src/formValues.ts +++ b/packages/widget/src/formValues.ts @@ -12,6 +12,7 @@ import { SupportedNetwork, supportedNetworkSchema, } from "./core/index.js"; +import { personalDataSchema } from "./core/PersonalData.js"; const paymentOptionWithTokenInfoSchema = z.object({ paymentOption: paymentOptionSchema, @@ -26,6 +27,7 @@ export const checkoutFormSchema = z.object({ accountAddress: addressSchema, network: supportedNetworkSchema.transform((x) => x as SupportedNetwork), paymentOptionWithTokenInfo: paymentOptionWithTokenInfoSchema, + personalData: personalDataSchema, flowRate: flowRateSchema.refine((x) => parseEther(x.amountEther) > 0n, { message: "Flow rate must be greater than 0.", }), diff --git a/packages/widget/src/index.ts b/packages/widget/src/index.ts index 7dbed390..a78df196 100644 --- a/packages/widget/src/index.ts +++ b/packages/widget/src/index.ts @@ -2,6 +2,7 @@ import { Widget } from "./Widget.js"; +export type { Callbacks } from "./Callbacks.js"; export type { WidgetProps } from "./CheckoutConfig.js"; export type { ChainId, @@ -10,6 +11,7 @@ export type { NetworkAssets, PaymentDetails, PaymentOption, + PersonalData, ProductDetails, SupportedNetwork, TimePeriod, @@ -29,7 +31,10 @@ export type { WalletManager } from "./WalletManager.js"; export { paymentDetailsSchema, paymentOptionSchema, + personalDataSchema, productDetailsSchema, } from "./core/index.js"; +export type { WidgetThemeOptions } from "./theme.js"; +export { createWidgetTheme } from "./theme.js"; export default Widget; diff --git a/packages/widget/src/theme.ts b/packages/widget/src/theme.ts index d706ad47..51042dc4 100644 --- a/packages/widget/src/theme.ts +++ b/packages/widget/src/theme.ts @@ -1,29 +1,12 @@ -import { Theme, ThemeOptions } from "@mui/material/styles"; +import { createTheme, ThemeOptions } from "@mui/material"; import { deepmerge } from "@mui/utils"; type ThemeMode = "light" | "dark"; -type DefaultTypography = Theme["typography"]; - -interface TypographyCustomVariants { - label: React.CSSProperties; -} - -declare module "@mui/material/styles" { - interface TypographyVariants extends TypographyCustomVariants {} - interface TypographyVariantsOptions extends TypographyCustomVariants {} -} - -declare module "@mui/material/Typography" { - interface TypographyPropsVariantOverrides { - label: true; - } -} - export const ELEVATION1_BG = `linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.03) 100%)`; -export const buildThemeOptions = (mode: ThemeMode): ThemeOptions => { - const themeWithDesignTokens = getCoreTheme(mode); +export const buildThemeOptions = (mode: ThemeMode): WidgetThemeOptions => { + const themeWithDesignTokens = getCoreThemeOptions(mode); return deepmerge( themeWithDesignTokens, @@ -31,22 +14,34 @@ export const buildThemeOptions = (mode: ThemeMode): ThemeOptions => { ); }; +/** + * The theme options are derived from MUI's theme options. + */ +export type WidgetThemeOptions = Omit< + ThemeOptions, + "unstable_strictMode" | "unstable_sxConfig" +>; + +/** + * Creates the theme that will ultimately be used inside the widget. + */ +export const createWidgetTheme = (themeOptions?: WidgetThemeOptions) => { + const defaultThemeOptions = buildThemeOptions( + themeOptions?.palette?.mode || "light", + ); + return createTheme(deepmerge(defaultThemeOptions, themeOptions)); +}; + const getModeStyleCB = (mode: ThemeMode) => (lightStyle: T, darkStyle: T): T => mode === "dark" ? darkStyle : lightStyle; -interface CoreThemeOptions - extends Required< - Pick< - ThemeOptions, - "palette" | "shadows" | "transitions" | "breakpoints" | "shape" - > - > { - typography: DefaultTypography; -} +type CoreThemeOptions = WidgetThemeOptions & { + typography: typeof coreTypography; +}; -const getCoreTheme = (mode: ThemeMode): CoreThemeOptions => { +const getCoreThemeOptions = (mode: ThemeMode): CoreThemeOptions => { const getModeStyle = getModeStyleCB(mode); return { palette: { @@ -89,80 +84,7 @@ const getCoreTheme = (mode: ThemeMode): CoreThemeOptions => { }, divider: getModeStyle("#E9EBEF", "#FFFFFF1F"), }, - typography: { - fontSize: 16, - htmlFontSize: 16, - - button: { - textTransform: "none", - }, - - h1: { - fontSize: "3.875rem", - fontWeight: 500, - lineHeight: 1, - }, - - h2: { - fontSize: "2.625rem", - fontWeight: 500, - lineHeight: 1, - }, - - h3: { - fontSize: "2rem", - fontWeight: 500, - lineHeight: 1.25, - }, - - h4: { - fontSize: "1.75rem", - fontWeight: 500, - lineHeight: 1.25, - }, - - h5: { - fontSize: "1.5rem", - fontWeight: 500, - lineHeight: 1.25, - }, - - subtitle1: { - fontSize: "1.25rem", - fontWeight: 500, - lineHeight: 1.5, - }, - - subtitle2: { - fontSize: "1.125rem", - fontWeight: 400, - lineHeight: 1.5, - }, - - body1: { - fontSize: "1rem", - fontWeight: 500, - lineHeight: 1.5, - }, - - body2: { - fontSize: "1rem", - fontWeight: 400, - lineHeight: 1.5, - }, - - caption: { - fontSize: "0.875rem", - lineHeight: 1.25, - fontWeight: 400, - }, - - label: { - fontSize: "0.75rem", - lineHeight: 1.5, - fontWeight: 400, - }, - } as DefaultTypography, + typography: coreTypography, // TODO: Only elevation 1 is used, find a way to overwrite only the first one. shadows: [ "none", // elevation 0 @@ -292,15 +214,82 @@ const getCoreTheme = (mode: ThemeMode): CoreThemeOptions => { }; }; +const coreTypography = { + fontSize: 16, + htmlFontSize: 16, + + button: { + textTransform: "none", + }, + + h1: { + fontSize: "3.875rem", + fontWeight: 500, + lineHeight: 1, + }, + + h2: { + fontSize: "2.625rem", + fontWeight: 500, + lineHeight: 1, + }, + + h3: { + fontSize: "2rem", + fontWeight: 500, + lineHeight: 1.25, + }, + + h4: { + fontSize: "1.75rem", + fontWeight: 500, + lineHeight: 1.25, + }, + + h5: { + fontSize: "1.5rem", + fontWeight: 500, + lineHeight: 1.25, + }, + + subtitle1: { + fontSize: "1.25rem", + fontWeight: 500, + lineHeight: 1.5, + }, + + subtitle2: { + fontSize: "1.125rem", + fontWeight: 400, + lineHeight: 1.5, + }, + + body1: { + fontSize: "1rem", + fontWeight: 500, + lineHeight: 1.5, + }, + + body2: { + fontSize: "1rem", + fontWeight: 400, + lineHeight: 1.5, + }, + + caption: { + fontSize: "0.875rem", + lineHeight: 1.25, + fontWeight: 400, + }, +} as const satisfies ThemeOptions["typography"]; + export function getThemedComponents( mode: ThemeMode, coreThemeOptions: CoreThemeOptions, // Core config can be used in components -): ThemeOptions { +): WidgetThemeOptions { // This is used to handle light and dark themes const getModeStyle = getModeStyleCB(mode); - const typography = coreThemeOptions.typography as DefaultTypography; - return { components: { MuiTypography: { @@ -358,7 +347,7 @@ export function getThemedComponents( MuiPaper: { styleOverrides: { root: { - border: `1px solid ${coreThemeOptions.palette.divider}`, + border: `1px solid ${coreThemeOptions.palette!.divider}`, }, }, }, @@ -374,7 +363,7 @@ export function getThemedComponents( styleOverrides: { vertical: { borderBottom: "1px solid", - borderColor: coreThemeOptions.palette.divider, + borderColor: coreThemeOptions.palette!.divider, ":last-child": { border: "none", }, @@ -427,7 +416,7 @@ export function getThemedComponents( paddingRight: 28, }, label: { - ...typography.subtitle2, + ...coreThemeOptions.typography.subtitle2, fontWeight: 500, }, }, diff --git a/packages/widget/src/utils.ts b/packages/widget/src/utils.ts index d60b1ebc..5a5d3813 100644 --- a/packages/widget/src/utils.ts +++ b/packages/widget/src/utils.ts @@ -5,6 +5,7 @@ import { FlowRate, flowRateSchema, mapTimePeriodToSeconds, + PersonalData, TimePeriod, } from "./core/index.js"; @@ -37,6 +38,21 @@ export function shortenHex(address: string, length = 4) { )}`; } +export function serializeRegExp(regex: RegExp): string { + return regex.toString(); +} + +export function deserializeRegExp(serialized: string): RegExp { + // The pattern and the flags need to be separated and passed to the RegExp constructor separately. + const match = serialized.match(/^\/(.*?)\/([gimsuy]*)$/); + if (!match) { + throw new Error("Invalid serialized RegExp"); + } + const [_, pattern, flags] = match; + + return new RegExp(pattern, flags); +} + export async function copyToClipboard(text: string) { if ("clipboard" in navigator) { return await navigator.clipboard.writeText(text); @@ -66,6 +82,17 @@ export function toFixedUsingString(numStr: string, decimalPlaces: number) { ); } +export function mapPersonalDataToObject(personalData: PersonalData) { + return personalData?.length > 0 + ? { + data: personalData.reduce( + (acc, { label, value }) => ({ ...acc, [label.toLowerCase()]: value }), + {}, + ), + } + : ({} as Record); +} + export function mapFlowRateToDefaultWrapAmount( defaultWrapAmount: { multiplier: number; @@ -135,3 +162,15 @@ function roundWeiToPrettyAmount(value: bigint) { export type Prettify = { [K in keyof T]: T[K]; } & {}; + +export type { + PersonalDataField, + PersonalDataFieldType, +} from "./PersonalDataFields.js"; +export { + EmailField, + EmailWithAliasField, + PhoneNumberField, +} from "./PersonalDataFields.js"; + +export type Errors = Record; diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 72afa9f0..dfb10a6e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,4 +3,3 @@ packages: - "packages/*" - "apps/*" - "tests" - - "docs" diff --git a/tests/data/editorConfigurations.json b/tests/data/editorConfigurations.json index c5e9c096..72e66eb7 100644 --- a/tests/data/editorConfigurations.json +++ b/tests/data/editorConfigurations.json @@ -141,6 +141,7 @@ } ] }, + "personalData": [], "type": "page" }, "schemaError": { @@ -167,6 +168,7 @@ } ] }, + "personalData": [], "type": "page" }, "randomUpfrontPaymentReceiver": { @@ -191,6 +193,7 @@ } ] }, + "personalData": [], "type": "page" }, "tonsOfOptions": { @@ -313,6 +316,7 @@ } ] }, + "personalData": [], "type": "page" } } \ No newline at end of file diff --git a/tests/specs/widgetMetamaskStreamTransactions.spec.ts b/tests/specs/widgetMetamaskStreamTransactions.spec.ts index fcdbb869..0a284f19 100644 --- a/tests/specs/widgetMetamaskStreamTransactions.spec.ts +++ b/tests/specs/widgetMetamaskStreamTransactions.spec.ts @@ -1,4 +1,3 @@ - import { rebounderAddresses } from "../pageObjects/basePage.js"; import { BuilderPage } from "../pageObjects/builderPage.js"; import { WidgetPage } from "../pageObjects/widgetPage.js";